卷 V · 邻里CH 20深度 20/24

进程间通信:管道、共享内存与拷贝次数

卷 III 讲了地址空间怎么把进程彻底隔开——这是好事,但也带来一个问题:隔开了,怎么说话?这一章的答案可以浓缩成一个算术题:数一数拷了几次、过了几趟线。

管道共享内存Unix socket拷贝次数

隔离的代价

两个线程通信是零成本的——它们共享地址空间,一个写全局变量,另一个直接就看见了(第 7 章)。

两个进程不行。它们的页表是分开的,进程 A 的任何一个地址在进程 B 那里都翻译到别处。A 没有任何办法把一个指针递给 B。

所以进程间通信只有两条路:

  1. 请内核当中间人——A 把数据交给内核,内核再交给 B。两次拷贝、两次过线。
  2. 让两边的页表指向同一批物理页——绕过内核。零拷贝、零过线。

所有的 IPC 机制,都是这两条路的变体。

▶ 动手 · 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:那个让程序莫名其妙死掉的信号

反过来:如果读端关了,写端还在写,写方会收到 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,让内核替你操心。

怎么选

场景选什么为什么
父子进程单向传数据流管道最简单,自带流控和 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 是在两者之间找了个折中。

下一章讲一种特殊的「通信」:它不传数据,只传一个编号,而且能在任何时刻打断你正在执行的代码。