想象一下:两个进程就像两个被关在各自独立小房间里的人,彼此看不到、摸不着对方的房间,墙上也没有门。他们想交换信息,唯一的办法就是请楼管(操作系统内核)帮忙传话,或者共用一个放在走廊里的公共信箱。
这篇文章要讲的就是这件事——进程间通信(IPC,Inter-Process Communication)。我会回答三个问题:它是什么、怎么用、底层是怎么实现的,全程用最朴素的比喻,配架构图和时序图,争取让没有任何操作系统基础的人也能一次看懂。
〇、先用一句话和一栋楼理解 IPC
IPC 就是”让两个互相隔离的进程,通过内核提供的机制交换数据或同步动作”的一套方法。
一个进程自己是没法直接去读另一个进程的内存的,原因我们下一节讲。于是操作系统出面,在中间搭了各种”通道”:有的像水管(管道),有的像信箱(消息队列),有的像一块公共黑板(共享内存),有的像拍肩膀(信号),还有的像能打到别的城市的电话(套接字)。
一、为什么进程之间”说不上话”?
要理解 IPC,先得理解一个问题:为什么进程不能直接去读对方的变量?
现代操作系统会给每个进程一个独立的虚拟地址空间。在进程 A 眼里,地址 0x1234 可能是它自己的一个变量;但在进程 B 眼里,0x1234 是它自己的另一个变量。同样的地址,指向的是两块完全不同的物理内存。 这就是”地址空间隔离”。
这个隔离是故意的、也是必要的:它保证一个进程崩溃或越界乱写,不会把别的进程搞挂;你的浏览器崩了,不会把正在写的文档也一起带走。
代价就是:两个进程之间没有天然的信息通道。于是只能求助第三方——内核。内核有最高权限,能看到所有进程的内存,也愿意做这个”中间人”。
下面这张分层图能让你一眼看懂”内核在中间”这件事:
flowchart TB
subgraph USER["用户态(每个进程的私有领地)"]
direction LR
A["进程 A<br/>自己的内存"]
B["进程 B<br/>自己的内存"]
end
subgraph KERNEL["内核态(唯一的中介,有权访问所有内存)"]
K["内核提供的通信通道<br/>管道 / 消息队列 / socket / 共享内存映射"]
end
A -->|"A 想发数据,交给内核"| K
K -->|"内核把数据交给 B"| B
一句话:用户态的进程之间隔着墙,所有正经的通信都要绕道内核;只有”共享内存”是个例外,它把同一块物理内存同时映射给两个进程,之后读写就不经过内核了(后面会细讲)。
二、IPC 全家福:一张图看懂所有方式
常见的 IPC 方式有下面这些。先有个全景印象,后面逐个拆开讲:
flowchart LR
A["进程 A<br/>发送方"]
B["进程 B<br/>接收方"]
subgraph CH["六种通信通道"]
direction TB
PIPE["① 管道 pipe<br/>像水管,字节流"]
FIFO["① 命名管道 FIFO<br/>有名字的管道"]
MSGQ["① 消息队列<br/>像信箱,一条条消息"]
SOCK["① 套接字 socket<br/>像电话,可跨机器"]
SHM["② 共享内存<br/>像公共黑板,最快需加锁"]
SIG["③ 信号<br/>像拍肩膀,只通知"]
end
A --> PIPE
A --> FIFO
A --> MSGQ
A --> SOCK
A <--> SHM
A -.-> SIG
PIPE --> B
FIFO --> B
MSGQ --> B
SOCK --> B
SHM <--> B
SIG -.-> B
图中 ① 表示数据要经过内核中转(有拷贝开销),② 表示不经过内核(最快,但需自己加锁),③ 表示只通知、不带数据。
各方式一句话对比:
| 方式 | 一句话理解 | 方向 | 数据形态 | 有没有消息边界 | 能否跨机器 | 相对速度 |
|---|---|---|---|---|---|---|
| 匿名管道 pipe | 一条水管,倒进去的字节按顺序流出来 | 单向(写端 → 读端) | 字节流 | 无 | 否 | 快 |
| 命名管道 FIFO | 给水管起个名字,没血缘关系的进程也能找到 | 单向(要双向就建两条) | 字节流 | 无 | 否 | 快 |
| 消息队列 | 一个信箱,按”整条消息”收发 | 多对多,可双向 | 一条条消息 | 有 | 否 | 中 |
| 共享内存 | 一块公共黑板,大家直接读写真内存 | 双向(双方都能读写) | 内存区域 | 无(需自己定协议) | 否 | 最快 |
| 信号 | 拍一下肩膀,告诉他”有事了” | 单向(发方 → 收方) | 一个编号 | — | 否 | — |
| 套接字 socket | 打电话,本机能打,跨城市也能打 | 双向(全双工) | 字节流或报文 | 因协议而异 | 能 | 中 |
“消息边界”是指:接收方能不能分清”一条消息”从哪里开始、到哪里结束。打个比方:管道是”往水管里倒水”,你连续倒两杯水,流出来时已经混在一起,分不清哪杯是哪杯——这就是无边界的字节流;消息队列是”寄包裹”,每个包裹都是独立整体,收件人每次签收的都是完整的一个包裹——这就是有边界。举个具体例子:发送方连续发 “hello” 和 “world” 两条消息,用管道读,read() 可能一次读到 “helloworld” 十个字节,也可能这次只读到 “hel”、剩下的下次再读,接收方不知道边界在哪;用消息队列收,第一次 msgrcv 一定得到完整的 “hello”、第二次得到完整的 “world”,边界被原样保留。所以当你的数据本身就是”一条一条”的记录时,无边界的方式就得自己约定分隔符(比如换行 \n)或长度前缀,否则就会发生”黏包”。
方向是另一个重要维度,新手常会忽略:管道、命名管道、信号是单向的——数据只朝一个方向走,要双向就得再建一条(或改用套接字);套接字是双向(全双工)的——两端能同时收发;消息队列和共享内存是多对多的——谁都能发、谁都能收。上面那张图里,单向箭头表示只能朝一个方向走,双箭头(<-->)表示两边都能读写。
严格说,信号量(semaphore)也常被归到 IPC 里,但它不是用来传数据的,而是用来协调”谁先谁后、谁能用共享资源”的,所以我们把它当作共享内存的”配套工具”来理解,会在共享内存一节一起说。
下面逐个讲清楚:是什么 → 怎么用 → 原理是什么。
三、管道 pipe:最古老的字节流
是什么
管道是一段存在于内核里的缓冲区,一端写、一端读。它像一根水管:你从这头倒水,水从另一头流出来,先倒进去的先流出来(先进先出,FIFO)。它传输的是没有边界的字节流——你写 5 字节再写 8 字节,读的人拿到的是一连串 13 字节,分不清哪 5 字节是”第一句”。方向上它是单向的:写端只管写、读端只管读,想双向通信就得建两条管道(或改用套接字)。
怎么用
你其实天天在用管道,只是没意识到。在 Linux/Unix 的 Shell 里,| 就是管道:
# 这条命令里藏着两个管道(|),数据从左往右一路流下去:
# cat file.txt → 读出 file.txt 的内容
# grep "hello" → 从上一步的结果里筛出包含 hello 的行
# wc -l → 统计最后还剩几行
cat file.txt | grep "hello" | wc -l
这里 cat 的输出顺着管道流给 grep,grep 的输出又流给 wc。三个进程串成一条流水线。
用 C 语言最直白的写法是这样:
#include <stdio.h> // 提供 printf:往屏幕打印
#include <string.h> // 提供 strlen:求字符串的长度
#include <unistd.h> // 提供 pipe、fork、read、write、close 等系统调用
#include <sys/wait.h> // 提供 wait:让父进程等子进程结束
int main(void) {
int fd[2]; // 声明两个整数,用来存管道两端的"文件描述符"
pipe(fd); // 创建管道:内核分配一段缓冲区,并填好 fd[0]=读端、fd[1]=写端
if (fork() == 0) { // fork 会复制出一个子进程;返回 0 说明"我现在是子进程"
/* ---------- 子进程:只负责读 ---------- */
close(fd[1]); // 子进程不需要写端,先关掉(避免误用)
char buf[64] = {0}; // 准备一个 64 字节的缓冲区,并全部清零
read(fd[0], buf, sizeof(buf)); // 从管道读端读数据,读到的字节放进 buf
printf("子进程收到:%s\n", buf); // 把读到的内容打印出来
close(fd[0]); // 用完读端,关闭
} else {
/* ---------- 父进程:只负责写 ---------- */
close(fd[0]); // 父进程不需要读端,先关掉
const char *msg = "hello from parent"; // 准备好要发送的一句话
write(fd[1], msg, strlen(msg)); // 把这句话写进管道写端
close(fd[1]); // 写完关闭写端;所有写端都关掉后,子进程的 read 会返回 0(表示没有更多数据)
wait(NULL); // 父进程等子进程跑完再继续,避免子进程的输出还没打印父进程就退出了
}
return 0; // 程序正常结束
}
编译运行:gcc pipe.c -o pipe && ./pipe,你会看到子进程打印出父进程写进来的那句话。这里 fork() 之后父进程和子进程就是两个进程,它们通过同一条管道通信——匿名管道天生就是靠父子进程来演示”两个进程”的(因为管道两端的描述符只能靠 fork 继承)。
实现原理
调用 pipe() 时,内核做了这么几件事:
- 创建一个 inode,里面挂着一个环形缓冲区(Linux 上默认约 64KB,即 16 个 4KB 页);
- 返回两个文件描述符:
fd[0]指向读端、fd[1]指向写端; - 之后
write()把数据从用户空间拷贝进内核缓冲区,read()再把数据从内核缓冲区拷贝回用户空间。
这里有两个关键点:
- 数据要经过内核两次拷贝(用户 → 内核 → 用户),所以比共享内存慢;
- 管道是”同步阻塞”的:缓冲区满了,
write会阻塞等读;缓冲区空了,read会阻塞等写。这天然形成了一种生产者-消费者的节奏控制。
四、命名管道 FIFO:给管道起个名字
是什么
匿名管道有个缺点:它只存在于有”亲缘关系”的进程之间(比如 fork 出来的父子进程),因为只有它们手里有那两个文件描述符。两个毫不相干的进程想通信,匿名管道就抓瞎了。
命名管道(FIFO)解决的就是这个:它在文件系统里有一个名字(一个文件路径,准确说是特殊文件,不是普通文件),任何进程只要知道这个名字,就能打开它来读写。它本质还是那根”水管”,只是多了块”门牌号”。方向上和匿名管道一样是单向的字节流,要双向就建两条。
怎么用
用 mkfifo 创建一个命名管道:
# 创建一个命名管道文件 /tmp/myfifo(它只是个"门牌号",本身不存数据)
mkfifo /tmp/myfifo
然后两个终端,一边写一边读:
# 终端 1:把 echo 的输出重定向(>)进这个管道,相当于"往水管里倒水"
# 注意:此时会阻塞住,直到有人从另一端来读,数据才真正流过去
echo "hello fifo" > /tmp/myfifo
# 终端 2:从管道里把数据读出来(cat 一打开管道,终端 1 的 echo 就解除阻塞)
cat /tmp/myfifo
你会看到终端 1 的命令在等终端 2 打开管道的那一刻,数据才真正流过去——这正是管道”同步阻塞”的体现。
用 C 语言写两个独立的小程序(一个写、一个读),就能看出命名管道是怎么被两个”毫不相干”的进程使用的:
先记住一件事:mkfifo 负责创建这个特殊文件(只需执行一次,它不会给你可读写的描述符),真正收发数据要再 open() 打开它——open 打开的不是普通文件,而是这个 FIFO 特殊文件,内核看 inode 类型就知道该走管道、而不是去碰磁盘。
写端 fifo_writer.c:
#include <stdio.h> // printf、perror
#include <stdlib.h> // exit
#include <string.h> // strlen
#include <fcntl.h> // open、O_WRONLY
#include <unistd.h> // access、write、close
#include <sys/stat.h> // mkfifo
#define FIFO_NAME "/tmp/myfifo" // 给管道起个名字,后面统一用它
int main(void) {
// 1) 如果这个"特殊文件"还不存在,先用 mkfifo 创建(这一步只需执行一次)
if (access(FIFO_NAME, F_OK) == -1) { // F_OK:只检查路径是否存在
if (mkfifo(FIFO_NAME, 0666) < 0) { // mkfifo 创建 FIFO;0666 是权限;失败会返回负数
perror("mkfifo"); // 打印"为什么失败"
exit(EXIT_FAILURE); // 并退出程序
}
}
// 2) 以"只写"方式打开 FIFO(打开的是特殊文件,不是普通文件);没有读端时 open 会阻塞
int fd = open(FIFO_NAME, O_WRONLY);
if (fd < 0) { // 打开失败(比如路径仍不存在)
perror("open"); // 打印错误
exit(EXIT_FAILURE); // 并退出
}
const char *msg = "hello fifo"; // 要写进去的一句话
write(fd, msg, strlen(msg)); // 把这句话写进管道
close(fd); // 关闭写端(对方会因此读到"没有更多数据")
return 0; // 程序正常结束
}
读端 fifo_reader.c:
#include <stdio.h> // printf、perror
#include <stdlib.h> // exit
#include <fcntl.h> // open、O_RDONLY
#include <unistd.h> // access、read、close
#include <sys/stat.h> // mkfifo
#define FIFO_NAME "/tmp/myfifo" // 和写端用同一个管道名字
int main(void) {
// 1) 如果管道还不存在,先创建(和写端一样,谁先启动谁负责建)
if (access(FIFO_NAME, F_OK) == -1) { // F_OK:只检查路径是否存在
if (mkfifo(FIFO_NAME, 0666) < 0) { // mkfifo 创建 FIFO;0666 是权限;失败返回负数
perror("mkfifo"); // 打印"为什么失败"
exit(EXIT_FAILURE); // 并退出
}
}
// 2) 以"只读"方式打开 FIFO;没有写端时 open 会阻塞,等着写端来写
int fd = open(FIFO_NAME, O_RDONLY);
if (fd < 0) { // 打开失败(比如路径仍不存在)
perror("open"); // 打印错误
exit(EXIT_FAILURE); // 并退出
}
char buf[64] = {0}; // 准备一个 64 字节的缓冲区,并清零
ssize_t n = read(fd, buf, sizeof(buf)); // 从管道读数据,返回实际读到的字节数
printf("读到 %zd 字节:%s\n", n, buf); // 打印读到的字节数和内容
close(fd); // 关闭读端
return 0; // 程序正常结束
}
编译并运行(两个程序都会”不存在就创建”,所以先启动哪一个都行;只是别在同一个终端里同时起两个,那样第二个 mkfifo 会因”已存在”报错):
# 1) 先编译出两个可执行文件
gcc fifo_writer.c -o writer
gcc fifo_reader.c -o reader
# 2) 终端 1 先启动任意一端,它会建好管道并阻塞着等对端(下面以写端为例)
./writer
# 3) 终端 2 再运行读端:两边一接通,数据立刻流过去,读端马上打印
./reader
实现原理
和匿名管道几乎一样,区别只在开头:mkfifo 会在文件系统里创建一个特殊文件(special file)。它虽然出现在目录里、有个名字,但不是普通文件——它没有任何数据块,磁盘上不存内容,只是一个”入口 / 门牌号”。
那操作系统怎么知道它不是普通文件、而是管道呢?答案是看 inode(索引节点)里的文件类型字段。Unix 里每个文件都对应一个 inode,inode 的 mode 字段记录了它是”普通文件、目录、FIFO、设备、符号链接、套接字”中的哪一种。mkfifo 创建的就是一个类型被标记为 FIFO(S_IFIFO) 的 inode。用 ls -l 看,它的权限串第一位是 p(普通文件是 -,目录是 d):
$ mkfifo /tmp/myfifo
$ ls -l /tmp/myfifo
prw-r--r-- 1 user user 0 10月24 12:00 /tmp/myfifo
# 最前面的 p = pipe(管道),如果是普通文件这里会是 -,目录则是 d
于是当某个进程 open() 这个路径时,内核读到 inode 的类型是 FIFO,就会把它交给管道子系统处理,而不是普通文件读写:读操作从它关联的内核缓冲区里取数据,写操作往缓冲区里塞数据,整个过程不碰磁盘。这跟设备文件很像——/dev/sda 看起来也是个文件,但读写它实际是在操作硬盘驱动,而不是往文件系统里存内容。
所以结论是:它确实是个文件(占一个目录项),但它是特殊文件;内核靠 inode 里的类型标记把它和普通文件区分开,一打开就知道该走管道逻辑,而不是去磁盘上读写。数据还是存在内核缓冲区里,读写规则和匿名管道完全相同。
五、消息队列:一条条有边界的”包裹”
是什么
管道是”倒水”,消息队列是”寄包裹”。每条消息是一个完整的、有边界的单元,还自带一个类型号。接收方可以按类型挑着收,收到的永远是”一整条”消息,不会和别的消息糊在一起。方向上是多对多、可双向的:任何一个进程都能往同一个队列里发消息,也能从里面取消息。
怎么用
用 System V 接口写两个程序(一个发、一个收)。关键是两个程序必须用同一个 key,才能找到同一个队列:
发送端 mq_sender.c:
#include <stdio.h> // printf
#include <string.h> // strlen、strcpy
#include <sys/ipc.h> // IPC 相关定义
#include <sys/msg.h> // msgget、msgsnd
#define MSG_KEY 1234 // 队列的"钥匙":两个进程必须用同一个 key 才能找到同一个队列
/* 消息结构体:内核要求"一个类型号 + 一段内容";两个程序的定义要一致 */
struct msgbuf {
long mtype; // 消息类型,必须是正数(这里用 1)
char mtext[128]; // 消息正文,最多 127 字符 + 结尾 \0
};
int main(void) {
// 打开(不存在就创建)key=1234 的消息队列,返回队列 ID
int msqid = msgget(MSG_KEY, 0666 | IPC_CREAT);
struct msgbuf send; // 准备要发送的消息
send.mtype = 1; // 类型设为 1
strcpy(send.mtext, "你好,消息队列"); // 填正文
msgsnd(msqid, &send, strlen(send.mtext), 0); // 发送:第3个参数是正文长度,第4个 0 表示队列满就阻塞
printf("发送端:已发送一条消息\n");
return 0; // 发送完退出;消息会留在队列里等接收端来取
}
接收端 mq_receiver.c:
#include <stdio.h> // printf
#include <sys/ipc.h> // IPC 相关定义
#include <sys/msg.h> // msgget、msgrcv、msgctl
#define MSG_KEY 1234 // 和发送端同一个 key
struct msgbuf {
long mtype; // 消息类型
char mtext[128]; // 消息正文
};
int main(void) {
int msqid = msgget(MSG_KEY, 0666 | IPC_CREAT); // 打开同一个队列
struct msgbuf recv = {0}; // 清零:msgrcv 不会补字符串结尾的 \0
ssize_t n = msgrcv(msqid, &recv, sizeof(recv.mtext), 1, 0); // 收类型 1 的消息
printf("接收端:收到 %zd 字节 = %s\n", n, recv.mtext);
msgctl(msqid, IPC_RMID, NULL); // 读完后删除队列,清理内核资源
return 0; // 程序正常结束
}
编译并运行(先启动哪个都行:消息会暂存在队列里):
gcc mq_sender.c -o mq_sender
gcc mq_receiver.c -o mq_receiver
./mq_sender # 终端 1:发送一条消息
./mq_receiver # 终端 2:用同一个 key 找到同一队列,取走消息
注意 msgrcv 的第四个参数 1 表示”只要类型为 1 的消息”。这就是它比管道强的地方:带类型、可挑选。
另外注意 recv = {0} 这个清零很关键:msgrcv 只把消息内容原样拷进来,不会帮你补字符串结尾的 \0。如果 recv 不提前清零,后面的 printf("%s", recv.mtext) 就可能继续往后打印出一堆乱码——这是消息队列最容易踩的小坑。
实现原理
内核为每个消息队列维护一个消息链表,每个节点是一条消息(对应内核里的 struct msg_msg,存着类型和内容)。
msgsnd:在内核里分配一个消息节点,把数据从用户空间拷进去,挂到链表尾部;msgrcv:在链表里找到第一条类型匹配的消息,把它取出来,数据拷回用户空间,节点释放。
因为它把”一条消息”当成一个整体来管理,所以边界是天然保留的——这就是它和管道最本质的区别。
六、共享内存:最快的通信方式(但要自己加锁)
是什么
前面所有方式,数据都要”用户 → 内核 → 用户”地来回拷贝。共享内存干脆跳过中转:让两个进程的页表都指向同一块物理内存。于是进程 A 写进去的值,进程 B 直接就能读到,零拷贝、几乎零延迟。方向上它是双向的:所有映射它的进程都能读、都能写。
代价是:因为太”直接”了,内核不再帮你做任何同步。如果两个进程同时读写,数据会乱。所以共享内存通常要搭配信号量或互斥锁一起用,自己约定”谁先写、谁后读”。
怎么用
用 POSIX 接口写两个程序(一个写、一个读),就能看到两个进程如何直接共享同一块内存:
写端 shm_writer.c:
#include <stdio.h> // printf、snprintf
#include <fcntl.h> // O_CREAT、O_RDWR
#include <unistd.h> // ftruncate、sleep
#include <sys/mman.h> // shm_open、mmap、munmap、shm_unlink
/* 两个进程必须用完全一样的结构体定义(真实项目会放到公共头文件里) */
struct shm_data {
int ready; // 0 = 还没数据,1 = 已写入可读
char msg[128]; // 要传递的消息正文
};
int main(void) {
// 1) 创建(或打开)名为 /myshm 的共享内存,返回文件描述符
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, sizeof(struct shm_data)); // 把共享内存大小设为结构体那么大
// 2) 把共享内存映射进本进程地址空间
struct shm_data *d = mmap(NULL, sizeof(struct shm_data),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 3) 安全写入消息,并把 ready 置 1 通知读端"有数据了"
snprintf(d->msg, sizeof(d->msg), "%s", "hello from writer"); // 限长写入,绝不越界
d->ready = 1; // 置标志,告诉读端可以读了
printf("写端:已写入,等读端来读...\n");
while (d->ready == 1) sleep(1); // 等读端读完把 ready 置回 0(最简同步)
munmap(d, sizeof(struct shm_data)); // 解除映射
shm_unlink("/myshm"); // 清理共享内存对象
return 0; // 程序正常结束
}
读端 shm_reader.c:
#include <stdio.h> // printf
#include <fcntl.h> // O_RDWR
#include <unistd.h> // sleep
#include <sys/mman.h> // shm_open、mmap、munmap
/* 和写端保持完全一致的结构体定义 */
struct shm_data {
int ready; // 0 = 还没数据,1 = 已写入可读
char msg[128]; // 要传递的消息正文
};
int main(void) {
// 1) 打开同一块共享内存(名字必须和写端一致;由先启动的写端创建)
int fd = shm_open("/myshm", O_RDWR, 0666);
// 2) 映射进本进程地址空间(和写端指向同一块物理内存)
struct shm_data *d = mmap(NULL, sizeof(struct shm_data),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
while (d->ready == 0) sleep(1); // 等写端把 ready 置 1
printf("读端:收到消息 = %s\n", d->msg); // 直接读到写端写进来的内容
d->ready = 0; // 告诉写端"读完了"
munmap(d, sizeof(struct shm_data)); // 解除映射
return 0; // 程序正常结束
}
编译并运行(先启动写端,再启动读端):
# 1) 先编译出两个可执行文件
gcc shm_writer.c -o shm_writer
gcc shm_reader.c -o shm_reader
# 2) 终端 1 先启动写端:创建共享内存、写入消息,然后等读端来读
./shm_writer
# 3) 终端 2 再启动读端:读到消息后把 ready 置回 0,写端随之退出
./shm_reader
这里用共享内存里的一个 ready 标志加 sleep 轮询,做了最简单的同步(约定谁先写、谁后读)。真实项目里这种”忙等”太浪费 CPU、而且标志本身不是原子的,一般会换成信号量或互斥锁——但作为演示,这已经能清楚看到:两个进程通过同一块内存直接交换了数据,中间没有任何拷贝。
注意越界:mmap 只是把 d 指向的结构体大小那段内存变成合法内存,C 语言不会替你检查越界。读写 d->msg 的 sizeof(d->msg) 字节内是安全的;一旦写超出这个范围就是越界——如果越到没被映射的下一页,程序会直接段错误(Segmentation fault);如果恰好落在相邻的已映射内存上,就会悄悄覆盖别的数据,留下更难排查的 bug。共享内存不会因为写超而自动变大,读写范围必须由你自己控制。另外,ftruncate 设置的对象大小要和 mmap 的映射大小保持一致,否则访问超出对象实际大小的部分可能触发 SIGBUS。
如何避免越界:核心原则是把”指针”和”大小”绑在一起,读写前先检查长度。上面的写端已经演示了其中两种——snprintf 限长写入、结构体 + sizeof 自动算边界。再补充两个常用技巧(下面 msg 代表你要写入的消息字符串):
- 写之前先检查长度,放不下就直接拒绝:
if (strlen(msg) >= sizeof(d->msg)) { // 消息长度 ≥ 缓冲区大小,连 \0 都放不下
fprintf(stderr, "消息太长\n"); // 打印错误提示
return 1; // 退出,避免越界写
}
snprintf(d->msg, sizeof(d->msg), "%s", msg); // 确认放得下,再写入
- 开发期用 AddressSanitizer 让越界当场现形,而不是等到段错误才懵:
gcc -fsanitize=address -g shm_writer.c -o shm_writer && ./shm_writer
# 一旦越界,程序会立刻报出越界发生在哪一行、越了多大
实现原理
关键就在 mmap 和页表:
shm_open让内核创建一块共享内存对象(底层是个文件,存在 tmpfs 上);mmap把这块内存映射进进程 A 的地址空间,同时把 A 页表里的对应项指向这块物理内存;- 进程 B 用同样的名字
shm_open+mmap,它的页表也指向同一块物理内存; - 之后两边读写,就是直接操作同一块物理内存,内核完全不参与数据的搬运。
这正是它最快的原因,也是它需要你自己做同步的原因——内核连”数据传没传”都不管了,更不会管”会不会同时读写冲突”。
七、信号 signal:最轻量的”拍肩膀”
是什么
信号不是一个”通道”,更像一个通知:内核或另一个进程,往目标进程的”待办”里塞一个编号,意思是”有件事发生了”。它几乎不携带数据(只有编号,最多再带一点点上下文)。方向上是单向的:从一个进程(或内核)指向另一个进程或进程组,被通知的一方不会回传数据。
常见的信号你一定见过:Ctrl+C 就是向前台进程发 SIGINT;kill <pid> 默认发 SIGTERM;程序崩溃报的 “Segmentation fault” 背后是 SIGSEGV。
怎么用
最简单的用法:给某个进程发信号。
kill -TERM 12345 # 给 12345 号进程发 SIGTERM:请求它"优雅退出"(进程可以自行清理后退出)
kill -KILL 12345 # 给 12345 号进程发 SIGKILL:强制杀死(进程无法捕获或忽略这个信号)
再用两个程序完整演示:一个注册处理函数等着收信号,另一个负责发信号(SIGKILL 和 SIGSTOP 除外,其余信号都能被捕获):
接收端 signal_receiver.c:
#include <stdio.h> // printf
#include <signal.h> // signal、SIGINT
#include <unistd.h> // getpid、sleep
// 信号处理函数:收到信号时内核会调用它,参数 sig 是信号编号
void on_sigint(int sig) {
printf("收到信号 %d,我要优雅收尾了\n", sig); // 打印收到的信号编号
}
int main(void) {
printf("我的 PID 是 %d,等别人发 SIGINT 给我\n", getpid()); // 打印自己的 PID,供发送端使用
signal(SIGINT, on_sigint); // 注册:收到 SIGINT 时执行 on_sigint
while (1) sleep(1); // 无限循环:每 1 秒睡一下,等着信号到来
return 0; // 因为上面是死循环,正常情况下走不到这行
}
发送端 signal_sender.c:
#include <stdio.h> // printf
#include <stdlib.h> // atoi
#include <signal.h> // kill、SIGINT
int main(int argc, char *argv[]) {
if (argc < 2) { // 命令行没带 PID 参数
printf("用法:%s <目标进程的 PID>\n", argv[0]); // 提示用法
return 1; // 退出
}
int pid = atoi(argv[1]); // 把命令行参数转成整数 PID
kill(pid, SIGINT); // 给这个 PID 的进程发 SIGINT
printf("已向 PID %d 发送 SIGINT\n", pid); // 提示发送完成
return 0; // 程序正常结束
}
编译并运行(先启动接收端,再把它的 PID 填给发送端):
gcc signal_receiver.c -o receiver
gcc signal_sender.c -o sender
./receiver # 终端 1:打印出自己的 PID,然后进入等待
./sender 12345 # 终端 2:把上面打印的 PID 填进来,发 SIGINT 给它
实现原理
每个进程在内核里有一个 task_struct(进程的”档案”),里面有一组挂起信号位图。别人发信号,就是在这张位图里把对应的位置成 1。内核在合适的时机(比如进程从内核态返回用户态时)检查这张位图,发现有信号,就调用你注册的处理函数,或执行默认动作(忽略 / 终止 / 转储核心)。
八、套接字 socket:本机能用,跨机器也能用
是什么
套接字是”通信端点”的抽象。它最出名的地方是网络编程——TCP/IP 套接字让不同机器上的进程通信。但套接字也有本机版:Unix 域套接字(Unix domain socket),性能和管道一个量级,但接口更通用。方向上是双向(全双工)的:两端可以同时收发数据,这也是它比管道更灵活的地方。
怎么用
用 Python 写两个程序:一个服务端、一个客户端(极简版,服务端只接收一条消息就退出)。这个例子用 TCP,本机能跑,跨机器时把客户端的 127.0.0.1 换成服务器 IP 也能跑:
服务端 tcp_server.py:
import socket # 导入 socket 模块
srv = socket.socket() # 创建一个服务端套接字(不传参数默认是 TCP)
srv.bind(("0.0.0.0", 9000)) # 绑定到本机所有网卡的 9000 端口
srv.listen(1) # 开始监听,最多允许 1 个连接排队
conn, addr = srv.accept() # 阻塞等待客户端连进来;返回连接对象 conn 和对方地址 addr
print("收到:", conn.recv(1024).decode()) # 从这条连接收最多 1024 字节,解码后打印
conn.close() # 关闭这条连接
客户端 tcp_client.py:
import socket # 导入 socket 模块
cli = socket.socket() # 创建一个客户端套接字(TCP)
cli.connect(("127.0.0.1", 9000)) # 连接本机的 9000 端口(对应上面的服务端)
cli.sendall(b"hello over TCP") # 发送一条二进制字节串
cli.close() # 关闭连接
运行:终端 1 先启动 python tcp_server.py(会阻塞等待),终端 2 再启动 python tcp_client.py,服务端立刻打印收到的消息。
实现原理
套接字本质上是一套”挂在文件描述符上的网络协议栈入口”。send/recv 的数据会经过内核的 TCP/IP 协议栈(分片、封包、加校验、排队、流量控制……),再从网卡发出去;本机的 Unix 域套接字则跳过网络部分,直接在两个进程间搬运数据。这也是它比管道”重”、但通用性最强的原因——一套接口,本机和跨机器都能用。
九、一张时序图看懂”写 → 读”全过程
前面讲的是”是什么”,现在用一张时序图,把管道的完整交互串起来。看懂这一张,其他方式就是”把中间那个盒子换一换”:
sequenceDiagram
autonumber
participant A as 进程 A(生产者)
participant K as 内核(管道缓冲区)
participant B as 进程 B(消费者)
A->>A: 准备好数据 "hello"
A->>K: write(fd[1], "hello")<br/>数据从 A 的用户空间拷贝进内核
Note over K: 数据暂存在内核的环形缓冲区
B->>K: read(fd[0], buf)<br/>尝试读取
K-->>B: 把数据从内核拷贝到 B 的用户空间<br/>返回读到的 5 字节
B->>B: 拿到 "hello",继续干活
Note over A,B: 关键:数据从不直接经过对方的内存<br/>内核是唯一的中转站
再看消息队列的收发,注意它一条一条的节奏(有边界):
sequenceDiagram
autonumber
participant A as 进程 A
participant Q as 内核消息队列
participant B as 进程 B
A->>Q: msgsnd() 放入消息1(类型1)
A->>Q: msgsnd() 放入消息2(类型2)
B->>Q: msgrcv(type=2)<br/>按类型取走消息2
Q-->>B: 返回完整的消息2
Note over A,Q: 消息1 还留在队列里<br/>因为 B 只收了类型2
十、怎么选:一张决策表
没有”最好”的 IPC,只有”最合适”的。按场景选:
| 你的场景 | 推荐方式 | 原因 |
|---|---|---|
| 父子 / 亲缘进程,传简单字节流 | 匿名管道 pipe | 最省事,fork 后直接继承 |
| 两个不相干的本机进程 | 命名管道 FIFO / Unix 套接字 / 消息队列 | 有名字或地址,可被对方找到 |
| 要传海量数据、追求极限速度 | 共享内存 + 信号量 | 零拷贝,最快 |
| 要”一条一条”带类型地收发 | 消息队列 | 天然有消息边界 |
| 只通知”某件事发生了”,不带数据 | 信号 | 最轻量 |
| 要跨机器(不同主机) | TCP/UDP 套接字 | 走网络协议栈 |
| 要双向、且希望接口统一好扩展 | 套接字 | 本机跨机一套接口 |
十一、总结
把整篇文章收成几句话:
- 进程因为地址空间隔离而无法直接通信,所以需要内核这个中介——这就是 IPC 存在的根本原因;
- 按”数据是否经过内核”可以把方式分成两类:管道 / 命名管道 / 消息队列 / 套接字要经过内核中转(有拷贝开销),共享内存建好后直接读写真内存(最快但需自己同步),信号则只传通知不传数据;
- 选型记住几个关键词:要速度选共享内存,要”一条条”选消息队列,要简单选管道,要跨机器选套接字,只通知选信号;
- 所有方式在”怎么用”上其实都很像:一方发、一方收,区别只在于”中间那个盒子”是水管、信箱、黑板还是电话线。
理解了这套框架,再去读 Linux 里 pipe、msgget、shm_open、socket 的源码或文档,就会觉得豁然开朗——它们不过是在”中间那个盒子”上做了不同实现而已。