Linux:System V 消息队列与信号量

1.消息队列

1.1 基本概念

在操作系统内部,有一个队列的逻辑结构,进程A和进程B是两个独立的进程,此时进程A和进程B都向这个队列的逻辑结构中存数据,hello world 属于进程A,你好 属于进程B,进程A可以通过这个队列去拿到 B 的数据,这种模式就是一种基于共享队列,进程之间可以实现基于数据块级别的进程间通信。不过这种情况下还会引发问题,因为不确定到底哪个数据属于哪个进程,所以我们还会给它加上类型。

像这样的模式我们就称之为消息队列:System V 消息队列是 UNIX/Linux 系统中一种经典的进程间通信(IPC)机制,由 AT&T 在 System V 操作系统中首次引入。它允许不同进程通过交换带有类型标识的消息来实现异步或同步的数据传输,消息由内核管理并暂存在队列中,直到被接收进程读取。

1.2 底层逻辑

在底层结构上,消息队列是一个由内核维护的、用于存储消息的链表结构。逻辑上是队列(FIFO),物理底层内核里是用「双向链表」实现的 。 每个消息队列在内核中表现为一个 msqid_ds 结构体实例,包含队列的权限、当前消息数量、总字节数、最近操作时间戳等信息。

创建消息队列的函数是:msgget

cpp 复制代码
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);

参数中的 key 是一个整数,用来标识一个全局唯一的消息队列,它也是由 ftok 函数去创建的, msflag 用来决定怎么创建 / 打开队列,由两部分组成:权限位和创建规则,即 0666 和 IPC_CREAT 等等,其实这就像是共享内存中的 shmget 。

它的返回值如果创建成功:返回 消息队列 ID(msqid) (非负整数),如果失败:返回 -1 。

每个 System V 消息队列在内核中都有唯一的队列标识符 msqid ,这样确定多进程是否使用的是指定的同一消息队列,也可以由 msqid 去确认,多进程依靠同一个 key(键值) ,通过 msgget() 函数映射到同一个 msqid,从而使用同一个消息队列通信。

Linux 内核会专门维护一张 System V IPC 全局表 (消息队列、共享内存、信号量各一张)。这张表的作用只有一个: **让不同进程,通过同一个 key,找到同一个内核对象。**因此不管是 shmget 还是 msgget ,还是后面讲解信号量要提到的 semget ,本质都是在查表: 如果找到就返回 ID ,没找到再根据标志决定是否创建。

而查看消息队列的信息,使用的指令是:ipcs -q :

删除就是 ipcrm -q ,和共享队列相比就只有选项不一样。

同时,在 System V 消息队列中,消息是进程间交换的数据单元,每条消息由类型(正长整型)数据(任意字节长度) 两部分组成,需要用一个结构体封装,这个结构体必须由用户自己定义、自己创建,操作系统不会提供、不会帮你分配 。内核要求消息必须符合以下逻辑格式(定义在 <sys/msg.h>):

cpp 复制代码
struct msgbuf
{
    long mtype;       // 消息类型,必须 > 0
    char mtext[1];    // 消息数据(实际长度可变)
};

mtext:消息数据区。实际使用时,程序员会定义比1更大的数组或结构体,内核在发送/接收时会根据指定长度拷贝数据。

mtype :消息类型,是一个正整数 。发送进程通过它标记消息类别,接收进程可以选择只接收特定类型的消息,从而实现多路复用。我们要细说一下这个消息类型,它是附加在消息上的一个 long 型标签,取值范围为 1 到 LONG_MAX0 是非法值,通常保留用于特殊含义,如接收任意消息)。

它的最主要作用是可以实现优先级/类别过滤, 接收进程可以指定只接收 mtype == 某值 的消息,或接收 mtype <= 某值 的消息,或接收任意 mtype > 0 的消息。另外还能模拟多通道通信,例如用类型 1 存放普通数据,类型 2 存放紧急数据,类型 3 存放控制指令。

1.3 内核数据结构

内核为每个消息队列维护一个结构体,通常在 <sys/msg.h> 中定义(简化版):

cpp 复制代码
struct msqid_ds 
{
    struct ipc_perm msg_perm;   // 权限及所有者信息
    struct msg *msg_first;      // 指向队列中第一条消息的指针
    struct msg *msg_last;       // 指向队列中最后一条消息的指针
    time_t msg_stime;           // 最后一次发送消息的时间
    time_t msg_rtime;           // 最后一次接收消息的时间
    time_t msg_ctime;           // 最后一次属性修改的时间
    unsigned short msg_cbytes;  // 队列中消息占用的总字节数
    msgqnum_t msg_qnum;         // 队列中当前消息的数量
    msglen_t msg_qbytes;        // 队列允许的最大字节数(上限)
    pid_t msg_lspid;            // 最后一次发送消息的进程ID
    pid_t msg_lrpid;            // 最后一次接收消息的进程ID
};

1.4 系统调用

消息队列的系统调用一共有四个,我们前面已经见到了 msgget ,它是用来创建消息队列的,还有剩下的 msgsnd 、msgrcv 、msgctl。

1.4.1 msgsnd

首先是 msgsnd :

cpp 复制代码
#include <sys/msg.h>

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

这个系统调用的作用就是向消息队列中写入信息,我们重点看后三个参数:

msgp 是指向消息结构体 的指针,结构体必须以 long mtype 开头。

msgsz 是消息数据部分的长度,不包含 mtype。 例如 strlen(msg.mtext)

msgflg 表示阻塞模式,因为消息队列也是有空间大小的,如果遇到写满的情况,消息队列就会发生堵塞,而这个参数的意义,就是表明在发生阻塞时的处理方式:如果填 0 就默认阻塞模式。队列满了就阻塞等待 ,直到有空位。填 IPC_NOWAIT:非阻塞。队列满了直接报错返回,不等待。

这个系统调用的返回值成功返回 0 ,失败返回 -1 。

1.4.2 msgrcv

第二个是 msgrcv :

cpp 复制代码
#include <sys/msg.h>

ssize_t msgrcv(
    int msqid,
    void *msgp,
    size_t msgsz,
    long msgtyp,
    int msgflg
);

这个函数msgrcv 的作用是从内核消息队列里**取一条消息。**它有五个参数,其中四个参数都是和 msgsnd 一样的,我们只要关注 msgtyp 就好:

它的作用是告诉操作系统按什么规则取消息,如果参数 =0:取队列第一条消息 (FIFO)。 >0 :只取类型等于 msgtyp 的第一条消息。<0 :取类型 ≤ 绝对值的最小类型消息

另外要注意的是,对于msgrcv的 msgtyp 这个参数,还有一个可填写的模式:填MSG_NOERROR:消息太长就截断,不报错。但是 msgsnd 里的 msgtyp 就没有这个选项,是因为:发送时:消息是你自己构造的 ,长度你完全知道,你传给 msgsndmsgsz 就是精确长度,内核严格按你给的长度存入队列,不会变长、不会溢出。但是对于接收端来说,你指定了要接收的长度 msgsz ,所以如果内核里的消息长度 > 你用户缓冲区的长度(msgsz),就可以用这个模式进行信息截断。

1.4.3 msgctl

cpp 复制代码
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msgctlSystem V 消息队列的控制函数 ,用来: 删除队列、查看队列信息、修改队列权限 。 它是操作消息队列本身的,不是收发消息。

最重要的是第二个参数 cmd ,代表你要让内核做什么操作,最常用 3 个:

IPC_RMID删除消息队列(最常用!)

IPC_STAT:获取队列信息(存在 buf 里)

IPC_SET:设置队列信息(权限、owner 等)

最后的 buf 指向 struct msqid_ds 结构体的指针,用于存 / 写队列信息 ,删除队列时直接传 NULL。

2. 信号量

2.1 概念铺垫

在正式介绍System V 信号量之前,我需要向大家先介绍几个概念:

  1. 多个执行流能看到并访问的公共资源,叫做共享资源。

  2. 任何时刻,如果只有一个执行流能访问公共资源,这种情况叫做 互斥。

  3. 有的公共资源需要被通过各种方式保护,这种被保护的公共资源叫做 临界资源。

  4. 那么这些临界资源,本质上都是CPU执行的进程中的代码去访问的,这些代码被划分为**临界区。**因此多进程并发访问的时候一定会被划分为 临界区 和 非临界区 ,同时,不访问共享资源的进程,没有临界区。 不是所有进程天生就一定有临界区。

好比我们前面讲解共享内存时写过的代码:

前面的操作都是非临界区,当循环内去访问共享内存中的数据的时候,这部分代码就是临界区。

  1. 多个执行流访问临界资源时,具有一定的顺序性,叫做 同步

  2. 一个操作或者一段代码,要么全部执行完成 ,要么完全不执行,中间不能被打断、不能只执行一半。 就像化学反应里的原子不可分割,操作是不可分割、不可中断的最小单元,这种概念,叫做 原子性

2.2 基本概念

在系统性地谈论信号量之前,我想先用一个例子帮助大家更好的理解:

现在有一个高铁座位表,表明这个高铁里面有一百个座位。这个高铁座位对所有人来说都是一种共享资源,而这一百个座位就代表了票量,这也从侧面说明了共享资源是有上限的。

那么就乘坐高铁这个行为来看,如果我想要坐高铁,就必须要先有高铁票。只有当买票成功之后,高铁里对应的座位才能属于你,即便你不去乘坐这一班高铁,这个位置的资源也是属于你的。因此我们可以说,买票本质是对资源的预订。如果当买票的人数非常多,你又想乘坐这一班高铁,那就需要候补,看能不能有余票。

那么映射到信号量的概念,高铁上的 100 个座位,就相当于是信号量初始值是 100 ,买票的本质就是在申请信号量,每卖出去一张票,信号量的值就减一。当进程想要申请信号量但因为信号量不足,进程就会在此阻塞等待。

并且有的高铁上有商务座,一般商务座的座位都非常少,假设一辆高铁上只有一个商务座座位,那么对于信号量来说,它的信号量就是 1 ,也就意味着,这个信号量的变化范围只有 1 和 0 。对于这样只有两态的信号量,有一个专有名词:二元信号量。

因此我们说,信号量的本质是一个非负整数计数器,它是用来描述 "空闲资源数量的多少" 这一数量信息,用于控制多个进程对共享资源的并发访问,它不传输数据。并且因为每个进程在申请信号量的时候,都会访问同一信号量,那么信号量本身就是共享资源。

那么既然信号量的值会加减变化,就必定有执行操作,分为:

1. P 操作(荷兰语 Proberen,测试):尝试将信号量的值减 1。若减后 ≥ 0,则成功;若减后 < 0,则阻塞进程直到信号量变为正。

2. V 操作(Verhogen,增加):将信号量的值加 1。若有进程因等待该信号量而阻塞,则唤醒其中一个。

2.3 系统调用

2.3.1 semget

cpp 复制代码
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

这个函数的作用是创建一个新的信号量集 ,或者获取一个已存在信号量集的标识符。它的三个参数:

key :IPC 键值,与消息队列的 msggetkey 完全一致。通常由 ftok() 生成,或者使用 IPC_PRIVATE 创建私有集。

nsems :"信号量集"中信号量的个数。创建新集时必须指定;如果只是获取已存在的集,可以填 0(内核会忽略)。

semflg :权限标志,与 msggetmsgflg 类似。常用组合:

1. IPC_CREAT:若不存在则创建。

2. IPC_EXCL:与 IPC_CREAT 一起使用时,若已存在则报错。

  1. 权限位(如 0644):指定所有者读/写权限,组和其他用户读权限。

返回值 :成功返回信号量集标识符 semid(类似 msqid),失败返回 -1。

注意:与消息队列不同,信号量集必须指定 nsems,因为内核需要预先分配信号量数组。

2.3.2 semop

cpp 复制代码
#include <sys/sem.h>

int semop(int semid, struct sembuf *sops, size_t nsops);

这个函数的作用是对一个或多个信号量执行一组原子操作。它有三个参数:

1. semid :信号量集标识符(由 semget 返回)。

2. sops :指向 struct sembuf 结构体数组的指针,每个结构体描述一个操作。

3. nsopssops 数组中的元素个数(一次可以同时操作多个信号量)。

其中 struct sembuf 的定义为:

cpp 复制代码
struct sembuf 
{
    unsigned short sem_num;  // 信号量在集中的索引(0 ~ nsems-1)
    short          sem_op;   // 操作数:负数=P操作,正数=V操作,0=等待0
    short          sem_flg;  // 标志:IPC_NOWAIT(非阻塞),SEM_UNDO(进程崩溃时自动恢复)
};

重点解释 sem_op 的三种取值(这也是理解信号量本质的关键):

1. sem_op > 0(V 操作) :信号量的值增加 sem_op。如果有进程因等待该信号量而阻塞(例如等待值从 0 变为正),内核会唤醒它们。这对应"释放资源"。

2. sem_op < 0(P 操作) :信号量的值减去 |sem_op|。如果减后结果仍 ≥ 0,则操作立即成功;如果减后结果 < 0,则进程阻塞直到信号量的值被其他进程增加到足够大(或者 IPC_NOWAIT 导致立即返回错误)。这对应"申请资源"。

3. sem_op == 0 :等待信号量的值变为 0。如果当前值为 0,立即成功;否则阻塞直到值为 0。这是一种同步点,常用于生产者-消费者模型中的"空缓冲"条件。

sem_flg 两个常用标志:

1. IPC_NOWAIT :当操作不能立即完成时,不阻塞进程,而是返回错误 EAGAIN

2. SEM_UNDO :记录本次操作,当进程异常退出(比如崩溃、被 kill)时,内核自动撤销 该进程对信号量做的所有改动,避免造成死锁或资源计数错误。强烈建议在 P 操作时加上 SEM_UNDO

一次 semop 调用可以指定多个 sops,内核会原子地执行这一组操作:要么全部成功,要么一个都不执行(回滚)。这允许你实现像"同时申请两个资源,要么都给,要么都不给"这样的复杂同步。

2.3.3 semctl

cpp 复制代码
#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);

这个函数是 System V 信号量的控制总入口 ,用于:删除信号量集、获取/设置单个信号量的值、获取/设置权限等。它与 msgctl 类似,但多了一个 semnum 参数(指定信号量集中的第几个信号量),而且第四个参数是一个联合体 union semun

重要union semun 需要用户自己定义(某些系统在头文件中已定义,但最好显式定义):

cpp 复制代码
union semun
{
    int              val;    // 用于 SETVAL
    struct semid_ds *buf;    // 用于 IPC_STAT / IPC_SET
    unsigned short  *array;  // 用于 GETALL / SETALL
};

常用 cmd 命令有下面几个:

cmd 命令 作用 第四参数使用
IPC_RMID 立即删除信号量集,唤醒所有阻塞的进程 不需要(可省略)
SETVAL semnum 指定的信号量设置为 arg.val 使用 arg.val
GETVAL 获取 semnum 指定的信号量的当前值 返回值即为信号量值(无需 arg
SETALL 将集合中所有信号量设置为 arg.array 的值 使用 arg.array
GETALL 获取所有信号量的值,存入 arg.array 使用 arg.array
IPC_STAT 获取信号量集的状态信息(权限、时间戳等) 使用 arg.buf
IPC_SET 设置信号量集的权限、uid/gid 使用 arg.buf

要注意的是:

  1. 创建信号量集后,信号量的初始值是不确定的 (通常为 0)。你必须使用 semctlSETVALSETALL 来显式初始化,否则 P 操作会一直阻塞。

  2. 删除信号量集与消息队列一样:semctl(semid, 0, IPC_RMID)(第二个参数 semnum 被忽略,填 0 即可)。

3. 内核组织管理进程IPC示意图

学习到这里,大家会发现System V 的三个成员其实真的很像,之所以让我们产生这一种感觉,主要是因为 kern_ipc_perm 这个结构体,它是 System V IPC 统一权限头部结构体,我们以共享内存在内核中的结构体举例:

其实,不管是消息队列、共享内存还是信号量,这三类 IPC 内核结构体,全都把它放在最开头 ,目的是为了统一权限校验、统一管理、内核通用操作。

cpp 复制代码
struct kern_ipc_perm {
    key_t          key;      // 用户传入的键值key
    uid_t          uid;      // 所有者用户ID
    gid_t          gid;      // 所有者组ID
    uid_t          cuid;     // 创建者用户ID
    gid_t          cgid;     // 创建者组ID
    mode_t         mode;     // 权限位 0666这类
    unsigned short seq;      // 序列号,生成IPC ID用
    void          *security;
};

kern_ipc_perm 统一定义了所有 IPC 对象的键值、所有者、权限模式、序列号等核心元数据。将其放在结构体起始位置,内核便可以通过通用指针强转,在不区分具体资源类型的情况下,对所有 IPC 对象执行统一的权限校验、键值匹配、ID 生成和资源管理。

System V IPC 设计初衷就是:三类通信机制共用一套权限体系 ,像:能不能访问、能不能读写、能不能删除这种问题,全都走 kern_ipc_perm 里面的 mode 权限位 不用为每种 IPC 单独写一套权限判断代码。极大地简化了内核代码的实现,保证了三类 IPC 资源在权限控制和生命周期管理上的一致性。

本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。

相关推荐
BIG_PEI2 小时前
如何判断Linux服务器上是否安装了rabbitmq
linux·服务器·rabbitmq
xhbh6662 小时前
Linux转发完全教程:ip_forward开启、iptables端口映射、双网卡NAT实战
服务器·网络·智能路由器·端口转发·端口映射·映射
云飞云共享云桌面2 小时前
SolidWorks 服务器通过云飞云共享云桌面10人研发共享方案
运维·服务器·3d·设计模式·电脑
日取其半万世不竭2 小时前
auditd:Linux 系统审计日志,记录谁动了你的服务器
linux·服务器·github
NashSKY2 小时前
使用 tmux 让服务器训练任务在后台持续运行
服务器·tmux
zincsweet2 小时前
进程管理:创建、终止、等待、替换
linux
条俐开水喉2 小时前
高密度AI算力服务器机房U位动态调度管理方案
运维·服务器·人工智能
A_QXBlms3 小时前
企业微信社群SOP自动化执行引擎开发,SCRM高效运营技术实现
运维·自动化·企业微信
鹏大师运维3 小时前
信创数据库开发--SQLark这款工具支持麒麟、统信
linux·数据库·数据库开发·麒麟·统信·sqlark·桌面操作系统