进程间通信:管道、共享内存与拷贝次数
卷 III 讲了地址空间怎么把进程彻底隔开——这是好事,但也带来一个问题:隔开了,怎么说话?这一章的答案可以浓缩成一个算术题:数一数拷了几次、过了几趟线。
隔离的代价
两个线程通信是零成本的——它们共享地址空间,一个写全局变量,另一个直接就看见了(第 7 章)。
两个进程不行。它们的页表是分开的,进程 A 的任何一个地址在进程 B 那里都翻译到别处。A 没有任何办法把一个指针递给 B。
所以进程间通信只有两条路:
- 请内核当中间人——A 把数据交给内核,内核再交给 B。两次拷贝、两次过线。
- 让两边的页表指向同一批物理页——绕过内核。零拷贝、零过线。
所有的 IPC 机制,都是这两条路的变体。
下面这台机器按拷贝次数和系统调用次数算账。关键操作:把消息大小从 1 KB 拖到 1 MB,看主导项怎么从「系统调用」变成「拷贝」。
管道:最 Unix 的那一个
pipe() 创建一对 fd:一个只读,一个只写。写进去的字节从读的那一头出来。
int fds[2];
pipe(fds); // fds[0] 读,fds[1] 写
if (fork() == 0) {
close(fds[1]); // 子进程只读
read(fds[0], buf, n);
} else {
close(fds[0]); // 父进程只写
write(fds[1], "hi", 2);
}
注意 fork 是必需的——管道靠继承 fd 来建立连接(第 16 章),所以它只能用在有亲缘关系的进程之间。(命名管道 mkfifo 在文件系统里留个名字,突破了这个限制。)
管道有两个内建的好性质,它们让 shell 管道能工作:
- 天然的流控:内核缓冲区(默认 64 KB)满了,写方就阻塞;空了,读方就阻塞。你不需要写任何背压逻辑,它自带。这就是为什么
cat huge.log | grep x不会把内存撑爆——cat会被grep的速度自动拖住。 - EOF 语义清晰:写端全部关闭后,读端
read返回 0。所以grep知道什么时候结束。
反过来:如果读端关了,写端还在写,写方会收到 SIGPIPE,默认动作是杀掉进程。
这就是 head 的工作原理:
$ cat huge.log | head -5
head 读够 5 行就退出了,管道读端关闭。cat 继续写 → 收到 SIGPIPE → 被杀。这是设计好的行为,不然 cat 会把整个大文件读完才发现没人要。
但在服务端代码里这会变成事故:客户端断开连接后你还在 write,进程直接死掉,而且日志里什么都没有。
标准做法是全局忽略它,改用 write 的返回值判断:
signal(SIGPIPE, SIG_IGN); // 忽略,让 write 返回 -1/EPIPE 而不是杀我
几乎所有网络库启动时都会做这一步。如果你的服务在客户端断连时会莫名其妙退出,先查这个。
Unix domain socket:能传 fd 的那一个
和管道代价差不多(两次拷贝、两次过线),但它有三个额外能力:
- 双向——管道是单向的。
- 无关进程也能用——通过文件系统路径(
/tmp/app.sock)连接。 - ★ 能传递 fd——通过
SCM_RIGHTS辅助数据。
第三条是第 16 章说的「fd 是一种能力」的直接应用。接收方拿到的是一个可用的 fd,哪怕它自己根本没权限打开那个文件。
这个能力支撑了一整类架构:
| 谁在用 | 怎么用 |
|---|---|
| systemd socket 激活 | systemd 先监听端口,请求来了才启动服务,把 listen fd 递过去。服务不用有绑定端口的权限 |
| Chrome 沙箱 | 渲染进程几乎不能调任何系统调用,需要文件时由主进程打开好递过来 |
| Docker | 容器运行时之间传递各种 fd |
| Nginx 平滑重启 | 新的 master 从旧的那里接管 listen fd,一个连接都不丢 |
顺带说一个实用的点:本机通信用 Unix socket 比用 TCP 回环快,因为后者要完整走一遍 TCP/IP 协议栈——分段、校验和、协议头、拥塞控制,全都白做。数据库、Redis 都支持 Unix socket 连接,本机部署时值得用。
共享内存:零拷贝,零过线
这是唯一真正绕过内核的方案:
// 两个进程映射同一块内存
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 42; // ← 另一个进程立刻就能看见。没有系统调用,没有拷贝
内核只在建立映射时参与了一次。之后 p[0] = 42 就是一条普通的 mov 指令——快到没有任何机制能超过它。
用管道时,内核帮你做了很多你没意识到的事:
- 同步——读写的顺序是确定的,不会读到写了一半的数据。
- 流控——缓冲区满了自动阻塞。
- 消息边界与 EOF——你知道什么时候结束。
共享内存一样都没有。你得到一块裸内存,剩下的全归你:
- 怎么知道对方写完了?你得自己设计信号量、futex、或者原子标志位。
- 怎么防止两边同时写?你得自己加锁。
- 写了一半崩溃了怎么办?共享内存里留下一个损坏的结构,而且它比进程活得久。
这是全书最极端的一次取舍:你用「所有的并发正确性问题」换了「零成本」。
所以共享内存只在两种情况下值得:数据量大到拷贝成本无法接受(视频帧、大矩阵),或者延迟要求苛刻到微秒级(高频交易、音频)。其余情况用管道或 socket,让内核替你操心。
「怎么用信号量和原子操作正确地同步两个进程」——那是《同时》的内容。这里只需要记住这一章的账:共享内存把成本从「拷贝和过线」转移到了「你自己的脑子」,而后者的 bug 通常更贵、更难查、更难复现。
怎么选
| 场景 | 选什么 | 为什么 |
|---|---|---|
| 父子进程单向传数据流 | 管道 | 最简单,自带流控和 EOF |
| 本机的客户端 / 服务器 | ★ Unix socket | 双向、能传 fd、比 TCP 回环快 |
| 可能跨机器 | TCP socket | 贵一点,但换机器不用改代码——这个好处通常值这笔钱 |
| 大块数据、极低延迟 | 共享内存 | 唯一能做到零拷贝的,但同步归你 |
| 只是通知一声 | 信号 / eventfd | 不传数据,只传「有事了」 |
- 管道 / Unix socket 传 64 KB:约 16 μs(两次系统调用 + 两次拷贝)
- 共享内存传 64 KB:约 0(就是几条 mov 指令)
- 本机 TCP 回环一个来回:约 50 μs(多了整个协议栈)
- 同机房一个来回:约 500 μs
最后一行值得单独看:你的微服务每调用一次别的服务,就付这个价。它是一次系统调用的五千倍,是共享内存的无穷倍。
这就是为什么「把一个服务拆成十个」这件事需要慎重——每一次跨服务调用都在这一行上。链路上串五个服务,光网络就 2.5 ms,还没算任何业务逻辑。
「Android 的 Binder 为什么号称『一次拷贝』?」
Android 没用上面任何一种,它自己造了一个:Binder。原因就是这一章的账——传统 IPC 要两次拷贝,而 Android 上 IPC 极其频繁(每次调用系统服务都是一次 IPC)。
Binder 的做法是:接收方进程提前 mmap 一块内核缓冲区。发送时:
- 传统:发送方 → 内核缓冲区(拷贝 1)→ 接收方缓冲区(拷贝 2)。
- Binder:发送方 → 内核缓冲区(拷贝 1)→ 接收方直接读,因为那块内存已经映射进它的地址空间了。
省掉了第二次。本质上是共享内存和消息传递的混合:用共享内存省拷贝,但仍然由内核驱动来管同步和权限,所以没有丢掉安全性。
这也解释了 Android 那个 1 MB 的 Binder 事务上限(实际可用约 800 KB,而且是整个进程共享的)——那块 mmap 缓冲区就那么大。所以:
TransactionTooLargeException就是撞了这个墙,常见于 Intent 里塞大 Bitmap、或者onSaveInstanceState存太多。- 正确做法是传 fd 而不是传数据——用
ParcelFileDescriptor(正是上面说的SCM_RIGHTS那一招)或ashmem,让对方自己去读。
这一章的一句话
进程间通信只有两条路:请内核当中间人(两次拷贝、两次过线),或者让两边页表指向同一批物理页(零成本,但同步全归你)。选哪条不看「哪个快」,看你愿不愿意自己承担正确性——而 Android 的 Binder 是在两者之间找了个折中。
下一章讲一种特殊的「通信」:它不传数据,只传一个编号,而且能在任何时刻打断你正在执行的代码。