Linux 进程间通信(五)System V通信方式之 消息队列、信号量
目录
查看消息队列的内核描述结构体 struct msqid_ds
上篇文章我们学习了解了System V的第一种进程间通信方式共享内存,System V 还有两种通信方式 : 消息队列和信号量,今天我们逐一学习。
共享内存、消息队列、信号量都属于System V标准的进程间通信方式。它们都具有某些共性,比如 :
- 它们都是由内核直接管理的系统级资源;
- 生命周期跟随内核,进程退出不会自动消失;
- 都使用 key + shmid / msqid / semid 的机制来标识和查找;
- 都通过 xxxget、xxxctl 等系统调用操作;
- 都可以用 ipcs 命令查看,用 ipcrm 命令删除。
一、消息队列
消息队列的原理

首先图的下方是 Linux 操作系统,上方是用户层,用户的进程 A 和进程 B 想要进行通信,图中画出了进程 A 和进程 B,但为了突出重点,暂时忽略了与进程 A 和进程 B 相关的一系列内核数据结构。我们可以将进程 A 和进程 B 看作用户态的代码逻辑,用户层的其余代码、操作系统部分则对应着消息队列的一系列系统调用,进程 A 和进程 B 正是通过这些系统调用,才能看到并操作处于内核中的消息队列。
此时进程 A 想要给进程 B 发送数据,那么使用消息队列时,这个数据必须以带类型的数据块形式进行发送,通过系统调用将数据块链接到内核的消息队列上;同理,进程 B 要使用消息队列给进程 A 发送数据,也需要将数据封装成带类型的数据块,通过系统调用提交到内核的消息队列中。紧接着进程 B 给 A 发送数据、进程 A 再给 B 发送数据,此时消息队列后面就链接着进行通信的全部数据块。
如果进程 B 想要去这个消息队列上查找进程 A 发送给自己的数据信息,而消息队列后面链接了这么多数据块,进程 B 要如何区分哪一个是进程 A 发送给自己的、哪一个是自己发送给进程 A 的数据块呢?
- 所以当进程 A 使用消息队列给进程 B 发送数据块时,这个数据块一定要附带一个类型标识,这个类型通常是整数,例如使用整数 1 表示这是进程 A 发送的数据块,进程 B 则用 2 作为标记表示这是进程 B 发送的数据块。此时进程 B 要找进程 A 发送给自己的数据块,就去使用系统调用查找标记为 1 的数据块即可;同理进程 A 去查找标记为 2 的数据块就能拿到进程 B 发来的内容。通过这种方式,进程拿到对应类型的数据块后即可获取数据进行通信,这个类型需要用户在需要进行通信的进程之间,提前做好代码层面的约定,否则无法实现精准筛选。
所以此时进程 A 和进程 B 就可以互相之间以发送带类型数据块的形式进行通信,但有两个核心前提:
- 必须让不同进程看到同一个消息队列,这是通信的基础,否则进程之间无法找到共同的消息载体;
- 允许不同进程使用系统调用,向内核中发送带类型的数据块,这是区分消息来源、实现精准接收的关键。
那么消息队列如何保证让不同的进程看到同一个队列呢?下面我们就来认识一下消息队列的接口 msgget 即可得知。
但是我们要先理解一个事情:既然进程 A 和进程 B 之间可以通过消息队列进行通信,那么同样的,进程 C 和进程 D 之间也可以通过消息队列进行通信。操作系统中存在大量进程,也就必然决定着系统中一定存在大量使用消息队列进行通信的进程,即意味着操作系统中存在着大量的消息队列。所以操作系统一定会将消息队列管理起来,管理的思路是 “先描述再组织”:首先必然会有描述消息队列的内核描述对象结构体(struct msqid_ds),用来记录队列的权限、大小、消息链表等信息;并且采用一定的数据结构(通常是数组)将所有队列的描述符组织起来,从此以后对消息队列的管理就转换成了对数组的增删查改操作,高效且有序。
消息队列的接口认识
在介绍系统调用之前,我们依旧先梳理一下整个消息队列的通信流程:
首先进程 A 会调用第一个系统调用接口(msgget)创建或获取消息队列,内核会维护一个消息链表结构,即消息队列本体。需要注意的是,消息队列是由内核创建并管理的,并不隶属于进程 A。又因为进程无法直接访问内核中的消息队列,所以进程只能通过第二个系统调用接口(msgsnd)将封装好的带类型消息数据块发送到内核的消息队列中,完成数据的投递。接下来进程 B 为了接收来自进程 A 的消息,会使用第三个系统调用接口(msgrcv),从内核的消息队列中按类型筛选并读取目标消息块,从而实现双方的进程间通信。通信完毕后,若进程不再需要使用消息队列,可通过第四个系统调用接口(msgctl)对队列进行控制操作:若需要删除消息队列,则传入 IPC_RMID 命令,内核会在所有关联进程都结束使用后,真正释放消息队列的内核资源,整个流程结束。
因为消息队列和信号量以及共享内存一样,都是严格遵循systemV标准的,所以接口设计上必然会有相似性,都遵循 System V IPC 的统一范式。消息队列的接口开头都是以 msg 进行开头,表示 message 消息:
msgget,消息队列获取接口


首先观察第一幅图,就是创建消息队列要使用到的 msgget 系统调用接口,第一个参数 key,回顾我们上一篇共享内存创建接口 shmget,它的第一个参数不也是 key 吗,同样的消息队列的第二个参数 msgflg 也就是和共享内存 shmget 的第三个参数 shmflg 一样的使用方法,我们会使用共享内存的 shmget 自然也就会使用消息队列的 megget。
但是在这里我i们还是在学习一下各个参数的作用:
第一个参数 key 和共享内存的 key 作用完全一致,是用于进程间约定要操作的消息队列,可由 ftok 生成或手动指定。
第二个参数 msgflg 是标志位,控制创建和打开的行为与权限:
- IPC_CREAT:如果不存在,就创建;如果已经存在,就直接打开使用。不管是消息队列、共享内存、信号量,用法都一样:
- IPC_EXCL:它必须和 IPC_CREAT 一起用,单独用没用!
- IPC_EXCL | IPC_CREAT : 一起使用时,会创建一个全新的、独一无二的消息队列。如果已经存在,就报错,绝不使用旧的。
- 权限位(如0666):控制队列的读写权限,和文件权限格式一致。

返回值 : 成功时返回消息队列标识符 msqid(非负整数);失败则返回 -1,并设置 errno 以指示错误原因。
ftok,获取key值

由于创建消息队列 msgget 也使用了参数 key,同样我们使用 ftok 函数通过 pathname 路径以及 proj_id 获取 key,通常来讲路径具有唯一性,同时还有一个 proj_id,那么只要用户在代码层面约定好两个进行通信的进程使用同一个 pathname 路径以及 proj_id,那么经过 ftok 函数的算法计算后返回的 key 一定就是相同的,即使用 key 可以标定唯一性。
就算 key 值和操作系统内原有的共享内存或者消息队列或者信号量的 key 重复了也没关系,再换一个 pathname 路径或者 proj_id 即可,那么此时消息队列就被创建出来的,同样的两个进程一个创建一个获取,这样就可以拿到消息队列描述符 msqid,所以此时必须让不同进程看到同一个队列就得到了保障。
msgsnd,消息队列数据发送接口
msgsnd 是消息队列的数据发送接口:

第一个参数 msqid 是由 msgget 返回的消息队列标识符。
msqid 是 msgget 函数返回的消息队列唯一标识符,本质是内核为每个消息队列分配的数字编号,作用是在系统中精准区分不同消息队列,并作为后续所有操作的凭证:在调用 msgsnd 发送消息、msgrcv 接收消息或 msgctl 控制 / 删除队列时,都必须传入 msqid,以此告知内核要操作的具体消息队列,确保进程间通信能精准指向目标队列,避免与系统中其他消息队列混淆。

第二个参数则是一个 void* 的参数 msgp ,观察上图,msgp 要求是一个结构体,这个结构体需要我们自己定义,类型是 struct msgbuf ,第一个成员变量是 mtype 即类型,是一个大于 0 的整数,用于给数据块进行标识,第二个成员则是一个变长数组,即作为一个数据块来进行使用,可以直接存储进程要发送的数据消息。
第三个参数 msgsz 是我们发送的消息数据部分的长度。
第四个参数 msgflg 是控制标志位,我们一般设置为 0 即可,表示阻塞式发送。
- IPC_NOWAIT:队列满时不阻塞,直接返回错误;
- 0:默认阻塞,直到队列有空间可写入。

返回值成功时返回 0;失败则返回 -1 并设置 errno。
msgrcv,消息队列数据数据接口
msgrcv 是消息队列的数据数据接口,它和上面的数据发送接口 msgsnd 是配套的 :

msgrcv 的参数和 msgsnd 类似,同样的第二个参数需要我们自己定义,定义一个 struct msgbuf 的结构体取地址传入即可,传入 msgrcv 之后,msgrcv 就会将要找的消息拷贝到我们传入的 struct msgbuf 的结构体中,
第三个参数 msgsz 是消息数据部分的最大接收长度。
第四个参数 msgtyp 则是要接受的消息的类型。这个类型的标识由用户约定使用什么标识,例如进程A发送的数据块使用整数1作为标识,那么进程B在进行接收消息的时候,就要在代码层面将msgrcv 的第四个参数 msgtyp 设置为1,那么进程B调用的 msgrcv 系统调用就会去进程B和进程A可以看到的同一个的消息队列中去寻找类型为整数1的数据块进行接收。
第五个参数 msgflg 是控制标志位:
- IPC_NOWAIT:队列空时不阻塞,直接返回错误;
- MSG_NOERROR:若消息数据超过 msgsz,则截断数据而不报错;
- MSG_EXCEPT:配合 msgtyp > 0 使用,接收不等于该类型的第一条消息。

返回值和 msgsnd 一样,成功时返回 0;失败则返回 -1 并设置 errno。
msgctl,消息队列控制与管理接口
msgctl 是消息队列的控制与管理接口,和 shmctl(共享内存控制)的设计完全一致。用于对消息队列执行查询、修改权限、删除等控制操作,最常用的场景是删除消息队列。

第一个参数 msqid 是 msgget 返回的消息队列标识符,指定要操作的目标队列。
第二个参数 cmd 是控制命令,决定要执行的操作,它的类型是 int,所以是个宏替换 :
- IPC_RMID:删除消息队列,内核会释放队列资源,不管是否还有进程在使用;
- IPC_STAT:获取队列的状态信息,拷贝到 buf 指向的 struct msqid_ds 结构体中;
- IPC_SET:修改队列的权限、所有者等信息,从 buf 中读取新配置。
第三个参数 buf 是指向 struct msqid_ds 结构体的指针,用于传递或接收队列状态信息;若执行 IPC_RMID 删除消息队列时,通常传 NULL。
返回值成功时返回 0;失败返回 -1 并设置 errno。
查看消息队列的内核描述结构体 struct msqid_ds

struct msqid_ds 是消息队列的内核描述结构体,和共享内存的 struct shmid_ds 设计高度一致。
首先这个结构体由内核维护,用于记录消息队列的所有信息,通过 msgctl(IPC_STAT) 可以将其拷贝到用户态。

并且观察这个 struct msqid_ds 结构体的第一个变量是 struct ipc_perm msg_perm; struct ipc_perm msg_perm 是消息队列的权限描述结构体,用于管理对象的访问权限与所有者信息:

其实这个 struct ipc_perm 类型的结构体共享内存中也有。

我们可以看出共享内存和消息队列中的返回给用户的对应的描述对象结构体的第一个变量都是struct ipc_perm 类型,包括下面我们将要学习的信号量里面也有,这其实是 SystemV 标准的专门设计,后面我们再讲为什么。
ipcs -q,查看消息队列

ipcs -q 是在命令行中查看消息队列并列出系统中所有存在的消息队列,并展示关键属性。
ipcrm -q msqid,删除消息队列
ipcrm -q msqid 则是在命令行中删除消息队列,通过指定的 msqid,直接删除系统中的目标消息队列,效果和 msgctl(msqid, IPC_RMID, NULL) 完全一致。
二、IPC在内核中的数据结构设计
IPC 在内核中的数据结构设计,就是指操作系统内核为了管理消息队列、共享内存、信号量等IPC进程间通信方式,所采用的结构体定义、组织方式与存储结构,也就是我们先描述再组织中描述的结构体,包括如何描述单个 IPC 对象、如何存储权限信息、如何将多个 IPC 对象组织成数组或链表进行统一管理。
上面我们看了用户层的共享内存描述对象 struct shmid_ds ,消息队列描述对象 struct msqid_ds,那么还有另一种通信方式信号量呢?用户层的信号量的描述对象是 semid_ds 吗?是不是信号量的描述对象 struct semid_ds 结构体的第一个变量仍然是 struct ipc_perm 类型的呢?
这里我们可以提前看一下信号量的描述对象 struct semid_ds 结构体是不是和我们的猜测一样 :

答案显而易见,信号量的数据结构描述对象 struct semid_ds 结构体的第一个变量仍然是 struct ipc_perm 类型的,并且仍然存在对应的 key 值。
共享内存,消息队列,信号量的设计都遵循 system V 标准,共享内存,消息队列,信号量都属于进程间通信IPC方式之一,那么IPC在内核中的数据结构设计又该是什么样子的呢?下面我们通过一幅图来了解内核中是如何设计和管理各个 IPC 结构体的 :
首先我们先将内核层 IPC 对应的描述对象拎出来:共享内存是 struct shmid_ds ,消息队列是 struct msqid_ds ,信号量 struct semid_ds ,同时将它们描述对象的第一个成员的类型—— struct ipc_perm 类型也提取出来。需要强调一点:这些描述对象只存在于内核态,是内核用来管理 IPC 资源的核心数据结构;我们在用户态代码中定义的同名结构体,只是和内核态结构体格式完全一致的“镜像拷贝”,并非内核里的真实对象,借助这种类比便于我们理解 IPC 在内核中的数据结构设计。
共享内存、消息队列、信号量都严格遵循 System V IPC 标准,在操作系统的运行过程中不可避免会有大量的进程采用共享内存、消息队列、信号量进行进程间通信,这些 IPC 对象数量庞大,操作系统必然要对它们进行统一管理。管理的核心思路是“先描述,再组织”:用 struct shmid_ds 描述共享内存,用 struct msqid_ds 描述消息队列,用 struct semid_ds 描述信号量,但这三个描述对象的类型各不相同,如何将它们高效组织起来,就成了内核需要解决的核心问题。
很简单,虽然这三个描述对象的类型都不相同,但是它们的第一个成员的类型是完全一致的——都是 struct ipc_perm 类型。那么我们完全可以将三个描述对象的第一个成员的地址取出来,用某种统一的数据结构进行存储组织,内核中采用的就是数组的形式进行存储,即采用数组的形式把所有 IPC 对象组织起来。此时对 IPC 的管理就转换成了对数组的增删查改,问题也随之而来:我们组织起来的只是三个描述对象第一个成员的地址,如何根据这个地址找到对应的完整 IPC 描述对象,进而访问其特有成员呢?
很简单,由于三个描述对象的第一个 struct ipc_perm 类型成员是放在描述对象的第一个位置,因此 struct ipc_perm 类型成员的地址和整个描述对象的起始地址是完全一致的,只是能看到的成员范围不同。我们知道,指针之间可以进行强制类型转换,那么内核会将数组中存储的 struct ipc_perm* 地址,强制类型转换为对应的完整描述对象的地址(比如 (struct shmid_ds*)perm_ptr),这样就可以采用描述对象指针的方式访问描述对象的任意一个成员了。那么新的问题又来了:操作系统如何知道这个 struct ipc_perm* 地址对应的是哪一种描述对象(共享内存/消息队列/信号量)?如果类型判断错误,强制转换就会导致数据访问错乱。
很简单,操作系统会在存储数组元素的同时,额外记录每个元素对应的描述对象类型标识(比如标记该元素对应 shmid_ds、msqid_ds 还是 semid_ds)。这样当想要通过 struct ipc_perm* 地址访问完整描述对象的成员时,就可以根据提前存储好的类型标识,进行精准的强制类型转换,避免类型混淆导致的错误。
同时存储数组的下标会返回给用户,这个下标就是对应描述对象的 IPC 描述符(shmid / msqid / semid),此后用户如果想要对描述对象继续操作,就可以通过将描述符传给系统调用,内核会根据下标找到对应的数组元素,进而完成对 IPC 对象的操作。这个数组的下标(即 IPC 描述符)是线性增长的,当增长到一定限度后会从 0 重新开始线性递增,这是因为此时起始位置对应的地址所指向的描述对象早已被释放,数组位置可以被重新利用,避免 ID 耗尽的问题。用户态若要查看或修改内核描述对象,必须通过 msgctl / shmctl / semctl 等系统调用,以“拷贝”的方式和内核交换数据。
那么我们宏观来看 IPC 在内核中的数据结构设计,会发现它非常类似 C++ 中的多态思想,只不过这里是用 C 语言手动实现的 “编译期多态”:即同一套数组管理与指针访问方式,会根据指针实际指向的对象类型不同,去访问对应类型的成员数据。这里的 struct ipc_perm 就相当于面向对象中的基类,封装了所有 IPC 对象的公共属性(权限、key、所有者等);而三类具体的 IPC 描述对象 —— 共享内存 struct shmid_ds、消息队列 struct msqid_ds、信号量 struct semid_ds,就相当于子类(派生类),在基类基础上扩展了各自类型的特有属性。
我们可以类比 C++ 的多态实现:C++ 中虚函数表指针会被放在对象内存布局的首位,派生类实例化时会先构造基类部分并将其放在子类地址的最前面,这和内核 IPC 的设计高度一致 ——struct ipc_perm 被放在每个子类描述对象的首位,保证基类指针与子类指针地址完全相同,从而可以通过强制类型转换实现 “基类指针指向子类对象” 的效果。需要特别注意的是:Linux 内核是用纯 C 语言编写的,在它诞生时 C++ 还未普及,这种 “基类 + 子类” 的设计是内核开发者用 C 语言手动实现的多态思想;而 C++ 后来的继承与多态特性,正是因为发现这种 “复用公共逻辑、扩展特有功能” 的设计模式在工程中极具价值,才被专门纳入语言规范,如今几乎所有面向对象语言都包含多态特性。
在 Linux 操作系统中,所有 System V IPC 资源(共享内存、消息队列、信号量)都被整合进内核的 IPC 子系统 中统一管理:内核通过一套公共的数组结构与 struct ipc_perm 基类逻辑,实现了对不同类型 IPC 对象的创建、查询、修改与删除,同时通过系统调用(如 msgctl/shmctl/semctl)向用户态提供操作接口,保证了内核管理的高效性与用户态交互的一致性。
三、信号量
铺垫
引出相关概念
信号量原理的讲解还需要一些知识概念进行铺垫,如下图 :

为了引出相关的概念,我们就以进程 A 和进程 B 使用共享内存进行通信为例进行讲解:通过上一篇关于共享内存的讲解我们知道共享内存本身是没有内置的同步、互斥保护机制的,当进程 A 正在写入数据时,若只写入了一部分数据就被调度器切走,进程 B 就可能提前读取未完整的数据,最终导致双方收发的数据不一致 —— 这就是典型的数据不一致问题。例如:进程 A 想写入 hello linux,目前只写入了 hello,此时进程 B 就过来读取,那么写入方想要传输的完整信息是 hello linux,但读取方只能读到 hello,这就造成了数据错乱。
所以我们就引出了第一个概念 :
1. 共享资源 : 多个执行流(进程)能够共同访问、可见的同一份公共资源,就叫做共享资源,例如共享内存、消息队列、文件、全局变量等。它是进程间通信的基础,但是如果不加控制地并发访问,可能导致数据混乱。
那如果共享资源不加以保护,并发访问时就必然会造成数据不一致的问题。那如何保护呢?
核心思路就是对访问共享资源的操作进行互斥控制。最常见的保护方式就是加锁:对一个共享资源加锁后,任何时刻只允许一个执行流(进程 / 线程)访问该资源,就叫做互斥。此时访问共享资源的两个进程就形成了互斥访问:当进程 A 在进行写入操作时,因为它已经持有了锁,进程 B 就无法访问共享资源;当进程 A 写入完成 hello linux 并释放锁后,进程 A 才会结束对共享资源的占用,此时进程 B 才能获取锁并访问共享资源,自然而然就能读取到完整的 hello linux,这样就对数据进行了有效保护,收和发的数据就保持一致了。
2. 临界资源 : 是一种特殊的共享资源,同一时刻只允许一个执行流访问,否则会引发数据不一致或逻辑错误。这类资源必须被互斥保护,就是保证任何时刻最多只有一个执行流进入临界区访问临界资源。
3. 互斥与同步(保护临界资源的两种核心方式)
- 互斥:保证任何时刻最多只有一个执行流进入临界区访问临界资源,解决 “竞争冲突” 问题,避免多个执行流同时修改数据。
- 同步:规定多个执行流访问临界资源的先后顺序,解决 “时序依赖” 问题,确保操作按预期逻辑执行。

再比如:一段 100 行的代码里,通常只有 5 到 10 行代码(例如操作共享内存的 read、write 等接口)在访问临界资源,我们把这段访问临界资源的代码片段称作临界区。
4. 临界区 : 代码中访问临界资源的那段代码片段。它不是资源本身,而是操作资源的代码,必须被互斥机制保护。完整的程序代码 = 临界区(访问临界资源) + 非临界区(不访问临界资源)。
现在我们就可以用上面的原理解释一个常见现象:多进程 / 多线程并发打印的问题。
我们还没有学习多线程,但多进程的场景我们已经见过了:例如父子进程同时向显示器打印消息,此时的消息可能出现三种情况:
- 消息错乱:例如子进程想要打印 hello,父进程想要打印 xxx,最终输出可能是 xxxlo 这样的混合内容;
- 顺序混乱:例如一会儿子进程先打印,一会儿父进程先打印,这是由于 CPU 调度器的调度顺序决定的,哪个进程先被调用完全由调度器说了算;
- 消息和命令行混在一起,可读性极差。
上述的命令行打印混乱现象,本质就是由于没有对共享资源进行保护。这里的共享资源是什么呢?打印过程中父进程和子进程都是向显示器进行写入,子进程继承父进程的资源,父进程和子进程都可以看到显示器;在 Linux 下 “一切皆文件”,所以我们也可以说显示器就是一个显示器文件,它就是这里的共享资源。由于没有对显示器文件这个共享资源进行互斥保护,才造成了上述打印消息错乱的现象。如果我们使用互斥机制对显示器文件进行保护,那么任何时刻就只会有一个执行流访问显示器文件,子进程写入就必须等待父进程向显示器写入完成后才能进行写入,此时必然就不会出现上述打印信息混乱、错序等现象。
5. 保护的本质 : 对共享资源的保护,本质是对访问临界资源的代码(临界区)进行保护—— 通过互斥锁、信号量等机制控制执行流进入 / 退出临界区,从而间接保证临界资源的安全访问。
信号量原理
首先我们要明确的是信号量其实不是进程间通信方式,信号量是进程间同步 / 互斥的控制机制,属于进程间通信的范畴。常被用来配合进程间通信完成有序数据传输。信号量本身不携带任何业务数据,只用来控制 “谁能进、谁要等”。和共享内存、消息队列、管道这类用来在进程间传送数据的 IPC 不一样。
信号量的核心本质是一个由内核维护的计数器,可以类比为 int cnt = n,这个计数器的值直接描述了可用临界资源的数量。不过要注意:信号量不只是一个普通计数器,它还包含一个等待队列,用于实现进程的阻塞与唤醒,二者共同构成完整的信号量机制。
实例一 :
我们可以通过一个例子理解信号量,比如说有一天我想去看电影,那我的第一步就是去购票软件查看附近电影院的放映厅是否还有座位。假设放映厅有 100 个座位,对应就有 100 张票,那么购票系统上就需要维护一个计数器,实时显示剩余座位数量。我是第一个购票的人,此时计数器初始值为 100;当我选座并购票成功后,计数器就会减 1,剩余票数变为 99。所以,看电影前先买票,买票的本质就是一种资源预定机制—— 提前申请并占用一个座位资源,确保观影时能正常使用。
这个票数计数器,每卖出一张票就减 1,代表电影院里的可用座位资源减少一个。当票数计数器的值减到 0 时,就代表所有座位资源都已被申请完毕,后续再购票的用户就无法再获得座位。
原理 :

此时在内存中有一大块临界资源,我们可以把它划分为很多小块的临界资源(比如图中的 36 个小块),默认一个进程只需要申请并使用其中一小块临界资源,一个进程就代表一个执行流。对于这 36 个小块的临界资源,我们最担心的问题是:过多的执行流同时争抢有限的资源。如果有 37 个甚至更多执行流来访问这 36 个小块资源,就会出现资源争抢和数据混乱。解决办法很简单:像电影院一样,维护一个计数器来统一管理资源分配。
对于这个计数器,我们将其初始值设为 int cnt = 36;每当有一个进程申请资源时,就执行原子性的减 1 操作(P 操作),并通过算法为进程分配对应的小块资源。当计数器减到 0 时,后续再申请资源的进程会被阻塞,进入信号量的等待队列,直到有进程释放资源(V 操作)唤醒它们,此时会提示 “没有可用资源”。所以说,进程申请计数器成功,就代表进程获得了访问对应临界资源的权限。进程申请了计数器资源后,可能不会立即去访问资源,这说明申请计数器资源本质是一种资源预定机制—— 先占住资源名额,再在合适的时候使用。计数器(信号量)的存在,可以有效保证同一时间进入共享资源的执行流数量不超过可用资源数,避免资源过载和数据混乱。所以每一个执行流,想要访问共享资源的某一部分时,不是直接访问,而是先申请计数器资源,就像看电影必须先买票才能进场一样,这是信号量控制资源访问的核心流程。程序员把这个受内核保护、具备原子性且能实现阻塞等待的计数器,叫做信号量(Semaphore)。
上面我们说要进行原子性的操作,那原子性是什么?
原子性就是一个操作要么完整执行到底,要么完全不执行,不会出现 “执行到一半被打断” 的中间状态。就像扔硬币一样,要么正面朝上,要么反面朝上,不可能停在中间。而在操作系统里,原子性保证了多个进程并发修改同一个变量时,不会出现数据错乱。
P / VC操作是什么?
P/V 操作是对信号量的两个原子操作,用来控制资源的申请与释放,是实现进程间互斥与同步的基础。
- P 操作的本质是申请资源(“占坑”),会先将信号量计数器 sem 原子减 1,如果减完后 sem ≥ 0 说明申请成功,进程继续执行。如果减完后 sem < 0 则说明给申请失败,进程被阻塞,进入该信号量的等待队列,直到有资源被释放。
- V 操作的本质是释放资源(“腾坑”),会先将信号量计数器 sem 原子加 1,如果加完后 sem > 0 说明仍有资源可用,进程继续执行,如果加完后 sem ≤ 0 就说明等待队列中有阻塞进程,唤醒其中一个,让它去申请资源。
实例二 :
如果有一天电影院老板将放映厅改造为仅 1 个座位,整场电影只允许 1 人观看,那么对应的电影票计数器初始值就为 1。此时无论有多少人想来看这场电影,最终都只能有 1 个人抢到票、进入放映厅观影 —— 这和多进程环境中 “同一时刻只允许一个执行流访问临界资源” 的场景一致。
原理 :
这种 “同一时刻只允许一个执行流访问临界资源” 的场景,我们称之为互斥(Mutual Exclusion),它保证临界资源不会被多个执行流同时操作,避免数据错乱。
我们把取值只能为 0 或 1 的两态计数器,叫做二元信号量(Binary Semaphore)。它本质上就像一把锁:sem = 1 表示资源可用(锁打开),sem = 0 表示资源被占用(锁锁住),P 操作相当于 “加锁”,V 操作相当于 “解锁”。我们将计数器初始值设为 1,核心原因是临界资源唯一且不可分割:不再把临界资源拆成多个小块,而是将它作为一个整体来申请和释放,确保同一时刻只有一个执行流能持有它,从而实现严格的互斥访问。
我们现在已经知道,要访问临界资源,必须先申请信号量计数器资源。但信号量计数器本身也是一种共享资源,同样可能被多个执行流并发访问。例如:计数器初始值为 int cnt = 36,进程申请资源时会执行 cnt--,但整数的自减操作在并发场景下并不是线程安全的,其实 cnt-- 在 C 语言层面看似是一条语句,但它并非原子操作,在并发环境下是不安全的。因为这条 C 语句会被 CPU 转化为更底层的汇编指令执行,通常一条 cnt-- 语句会被拆解为3 条汇编指令串行执行 :
- 将 cnt 变量的值从内存加载到 CPU 寄存器中(CPU 只能对寄存器中的数据进行运算);
- 在 CPU 寄存器内执行减 1 操作;
- 将计算结果写回到 cnt 变量的内存位置。
但是由于操作系统调度器的存在,进程在运行过程中随时可能被切换,这就会导致数据错乱:假设此时 cnt = 1(临界资源只允许一个进程访问),进程 A 申请资源,cnt-- 开始执行:进程 A 执行到第二步后,寄存器中 cnt 的计算结果为 0,但还没来得及写回内存,就被调度器切走了;此时内存中 cnt 的值仍然是 1,进程 B 前来申请资源,看到 cnt = 1 就允许执行 cnt--;之后进程 A 被切回,将寄存器中 cnt=0 的结果写回内存;进程 B 完成 cnt-- 后,也将 cnt=0 写回内存。最终结果是:两个进程都成功申请到了资源,这完全违背了 “只允许一个执行流访问” 的设计目标。这充分说明:普通整数的 -- 操作在并发场景下是不安全的。
那么,我们如何保证信号量的 PV 操作不会出现普通整数 --/++ 那样的数据错乱问题呢?
- 核心方案就是:保证 PV 操作具有原子性。原子性意味着整个操作要么完整执行到底,要么完全不执行,不存在 “执行到一半被打断” 的中间状态。具体实现方式就是:通过硬件或内核手段,将 cnt--/cnt++ 对应的多条汇编指令封装为一条不可分割的原子指令。我们知道 CPU 是逐条执行汇编指令的,原子指令一旦开始执行就不会被中断,必须执行完毕并将结果写回内存后,才允许进程切换。这种原子性保证了信号量计数器的安全,进而保证了临界资源的安全,最终保证了申请临界资源的执行流数量符合预期。
归纳总结
信号量通过内核维护的原子计数器和PV 原子操作,分别实现进程间的互斥与同步:互斥使用初始值为 1 的二元信号量,P 操作加锁、V 操作解锁,保证同一时刻只有一个执行流进入临界区访问临界资源;同步使用初始值为 0 的信号量,让一个执行流通过 P 操作阻塞等待,另一个执行流完成任务后用 V 操作唤醒,严格控制执行的先后顺序,最终保障并发环境下共享资源安全、进程协作有序。
二元信号量与互斥:若信号量取值仅为 0 和 1(两态),则称为二元信号量,核心作用是实现互斥访问,保证同一时刻只有一个执行流能进入临界区。
信号量的本质作用:申请信号量本质是一种临界资源预定机制—— 先占住资源名额,再实际使用,避免多个执行流同时争抢资源导致数据错乱。
信号量为何属于进程间通信(IPC)?
- 通信的范畴不仅限于 “传输业务数据”,进程间的协同与同步本质也是一种通信。信号量需要被所有协作进程共享可见,通过 PV 操作传递 “资源可用 / 释放” 的控制信号,实现进程间的有序协作,因此被归为 IPC 机制的一种(属于控制类 IPC,而非数据传输类 IPC)。
四、总结
本文介绍了SystemV进程间通信(IPC)的三种方式:共享内存、消息队列和信号量。这三种方式都由内核管理,生命周期跟随内核,使用key+ID机制标识,通过特定系统调用操作。重点讲解了消息队列的原理和接口:进程通过msgget创建/获取队列,msgsnd发送带类型消息块,msgrcv接收特定类型消息,msgctl管理队列。文章还分析了IPC内核数据结构设计,采用"先描述再组织"思路,通过基类struct ipc_perm实现统一管理。最后引入信号量概念,解释了其通过PV原子操作实现进程同步/互斥的原理,以及如何保护临界资源。信号量虽不传输数据,但作为控制机制被归为IPC范畴。
谢谢大家的观看!
更多推荐




所有评论(0)