当前位置: 首页 > news >正文

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. 步骤1:操作系统创建物理内存块
    进程A调用系统调用,让操作系统在物理内存中开辟一块绿色的内存区域(比如4KB)。这块内存由操作系统管理,不属于任何一个进程私有。

  2. 步骤2:映射到进程A的地址空间
    操作系统修改进程A的页表,把刚才创建的物理内存块映射到进程A地址空间的“共享区”(还记得地址空间的布局吗?代码区、数据区、堆、共享区、栈),然后给进程A返回一个虚拟地址(比如0x7f1234567000)。

  3. 步骤3:映射到进程B的地址空间
    进程B也调用系统调用,请求访问同一块物理内存。操作系统同样修改进程B的页表,把这块物理内存映射到进程B的共享区,也返回一个虚拟地址(比如0x7f9876543000)。

  4. 步骤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);

参数拆解:

  1. shmid:共享内存标识符(shmget()的返回值)——告诉操作系统要挂接哪块共享内存。
  2. shmaddr:指定共享内存要映射到进程地址空间的哪个位置(虚拟地址)。通常设为NULL,让操作系统自动分配一个合适的地址(在共享区)。不建议手动指定,因为很容易和其他地址冲突。
  3. 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);

参数:

  • shmaddrshmat()返回的虚拟地址——告诉操作系统要去关联哪块共享内存。

返回值:

  • 成功:返回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);

参数拆解:

  1. shmid:共享内存标识符——要控制哪块共享内存。
  2. cmd:控制命令,常用的有:
    • IPC_RMID:删除共享内存。这是我们最常用的命令。
    • IPC_STAT:获取共享内存的属性,把shmid_ds结构体的内容拷贝到buf中。
    • IPC_SET:修改共享内存的属性(比如权限),通过buf传递新的属性。
  3. 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)时,操作系统会做两件事:

  1. 标记共享内存为“待删除”(状态变为dest);
  2. 当共享内存的引用计数(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

编译并运行:

  1. 编译:make
  2. 打开两个终端,第一个终端运行./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.
    
  3. 第二个终端运行./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.
    
  4. 观察结果:process_b成功读到了process_a写的内容,说明共享内存通信成功!
  5. 测试结束后,用ipcs -m查看,确认共享内存已被删除(没有泄漏)。

2.7 共享内存的优缺点与应用场景

现在总结一下它的优缺点:

2.7.1 优点:高效是核心
  1. 速度最快:数据直接在物理内存中共享,不需要在进程和内核之间拷贝(管道、消息队列都需要拷贝),是所有IPC机制中效率最高的。
  2. 双向通信:进程可以同时读写共享内存(管道是半双工,需要两个管道才能实现双向通信)。
  3. 灵活:共享内存的大小可以自定义(只要是4KB的整数倍),数据格式也由用户自定义(比如字符串、结构体、数组等)。
2.7.2 缺点:需要自己处理同步
  1. 无同步机制:共享内存本身不提供任何同步互斥机制。如果多个进程同时读写共享内存,会导致“数据不一致”问题(比如进程A写了一半,进程B就开始读)。
  2. 需要手动管理生命周期:共享内存生命周期随内核,必须手动删除,否则会内存泄漏。

比如咱们刚才的实战中,process_a sleep了10秒,process_b sleep了15秒——这其实是一种“简单的同步”,确保process_a写完后process_b再读。但在实际开发中,这种“sleep同步”很不靠谱,必须用专门的同步机制(比如信号量)来保证互斥访问。

2.7.3 应用场景:频繁交换大量数据

共享内存适合用于进程间需要频繁交换大量数据的场景:

  1. 高性能服务器:比如Web服务器的多个进程之间共享缓存数据(避免重复加载)。
  2. 多进程数据处理:比如大数据处理场景中,多个进程共享原始数据,各自处理后再写回共享内存。
  3. 实时系统:对通信延迟要求高的场景(比如工业控制),共享内存的低延迟优势很明显。

三、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);

参数拆解:

  1. msgqid:消息队列标识符(msgget()的返回值);
  2. msgp:指向消息块结构体(struct msgbuf)的指针;
  3. msgsz:消息内容的大小(mtext的长度,不包括mtype的大小);
  4. 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);

参数拆解:

  1. msgqid:消息队列标识符;
  2. msgp:指向消息块结构体的指针,用来存储接收的消息;
  3. msgsz:消息内容的最大长度(避免缓冲区溢出);
  4. msgtyp:指定接收的消息类型:
    • msgtyp = 0:接收队列中的第一个消息(不筛选类型);
    • msgtyp > 0:接收类型等于msgtyp的第一个消息;
    • msgtyp < 0:接收类型小于等于-msgtyp的第一个消息;
  5. 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 优点:按类型接收,有序性
  1. 按类型接收:可以筛选消息类型,多个进程共享一个队列时不会混乱。
  2. 消息有序:队列是“先进先出”(FIFO)的,消息的发送顺序和接收顺序一致。
  3. 有缓冲:消息队列有一定的缓冲区大小(msg_qbytes),发送方和接收方不需要同时运行(管道需要双方同时运行,否则会阻塞)。
3.5.2 缺点:效率低,接口复杂
  1. 效率低:消息需要在进程和内核之间拷贝(发送时:进程→内核队列;接收时:内核队列→进程),效率比共享内存低。
  2. 消息大小限制:每个消息的大小和队列的总大小都有上限(由内核参数控制,比如默认单个消息最大8KB,队列总大小最大16KB),不适合传递大量数据。
  3. 接口复杂:需要自己定义消息块结构体,发送/接收时要处理消息类型,比共享内存麻烦。
3.5.3 应用场景:少量、带类型的消息传递

消息队列适合用于进程间传递少量、需要按类型区分的消息的场景:

  1. 进程间的命令传递:比如一个进程给另一个进程发送“启动”“停止”“重启”等命令(类型1=启动,类型2=停止)。
  2. 异步通信:发送方发送消息后不用等待接收方立即处理,接收方可以在合适的时候读取消息。

四、SystemV信号量:同步互斥的“计数器”

信号量是SystemV IPC的第三个成员,但它的作用不是“传递数据”,而是“同步互斥”——解决多个进程访问共享资源时的数据不一致问题。咱们上节课讲共享内存时提到过,共享内存需要同步机制,信号量就是最常用的同步工具。

4.1 为什么需要信号量?—— 数据不一致问题

咱们先看一个例子:两个进程(A和B)同时往共享内存里写数据。

进程A的逻辑:往共享内存写“Hello”;
进程B的逻辑:往共享内存写“World”。

如果没有同步机制,可能会发生这样的情况:

  1. 进程A写了“H”到共享内存;
  2. 操作系统调度,切换到进程B;
  3. 进程B写了“W”到共享内存(覆盖了“H”);
  4. 切换回进程A,A继续写“ello”,共享内存变成“Wello”;
  5. 切换到进程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:

  1. 进程A读计数器=1;
  2. 切换到进程B,读计数器=1;
  3. 进程A减1,计数器=0;
  4. 切换到进程B,减1,计数器=-1。

最终计数器=-1,而不是我们期望的0——这就错了。所以,PV操作必须原子,确保计数器的正确性。

4.4.2 P操作:申请资源(Proberen,荷兰语“尝试”)

定义:申请一个单位的临界资源。

操作逻辑:

  1. 信号量计数器(S)减1(S = S - 1);
  2. 如果S ≥ 0:申请成功,进程可以进入临界区;
  3. 如果S < 0:申请失败,进程被阻塞,放入信号量的等待队列。

比如电影院例子:P操作就是“买票”,计数器减1。如果计数器≥0,买到票;如果计数器<0,没票了,等待。

4.4.3 V操作:释放资源(Verhogen,荷兰语“释放”)

定义:释放一个单位的临界资源。

操作逻辑:

  1. 信号量计数器(S)加1(S = S + 1);
  2. 如果S > 0:释放成功,没有进程在等待,直接返回;
  3. 如果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);

参数拆解:

  1. key:由ftok()生成的唯一键值;
  2. nsems:信号量集中包含的信号量个数。如果是创建新的信号量集,必须指定nsems(比如1,表示一个信号量);如果是获取已存在的,nsems可以设为0;
  3. 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:信号量集标识符

和之前的shmidmsgqid一样,是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)编译运行
  1. 编译:make
  2. 打开两个终端:
    • 终端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
      ...
      
(2)结果分析

从输出可以看到:

  • 写者(1234)执行P操作后,才能进入临界区写数据,写完执行V操作释放;
  • 读者(1235)必须等写者执行V操作后,才能执行P操作进入临界区读数据;
  • 同一时刻只有一个进程在访问共享内存(临界区),没有出现数据混乱——这就是信号量的互斥作用!

4.9 信号量的优缺点与应用场景

4.9.1 优点:解决同步互斥问题
  1. 保证数据一致性:能有效防止多个进程同时进入临界区,避免数据不一致、覆盖等问题。
  2. 支持多种同步场景:除了二元信号量(互斥),还支持计数信号量(比如允许3个进程同时访问资源)。
  3. 内核级保护:信号量的操作由内核保证原子性,不会出现“半执行”的中间状态,可靠性高。
4.9.2 缺点:接口复杂,设计冗余
  1. 接口繁琐:需要手动定义union semun,操作要通过struct sembuf结构体,比共享内存、消息队列的接口更复杂。
  2. 信号量集设计冗余:SystemV信号量默认是“信号量集”(多个信号量打包),但大多数场景只需要单个信号量,显得有点冗余。
  3. 调试困难:如果信号量使用不当(比如漏了V操作),会导致死锁,且调试起来比较麻烦,需要用ipcs查看信号量状态。
4.9.3 应用场景:临界资源保护

信号量的核心作用是“保护临界资源”,不管是SystemV IPC还是其他IPC机制,只要涉及多进程共享资源,都需要信号量:

  1. 共享内存的同步:这是最常见的场景,比如上面的实战,用信号量保护共享内存的读写。
  2. 打印机/显示器等硬件资源:多个进程不能同时使用打印机,用二元信号量保证同一时刻只有一个进程打印。
  3. 文件的互斥访问:多个进程同时写同一个文件时,用信号量保证互斥,避免文件内容混乱。

五、SystemV IPC 三大组件对比与总结

咱们学完了SystemV IPC的三个核心组件——共享内存、消息队列、信号量,现在来做个整体对比,帮大家梳理清楚它们的定位和区别。

5.1 三大组件核心对比

特性共享内存(Shared Memory)消息队列(Message Queue)信号量(Semaphore)
核心作用传递大量数据(高效)传递少量、带类型的消息同步互斥(无数据传递)
数据传递方式直接访问物理内存(无拷贝)进程→内核队列→进程(两次拷贝)不传递数据,只传递“同步信号”
效率最高(无拷贝)中等(两次拷贝)高(仅内核计数器操作)
关键接口shmget/shmat/shmdt/shmctlmsgget/msgsnd/msgrcv/msgctlsemget/semop/semctl
标识方式key(查找)+ shmid(操作)key(查找)+ msgqid(操作)key(查找)+ semid(操作)
生命周期随内核(需手动删除)随内核(需手动删除)随内核(需手动删除)
同步互斥无(需配合信号量)自带FIFO有序性,无互斥(需信号量)本身就是同步互斥工具
适用场景频繁交换大量数据(如服务器缓存)少量、带类型的消息(如命令传递)保护临界资源(如共享内存、打印机)

5.2 SystemV IPC 的共性与设计思想

不管是共享内存、消息队列还是信号量,它们都遵循SystemV IPC的统一设计思想:

(1)“先描述,再组织”的内核管理

操作系统对每种SystemV IPC资源,都会:

  1. 描述:用一个结构体记录资源属性(如shmid_dsmsqid_dssemid_ds),这些结构体的第一个字段都是struct ipc_perm(存储key和权限);
  2. 组织:用一个内核数组管理所有资源的ipc_perm指针,通过shmid/msgqid/semid作为数组下标,快速定位资源。

这种设计让内核能统一管理所有SystemV IPC资源,简化了代码逻辑。

(2)key + ID 的双层标识
  • key:内核层面的唯一标识,用于“查找”资源(比如shmget()用key判断资源是否存在);
  • ID:进程层面的操作句柄,用于“操作”资源(比如挂接、发送消息、P/V操作)。

这种双层标识的好处是:key由用户约定(通过ftok()),保证多进程能找到同一份资源;ID由内核分配,确保进程操作的是正确的资源。

(3)生命周期随内核

所有SystemV IPC资源的生命周期都“随内核”——除非手动删除(ipcrmxxxctl(IPC_RMID))或内核重启,否则资源会一直存在。这既是优点(进程退出后资源不丢失),也是缺点(容易内存泄漏)。

5.3 SystemV IPC 的现状与替代方案

虽然SystemV IPC是Linux内核中的经典IPC机制,但现在它的应用越来越少了,主要原因是:

  1. 接口复杂:比如信号量需要手动定义联合体,消息队列需要自定义消息块,不如后来的POSIX IPC接口简洁;
  2. 不符合“一切皆文件”理念:SystemV IPC的ID是独立管理的,和文件描述符不兼容,而Linux的设计哲学是“一切皆文件”,POSIX IPC(如mmap()、POSIX信号量)更符合这一理念;
  3. 功能有限:比如共享内存不支持动态扩容,消息队列有消息大小限制,信号量集设计冗余。

现在更多使用的替代方案:

  • 共享内存:用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内核的资源管理、进程同步等核心概念,都会有更深刻的认识!

http://www.dtcms.com/a/347138.html

相关文章:

  • MiMo-VL 技术报告
  • 文献阅读笔记【物理信息机器学习】:Physics-informed machine learning
  • AI+预测3D新模型百十个定位预测+胆码预测+去和尾2025年8月23日第168弹
  • Java 泛型 T、E、K、V、?、S、U、V
  • 脑洞补给站—金湾读书会—第二期—课题分离——20250823
  • GitHub 热榜项目 - 日榜(2025-08-23)
  • 小白成长之路-k8s原理(一)
  • 新能源电池深孔检测:新启航方案以激光频率梳技术打破光学遮挡,达 2μm 级
  • imx6ull-驱动开发篇36——Linux 自带的 LED 灯驱动实验
  • 使用Ollama部署自己的本地模型
  • LeetCode第1019题 - 链表中的下一个更大节点
  • IntelliJ IDEA 集成 ApiFox 操作与注解规范指南
  • 【K8s】微服务
  • 浙江龙庭翔新型建筑材料有限公司全屋定制:畅享品质生活新境界!
  • window将exe注册成服务
  • 【40页PPT】企业如何做好大数据项目的选型(附下载方式)
  • 说说你对Integer缓存的理解?
  • 商超高峰客流统计误差↓75%!陌讯多模态融合算法在智慧零售的实战解析
  • 基于 FastAPI 和 OpenFeature 使用 Feature Flag 控制业务功能
  • 【Game】Powerful——Punch and Kick(12.2)
  • Ape.Volo项目源码学习(1:源码下载及运行)
  • 【KO】前端面试题四
  • 08_正则表达式
  • goland编译过程加载dll路径时出现失败
  • 【golang】ORM框架操作数据库
  • 8.23 JavaWeb(登录 P156-P170)
  • 什么是多元线性回归,系数、自变量、因变量是什么,多元线性回归中的线性是什么
  • 【KO】前端面试五
  • yolo训练实例python篇(二)
  • Python训练营打卡 DAY 45 Tensorboard使用介绍