Linux:进程间通信(IPC)-SystemV
Linux:进程间通信(IPC)-SystemV
这篇博客要开启IPC的“进阶篇”——SystemV标准下的IPC机制。这可是Linux内核里经典的进程间通信方案,包括共享内存、消息队列、信号量三大块。咱们今天的核心目标很明确:先把最常用、最高效的共享内存彻底搞懂,再顺带着把消息队列的原理和信号量的核心概念讲清楚,为后面多线程的学习打个基础。
在正式开始前,咱们先回顾一个核心结论——进程间通信的本质,是让不同进程先看到同一份资源。不管是之前的管道(共享文件缓冲区),还是今天要讲的共享内存(共享物理内存块)、消息队列(共享内核队列),本质上都是围绕“共享资源”做文章。不同的是,这份“共享资源”的形式不一样,就决定了通信方式的差异。大家记住这句话,后面理解所有IPC机制都会事半功倍~
一、前情回顾:管道通信
在聊SystemV之前,咱们先快速复盘一下管道通信~
1.1 匿名管道:父子进程的“秘密通道”
咱们上节课讲过,匿名管道通过pipe()
系统调用创建,返回两个文件描述符(读端fd[0]、写端fd[1])。它的核心特点是只能用于有血缘关系的进程(比如父子、兄弟),因为管道的文件描述符需要通过fork继承才能让子进程看到。
本质上,匿名管道是操作系统在内核中创建的一块临时文件缓冲区——注意,是“临时”的,而且数据不会落到磁盘(无IO操作),进程读写管道,其实就是读写这块内核缓冲区。比如父进程写数据到fd[1],数据先到内核缓冲区,子进程再从fd[0]读走,这样就实现了通信。
这里有个小问题想问问大家:为什么匿名管道不能用于无血缘关系的进程?没错!因为无血缘进程没办法继承对方的管道文件描述符,也就看不到同一份内核缓冲区,自然没法通信~
1.2 命名管道:打破血缘限制的“公共管道”
为了解决匿名管道的血缘限制,就有了命名管道(FIFO)。它通过mkfifo()
命令或系统调用创建一个有路径名的管道文件(比如/tmp/myfifo
),不同进程只要通过这个路径名打开这个文件,就能看到同一份内核缓冲区——这就绕开了“继承”的限制,实现了无血缘进程的通信。
但不管是匿名还是命名管道,它们都有一个共性:数据需要在进程和内核之间拷贝(进程→内核缓冲区→另一个进程)。这就决定了管道的效率不算特别高,尤其是当进程间需要频繁交换大量数据时,拷贝开销会很明显。
而咱们今天要讲的SystemV共享内存,就是为了解决“效率”问题而生的——它直接让多个进程访问同一块物理内存,省去了数据拷贝的步骤,是所有IPC机制中速度最快的。
二、SystemV共享内存:高效通信的“王者”
咱们先给共享内存下一个通俗的定义:操作系统在内核中创建一块物理内存区域,然后通过页表映射到多个进程的地址空间,让这些进程直接通过虚拟地址访问同一块物理内存,从而实现数据共享。
大家可以这么理解:就像两个同学共用一个笔记本,不用互相传纸条(拷贝数据),直接在同一个本子上写和读,效率自然高。
2.1 共享内存的基本原理
-
步骤1:操作系统创建物理内存块
进程A调用系统调用,让操作系统在物理内存中开辟一块绿色的内存区域(比如4KB)。这块内存由操作系统管理,不属于任何一个进程私有。 -
步骤2:映射到进程A的地址空间
操作系统修改进程A的页表,把刚才创建的物理内存块映射到进程A地址空间的“共享区”(还记得地址空间的布局吗?代码区、数据区、堆、共享区、栈),然后给进程A返回一个虚拟地址(比如0x7f1234567000)。 -
步骤3:映射到进程B的地址空间
进程B也调用系统调用,请求访问同一块物理内存。操作系统同样修改进程B的页表,把这块物理内存映射到进程B的共享区,也返回一个虚拟地址(比如0x7f9876543000)。 -
步骤4:进程直接读写共享内存
此时,进程A通过自己的虚拟地址(0x7f1234567000)往共享内存写数据,进程B通过自己的虚拟地址(0x7f9876543000)就能直接读到——因为这两个虚拟地址最终指向同一块物理内存!
这里有个关键问题:共享内存映射到进程的“共享区”,会不会和动态库(也在共享区)冲突?大家放心,不会的~共享区很大,动态库占一块,共享内存占另一块,操作系统会做好地址分配,互不干扰。就像一个大教室,你坐第一排,动态库坐第二排,互不影响。
2.2 共享内存的创建与获取:从key到shmid
要使用共享内存,第一步就是“创建”或“获取”它——这就需要两个核心工具:ftok()
函数和shmget()
系统调用。
2.2.1 为什么需要key?—— 共享内存的“身份证”
咱们之前说过,进程间通信的前提是“看到同一份资源”。那么,多个进程怎么确定自己访问的是同一块共享内存呢?总不能靠“猜”吧?
这里就需要一个唯一的标识——key(键值)。key就像共享内存的“身份证”,操作系统通过key来区分系统中所有的共享内存:
- 进程A用key创建共享内存;
- 进程B用同一个key就能获取到这块共享内存。
那问题来了:key从哪来?总不能让我们手动写一个数字吧(比如12345)?虽然理论上可以,但手动写容易和系统中其他共享内存的key冲突。所以,Linux提供了ftok()
函数,帮我们生成一个“冲突概率极低”的key。
2.2.2 ftok():生成唯一key
ftok()
函数的作用很简单:根据一个路径名(pathname)和一个项目ID(proj_id),通过特定算法生成一个SystemV IPC的key。
它的函数原型长这样:
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);
- 参数1:pathname:一个存在的路径名(比如
/home/student
)。路径名本身具有唯一性,这是key唯一性的基础。 - 参数2:proj_id:一个1~255之间的整数(自定义,比如66)。相当于给同一个路径名加个“后缀”,方便区分不同的共享内存。
- 返回值:成功返回生成的key(
key_t
类型,本质是整数);失败返回-1,并设置errno
。
举个例子:如果进程A和进程B都用ftok("/home/student", 66)
,那么它们生成的key是完全一样的——这就保证了它们能访问同一块共享内存。
这里有个小互动:有同学可能会问,ftok()
会不会生成重复的key?理论上有可能,但概率极低。因为路径名本身唯一,再加上proj_id的区分,除非两个进程用了完全一样的路径名和proj_id,否则很难冲突。如果真冲突了,怎么办?很简单,改一下proj_id(比如从66改成88)或者换个路径名就行~
还有个更关键的问题:为什么不让操作系统直接生成key?比如调用shmget()
时,操作系统自动返回一个唯一key,多方便?
大家想想这个逻辑:如果操作系统给进程A生成了一个key=12345,进程B怎么知道这个key是12345呢?总不能让进程A先用管道把key传给进程B吧?那共享内存就成了“依赖管道的通信方案”,失去了独立性。所以,key必须由用户层“约定”——通过ftok()
的路径名和proj_id约定,两个进程不用提前通信,就能生成同一个key,这才是独立的通信方案!
2.2.3 shmget():创建/获取共享内存的“入口”
有了key之后,就可以通过shmget()
系统调用来创建或获取共享内存了。它的作用是:向操作系统申请一块SystemV共享内存,并返回一个共享内存标识符(shmid)。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
咱们逐个拆解参数:
(1)参数1:key(共享内存的“身份证”)
就是咱们用ftok()
生成的key。操作系统会根据这个key,在系统中查找对应的共享内存:
- 如果共享内存不存在,且
shmflg
设置了IPC_CREAT
,就创建新的共享内存; - 如果共享内存已存在,就直接返回它的标识符。
(2)参数2:size(共享内存的大小)
指定要创建的共享内存大小,单位是字节。这里有个重要建议:size最好设置为4KB(4096字节)的整数倍。
为什么?因为Linux内核管理内存是以“页”为单位的,一页的大小默认是4KB。如果size不是4KB的整数倍,比如设为4097字节,操作系统会自动分配8192字节(两页),但我们只能使用前4097字节——多出来的字节不能用,否则就是内存越界,会触发段错误。
举个例子:设置size=4096(1页),操作系统分配4096字节;设置size=5000,操作系统还是分配8192字节,但我们只能用5000字节。所以,为了不浪费内存,尽量按4KB的整数倍设置size。
(3)参数3:shmflg(创建标志+权限)
这个参数是“组合拳”,由两部分组成:创建标志和权限标志,用“按位或(|)”连接。
① 创建标志:控制“创建/获取”行为
主要有两个关键标志:
IPC_CREAT
:如果key对应的共享内存不存在,就创建它;如果已存在,就直接获取它(相当于“有则取之,无则建之”)。IPC_EXCL
:必须和IPC_CREAT
一起使用(IPC_CREAT | IPC_EXCL
)。如果key对应的共享内存已存在,就返回-1(失败);只有不存在时才创建。
这两个标志的组合很有用:
- 想创建一个“全新”的共享内存(确保不是已存在的旧内存),就用
IPC_CREAT | IPC_EXCL
; - 想“获取已存在的共享内存,没有就创建”,就单独用
IPC_CREAT
。
比如进程A负责创建共享内存,用IPC_CREAT | IPC_EXCL
,这样如果共享内存已存在(比如上次运行没删),就会报错,避免重复创建;进程B负责获取,用IPC_CREAT
,如果共享内存已存在就直接用,不存在就创建(作为备用)。
② 权限标志:控制共享内存的访问权限
和文件权限类似,用八进制数表示(比如0666、0644)。权限控制的是“其他进程能否访问这块共享内存”:
- 0666:所有用户都能读和写(开发时常用,方便测试);
- 0644:所有者能读和写,其他用户只能读。
比如shmflg = IPC_CREAT | IPC_EXCL | 0666
,表示“创建全新的共享内存,权限为0666”。
(4)返回值:shmid(共享内存的“操作句柄”)
- 成功:返回一个非负整数,叫做共享内存标识符(shmid)。这个shmid是进程操作共享内存的“句柄”,后面挂接、去关联、删除共享内存,都要用它。
- 失败:返回-1,并设置
errno
(比如key冲突、内存不足、权限不够)。
这里必须区分两个概念:key和shmid:
- key:是操作系统层面的唯一标识,用于“查找”共享内存(比如
shmget()
用key找内存); - shmid:是进程层面的操作句柄,用于“操作”共享内存(比如挂接、删除)。
打个比方:key就像“身份证号”,唯一标识一个人;shmid就像“银行卡号”,你用银行卡号取钱,而不是用身份证号。一旦通过shmget()
拿到shmid,后面就再也不用key了——所有操作都靠shmid。
还有个小细节:shmid的数值通常很大(比如2031620),不像文件描述符(0、1、2、3…)那么小。这是因为SystemV IPC的标识符是独立管理的,和文件描述符不属于同一个体系——这也是SystemV IPC后来被边缘化的原因之一(不符合“一切皆文件”的设计理念)。
2.3 共享内存的内核管理机制:先描述,再组织
咱们之前学过,操作系统管理任何资源,都离不开“先描述,再组织”的逻辑——共享内存也不例外。
2.3.1 描述共享内存:shmid_ds结构体
操作系统为每一块SystemV共享内存,都在 kernel 中创建一个struct shmid_ds
结构体,用来描述它的所有属性。这个结构体就像共享内存的“身份证信息”,包含了大小、权限、引用计数、key等关键信息。
咱们来看一下shmid_ds
的核心字段(简化版):
struct shmid_ds {struct ipc_perm shm_perm; // 权限相关信息(最重要的字段)size_t shm_segsz; // 共享内存的大小(字节)pid_t shm_cpid; // 创建共享内存的进程PIDpid_t shm_lpid; // 最后一次操作共享内存的进程PIDshmatt_t shm_nattch;// 当前挂接共享内存的进程数(引用计数)time_t shm_atime; // 最后一次挂接的时间time_t shm_dtime; // 最后一次去关联的时间time_t shm_ctime; // 最后一次修改属性的时间// 其他字段...
};
其中,struct ipc_perm
是所有SystemV IPC资源(共享内存、消息队列、信号量)共有的结构体,用来存储权限和key:
struct ipc_perm {key_t __key; // 共享内存的keyuid_t uid; // 所有者的用户IDgid_t gid; // 所有者的组IDuid_t cuid; // 创建者的用户IDgid_t cgid; // 创建者的组IDmode_t mode; // 权限(和文件权限类似)// 其他字段...
};
大家发现没?shmid_ds
里的shm_nattch
字段(引用计数)特别重要——它记录了当前有多少个进程挂接了这块共享内存。只有当shm_nattch
减到0时,操作系统才允许删除共享内存(避免删除正在被使用的内存)。
2.3.2 组织共享内存:内核数组
操作系统会把所有shmid_ds
结构体,用一个内核数组来管理。这个数组的类型是struct ipc_perm *
(因为所有SystemV IPC资源的结构体第一个字段都是ipc_perm
,可以统一管理)。
而咱们通过shmget()
拿到的shmid
,本质上就是这个数组的下标!比如shmid=2031620,就表示对应的shmid_ds
结构体在数组的第2031620个位置(当然,内核会做一些映射,不会真的创建这么大的数组,但逻辑上是下标)。
这就解释了为什么shmid是整数——它就是数组的索引,通过索引就能快速找到对应的shmid_ds
结构体,从而操作共享内存。
2.4 共享内存的挂接与去关联:从内核到进程地址空间
创建完共享内存,还不能直接用——因为它还只是内核中的一块物理内存,进程的地址空间里还没有映射它。必须通过“挂接”操作,把共享内存映射到进程的地址空间,进程才能通过虚拟地址访问它。
2.4.1 shmat():把共享内存“挂”到进程地址空间
shmat()
的作用是:将指定的共享内存,映射到当前进程的地址空间,并返回一个虚拟地址。
函数原型:
#include <sys/types.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
参数拆解:
- shmid:共享内存标识符(
shmget()
的返回值)——告诉操作系统要挂接哪块共享内存。 - shmaddr:指定共享内存要映射到进程地址空间的哪个位置(虚拟地址)。通常设为
NULL
,让操作系统自动分配一个合适的地址(在共享区)。不建议手动指定,因为很容易和其他地址冲突。 - shmflg:挂接标志,通常设为0(默认权限,读写权限由共享内存本身的权限决定)。也可以设为
SHM_RDONLY
,表示挂接后只能读,不能写。
返回值:
- 成功:返回共享内存映射到进程地址空间的虚拟地址(
void *
类型),进程通过这个地址就能直接读写共享内存。 - 失败:返回
(void *)-1
,并设置errno
。
举个例子:
// 挂接共享内存,让系统自动分配地址
void *shm_addr = shmat(shmid, NULL, 0);
if (shm_addr == (void *)-1) {perror("shmat failed");exit(1);
}
// 挂接成功后,把shm_addr强转为char*,当作字符串读写
char *shm_str = (char *)shm_addr;
strcpy(shm_str, "Hello, Shared Memory!"); // 写共享内存
printf("Read from shared memory: %s\n", shm_str); // 读共享内存
大家觉得shmat()
返回的虚拟地址,和malloc()
返回的地址有什么相似之处?没错!它们都是虚拟地址,都指向一块可访问的内存区域,而且都可以直接用指针操作——不用调用read()
/write()
这样的系统调用,这也是共享内存比管道高效的核心原因(少了系统调用和数据拷贝)。
2.4.2 shmdt():把共享内存“摘”下来
当进程不再需要共享内存时,需要用shmdt()
把它从自己的地址空间“去关联”——也就是删除页表中虚拟地址到物理内存的映射。
函数原型:
#include <sys/shm.h>int shmdt(const void *shmaddr);
参数:
- shmaddr:
shmat()
返回的虚拟地址——告诉操作系统要去关联哪块共享内存。
返回值:
- 成功:返回0,并将共享内存的引用计数(
shmid_ds.shm_nattch
)减1。 - 失败:返回-1,并设置
errno
。
注意:shmdt()
只是“去关联”,不是“删除”共享内存!它只是让当前进程看不到这块共享内存了,但共享内存本身还在 kernel 中,其他挂接的进程还能继续使用。只有当所有进程都shmdt()
(shm_nattch
减到0),并且调用shmctl()
删除后,共享内存才会被释放。
举个例子:
// 去关联共享内存
if (shmdt(shm_addr) == -1) {perror("shmdt failed");exit(1);
}
printf("Shared memory detached successfully\n");
这里有个常见问题:如果进程退出了,没调用shmdt()
会怎么样?不用担心!进程退出时,操作系统会自动清理它的地址空间,包括共享内存的映射——也就是自动调用shmdt()
,把shm_nattch
减1。但共享内存本身还是会留在 kernel 中,除非手动删除。
2.5 共享内存的查看与删除:避免内存泄漏
共享内存的生命周期是“随内核”的——也就是说,除非手动删除,或者内核重启,否则共享内存会一直存在于 kernel 中,即使创建它的进程已经退出。如果忘了删除,就会造成“内存泄漏”(内核内存被占用,无法释放)。
所以,咱们必须学会如何查看和删除共享内存。
2.5.1 查看共享内存:ipcs -m 命令
ipcs
是Linux下查看SystemV IPC资源的命令,-m
选项表示只查看共享内存。
执行ipcs -m
,会输出类似这样的结果:
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x660106fa 2031620 student 666 4096 1
字段含义:
- key:共享内存的key(16进制表示);
- shmid:共享内存标识符;
- owner:共享内存的所有者(用户名);
- perms:共享内存的权限(8进制);
- bytes:共享内存的大小(字节);
- nattch:当前挂接的进程数(引用计数);
- status:状态(比如
dest
表示标记为删除)。
通过这个命令,咱们能清楚地看到系统中所有共享内存的状态——比如有没有泄漏的共享内存,当前有多少进程在使用它。
2.5.2 删除共享内存:ipcrm -m 与 shmctl()
删除共享内存有两种方式:命令行删除和代码中删除。
(1)命令行删除:ipcrm -m shmid
ipcrm
是删除SystemV IPC资源的命令,-m
选项表示删除共享内存,后面跟shmid
。
比如要删除shmid=2031620的共享内存:
ipcrm -m 2031620
执行后,再用ipcs -m
查看,就会发现这块共享内存消失了。
注意:不能用key删除,只能用shmid——因为key是内核层面的标识,而ipcrm
是用户层命令,操作的是shmid(进程层面的句柄)。
(2)代码中删除:shmctl() 系统调用
在代码中,通过shmctl()
系统调用来删除共享内存。shmctl()
是共享内存的“控制接口”,除了删除,还能获取/修改共享内存的属性。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数拆解:
- shmid:共享内存标识符——要控制哪块共享内存。
- cmd:控制命令,常用的有:
IPC_RMID
:删除共享内存。这是我们最常用的命令。IPC_STAT
:获取共享内存的属性,把shmid_ds
结构体的内容拷贝到buf
中。IPC_SET
:修改共享内存的属性(比如权限),通过buf
传递新的属性。
- buf:指向
struct shmid_ds
的指针:- 当
cmd=IPC_STAT
时,buf
用来存储获取到的属性; - 当
cmd=IPC_SET
时,buf
用来传递新的属性; - 当
cmd=IPC_RMID
时,buf
设为NULL
即可(不需要属性)。
- 当
返回值:
- 成功:返回0;
- 失败:返回-1,并设置
errno
。
举个例子:删除共享内存
// 删除共享内存
if (shmctl(shmid, IPC_RMID, NULL) == -1) {perror("shmctl IPC_RMID failed");exit(1);
}
printf("Shared memory deleted successfully\n");
这里有个关键细节:当调用shmctl(shmid, IPC_RMID, NULL)
时,操作系统会做两件事:
- 标记共享内存为“待删除”(状态变为
dest
); - 当共享内存的引用计数(
shm_nattch
)减到0时,立即释放这块物理内存。
也就是说,如果还有进程在挂接共享内存(shm_nattch > 0
),调用IPC_RMID
后,共享内存不会立即删除,而是等所有进程都shmdt()
后才删除——这样就避免了删除正在被使用的内存。
2.6 共享内存的代码实战:两个进程的通信
光说不练假把式,咱们来写两个进程(process_a 和 process_b),实现基于共享内存的通信:
- process_a:创建共享内存,挂接后往里面写数据,最后删除共享内存;
- process_b:获取共享内存,挂接后从里面读数据。
2.6.1 步骤1:封装公共头文件(common.h)
为了让两个进程共享key的生成参数和函数,我们先写一个公共头文件common.h
:
#ifndef COMMON_H
#define COMMON_H#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <errno.h>// 生成key的路径名和项目ID(两个进程必须一致)
#define PATHNAME "/home/student"
#define PROJ_ID 66// 共享内存大小(4KB的整数倍)
#define SHM_SIZE 4096// 封装:获取key
key_t get_key() {key_t key = ftok(PATHNAME, PROJ_ID);if (key == -1) {perror("ftok failed");exit(EXIT_FAILURE);}printf("ftok success! key = 0x%x\n", key);return key;
}// 封装:创建/获取共享内存
int create_shm(key_t key, size_t size) {// IPC_CREAT | IPC_EXCL:创建全新的共享内存,已存在则失败int shmid = shmget(key, size, IPC_CREAT | IPC_EXCL | 0666);if (shmid == -1) {perror("shmget failed");exit(EXIT_FAILURE);}printf("create shm success! shmid = %d\n", shmid);return shmid;
}// 封装:获取已存在的共享内存
int get_shm(key_t key, size_t size) {// 单独用IPC_CREAT:存在则获取,不存在则创建int shmid = shmget(key, size, IPC_CREAT | 0666);if (shmid == -1) {perror("shmget failed");exit(EXIT_FAILURE);}printf("get shm success! shmid = %d\n", shmid);return shmid;
}// 封装:挂接共享内存
void *attach_shm(int shmid) {void *shm_addr = shmat(shmid, NULL, 0);if (shm_addr == (void *)-1) {perror("shmat failed");exit(EXIT_FAILURE);}printf("attach shm success! shm_addr = %p\n", shm_addr);return shm_addr;
}// 封装:去关联共享内存
void detach_shm(void *shm_addr) {if (shmdt(shm_addr) == -1) {perror("shmdt failed");exit(EXIT_FAILURE);}printf("detach shm success!\n");
}// 封装:删除共享内存
void delete_shm(int shmid) {if (shmctl(shmid, IPC_RMID, NULL) == -1) {perror("shmctl IPC_RMID failed");exit(EXIT_FAILURE);}printf("delete shm success!\n");
}#endif // COMMON_H
2.6.2 步骤2:process_a 的实现(写数据)
process_a.c
负责创建共享内存、写数据、删除共享内存:
#include "common.h"
#include <unistd.h>int main() {// 1. 获取keykey_t key = get_key();// 2. 创建共享内存(4KB)int shmid = create_shm(key, SHM_SIZE);// 3. 挂接共享内存void *shm_addr = attach_shm(shmid);// 4. 往共享内存写数据char *msg = "Hello from process_a! This is shared memory.";strncpy((char *)shm_addr, msg, SHM_SIZE - 1); // 留一个字节存'\0'printf("process_a write to shm: %s\n", (char *)shm_addr);// 等待process_b读数据(模拟业务逻辑)sleep(10);// 5. 去关联共享内存detach_shm(shm_addr);// 6. 删除共享内存delete_shm(shmid);return 0;
}
2.6.3 步骤3:process_b 的实现(读数据)
process_b.c
负责获取共享内存、读数据:
#include "common.h"
#include <unistd.h>int main() {// 1. 获取key(和process_a用一样的PATHNAME和PROJ_ID)key_t key = get_key();// 2. 获取共享内存(process_a已经创建了)int shmid = get_shm(key, SHM_SIZE);// 3. 挂接共享内存void *shm_addr = attach_shm(shmid);// 4. 从共享内存读数据printf("process_b read from shm: %s\n", (char *)shm_addr);// 等待process_a删除共享内存(模拟业务逻辑)sleep(15);// 5. 去关联共享内存detach_shm(shm_addr);// 注意:process_b不删除共享内存,删除由process_a负责return 0;
}
2.6.4 步骤4:编译与测试
写一个Makefile
方便编译:
CC = gcc
CFLAGS = -Wall -Wextraall: process_a process_bprocess_a: process_a.c common.h$(CC) $(CFLAGS) -o process_a process_a.cprocess_b: process_b.c common.h$(CC) $(CFLAGS) -o process_b process_b.cclean:rm -f process_a process_b
编译并运行:
- 编译:
make
- 打开两个终端,第一个终端运行
./process_a
:ftok success! key = 0x660106fa create shm success! shmid = 2031620 attach shm success! shm_addr = 0x7f8a1b2c3000 process_a write to shm: Hello from process_a! This is shared memory.
- 第二个终端运行
./process_b
:ftok success! key = 0x660106fa get shm success! shmid = 2031620 attach shm success! shm_addr = 0x7f9b2c3d4000 process_b read from shm: Hello from process_a! This is shared memory.
- 观察结果:process_b成功读到了process_a写的内容,说明共享内存通信成功!
- 测试结束后,用
ipcs -m
查看,确认共享内存已被删除(没有泄漏)。
2.7 共享内存的优缺点与应用场景
现在总结一下它的优缺点:
2.7.1 优点:高效是核心
- 速度最快:数据直接在物理内存中共享,不需要在进程和内核之间拷贝(管道、消息队列都需要拷贝),是所有IPC机制中效率最高的。
- 双向通信:进程可以同时读写共享内存(管道是半双工,需要两个管道才能实现双向通信)。
- 灵活:共享内存的大小可以自定义(只要是4KB的整数倍),数据格式也由用户自定义(比如字符串、结构体、数组等)。
2.7.2 缺点:需要自己处理同步
- 无同步机制:共享内存本身不提供任何同步互斥机制。如果多个进程同时读写共享内存,会导致“数据不一致”问题(比如进程A写了一半,进程B就开始读)。
- 需要手动管理生命周期:共享内存生命周期随内核,必须手动删除,否则会内存泄漏。
比如咱们刚才的实战中,process_a sleep了10秒,process_b sleep了15秒——这其实是一种“简单的同步”,确保process_a写完后process_b再读。但在实际开发中,这种“sleep同步”很不靠谱,必须用专门的同步机制(比如信号量)来保证互斥访问。
2.7.3 应用场景:频繁交换大量数据
共享内存适合用于进程间需要频繁交换大量数据的场景:
- 高性能服务器:比如Web服务器的多个进程之间共享缓存数据(避免重复加载)。
- 多进程数据处理:比如大数据处理场景中,多个进程共享原始数据,各自处理后再写回共享内存。
- 实时系统:对通信延迟要求高的场景(比如工业控制),共享内存的低延迟优势很明显。
三、SystemV消息队列
讲完了共享内存,咱们再来聊聊SystemV消息队列。它也是SystemV IPC的一员,但现在用得不多了,咱们重点理解它的原理和与共享内存的区别。
3.1 消息队列的基本原理:内核中的“队列”
消息队列的本质是:操作系统在内核中维护的一个“队列”数据结构,进程可以向队列中发送“带类型的消息块”,也可以从队列中接收“指定类型的消息块”。
大家可以把它理解成“内核中的信箱”:
- 进程A想给进程B发消息,就把消息打包成“消息块”(带类型),扔进信箱;
- 进程B想读消息,就从信箱里按类型取出对应的消息块。
消息队列的核心特点是**“按类型接收”**——这是它和共享内存、管道最大的区别。比如进程A发类型1的消息,进程B可以只接收类型1的消息,忽略其他类型的消息。
3.2 消息队列的核心概念:消息块与类型
3.2.1 消息块的结构
消息队列中传递的“消息”,不是简单的字符串,而是一个“带类型的消息块”。用户需要自己定义消息块的结构体,格式如下:
struct msgbuf {long mtype; // 消息类型(必须 > 0)char mtext[1024]; // 消息内容(自定义大小)
};
- mtype:消息类型,必须是正整数(比如1、2、3)。接收方可以根据mtype筛选消息。
- mtext:消息内容,用户自定义(可以是字符串、结构体等)。
3.2.2 消息类型的作用
消息类型的设计非常巧妙,它解决了“多个进程共享一个消息队列时,如何区分消息归属”的问题:
- 比如有3个进程(A、B、C)共享一个消息队列:
- A发类型1的消息(给B);
- A发类型2的消息(给C);
- B只接收类型1的消息,C只接收类型2的消息;
- 这样就不会出现“B收到给C的消息”的情况。
3.3 消息队列的核心接口
消息队列的接口和共享内存很像,也是“创建/获取→发送/接收→删除”的流程,核心接口有msgget()
、msgsnd()
、msgrcv()
、msgctl()
。
3.3.1 msgget():创建/获取消息队列
作用:创建或获取一个SystemV消息队列,返回消息队列标识符(msgqid)。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgget(key_t key, int msgflg);
参数和shmget()
几乎一样:
- key:由
ftok()
生成的唯一键值; - msgflg:创建标志(
IPC_CREAT
/IPC_EXCL
)+ 权限(比如0666); - 返回值:成功返回msgqid,失败返回-1。
比如创建一个全新的消息队列:
key_t key = ftok("/home/student", 88);
int msgqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
if (msgqid == -1) {perror("msgget failed");exit(1);
}
3.3.2 msgsnd():发送消息
作用:向指定的消息队列发送一个带类型的消息块。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgsnd(int msgqid, const void *msgp, size_t msgsz, int msgflg);
参数拆解:
- msgqid:消息队列标识符(
msgget()
的返回值); - msgp:指向消息块结构体(
struct msgbuf
)的指针; - msgsz:消息内容的大小(
mtext
的长度,不包括mtype
的大小); - msgflg:发送标志,通常设为0(阻塞发送,队列满时等待)。也可以设为
IPC_NOWAIT
(非阻塞,队列满时立即返回错误)。
返回值:成功返回0,失败返回-1。
举个例子:发送类型1的消息
struct msgbuf msg;
msg.mtype = 1; // 消息类型为1
strcpy(msg.mtext, "Hello from msg sender!");// 发送消息(msgsz = strlen(msg.mtext))
if (msgsnd(msgqid, &msg, strlen(msg.mtext), 0) == -1) {perror("msgsnd failed");exit(1);
}
printf("send msg success! mtype = %ld, mtext = %s\n", msg.mtype, msg.mtext);
3.3.3 msgrcv():接收消息
作用:从指定的消息队列中接收指定类型的消息块。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>ssize_t msgrcv(int msgqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
参数拆解:
- msgqid:消息队列标识符;
- msgp:指向消息块结构体的指针,用来存储接收的消息;
- msgsz:消息内容的最大长度(避免缓冲区溢出);
- msgtyp:指定接收的消息类型:
msgtyp = 0
:接收队列中的第一个消息(不筛选类型);msgtyp > 0
:接收类型等于msgtyp
的第一个消息;msgtyp < 0
:接收类型小于等于-msgtyp
的第一个消息;
- msgflg:接收标志,通常设为0(阻塞接收,队列空时等待)。也可以设为
IPC_NOWAIT
(非阻塞,队列空时立即返回错误)。
返回值:成功返回接收的消息内容长度(mtext
的长度),失败返回-1。
举个例子:接收类型1的消息
struct msgbuf msg;
// 接收类型1的消息,最大长度1024
ssize_t len = msgrcv(msgqid, &msg, sizeof(msg.mtext), 1, 0);
if (len == -1) {perror("msgrcv failed");exit(1);
}
// 给消息内容加'\0'(避免乱码)
msg.mtext[len] = '\0';
printf("recv msg success! mtype = %ld, mtext = %s\n", msg.mtype, msg.mtext);
3.3.4 msgctl():控制消息队列
作用:删除消息队列,或获取/修改消息队列的属性。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgctl(int msgqid, int cmd, struct msqid_ds *buf);
参数和shmctl()
类似:
- msgqid:消息队列标识符;
- cmd:控制命令,常用
IPC_RMID
(删除消息队列)、IPC_STAT
(获取属性)、IPC_SET
(修改属性); - buf:指向
struct msqid_ds
的指针(删除时设为NULL)。
struct msqid_ds
是描述消息队列的内核结构体,核心字段包括:
struct msqid_ds {struct ipc_perm msg_perm; // 权限和keysize_t msg_qbytes;// 队列最大字节数pid_t msg_lspid; // 最后一次发送消息的进程PIDpid_t msg_lrpid; // 最后一次接收消息的进程PIDmsgqnum_t msg_qnum; // 队列中当前的消息数time_t msg_stime; // 最后一次发送消息的时间time_t msg_rtime; // 最后一次接收消息的时间time_t msg_ctime; // 最后一次修改属性的时间
};
举个例子:删除消息队列
if (msgctl(msgqid, IPC_RMID, NULL) == -1) {perror("msgctl IPC_RMID failed");exit(1);
}
printf("delete msg queue success!\n");
3.4 消息队列的查看与删除:ipcs -q 与 ipcrm -q
和共享内存类似,消息队列也可以用命令查看和删除:
- 查看:
ipcs -q
- 删除:
ipcrm -q msgqid
执行ipcs -q
,输出类似这样:
------ Message Queues --------
key msqid owner perms used-bytes messages
0x660106fc 2031621 student 666 28 1
- used-bytes:队列中已使用的字节数;
- messages:队列中的消息个数。
3.5 消息队列的优缺点与应用场景
3.5.1 优点:按类型接收,有序性
- 按类型接收:可以筛选消息类型,多个进程共享一个队列时不会混乱。
- 消息有序:队列是“先进先出”(FIFO)的,消息的发送顺序和接收顺序一致。
- 有缓冲:消息队列有一定的缓冲区大小(
msg_qbytes
),发送方和接收方不需要同时运行(管道需要双方同时运行,否则会阻塞)。
3.5.2 缺点:效率低,接口复杂
- 效率低:消息需要在进程和内核之间拷贝(发送时:进程→内核队列;接收时:内核队列→进程),效率比共享内存低。
- 消息大小限制:每个消息的大小和队列的总大小都有上限(由内核参数控制,比如默认单个消息最大8KB,队列总大小最大16KB),不适合传递大量数据。
- 接口复杂:需要自己定义消息块结构体,发送/接收时要处理消息类型,比共享内存麻烦。
3.5.3 应用场景:少量、带类型的消息传递
消息队列适合用于进程间传递少量、需要按类型区分的消息的场景:
- 进程间的命令传递:比如一个进程给另一个进程发送“启动”“停止”“重启”等命令(类型1=启动,类型2=停止)。
- 异步通信:发送方发送消息后不用等待接收方立即处理,接收方可以在合适的时候读取消息。
四、SystemV信号量:同步互斥的“计数器”
信号量是SystemV IPC的第三个成员,但它的作用不是“传递数据”,而是“同步互斥”——解决多个进程访问共享资源时的数据不一致问题。咱们上节课讲共享内存时提到过,共享内存需要同步机制,信号量就是最常用的同步工具。
4.1 为什么需要信号量?—— 数据不一致问题
咱们先看一个例子:两个进程(A和B)同时往共享内存里写数据。
进程A的逻辑:往共享内存写“Hello”;
进程B的逻辑:往共享内存写“World”。
如果没有同步机制,可能会发生这样的情况:
- 进程A写了“H”到共享内存;
- 操作系统调度,切换到进程B;
- 进程B写了“W”到共享内存(覆盖了“H”);
- 切换回进程A,A继续写“ello”,共享内存变成“Wello”;
- 切换到进程B,B继续写“orld”,共享内存变成“World”。
最终结果是“World”,而不是我们期望的“HelloWorld”或“WorldHello”——这就是“数据不一致”问题。
为什么会这样?因为两个进程同时访问了“共享内存”这个临界资源,而且访问的代码(写数据)没有任何保护——这部分代码叫做临界区。
所以,我们需要一种机制,确保“同一时刻只有一个进程进入临界区”——这就是互斥;或者确保“进程A先执行完临界区,进程B再执行”——这就是同步。信号量就是实现互斥和同步的核心工具。
4.2 核心概念辨析:临界资源、临界区、互斥、同步
在讲信号量之前,咱们必须先把这几个概念搞清楚——这是理解信号量的基础。
4.2.1 临界资源(Critical Resource)
定义:被多个进程共享,且需要互斥访问的资源(比如共享内存、打印机、显示器、文件等)。
比如:
- 共享内存是临界资源(多个进程读写);
- 打印机是临界资源(多个进程不能同时打印);
- 显示器是临界资源(多个进程同时打印会乱码)。
4.2.2 临界区(Critical Section)
定义:进程中访问临界资源的那段代码。
比如:
- 进程A中“往共享内存写数据”的代码,就是临界区;
- 进程B中“从共享内存读数据”的代码,也是临界区。
临界区的特点:必须互斥访问——同一时刻只能有一个进程进入临界区。
4.2.3 互斥(Mutual Exclusion)
定义:确保同一时刻只有一个进程进入临界区,访问临界资源。
生活中的例子:
- 上厕所:厕所是临界资源,上厕所的过程是临界区,同一时刻只能一个人用。
- ATM取钱:ATM机是临界资源,取钱的过程是临界区,同一时刻只能一个人操作。
4.2.4 同步(Synchronization)
定义:确保多个进程按预定的顺序执行临界区(比如进程A先写,进程B再读)。
生活中的例子:
- 做饭:洗菜(进程A)→切菜(进程B)→炒菜(进程C)。必须先洗菜,再切菜,最后炒菜,这就是同步。
- 生产消费者模型:生产者生产产品(写共享内存),消费者消费产品(读共享内存)。必须生产者先生产,消费者再消费,这也是同步。
4.3 信号量的本质:一个计数器
信号量的本质很简单——一个用于描述临界资源中可用资源数量的计数器。
咱们用生活中的例子理解:电影院的座位。
- 电影院有100个座位(临界资源),可用资源数量是100;
- 电影院用一个计数器(信号量)记录可用座位数,初始值=100;
- 每卖一张票(进程申请资源),计数器减1(100→99→98…);
- 每有一个观众离场(进程释放资源),计数器加1(98→99→100…);
- 当计数器=0时,没有可用座位,不再售票(进程不能申请资源,需要等待)。
这个计数器就是信号量的雏形——它的核心作用是控制访问临界资源的进程数量。
4.4 信号量的核心操作:PV操作(原子操作)
信号量有两个核心操作,分别对应“申请资源”和“释放资源”——这两个操作必须是原子的(Atomic)。
4.4.1 什么是原子操作?
定义:一件事情要么不做,要么做完,没有“中间状态”。
比如:你给朋友转100块钱,要么转账成功(你的账户减100,朋友的账户加100),要么转账失败(两个账户都不变)——不会出现“你的账户减了100,朋友的账户没加”的中间状态。
为什么PV操作必须原子?因为如果PV操作不是原子的,计数器本身会出问题。比如两个进程同时申请资源,计数器初始值=1:
- 进程A读计数器=1;
- 切换到进程B,读计数器=1;
- 进程A减1,计数器=0;
- 切换到进程B,减1,计数器=-1。
最终计数器=-1,而不是我们期望的0——这就错了。所以,PV操作必须原子,确保计数器的正确性。
4.4.2 P操作:申请资源(Proberen,荷兰语“尝试”)
定义:申请一个单位的临界资源。
操作逻辑:
- 信号量计数器(S)减1(S = S - 1);
- 如果S ≥ 0:申请成功,进程可以进入临界区;
- 如果S < 0:申请失败,进程被阻塞,放入信号量的等待队列。
比如电影院例子:P操作就是“买票”,计数器减1。如果计数器≥0,买到票;如果计数器<0,没票了,等待。
4.4.3 V操作:释放资源(Verhogen,荷兰语“释放”)
定义:释放一个单位的临界资源。
操作逻辑:
- 信号量计数器(S)加1(S = S + 1);
- 如果S > 0:释放成功,没有进程在等待,直接返回;
- 如果S ≤ 0:有进程在等待,唤醒等待队列中的一个进程,让它进入临界区。
比如电影院例子:V操作就是“离场”,计数器加1。如果计数器≤0,说明有人在等票,唤醒一个等待的人。
4.5 二元信号量:本质是“锁”
如果信号量的计数器只能取0或1,这样的信号量叫做二元信号量(Binary Semaphore)——它的本质就是一把“锁”。
二元信号量的操作逻辑:
- 初始值S=1(表示临界资源可用);
- 进程A要进入临界区:执行P操作(S=0),申请成功,“上锁”;
- 进程B要进入临界区:执行P操作(S=-1),申请失败,阻塞等待;
- 进程A离开临界区:执行V操作(S=0),释放资源,唤醒进程B;
- 进程B被唤醒:执行P操作(S=-1+1=0),申请成功,“上锁”,进入临界区。
这样就实现了“同一时刻只有一个进程进入临界区”——也就是互斥。
比如上厕所:
- 信号量初始值S=1(厕所可用);
- 你进去:P操作(S=0),上锁;
- 别人想进:P操作(S=-1),阻塞等待;
- 你出来:V操作(S=0),解锁,唤醒别人;
- 别人进去:P操作(S=0),上锁。
这就是二元信号量实现互斥的核心逻辑——也是我们最常用的信号量场景。
4.6 SystemV信号量的核心接口
SystemV信号量比共享内存、消息队列的接口复杂一些,因为它支持“信号量集”(多个信号量打包管理)。咱们重点讲最常用的“单个信号量”的操作。
4.6.1 semget():创建/获取信号量集
作用:创建或获取一个SystemV信号量集(可以包含多个信号量),返回信号量集标识符(semid)。
函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semget(key_t key, int nsems, int semflg);
参数拆解:
- key:由
ftok()
生成的唯一键值; - nsems:信号量集中包含的信号量个数。如果是创建新的信号量集,必须指定nsems(比如1,表示一个信号量);如果是获取已存在的,nsems可以设为0;
- semflg:创建标志(
IPC_CREAT
/IPC_EXCL
)+ 权限(比如0666)。
返回值:成功返回semid,失败返回-1。
举个例子:创建一个包含1个信号量的信号量集
key_t key = ftok("/home/student", 99);
// 创建信号量集,包含1个信号量,权限0666
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid == -1) {perror("semget failed");exit(1);
}
printf("create semaphore set success! semid = %d\n", semid);
4.6.2 semctl():初始化/删除信号量
SystemV信号量创建后,计数器初始值是0,必须用semctl()
初始化它的值(比如二元信号量初始值设为1)。
semctl()
的函数原型:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semctl(int semid, int semnum, int cmd, ...);
不过这里有个“坑”:semctl()
的第四个参数是可变参数,当cmd=SETVAL
时,这个参数需要是一个union semun
类型的变量——但这个联合体在标准库中并没有默认定义,需要我们自己手动声明!
(1)手动定义 union semun
// 必须手动定义semun联合体,否则编译会报错
union semun {int val; // 用于SETVAL:设置信号量的初始值struct semid_ds *buf; // 用于IPC_STAT/IPC_SET:获取/修改信号量集属性unsigned short *array; // 用于SETALL/GETALL:设置/获取所有信号量的值struct seminfo *__buf; // 用于IPC_INFO:获取系统级信号量信息(扩展用)
};
这里咱们只关注val
字段——用SETVAL
初始化单个信号量时,就给val
赋值(比如二元信号量初始值设为1)。
(2)用 SETVAL 初始化信号量
举个例子:把信号量集(semid)中编号为0的信号量(单个信号量)初始化为1(二元信号量,用于互斥):
union semun sem_union;
sem_union.val = 1; // 信号量初始值设为1(可用)// 初始化信号量:semid=信号量集ID,semnum=0(第一个信号量),cmd=SETVAL
if (semctl(semid, 0, SETVAL, sem_union) == -1) {perror("semctl SETVAL failed");exit(1);
}
printf("semaphore initialized success! initial value = %d\n", sem_union.val);
问:为什么标准库不默认定义union semun
呢?其实是因为早期SystemV标准对这个联合体的定义不统一,不同编译器可能有不同实现,所以标准库干脆把定义权交给用户——这样虽然麻烦,但能避免兼容性问题。大家编译时如果遇到“semun未定义”的错误,就知道是没手动声明这个联合体啦~
(3)用 IPC_RMID 删除信号量集
和共享内存、消息队列一样,信号量集的生命周期也是“随内核”,不用了必须手动删除,否则会内存泄漏。删除用semctl()
的IPC_RMID
命令:
// 删除信号量集:semnum设为0(单个信号量),buf设为NULL
if (semctl(semid, 0, IPC_RMID, NULL) == -1) {perror("semctl IPC_RMID failed");exit(1);
}
printf("semaphore set deleted success!\n");
4.6.3 semop():实现PV操作(核心)
信号量的核心功能是“申请资源(P操作)”和“释放资源(V操作)”,这两个操作都通过semop()
系统调用来实现。
semop()
的作用是:对信号量集中的一个或多个信号量执行原子操作(支持批量操作,但咱们常用单个信号量)。
(1)函数原型
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semop(int semid, struct sembuf *sops, size_t nsops);
(2)参数拆解
① semid:信号量集标识符
和之前的shmid
、msgqid
一样,是semget()
的返回值,告诉操作系统要操作哪个信号量集。
② sops:指向 struct sembuf 的指针
struct sembuf
是描述“信号量操作”的结构体,每个结构体对应一个信号量的一次操作(P或V)。它的定义如下:
struct sembuf {unsigned short sem_num; // 信号量在集中的编号(0表示第一个)short sem_op; // 操作类型:-1=P操作,1=V操作short sem_flg; // 操作标志,常用SEM_UNDO
};
咱们逐个说字段:
- sem_num:如果是单个信号量,直接填0(信号量集的第一个元素);如果是多个信号量,填对应的编号(比如1、2)。
- sem_op:核心字段,决定是P操作还是V操作:
sem_op = -1
:P操作(申请资源),信号量计数器减1;sem_op = 1
:V操作(释放资源),信号量计数器加1。
- sem_flg:推荐设为
SEM_UNDO
——这个标志非常重要!它的作用是:如果进程执行P操作后崩溃(没来得及执行V操作),内核会自动恢复信号量的初始值,避免信号量一直处于“0”状态,导致其他进程永久阻塞(死锁)。
举个例子:定义一个P操作的struct sembuf
:
struct sembuf p_op;
p_op.sem_num = 0; // 操作第一个信号量
p_op.sem_op = -1; // P操作(申请资源)
p_op.sem_flg = SEM_UNDO; // 崩溃时自动恢复
③ nsops:要执行的操作个数
如果只操作一个信号量,填1;如果批量操作多个信号量(比如同时对2个信号量执行P操作),填对应的个数(比如2)。咱们常用1。
(3)P操作与V操作的代码实现
咱们封装两个函数,分别实现P操作和V操作,方便后续使用:
// 封装:P操作(申请资源)
void sem_p(int semid) {struct sembuf p_op;p_op.sem_num = 0;p_op.sem_op = -1;p_op.sem_flg = SEM_UNDO;if (semop(semid, &p_op, 1) == -1) {perror("semop P failed");exit(1);}printf("P operation success! semaphore value -= 1\n");
}// 封装:V操作(释放资源)
void sem_v(int semid) {struct sembuf v_op;v_op.sem_num = 0;v_op.sem_op = 1;v_op.sem_flg = SEM_UNDO;if (semop(semid, &v_op, 1) == -1) {perror("semop V failed");exit(1);}printf("V operation success! semaphore value += 1\n");
}
这里有个关键问题:如果进程执行sem_p()
后崩溃了,没执行sem_v()
,会怎么样?比如进程A执行P操作(信号量从1→0),然后崩溃了——如果没有SEM_UNDO
,信号量会一直是0,其他进程执行P操作时会永久阻塞。但有了SEM_UNDO
,内核会在进程退出时,自动把信号量恢复为初始值1,避免死锁。这就是SEM_UNDO
的核心作用,一定要加上!
4.7 信号量的查看与删除:ipcs -s 与 ipcrm -s
和共享内存、消息队列一样,信号量也可以用命令行查看和删除:
(1)查看信号量集:ipcs -s
ipcs -s
输出类似这样:
------ Semaphore Arrays --------
key semid owner perms nsems
0x660106fd 2031622 student 666 1
字段含义:
- key:信号量集的key;
- semid:信号量集标识符;
- owner:所有者用户名;
- perms:权限;
- nsems:信号量集中的信号量个数。
如果想查看信号量的具体值(计数器当前是多少),可以用ipcs -s -i semid
(查看单个信号量集的详细信息):
ipcs -s -i 2031622
输出中会包含semval
字段,就是当前信号量的计数器值(比如semval: 1
表示初始值1,还没执行P操作)。
(2)删除信号量集:ipcrm -s semid
ipcrm -s 2031622
执行后,信号量集会被立即删除,其他进程再操作这个semid会报错。
4.8 信号量实战:保护共享内存的互斥访问
咱们结合“共享内存+信号量”做一个实战:两个进程(A写、B读)共享一块内存,用二元信号量保证“同一时刻只有一个进程访问共享内存”,避免数据不一致。
4.8.1 步骤1:封装公共头文件(sem_shm_common.h)
把共享内存和信号量的接口都封装进来:
#ifndef SEM_SHM_COMMON_H
#define SEM_SHM_COMMON_H#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <errno.h>
#include <unistd.h>// 1. 公共参数(共享内存和信号量共用)
#define PATHNAME "/home/student"
#define SHM_PROJ_ID 66 // 共享内存的proj_id
#define SEM_PROJ_ID 99 // 信号量的proj_id(和共享内存区分)
#define SHM_SIZE 4096 // 共享内存大小(4KB)// 2. 手动定义semun联合体(信号量初始化用)
union semun {int val;struct semid_ds *buf;unsigned short *array;struct seminfo *__buf;
};// 3. 共享内存相关函数(复用之前的封装)
key_t get_shm_key() {key_t key = ftok(PATHNAME, SHM_PROJ_ID);if (key == -1) {perror("ftok shm failed");exit(EXIT_FAILURE);}printf("shm key = 0x%x\n", key);return key;
}int create_shm(key_t key) {int shmid = shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);if (shmid == -1) {perror("shmget create failed");exit(EXIT_FAILURE);}printf("shm created! shmid = %d\n", shmid);return shmid;
}int get_shm(key_t key) {int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);if (shmid == -1) {perror("shmget get failed");exit(EXIT_FAILURE);}printf("shm got! shmid = %d\n", shmid);return shmid;
}void *attach_shm(int shmid) {void *addr = shmat(shmid, NULL, 0);if (addr == (void *)-1) {perror("shmat failed");exit(EXIT_FAILURE);}printf("shm attached! addr = %p\n", addr);return addr;
}void detach_shm(void *addr) {if (shmdt(addr) == -1) {perror("shmdt failed");exit(EXIT_FAILURE);}printf("shm detached!\n");
}void delete_shm(int shmid) {if (shmctl(shmid, IPC_RMID, NULL) == -1) {perror("shmctl delete failed");exit(EXIT_FAILURE);}printf("shm deleted!\n");
}// 4. 信号量相关函数
key_t get_sem_key() {key_t key = ftok(PATHNAME, SEM_PROJ_ID);if (key == -1) {perror("ftok sem failed");exit(EXIT_FAILURE);}printf("sem key = 0x%x\n", key);return key;
}int create_sem_set(key_t key) {// 创建包含1个信号量的信号量集int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);if (semid == -1) {perror("semget create failed");exit(EXIT_FAILURE);}printf("sem set created! semid = %d\n", semid);return semid;
}int get_sem_set(key_t key) {int semid = semget(key, 1, IPC_CREAT | 0666);if (semid == -1) {perror("semget get failed");exit(EXIT_FAILURE);}printf("sem set got! semid = %d\n", semid);return semid;
}void init_sem(int semid, int init_val) {union semun sem_union;sem_union.val = init_val;if (semctl(semid, 0, SETVAL, sem_union) == -1) {perror("semctl init failed");exit(EXIT_FAILURE);}printf("sem initialized! init_val = %d\n", init_val);
}void sem_p(int semid) {struct sembuf p_op = {0, -1, SEM_UNDO};if (semop(semid, &p_op, 1) == -1) {perror("semop P failed");exit(EXIT_FAILURE);}printf("[%d] P operation done\n", getpid());
}void sem_v(int semid) {struct sembuf v_op = {0, 1, SEM_UNDO};if (semop(semid, &v_op, 1) == -1) {perror("semop V failed");exit(EXIT_FAILURE);}printf("[%d] V operation done\n", getpid());
}void delete_sem_set(int semid) {if (semctl(semid, 0, IPC_RMID, NULL) == -1) {perror("semctl delete failed");exit(EXIT_FAILURE);}printf("sem set deleted!\n");
}#endif // SEM_SHM_COMMON_H
4.8.2 步骤2:进程A(写共享内存)
writer.c
:负责创建共享内存和信号量,向共享内存写数据(用信号量保护临界区):
#include "sem_shm_common.h"int main() {// 1. 创建共享内存key_t shm_key = get_shm_key();int shmid = create_shm(shm_key);void *shm_addr = attach_shm(shmid);// 2. 创建并初始化信号量(二元信号量,初始值1)key_t sem_key = get_sem_key();int semid = create_sem_set(sem_key);init_sem(semid, 1); // 初始值1:资源可用// 3. 循环写数据(每次写前执行P,写完执行V)for (int i = 0; i < 5; i++) {// P操作:申请资源,进入临界区sem_p(semid);// 临界区:写共享内存char msg[64];snprintf(msg, sizeof(msg), "Writer[%d] says: Hello %d", getpid(), i);strncpy((char *)shm_addr, msg, SHM_SIZE - 1);printf("[%d] Write to shm: %s\n", getpid(), (char *)shm_addr);// 模拟业务耗时sleep(2);// V操作:释放资源,离开临界区sem_v(semid);// 等待读者读数据sleep(1);}// 4. 清理资源detach_shm(shm_addr);delete_shm(shmid);delete_sem_set(semid);return 0;
}
4.8.3 步骤3:进程B(读共享内存)
reader.c
:负责获取共享内存和信号量,从共享内存读数据(同样用信号量保护临界区):
#include "sem_shm_common.h"int main() {// 1. 获取共享内存(writer已经创建)key_t shm_key = get_shm_key();int shmid = get_shm(shm_key);void *shm_addr = attach_shm(shmid);// 2. 获取信号量集(writer已经创建)key_t sem_key = get_sem_key();int semid = get_sem_set(sem_key);// 3. 循环读数据(每次读前执行P,读完执行V)for (int i = 0; i < 5; i++) {// P操作:申请资源,进入临界区sem_p(semid);// 临界区:读共享内存printf("[%d] Read from shm: %s\n", getpid(), (char *)shm_addr);// 模拟业务耗时sleep(2);// V操作:释放资源,离开临界区sem_v(semid);// 等待写者写数据sleep(1);}// 4. 清理资源(不删除共享内存和信号量,由writer负责)detach_shm(shm_addr);return 0;
}
4.8.4 步骤4:编译与测试
写一个Makefile
:
CC = gcc
CFLAGS = -Wall -Wextraall: writer readerwriter: writer.c sem_shm_common.h$(CC) $(CFLAGS) -o writer writer.creader: reader.c sem_shm_common.h$(CC) $(CFLAGS) -o reader reader.cclean:rm -f writer reader
(1)编译运行
- 编译:
make
- 打开两个终端:
- 终端1运行
./writer
:shm key = 0x660106fa shm created! shmid = 2031620 shm attached! addr = 0x7f8a1b2c3000 sem key = 0x660106fd sem set created! semid = 2031622 sem initialized! init_val = 1 [1234] P operation done [1234] Write to shm: Writer[1234] says: Hello 0 [1234] V operation done [1234] P operation done [1234] Write to shm: Writer[1234] says: Hello 1 [1234] V operation done ...
- 终端2运行
./reader
:shm key = 0x660106fa shm got! shmid = 2031620 shm attached! addr = 0x7f9b2c3d4000 sem key = 0x660106fd sem set got! semid = 2031622 [1235] P operation done [1235] Read from shm: Writer[1234] says: Hello 0 [1235] V operation done [1235] P operation done [1235] Read from shm: Writer[1234] says: Hello 1 [1235] V operation done ...
- 终端1运行
(2)结果分析
从输出可以看到:
- 写者(1234)执行P操作后,才能进入临界区写数据,写完执行V操作释放;
- 读者(1235)必须等写者执行V操作后,才能执行P操作进入临界区读数据;
- 同一时刻只有一个进程在访问共享内存(临界区),没有出现数据混乱——这就是信号量的互斥作用!
4.9 信号量的优缺点与应用场景
4.9.1 优点:解决同步互斥问题
- 保证数据一致性:能有效防止多个进程同时进入临界区,避免数据不一致、覆盖等问题。
- 支持多种同步场景:除了二元信号量(互斥),还支持计数信号量(比如允许3个进程同时访问资源)。
- 内核级保护:信号量的操作由内核保证原子性,不会出现“半执行”的中间状态,可靠性高。
4.9.2 缺点:接口复杂,设计冗余
- 接口繁琐:需要手动定义
union semun
,操作要通过struct sembuf
结构体,比共享内存、消息队列的接口更复杂。 - 信号量集设计冗余:SystemV信号量默认是“信号量集”(多个信号量打包),但大多数场景只需要单个信号量,显得有点冗余。
- 调试困难:如果信号量使用不当(比如漏了V操作),会导致死锁,且调试起来比较麻烦,需要用
ipcs
查看信号量状态。
4.9.3 应用场景:临界资源保护
信号量的核心作用是“保护临界资源”,不管是SystemV IPC还是其他IPC机制,只要涉及多进程共享资源,都需要信号量:
- 共享内存的同步:这是最常见的场景,比如上面的实战,用信号量保护共享内存的读写。
- 打印机/显示器等硬件资源:多个进程不能同时使用打印机,用二元信号量保证同一时刻只有一个进程打印。
- 文件的互斥访问:多个进程同时写同一个文件时,用信号量保证互斥,避免文件内容混乱。
五、SystemV IPC 三大组件对比与总结
咱们学完了SystemV IPC的三个核心组件——共享内存、消息队列、信号量,现在来做个整体对比,帮大家梳理清楚它们的定位和区别。
5.1 三大组件核心对比
特性 | 共享内存(Shared Memory) | 消息队列(Message Queue) | 信号量(Semaphore) |
---|---|---|---|
核心作用 | 传递大量数据(高效) | 传递少量、带类型的消息 | 同步互斥(无数据传递) |
数据传递方式 | 直接访问物理内存(无拷贝) | 进程→内核队列→进程(两次拷贝) | 不传递数据,只传递“同步信号” |
效率 | 最高(无拷贝) | 中等(两次拷贝) | 高(仅内核计数器操作) |
关键接口 | shmget/shmat/shmdt/shmctl | msgget/msgsnd/msgrcv/msgctl | semget/semop/semctl |
标识方式 | key(查找)+ shmid(操作) | key(查找)+ msgqid(操作) | key(查找)+ semid(操作) |
生命周期 | 随内核(需手动删除) | 随内核(需手动删除) | 随内核(需手动删除) |
同步互斥 | 无(需配合信号量) | 自带FIFO有序性,无互斥(需信号量) | 本身就是同步互斥工具 |
适用场景 | 频繁交换大量数据(如服务器缓存) | 少量、带类型的消息(如命令传递) | 保护临界资源(如共享内存、打印机) |
5.2 SystemV IPC 的共性与设计思想
不管是共享内存、消息队列还是信号量,它们都遵循SystemV IPC的统一设计思想:
(1)“先描述,再组织”的内核管理
操作系统对每种SystemV IPC资源,都会:
- 描述:用一个结构体记录资源属性(如
shmid_ds
、msqid_ds
、semid_ds
),这些结构体的第一个字段都是struct ipc_perm
(存储key和权限); - 组织:用一个内核数组管理所有资源的
ipc_perm
指针,通过shmid
/msgqid
/semid
作为数组下标,快速定位资源。
这种设计让内核能统一管理所有SystemV IPC资源,简化了代码逻辑。
(2)key + ID 的双层标识
- key:内核层面的唯一标识,用于“查找”资源(比如
shmget()
用key判断资源是否存在); - ID:进程层面的操作句柄,用于“操作”资源(比如挂接、发送消息、P/V操作)。
这种双层标识的好处是:key由用户约定(通过ftok()
),保证多进程能找到同一份资源;ID由内核分配,确保进程操作的是正确的资源。
(3)生命周期随内核
所有SystemV IPC资源的生命周期都“随内核”——除非手动删除(ipcrm
或xxxctl(IPC_RMID)
)或内核重启,否则资源会一直存在。这既是优点(进程退出后资源不丢失),也是缺点(容易内存泄漏)。
5.3 SystemV IPC 的现状与替代方案
虽然SystemV IPC是Linux内核中的经典IPC机制,但现在它的应用越来越少了,主要原因是:
- 接口复杂:比如信号量需要手动定义联合体,消息队列需要自定义消息块,不如后来的POSIX IPC接口简洁;
- 不符合“一切皆文件”理念:SystemV IPC的ID是独立管理的,和文件描述符不兼容,而Linux的设计哲学是“一切皆文件”,POSIX IPC(如
mmap()
、POSIX信号量)更符合这一理念; - 功能有限:比如共享内存不支持动态扩容,消息队列有消息大小限制,信号量集设计冗余。
现在更多使用的替代方案:
- 共享内存:用
mmap()
(POSIX共享内存)替代,支持文件映射,接口更简洁; - 消息传递:用Unix域套接字(
AF_UNIX
)或网络套接字替代,支持双向通信,能传递大量数据; - 信号量:用POSIX信号量(
sem_init()
/sem_wait()
/sem_post()
)替代,接口更简单,支持线程间同步。
但这并不意味着SystemV IPC不重要——理解它的原理(如内核资源管理、同步互斥),能帮我们更好地理解Linux内核的设计思想,对学习其他IPC机制也有很大帮助。
七、总结
本篇博客阐释了SystemV IPC的三大组件——共享内存、消息队列、信号量~
核心为三句话:
1. 进程间通信的本质是“让不同进程看到同一份资源”:
共享内存是共享物理内存,消息队列是共享内核队列,信号量是共享计数器;
2. 同步互斥是多进程共享资源的核心问题:
共享内存和消息队列需要信号量来保护,信号量通过PV操作(原子操作)实现互斥和同步;
3. SystemV IPC的设计思想是“先描述,再组织”:
内核用结构体描述资源,用数组组织资源,通过key+ID实现多进程共享。
理解了SystemV IPC,你对Linux内核的资源管理、进程同步等核心概念,都会有更深刻的认识!