【Linux/C++多进程篇(二) 】万字解析从“传纸条”到“建仓库”:一文读懂linux系统编程之进程间通信 (IPC)

⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页:YYYing.
⭐️Linux/C++进阶系列专栏:【从零开始的linux/c++进阶编程】
系列上期内容:【Linux/C++多进程篇(一) 】C/C++ 程序中神奇的“分身术”
系列下期内容:【Linux/C++多线程篇(一) 】多线程编程入门
目录

前言:
上篇文章我们让程序学会了“分身术”,变出了父子两个甚至多个进程。但很快我们发现了一个尴尬的问题:多个进程的用户空间是相互独立的,其栈区、堆区、静态区的数据都是彼此私有的,所以我们不可能通过用户空间中的区域完成多个进程之间数据的通信
那么今天,我们就来聊聊如何在这些独立的进程之间搭建桥梁。从最原始的“传纸条”(管道、消息队列),到高效的“建共享仓库”(共享内存),带你彻底搞懂 C++ 进程间通信(IPC)的核心逻辑。
进程间通信(IPC)
现在由于我们多个进程内存中的用户空间是独立的,但对于内存中的内核空间是共享的,所以我们可以利用内核空间来完成对数据的通信工作,本质上,在内核空间创建一个特殊的区域,一个 进程向该区域中存放数据,另一个进程可以从该区域中读取数据。

一、进程间通信的基础概念
IPC (Inter-Process Communication) :进程间通信
使用内核空间来完成多个进程间相互通信,根据使用的容器或方式不同,分为三类通信机制:内核提供的通信方式(传统的通信方式)、system V提供的通信方式、套接字通信
更详细来说:
| 内核提供的通信方式(传统的通信方式) | 无名管道 | 有名管道 | 信号 |
| system V提供的通信方式 | 消息队列 | 共享内存 | 信号灯集 |
| 套接字通信 | socket网络通信 | —— | —— |
二、内核提供的通信方式
我们内核提供的传统通信方式也就是两种管道与信号,那我们就从管道开始说起:
管道的原理:管道是一种特殊的文件,该文件不用于存储数据,只用于进程间通信。管道分为有名管道和无名管道。
在内核空间创建出一个管道通信,一个进程可以将数据写入管道,经由管道缓冲到另一个进程中读取
2.1、无名管道
顾名思义就是没有名字的管道,会在内存中创建出该管道,不存在于文件系统,随着进程结束而消失
值得一提的是,无名管道仅适用于亲缘进程间通信,不适用于非亲缘进程间通信
📖 无名管道的API
| 函数原型 | int pipe(int fildes[2]); |
| 头文件 | unistd.h |
| 功能 | 创建一个无名管道,并返回该管道的两个文件描述符 |
| 参数说明 | 是一个整型数组,用于返回打开的管道的两端的文件描述符, fildes[0]表示读端 fildes[1]表示写端 |
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
📖 代码案例
需要注意的地方都有注释。
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> using namespace std; int main(int argc, const char *argv[]){ //可以在此创建管道文件,并返回该管道文件的两端,那么父子进程都会拥有该管道两端的文件描述符 int fildes[2]; //存放管道文件的两端文件描述符 //创建无名管道,并返回该管道的两端文件描述符 if(pipe(fildes) == -1) { perror("pipe error"); return -1; } printf("fildes[0] = %d, fildes[1] = %d\n", fildes[0], fildes[1]); //3 4 pid_t pid = fork(); //创建一个子进程 //也不可以在此创建管道文件,因为如果在此创建,那么父进程中和子进程中会分别创建一个无名管道 if(pid > 0) { //父进程 //不用读端,就关闭 close(fildes[0]); char wbuf[128] = "hello world"; //想要将该数据发送给子进程使用 //如果在此创建管道,那么只能父进程使用,子进程用不了,因为子进程中没有管道文件的读端和写端文件描述符 //将上述数据发送给子进程,只需将数据通过写端写入管道文件中 write(fildes[1], wbuf, strlen(wbuf)); //关闭写端 close(fildes[1]); wait(NULL); //等待回收子进程 }else if(pid == 0) { //子进程 //关闭写端 close(fildes[1]); //通过读端从管道文件中读取数据 char rbuf[128] = ""; read(fildes[0], rbuf, sizeof(rbuf)); printf("收到父进程的数据为:%s\n", rbuf); //将数据输出到终端 //关闭读端 close(fildes[0]); //退出子进程 exit(EXIT_SUCCESS); }else { perror("fork error"); return -1; } return 0; }结果演示:

那我们发现我们的管道文件确实是可以缓冲一部分数据,然后再发给对端,那么这个管道的缓冲总不可能是无限的吧,没错,我们依旧可以试着验证一下。
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> using namespace std; int main(int argc, const char *argv[]){ int fildes[2]; //存放管道文件的两端文件描述符 //创建无名管道,并返回该管道的两端文件描述符 if(pipe(fildes) == -1) { perror("pipe error"); return -1; } printf("fildes[0] = %d, fildes[1] = %d\n", fildes[0], fildes[1]); char buf = 'A'; int count = 0; //定义一个字符变量 while(1) { write(fildes[1], &buf, 1); // 每次都向管道写入buf中的1字节数据 count++; printf("count = %d\n", count); } //关闭文件描述符 close(fildes[0]); close(fildes[1]); return 0; }可以看到最终我们一共存了65536字节的数据。

2.2、有名管道
顾名思义就是有名字的管道文件,会在文件系统中创建一个真实存在的管道文件
而它不同于我们上面的无名管道:其既可以完成亲缘进程间通信,也可以完成非亲缘进程间通信
📖 有名管道的API
| 函数原型 | int mkfifo(const char *pathname, mode_t mode); |
| 头文件 | sys/stat.h sys/types.h |
| 功能 | 创建一个有名管道文件,并存在与文件系统中 |
| 参数说明 | 参数1:管道文件的名称 参数2:管道文件的权限,内容详见open函数的mode参数 |
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
| 注意:管道文件被创建后,其他进程就可以进行打开读写操作了,但是,必须要保证当前管道文件的两端都打开后,才能开始进行读写操作,否则函数会在open处阻塞
📖 代码案例
我们用三个文件分别去执行创建管道,发送消息,接受消息的操作。
create.cpp
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> int main(int argc, const char *argv[]) { //该文件主要负责创建管道文件,注意:如果管道文件已经存在,则mkfifo函数会报错 if(mkfifo("./myfifo", 0664) == -1) { perror("mkfifo error"); return -1; } printf("管道创建成功\n"); return 0; }send.cpp
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> int main(int argc, const char *argv[]) { //打开管道文件 int sfd = -1; if((sfd = open("./myfifo", O_WRONLY)) == -1) { perror("open error"); return -1; } //准备要写入的数据 char wbuf[128] = ""; while(1) { printf("请输入>>>"); fgets(wbuf, sizeof(wbuf), stdin); //从终端输入数据 wbuf[strlen(wbuf)-1] = 0; //将换行换成'\0' //将数据写入管道 write(sfd, wbuf, strlen(wbuf)); if(strcmp(wbuf, "quit") == 0) { break; } } //关闭文件 close(sfd); return 0; }recv.cpp
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> int main(int argc, const char *argv[]) { //打开管道文件 int rfd = -1; if((rfd = open("./myfifo", O_RDONLY)) == -1) { perror("open error"); return -1; } //准备要写入的数据 char rbuf[128] = ""; while(1) { //将容器清空 bzero(rbuf, sizeof(rbuf)); //从管道文件中读取数据 read(rfd, rbuf, sizeof(rbuf)); printf("收到数据为:%s\n", rbuf); if(strcmp(rbuf, "quit") == 0) { break; } } //关闭文件 close(rfd); return 0; } 最终效果图:

2.3、管道特点
总的来说,我们管道通信有着以下6个特点:
管道可以实现自己给自己发消息对管道中数据的操作是一次性的,当管道中的数据被读取后,就从管道中消失了,再读取时会被阻塞管道文件的大小:64K由于返回的是管道文件的文件描述符,所以对管道的操作只能是文件IO相关函数,但是,不可以使用 lseek对光标进行偏移,必须做到先进先出管道的读写特点: 当读端存在时:写端有多少写多少,直到写满64k后,在write处阻塞当读端不存在时:写端再向管道中写入数据时,会发生管道破裂,内核空间会向用户空间发射一个 SIGPIPE信号,进程收到该信号后,退出当写端存在时:读端有多少读多少,没有数据,会在read出阻塞 当写端不存在时:读端有多少读多少,没有数据,不会在read处阻塞了管道通信是半双工通信方式,当然这三种通信方式在我们计网系列也提到过。
单工:只能进程A向B发送消息
半双工:同一时刻只能A向B发消息
全双工:任意时刻,AB可以互相通
2.4、信号
📖 信号相关概念
- 信号是软件模拟硬件的中断功能,信号是软件实现的,中断是硬件实现的
- 信号是linux内核实现的,没有内核就没有信号的概念
- 用户可以给进程发信号:例如键入ctrl + c
内核可以向进程发送信号:例如SIGPIPE
一个进程可以给另一个进程发送信号,需要通过相关函数来完成
- 信号通信是属于异步通信工作
同步:表示多个任务有先后顺序的执行,例如去银行办理业务
异步:表示多个任务没有先后顺序执行,例如你在敲代码,你妈妈在做饭
📖 通信原理图
我们信号通信就属于异步通信,如果上面的文字看的不是太懂,我们不妨来看看下图

📖 对信号的处理函数:signal
| 函数原型 | sighandler_t signal(int signum, sighandler_t handler); |
| 头文件 | signal.h |
| 功能 | 将信号与信号处理方式绑定到一起 |
| 参数说明 | 参数1:要处理的信号 参数2:处理方式 SIG_IGN:忽略 SIG_DFL:默认,一般信号的默认操作都是杀死进程 typedef void (*sighandler_t)(int):用户自定义的函数 |
| 返回值 | 成功返回处理方式的起始地址,失败返回SIG_ERR并置位错误码 |
注意:只要程序与信号绑定一次,后续但凡程序收到该信号,对应的处理方式就会立即响应
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> using namespace std; void handler(int signo){ if(signo == SIGINT){ printf("用户键入了ctrl + c\n"); } } int main(int argc, const char *argv[]){ // 1.尝试忽略SIGINT信号 // if((signal(SIGINT, SIG_IGN) == SIG_ERR)){ // perror("signal error"); // return -1; // } // 2.尝试捕获SIGINT信号 // if((signal(SIGINT, handler) == SIG_ERR)){ // perror("signal error"); // return -1; // } // 3.尝试默认相关信号操作——默认就是退出进程 if((signal(SIGINT, SIG_DFL) == SIG_ERR)){ perror("signal error"); return -1; } while(1){ printf("YYYing\n"); sleep(1); } return 0; }📖 尝试捕获和忽略SIGKILL信号
是否还记得我们上一节课讲的那些信号吗,在最后,我们介绍了两种特殊信号——SIGKILL和SIGSTOP,这两个信号既不能被捕获,也不能被忽略。
那么现在我们试图将特殊信号忽略或者捕获会发生什么事呢?
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> using namespace std; void handler(int signo){ if(signo == SIGINT){ printf("用户键入了ctrl + c\n"); } } int main(int argc, const char *argv[]){ // 1.尝试忽略SIGKILL信号,函数直接报错,不让你填此参数 // if((signal(SIGKILL, SIG_IGN) == SIG_ERR)){ // perror("signal error"); // return -1; // } // 2.尝试捕获SIGKILL信号,上同 // if((signal(SIGKILL, handler) == SIG_ERR)){ // perror("signal error"); // return -1; // } // 3.尝试默认相关信号操作——默认就是退出进程,上同 if((signal(SIGKILL, SIG_DFL) == SIG_ERR)){ perror("signal error"); return -1; } while(1){ printf("我真的还想再活100年\n"); sleep(1); } return 0; 如果你试过这忽略特殊信号的三种情况的话就会发现,无论哪一种都会报错Invalid arguement.
📖 使用信号的方式完成对僵尸进程的回收
当子进程退出后,会向父进程发送一个SIGCHLD的信号,当父进程收到该信号后,可以将其进行捕获,在信号处理函数中,可以以非阻塞的方式回收僵尸进程,此处的类似应用在我们后续项目中也仍然会用到,是一种相当不错的技术。
那我们现在就来看看这种回收方式。
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> using namespace std; void handler(int signo){ if(signo == SIGCHLD){ //回收僵尸进程 while(waitpid(-1, NULL, WNOHANG) > 0); } } int main(int argc, const char *argv[]){ //当子进程退出后,会向父进程发送一个SIGCHLD的信号, //我们可以将其捕获,在信号处理函数中将子进程资源回收 if(signal(SIGCHLD, handler) == SIG_ERR){ perror("signal error"); return -1; } //创建10个僵尸进程 for(int i=0; i<10; i++){ if(fork() == 0){ //当子进程创建出来后,立马扼杀在摇篮中 exit(EXIT_SUCCESS); } } while(1); return 0; }此处回收僵尸进程的设计非常巧妙,非阻塞回收可以巧妙的避免我们wait函数可能造成死锁的情况,而循环可以解决多个子进程同时终止,但信号可能会被合并(同一个信号多次触发,处理函数只执行一次)的情况。
📖 信号发送函数:kill、raise
| 函数原型 | int kill(pid_t pid, int sig); | int raise(int sig); |
| 头文件 | signal.h | signal.h |
| 功能 | 向指定进程或进程组发送信号 | 向自己发送信号 |
| 参数说明 | 参数1:进程号或进程组号 >0:表示向执行进程发送信号 =0:向当前进程所在的进程组中的所有进程发送信号 =-1:向所有进程发送信号 <-1:向指定进程组发送信号,进程组的ID号为给定pid的绝对值 参数2:要发送的信号 | 向自己发送信号 等价于:kill(getpid(), sig); |
| 返回值 | 成功返回0,失败返回-1并置位错误码 | 成功返回0,失败返回非0数组 |
现在来看看代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> using namespace std; //定义信号处理函数 void handler(int signo){ if(signo == SIGUSR1){ printf("来了来了,我终于能玩上生化9了\n"); //向自己发送一个自杀信号 kill(getpid(), SIGKILL) raise(SIGKILL); } } int main(int argc, const char *argv[]){ //将子进程发送的信号绑定到指定功能中 if(signal(SIGUSR1, handler) == SIG_ERR){ perror("signal error"); return -1; } //创建父子进程 pid_t pid = fork(); if(pid > 0){ //父进程 while(1){ printf("我好想玩生化危机9\n"); sleep(1); } } else if(pid == 0){ //子进程 sleep(5); printf("距离生化危机9发售还有一点时间\n"); kill(getppid(), SIGUSR1); //向自己的父进程发送了一个自定义的信号 exit(EXIT_SUCCESS); //退出进程 } return 0; }可以看到我们的父进程想玩生化9的状态已经到达了一种疯癫的状态,但最后也是如愿以偿的让父进程玩到了生化危机9。

2.5、三种机制总结
| 特性 | 无名管道 (Anonymous Pipe) | 有名管道 (Named Pipe / FIFO) | 信号 (Signal) |
|---|---|---|---|
| 主要用途 | 亲缘进程(父子)间的简单字节流通信 | 任意进程间的字节流通信 | 异步事件通知、简单进程控制 |
| 数据拷贝 | 两次拷贝(用户→内核缓冲区→用户) | 两次拷贝(用户→内核缓冲区→用户) | 无数据拷贝(仅传递信号编号,可附带少量信息 via sigqueue) |
| 同步需求 | 无需额外同步(阻塞读写自然同步) | 无需额外同步(阻塞读写自然同步) | 无需同步,但需处理可重入问题 |
| 生命周期 | 随进程,所有文件描述符关闭后销毁 | 随文件系统,unlink 后销毁,数据不持久 | 瞬时事件,信号处理函数执行即结束 |
| 性能 | 中等 | 中等 | 高(轻量级) |
| 编程难度 | 简单 | 中等(需处理文件路径和权限) | 中等(需考虑异步信号安全函数) |
三、system V提供的通信方式
在学完上面的内容过后,我们不难发现,对于内核提供的三种通信方式:对管道而言,只能实现单向的数据通信,而对信号通信而言,又只能完成多进程之间信号的通知,不能起到数据传输的效果。为了解决上述问题,引入的“system V”进程间通信
- system V提供的进程间通信方式分别是:消息队列、共享内存、信号量(信号灯集)
- 有关system V进程间通信对象相关的指令:
ipcs 可以查看所有的信息(消息队列、共享内存、信号量)
ipcs -q:可以查看消息队列的信息
ipcs -m:可以查看共享内存的信息
ipcs -s:可以查看信号量的信息
ipcrm -q/m/s ID :可以删除指定ID的IPC对象
- 上述的三种通信方式,也是借助内核空间完成的相关通信,原理是在内核空间创建出相关的对象容器,在进行进程间通信时,可以将信息放入对象中,另一个进程就可以从该容器中取数据了。
- 与内核提供的管道、信号通信不同:system V 的 IPC 对象实现了数据传递的容器与程序相分离,也就是说,即使程序以己经结束,但是放入到容器中的数据依然存在,除非将容器手动删除
共同特点:
- 内核持久性:IPC 对象一经创建,除非显式删除或系统重启,否则一直存在。
- 全局标识:每个对象由一个唯一的
key标识,或通过id引用。 - 系统调用接口:所有操作通过系统调用陷入内核
3.1、消息队列
消息队列是一个由内核维护的消息链表。每个消息包含:
- 类型(long mtype,正数)
- 数据(任意长度,通常有上限)
进程可以向队列发送消息,也可以按类型接收消息——不一定先进先出,可以优先读取特定类型的消息。
如下图,我们消息队列先分别让3个进程放入4个消息,我们A进程取走第3个消息,B进程取走了第一个消息。

📖 消息队列实现的API
1、创建key值
| 函数原型 | key_t ftok(const char *pathname, int proj_id); |
| 头文件 | sys/ipc.h sys/types.h |
| 功能 | 通过给定的文件以及给定的一个随机值,创建出一个4字节整数的key值,用于system V IPC对象的创建 |
| 参数说明 | 参数1:一个文件路径,要求是已经存在的文件路径,提供了key值3字节的内容,其中,文件的设备号占1字节,文件的inode号占2字节。 参数2:一个随机整数,取后8位(1字节)跟前面的文件共同组成key值,必须是非0的数字。 |
| 返回值 | 成功返回key值,失败返回-1并置位错误码 |
2、通过key值,创建消息队列
| 函数原型 | int msgget(key_t key, int msgflg); |
| 头文件 | sys/ipc.h sys/types.h sys/msg.h |
| 功能 | 通过给定的key值,创建出一个消息队列的对象,并返回消息队列的句柄ID,后期可以通过该ID操作整个消息队列 |
| 参数说明 | 参数1:key值,该值可以是IPC_PRIVATE,也可以是ftok创建出来的,前者只用于亲缘进程间的通信。 参数2:创建标识 IPC_CREAT:创建并打开一个消息队列,如果消息队列已经存在,则直接打开。 IPC_EXCL:确保本次创建处理的是一个新的消息队列,如果消息队列已经存在,则报错,错误码位EEXIST。 0664:该消息队列的操作权限。 eg:IPC_CREAT|0664 或者 IPC_CREAT|IPC_EXCL|0664 |
| 返回值 | 成功返回消息队列的ID号,失败返回-1并置位错误码 |
3、向消息队列中存放数据
| 函数原型 | int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); |
| 头文件 | sys/ipc.h sys/types.h sys/msg.h |
| 功能 | 向消息队列中存放一个指定格式的消息 |
| 参数说明 | 参数1:打开的消息队列的id号 参数2:要发送的消息的起始地址,消息一般定义为一个结构体类型,由用户手动定义 struct msgbuf { long mtype; /* message type, must be > 0 */ 消息的类型 char mtext[1]; /* message data */ 消息正文 。。。 }; 参数3:消息正文的大小 参数4:是否阻塞的标识 0:标识阻塞形式向消息队列中存放消息,如果消息队列满了,就在该函数处阻塞 IPC_NOWAIT:标识非阻塞的形式向消息队列中存放消息,如果消息队列满了,直接返回 |
| 返回值 | 返回值:成功返回0,失败返回-1并置位错误码 |
4、从消息队列中拿取消息
| 函数原型 | ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg); |
| 头文件 | sys/ipc.h sys/types.h sys/msg.h |
| 功能 | 从消息队列中取数指定类型的消息放入给定的容器中 |
| 参数说明 | 参数1:打开的消息队列的id号 参数2:要接收的消息的起始地址,消息一般定义为一个结构体类型,由用户手动定义 struct msgbuf { long mtype; /* message type, must be > 0 */ 消息的类型 char mtext[1]; /* message data */ 消息正文 。。。 }; 参数3:消息正文的大小 参数4:要接收的消息类型 0:表示每次都取消息队列中的第一个消息,无论类型 >0:读取队列中第一个类型为msgtyp的消息、 <0:读取队列中的一个消息,消息为绝对值小于msgtyp的第一个消息 eg:8-->3-->6-->5-->20-->2 -5: 会从队列中绝对值小于5的类型的消息中选取第一个消息,就是3 参数5:是否阻塞的标识 0:标识阻塞形式向消息队列中读取消息,如果消息队列空了,就在该函数处阻塞 IPC_NOWAIT:标识非阻塞的形式向消息队列中读取消息,如果消息队列空了,直接返回 |
| 返回值 | 成功返回实际读取的正文大小,失败返回-1并置位错误码 |
5、销毁消息队列
| 函数原型 | int msgctl(int msqid, int cmd, struct msqid_ds *buf); |
| 头文件 | sys/ipc.h sys/types.h sys/msg.h |
| 功能 | 对给定的消息队列执行相关的操作,该操作由cmd参数而定 |
| 参数说明 | 参数1:消息队列的ID号 参数2:要执行的操作 IPC_RMID:删除一个消息队列,当cmd为该值时,第三个参数可以省略填NULL即可 IPC_STAT:表示获取当前消息队列的属性,此时第三个参数就是存放获取的消息队列属性的容器起始地址 IPC_SET:设置当前消息队列的属性,此时第三个参数就是要设置消息队列的属性数据的起始地址 参数3:消息队列数据容器结构体,如果第二个参数为IPC_RMID,则该参数忽略填NULL即可,如果 是 IPC_STAT、 IPC_SET填如下结构体:
而第一个成员又是一个结构体:
|
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
📖 代码案例
现在就让我们来搞一个通信样例。
- 发送端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #include <sys/ipc.h> #include <sys/msg.h> using namespace std; struct msgbuff { long mtype; // 消息类型 char mtext[1024]; // 消息正文 }; // 正文的大小 #define MSGSZ (sizeof(struct msgbuff) - sizeof(long)) int main(int argc, const char *argv[]){ // 1、创建key值,用于创建一个消息队列 key_t key = ftok("/", 'k'); //参数1:已经存在的路径,参数2:随机值 if(key == -1){ perror("ftok error"); return -1; } printf("key = %#x\n", key); // 2、通过Key值创建出一个消息队列,并返回该消息队列的id int msqid = -1; if((msqid = msgget(key, IPC_CREAT|0664)) == -1){ perror("msgget error"); return -1; } printf("msqid = %d\n", msqid); // 3、向消息队列中存放数据 // 组建消息 struct msgbuff buf; while(1){ printf("请输入消息的类型:"); scanf("%ld", &buf.mtype); getchar(); printf("请输入消息正文:"); fgets(buf.mtext, MSGSZ, stdin); buf.mtext[strlen(buf.mtext) - 1] = '\0'; // 将上述组装好的消息放入消息队列中 msgsnd(msqid, &buf, MSGSZ, 0); printf("消息存入成功\n"); if(strcmp(buf.mtext, "quit") == 0){ break; } } return 0; }- 接收端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #include <sys/ipc.h> #include <sys/msg.h> using namespace std; struct msgbuff { long mtype; // 消息类型 char mtext[1024]; // 消息正文 }; // 正文的大小 #define MSGSZ (sizeof(struct msgbuff) - sizeof(long)) int main(int argc, const char *argv[]){ // 1、创建key值,用于创建一个消息队列 key_t key = ftok("/", 'k'); //参数1:已经存在的路径,参数2:随机值 if(key == -1){ perror("ftok error"); return -1; } printf("key = %#x\n", key); // 2、通过Key值创建出一个消息队列,并返回该消息队列的id int msqid = -1; if((msqid = msgget(key, IPC_CREAT|0664)) == -1){ perror("msgget error"); return -1; } printf("msqid = %d\n", msqid); // 3、向消息队列中存放数据 // 组建消息 struct msgbuff buf; while(1){ bzero(&buf, sizeof(buf)); // 读取消息 msgrcv(msqid, &buf, MSGSZ, 5, 0); // 参数4表示读取的消息类型, 参数5表示是否堵塞 printf("读取到的消息为:%s\n", buf.mtext); if(strcmp(buf.mtext, "quit") == 0){ break; } } // 4、删除消息队列 if(msgctl(msqid, IPC_RMID, NULL) == -1){ perror("msgctl error"); return -1; } return 0; }📖 适用场景、优缺点及注意事项
- 适用:需要结构化、按类型过滤的消息传递;多个进程向一个中心进程汇报的场景。
- 优点:消息有边界,可以按类型读取;独立于进程生命周期。
- 缺点:性能中等(需要用户-内核数据拷贝);消息大小有限制(通常受系统限制)。
注意事项:
- 对于消息而言,由两部分组成:消息的类型和消息正文,消息结构体由用户自定义
- 对于消息队列而言,任意一个进程都可以向消息队列中发送消息,也可以从消息队列中取消息
- 多个进程,使用相同的key值打开的是同一个消息队列
- 对消息队列中的消息读取操作是一次性的,被读取后,消息队列中不存在该消息了
- 消息队列的大小:16K
3.2、共享内存
共享内存让多个进程直接读写同一块物理内存。这是最快的 IPC 形式,因为数据无需在内核和用户空间之间拷贝。但必须配合同步机制(如信号量)使用,防止数据竞争。

📖 共享内存实现的API
1、创建key值,第一步与我们刚才是一样的
| 函数原型 | key_t ftok(const char *pathname, int proj_id); |
| 头文件 | sys/ipc.h sys/types.h |
| 功能 | 通过给定的文件以及给定的一个随机值,创建出一个4字节整数的key值,用于system V IPC对象的创建 |
| 参数说明 | 参数1:一个文件路径,要求是已经存在的文件路径,提供了key值3字节的内容,其中,文件的设备号占1字节,文件的inode号占2字节。 参数2:一个随机整数,取后8位(1字节)跟前面的文件共同组成key值,必须是非0的数字。 |
| 返回值 | 成功返回key值,失败返回-1并置位错误码 |
2、通过key值创建共享内存段
| 函数原型 | int msgget(key_t key, int msgflg); |
| 头文件 | sys/ipc.h sys/shm.h |
| 功能 | 申请指定大小的物理内存,映射到内核空间,创建出共享内存段 |
| 参数说明 | 参数1:key值,该值可以是IPC_PRIVATE,也可以是ftok创建出来的,前者只用于亲缘进程间的通信。 参数2:申请的大小,是一页(4096字节)的整数倍,并且向上取整 参数3:创建标识 IPC_CREAT:创建并打开一个共享内存段,如果共享内存段已经存在,则直接打开。 IPC_EXCL:确保本次创建处理的是一个新的共享内存段,如果共享内存段已经存在,则报错,错误码位EEXIST。 0664:该共享内存段的操作权限。 eg:IPC_CREAT|0664 或者 IPC_CREAT|IPC_EXCL|0664 |
| 返回值 | 成功返回共享内存段的ID号,失败返回-1并置位错误码 |
3、将共享内存段的地址映射到用户空间
| 函数原型 | void *shmat(int shmid, const void *shmaddr, int shmflg); |
| 头文件 | sys/types.h sys/shm.h |
| 功能 | 将共享内存段映射到用户空间 |
| 参数说明 | 参数1:共享内存的id号 参数2:物理内存的起始地址,一般填NULL,由系统自动选择一个合适的对齐页 参数3:对共享内存段的操作 0:表示读写操作 SHM_RDONLY:只读 |
| 返回值 | 成功返回用于操作共享内存的指针,失败返回(void*)-1并置位错误码 |
4、释放共享内存的映射关系
| 函数原型 | int shmdt(const void *shmaddr); |
| 头文件 | sys/types.h sys/shm.h |
| 功能 | 将进程与共享内存的映射取消 |
| 参数说明 | 共享内存的指针 |
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
5、共享内存的控制函数
| 函数原型 | int shmctl(int shmid, int cmd, struct shmid_ds *buf); |
| 头文件 | sys/ipc.h sys/shm.h |
| 功能 | 根据给定的不同的cmd执行不同的操作 |
| 参数说明 | 参数1:共享内存的ID号 参数2:要执行的操作 IPC_RMID:删除共享内存段,填此值后第三个参数可以省略 IPC_STAT:表示获取当前共享的属性,此时第三个参数就是存放获取的属性的容器起始地址 IPC_SET:设置当前共享内存的属性,此时第三个参数就是要设置共享内存的属性数据的起始地址 参数3:如果参数2为IPC_RMID,则参数3可以省略填NULL,如果参数2为另外两个,参数3填如下 结构体变量
而第一个成员也又是一个结构体:
|
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
📖 代码案例
依旧是以一个小通信项目案例来看看我们的函数应用:
- 发送端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/shm.h> using namespace std; #define PAGE_SIZE 4096 int main(int argc, const char *argv[]){ //1、创建key值 key_t key = ftok("/", 'k'); if(key == -1){ perror("ftok error"); return -1; } printf("key = %#x\n", key); //输出key值 //2、通过key值创建共享内存段 int shmid = -1; if((shmid= shmget(key, PAGE_SIZE, IPC_CREAT|0664)) == -1){ perror("shmget error"); return -1; } printf("shmid = %d\n", shmid); //3、将共享内存段映射到用户空间 char *addr = (char *)shmat(shmid, NULL, 0); //NULL表示让系统自动寻找对齐页 //0表示对该共享内存段的操作是读写操作打开 if(addr == (void*)-1){ perror("shmat error"); return -1; } printf("addr = %p\n", addr); //输出共享内存段映射的地址 //4、对共享内存进行操作 while(1){ printf("请输入>>>"); fgets(addr, PAGE_SIZE, stdin); //从终端输入数据放入共享内存中 addr[strlen(addr)-1] = 0; if(strcmp(addr, "quit") == 0){ break; } } sleep(5); //休眠5秒 printf("结束吧\n"); //5、取消映射 if(shmdt(addr) == -1){ perror("取消映射\n"); return -1; } return 0; }- 接收端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/shm.h> using namespace std; #define PAGE_SIZE 4096 int main(int argc, const char *argv[]){ //1、创建key值 key_t key = ftok("/", 'k'); if(key == -1){ perror("ftok error"); return -1; } printf("key = %#x\n", key); //一页的大小 //输出key值 //2、通过key值创建共享内存段 int shmid = -1; if((shmid= shmget(key, PAGE_SIZE, IPC_CREAT|0664)) == -1){ perror("shmget error"); return -1; } printf("shmid = %d\n", shmid); //3、将共享内存段映射到用户空间 char *addr = (char *)shmat(shmid, NULL, 0); //NULL表示让系统自动寻找对齐页 //0表示对该共享内存段的操作是读写操作打开 if(addr == (void*)-1){ perror("shmat error"); return -1; } printf("addr = %p\n", addr); //输出共享内存段映射的地址 //4、对共享内存进行操作 while(1){ sleep(2); printf("读取到消息为:%s\n", addr); //通过地址访问共享内存中的数据 if(strcmp(addr, "quit") == 0){ break; } } //5、取消映射 if(shmdt(addr) == -1){ perror("取消映射\n"); return -1; } //6、删除共享内存段 if(shmctl(shmid, IPC_RMID, NULL) == -1){ perror("shmctl error"); return -1; } return 0; }📖 适用场景、优缺点及注意事项
- 适用:大量数据交换、需要高性能的场景(如数据库缓存、游戏服务器状态共享)。
- 优点:速度极快(零拷贝);可映射大块内存。
- 缺点:需要额外同步;编程复杂;多个进程同时读写需处理竞态。
注意事项:
- 共享内存是多个进程共享同一个内存空间,使用时可能会产生竞态,为了解决这个问题,共享 内存一般会跟信号量一起使用,完成进程的同步功能
- 共享内存VS消息队列:消息队列能够保证数据的不丢失性,而共享内存能够保证数据的时效性
- 对共享内存的读取操作不是一次性的,当读取后,数据依然存放在共享内存中
- 使用共享内存,跟正常使用指针是一样的,使用时,无需再进行用户空间与内核空间的切换 了,所以说,共享内存是所有进程间通信方式中效率最高的一种通信方式。
3.3、信号量(信号灯集)
信号量是一个计数器,用于控制多个进程对共享资源的访问。它不是用来传输数据,而是用来同步。System V 信号量可以操作一组信号量(信号量集合),支持原子操作。

📖 信号量相关的API
1、创建key值,第一步都是一样的
| 函数原型 | key_t ftok(const char *pathname, int proj_id); |
| 头文件 | sys/ipc.h sys/types.h |
| 功能 | 通过给定的文件以及给定的一个随机值,创建出一个4字节整数的key值,用于system V IPC对象的创建 |
| 参数说明 | 参数1:一个文件路径,要求是已经存在的文件路径,提供了key值3字节的内容,其中,文件的设备号占1字节,文件的inode号占2字节。 参数2:一个随机整数,取后8位(1字节)跟前面的文件共同组成key值,必须是非0的数字。 |
| 返回值 | 成功返回key值,失败返回-1并置位错误码 |
2、通过key值创建信号量集
| 函数原型 | int semget(key_t key, int nsems, int semflg); |
| 头文件 | sys/ipc.h sys/types.h sys/sem.h |
| 功能 | 通过给定的key值创建一个信号量集 |
| 参数说明 | 参数1:key值,该值可以是IPC_PRIVATE,也可以是ftok创建出来的,前者只用于亲缘进程间的通信。 参数2:信号量数组中信号量的个数 参数3:创建标识 IPC_CREAT:创建并打开一个消息队列,如果信号量集已经存在,则直接打开。 IPC_EXCL:确保本次创建处理的是一个新的信号量集,如果信号量集已经存在,则报错,错误码位EEXIST。 0664:该消息队列的操作权限。 eg:IPC_CREAT|0664 或者 IPC_CREAT|IPC_EXCL|0664 |
| 返回值 | 成功返回信号量集的ID号,失败返回-1并置位错误码 |
3、关于信号量集的操作:P(申请资源)V(释放资源)
| 函数原型 | int semop(int semid, struct sembuf *sops, size_t nsops); |
| 头文件 | sys/ipc.h sys/types.h sys/sem.h |
| 功能 | 完成对信号量数组的操作 |
| 参数说明 | 参数1:信号量数据ID号 参数2:有关信号量操作的结构体变量起始地址,该结构体中包含了操作的信号量编号和申请还是释放的操作 struct sembuf{ unsigned short sem_num; /* semaphore number */ 要操作的信号量的编号 short sem_op; /* semaphore operation */ 要进行的操作,大于 0 表示释放资源,小于 0 表示申请资源 short sem_flg; /* operation flags */ 操作标识位,0标识阻塞方 式,IPC_NOWAIT表示非阻塞 } 参数3:本次操作的信号量的个数 |
| 返回值 | 成功返回0,失败返回-1并置位错误码 |
4、关于信号量集的控制函数
| 函数原型 | int semctl(int semid, int semnum, int cmd, ...); |
| 头文件 | sys/ipc.h sys/types.h sys/sem.h |
| 功能 | 执行有关信号量集的控制函数,具体控制内容取决于cmd |
| 参数说明 | 参数1:信号量集的ID 参数2:要操作的信号量的编号,编号是从0开始 参数3:要执行的操作 IPC_RMID:表示删除信号量集,cmd为该值时,参数2可以忽略,参数4可以不填 SETVAL:表示对参数2对应的信号量进行设置操作(初始值) GETVAL:表示对参数2对应的信号量进行获取值操作 SETALL:设置信号量集中所有信号量的值 GETALL:获取信号量集中的所有信号量的值 IPC_STAT:表示获取当前信号量集的属性 IPC_SET:表示设置当前信号量集的属性 参数4:根据不同的cmd值,填写不同的参数值,所以该处是一个共用体变量 union semun { int val; /* Value for SETVAL */ 设置信号量的值 struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */ 关于 信号量集属性的操作 unsigned short *array; /* Array for GETALL, SETALL */ 对于信 号量集中所有信号量的操作 struct seminfo *__buf; /* Buffer for IPC_INFO (Linux-specific) */ }; |
| 返回值 | 成功时:SETVAL、IPC_RMID返回0,GETVAL返回当前信号量的值 失败时:返回-1并置位 错误码 |
📖 代码案例
其实在看完我们ctl控制函数后,我们就不难看出:我们信号量的操作若要直接应用在我们的项目中是非常复杂的,那么我们就可以将其封装为数个接口:信号量集的创建、申请资源、释放资源、销毁信号量集
sem.h
#ifndef _SEM_H_ #define _SEM_H_ //创建信号量集并初始化:semcount表示本次创建的信号量集中信号灯的个数 int create_sem(int semcount); //申请资源操作,semno表示要被申请资源的信号量编号 int sem_P(int semid, int semno); //释放资源操作,semno表示要被释放资源的信号量编号 int sem_V(int semid, int semno); //删除信号量集 int delete_sem(int semid); #endifsem.cpp
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/shm.h> #include <sys/sem.h> using namespace std; union semun { int val; // 设置信号量的值 struct semid_ds *buf; //关于信号量集属性的操作 unsigned short *array; //对于信号量集中所有信号量的操作 struct seminfo *__buf; /* Buffer for IPC_INFO(Linux-specific) */ }; //定义一个关于对信号量初始化函数 int init_sem(int semid, int semno){ int val = -1; //让用户输入信号量的初始值 printf("请输入第%d个信号量的初始值:", semno+1); scanf("%d", &val); getchar(); //吸收回车,以免影响其他程序 //调用semctl完成设置 union semun us; us.val = val; if(semctl(semid, semno, SETVAL, us) == -1){ perror("semctl error"); return -1; } return 0; } //创建信号量集并初始化:semcount表示本次创建的信号量集中信号灯的个数 int create_sem(int semcount) { //1、创建key值 key_t key = ftok("/", 'k'); if(key == -1){ perror("ftok error"); return -1; } //2、通过key值创建信号量集 int semid = -1; if((semid = semget(key, semcount, IPC_CREAT|IPC_EXCL|0664)) == -1){ if(errno == EEXIST){ //表示信号量集已经存在,直接打开即可 semid = semget(key, semcount, IPC_CREAT|0664); //将信号量集直接打开 return semid; } perror("semget error"); return -1; } //3、循环将信号量集中的所有信号量进行初始化 //该操作,只有在第一次创建信号量集时需要进行操作, //后面再打开该信号量集时,就无需进行初始化操作了 for(int i=0; i<semcount; i++){ init_sem(semid, i); //调用自定义函数将每个信号量进行初始化 } //将信号量集的id返回 return semid; } //申请资源操作,semno表示要被申请资源的信号量编号 int sem_P(int semid, int semno){ //定义一个结构体变量 struct sembuf buf; buf.sem_num = semno; //要操作的信号编号 buf.sem_op = -1; //-1表示要申请该信号量的资源 buf.sem_flg = 0; //表示阻塞形式进行申请 //调用semop函数完成资源的申请 if(semop(semid, &buf, 1) == -1){ perror("P error"); return -1; } return 0; } //释放资源操作,semno表示要被释放资源的信号量编号 int sem_V(int semid, int semno){ //定义一个结构体变量 struct sembuf buf; buf.sem_num = semno; buf.sem_op = 1; buf.sem_flg = 0; //要操作的信号编号 //1表示要释放该信号量的资源 //表示阻塞形式进行释放 //调用semop函数完成资源的释放 if(semop(semid, &buf, 1) == -1){ perror("V error"); return -1; } return 0; } //删除信号量集 int delete_sem(int semid){ //调用semctl函数完成对该信号量集的删除 if(semctl(semid, 0, IPC_RMID) == -1){ perror("delete error"); return -1; } return 0; }我们封装好了我们的信号量的接口,现在就解决我们共享内存最后遗留的竞态问题吧。
- 发送端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/shm.h> #include <sys/sem.h> #include"sem.h" using namespace std; #define PAGE_SIZE 4096 int main(int argc, const char *argv[]){ //11、创建并打开信号量集 int semid = create_sem(2); //1、创建key值 key_t key = ftok("/", 'k'); if(key == -1) { perror("ftok error"); return -1; } printf("key = %#x\n", key); //将自定义的头文件加入 //一页的大小 //调用自定义函数,完成对信号量集的创建 //输出key值 //2、通过key值创建共享内存段 int shmid = -1; if((shmid= shmget(key, PAGE_SIZE, IPC_CREAT|0664)) == -1){ perror("shmget error"); return -1; } printf("shmid = %d\n", shmid); //3、将共享内存段映射到用户空间 char *addr = (char *)shmat(shmid, NULL, 0); //NULL表示让系统自动寻找对齐页 //0表示对该共享内存段的操作是读写操作打开 if(addr == (void*)-1) { perror("shmat error"); return -1; } printf("addr = %p\n", addr); //输出共享内存段映射的地址 //4、对共享内存进行操作 while(1) { //22、调用自定义函数:申请0号信号量的资源 sem_P(semid, 0); printf("请输入>>>"); fgets(addr, PAGE_SIZE, stdin); //从终端输入数据放入共享内存中 addr[strlen(addr)-1] = 0; //33、调用自定义函数:释放1号信号量的资源 sem_V(semid, 1); if(strcmp(addr, "quit") == 0) { break; } } //5、取消映射 if(shmdt(addr) == -1) { perror("取消映射\n"); return -1; } //44、调用自定义函数:删除信号量集 delete_sem(semid); return 0; }- 接收端代码
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/shm.h> #include <sys/sem.h> #include"sem.h" using namespace std; #define PAGE_SIZE 4096 int main(int argc, const char *argv[]){ //11、创建并打开信号量集 int semid = create_sem(2); //1、创建key值 key_t key = ftok("/", 'k'); if(key == -1){ perror("ftok error"); return -1; } printf("key = %#x\n", key); //将自定义的头文件加入 //一页的大小 //调用自定义函数,完成对信号量集的创建 //输出key值 //2、通过key值创建共享内存段 int shmid = -1; if((shmid= shmget(key, PAGE_SIZE, IPC_CREAT|0664)) == -1){ perror("shmget error"); return -1; } printf("shmid = %d\n", shmid); //3、将共享内存段映射到用户空间 char *addr = (char *)shmat(shmid, NULL, 0); //NULL表示让系统自动寻找对齐页 //0表示对该共享内存段的操作是读写操作打开 if(addr == (void*)-1){ perror("shmat error"); return -1; } printf("addr = %p\n", addr); //输出共享内存段映射的地址 //4、对共享内存进行操作 while(1){ //22、调用自定义函数:申请0号信号量的资源 sem_P(semid, 1); printf("读到的信息为:%s\n", addr); //33、调用自定义函数:释放1号信号量的资源 if(strcmp(addr, "quit") == 0){ break; } sem_V(semid, 0); } //5、取消映射 if(shmdt(addr) == -1){ perror("取消映射\n"); return -1; } //44、调用自定义函数:删除信号量集 delete_sem(semid); return 0; }现在我们的接收端与发送端就不会去强占我们的信号量集了。
📖 适用场景、优缺点及注意事项
- 适用:保护共享资源(共享内存、文件等);实现进程同步(如生产者-消费者)。
- 优点:支持原子操作多个信号量;可以处理复杂的同步逻辑。
- 缺点:接口复杂(需要联合体 semun);操作失误可能导致死锁;SEM_UNDO 可避免进程意外退出时资源泄露
注意事项:
- 信号量集是完成多个进程间同步问题的,一般不进行信息的通信
- 信号量集的使用,本质上是对多个value值进行管控,每个信号量控制一个进程,在进程执行 前,申请一个信号量的资源,执行后,释放另一个信号量的资源
- 如果当前进程申请的信号量值为0,则当前进程在申请处阻塞,直到其他进程将该信号量中的 资源增加到大于0
3.4、三种机制的总结
| 特性 | 消息队列 | 共享内存 | 信号量 |
|---|---|---|---|
| 主要用途 | 传递结构化消息 | 高效共享数据 | 同步访问共享资源 |
| 数据拷贝 | 两次拷贝(用户→内核→用户) | 零拷贝(直接访问物理内存) | 无数据传输 |
| 同步需求 | 无需同步(内核维护队列) | 需要外部同步(如信号量) | 自身就是同步机制 |
| 生命周期 | 内核持久,直到显式删除 | 内核持久,直到显式删除 | 内核持久,直到显式删除 |
| 性能 | 中等 | 高 | 低(仅做同步) |
| 编程难度 | 简单 | 中等(需考虑同步) | 复杂 |
结语
进程间通信是多进程编程的必修课。从最原始的管道,到灵活的信号,再到功能强大的 System V 三剑客,每一种 IPC 机制都有其独特的定位。理解它们,你就能像搭积木一样,构建出复杂的多进程系统。
我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。
无限进步,我们下次再见!
---⭐️封面自取⭐️---




