IPC 选型:四把锤子,什么时候该抽哪把

IPC 选型:四把锤子,什么时候该抽哪把
隔壁老康难度 ★★★★☆ · 前置:mmap 与共享内存:同一块物理页,两个进程的地址不一样 | 一句话结论:选 IPC 只看两个问题——要不要跨机器、数据要不要被同步排队;这两个答案一确定,剩下的选项基本就剩一个。
三小时讨论,最后选了 Redis
一个服务要处理这样一串活,用户上传图片,生成三个尺寸的缩略图,转 WebP,再上传到对象存储。
最初写在一个进程里,用线程池,跑得也挺好,直到有人提议拆开。
图片处理是 CPU 密集的,应该单独一个进程,主服务只负责接收请求,中间用 IPC 传文件路径。
提议听起来完全合理,于是开会讨论用什么 IPC。讨论了三小时,结论是用 Redis 吧,稳。于是多了一个 Redis 实例、多了一套序列化、多了一个故障点,三个月后 Redis 内存告警,因为临时文件路径和状态全存在里面,没人清理。后来复盘的时候,真正的问题只有一句,这两个进程在同一台机器上吗。我们花了三个小时讨论怎么传,却没有一个人先问这一句,如果先问了,那次会议大概三分钟就能结束,因为答案会让后面两个半小时的争论全部作废。(这件事我记了很久,它不是技术判断失误,是整个讨论都建立在没人验证过的前提上,而那个前提一句话就能问出来,问的人不需要资深。)
在同一台机器上、数据量不大、要求可靠队列,用 Unix domain socket 就够了,nginx 和 php-fpm 之间就是它,几十年没变过。
一张表定生死
IPC(Inter-Process Communication)是给两个进程传东西的一套机制,Linux 上主要的就那么几种,用一张表摆在一起,比讲十句定义都快。
| 方式 | 数据在哪 | 能否跨机器 | 有没有消息边界 | 同步 |
|---|---|---|---|---|
| 匿名管道 | 内核 64KB 缓冲 | 否 | 否(字节流) | 内核代劳 |
| FIFO | 内核 64KB 缓冲 | 否 | 否 | 内核代劳 |
| Unix domain socket | 内核缓冲 | 否 | 是(SOCK_SEQPACKET)或否 |
内核代劳 |
| TCP socket | 内核缓冲 | 是 | 否 | 内核代劳 |
| 共享内存 | 同一块物理页 | 否 | 自己定 | 你自己管 |
| 消息队列(POSIX) | 内核 | 否 | 是 | 内核代劳 |
| 消息队列(SysV) | 内核 | 否 | 是 | 内核代劳 |
| 信号量 | 内核计数器 | 否 | — | 它就是同步工具 |
| 信号 | 一个数字 | 否 | 是(就一个数) | 异步 |
| 文件 | 磁盘 | 是(慢) | 自己定 | 自己管 |
这张表背下来,九成的选型当场有答案。
Unix domain socket 被严重低估
它的性能和管道同量级,但比管道能做的多得多,下面每一行都是管道做不到的事。
| 能力 | 管道 | Unix domain socket |
|---|---|---|
| 双向 | 否(单向,要建两根) | 是 |
| 有消息边界 | 否 | 是(SOCK_SEQPACKET)或 SOCK_DGRAM |
| 传文件描述符 | 否 | 是(SCM_RIGHTS) |
| 传凭据(uid/gid/pid) | 否 | 是(SO_PEERCRED) |
| 多客户端连一个服务 | 否 | 是(listen/accept) |
| 有名字、有权限 | 要 mkfifo |
是(文件权限就是访问控制) |
| 走网络栈 | 否 | 否(但如果换成 TCP 改一行) |
最后一行是关键,用 Unix domain socket 写的服务,想改成跨机器只需要把 AF_UNIX 换成 AF_INET 加个地址,用管道或者共享内存写的,改不了,这一条我原本以为只是理论上好看,直到有次临时要在中间加个代理,才发现别人的服务改了两行配置就上了,而我们那个走共享内存的模块,只能推倒重来,那次之后我选型时会把跨机器的可能性往前排一位,哪怕眼下明确不会跨。
所以本地进程间通信的默认答案应该是它。
什么时候才真的需要共享内存
只有当数据量大、访问频繁、零拷贝是刚需的时候,这三个条件里少一个,答案就该换成别的。
| 场景 | 合适吗 |
|---|---|
| 两个进程共享一个 10GB 的只读索引 | 完美 |
| 一个把视频帧写给另一个处理 | 省掉拷贝 |
| 高频计数器、状态机 | 可以,但要自己加锁 |
| 传几百字节的请求 | 杀鸡用牛刀,socket 更简单 |
| 需要排队、有顺序保证 | 共享内存没有队列 |
只读共享大文件这条值得单独说,它其实不需要共享内存 API,只需要 mmap 一个文件加 MAP_SHARED,这是最被低估的用法,一个 10GB 索引,十个进程同时 mmap,物理内存里只有一份,谁也改不了谁,因为它本来就是只读的,连锁都不用加,而这一份内存还算在页缓存里,free 里看到的 buff/cache 涨了不是因为谁在漏,是因为你确实在用。(我第一次在生产上看到这个用法,还以为是有人偷懒没做拷贝,查了半天才明白那才是最省事的写法,也是唯一一个既省内存又不用管同步的组合。)
三组对照
对照一,管道 vs Unix domain socket。
两者都用内核缓冲区,容量都是几十 KB 到几 MB,差别在能做什么。
| 管道 | UDS | |
|---|---|---|
| 创建成本 | 一次 pipe() |
一次 socket() + bind + listen |
| 单向/双向 | 单向 | 双向 |
| 服务多个客户端 | 做不到 | 天然支持 |
| 传 fd | 不行 | SCM_RIGHTS |
SCM_RIGHTS 是个神奇的能力,可以把一个打开的 fd 传给另一个进程,systemd 就是靠它把监听 socket 传给服务的(LISTEN_FDS),传的是引用而不是文件内容,接收方拿到的 fd 直接指向同一个内核对象,所以 nginx 在 reload 的时候不断连接,不是因为它在某个地方存了旧的 socket,而是因为那种传递本来就不复制任何东西,这也解释了一个反直觉的现象,父进程关掉自己那个 fd 之后,子进程手里那个还能用,引用计数没掉到零,反过来说,你把 fd 传出去之后又忘了关自己那份,文件就一直关不掉,lsof 里会看到一个连你都想不起是谁打开的句柄,占着 inode 不放,磁盘空间明明删了却降不下去。
对照二,消息队列 vs socket。
| POSIX 消息队列 | UDP socket | 消息队列(SysV) | |
|---|---|---|---|
| 持久性 | 内核里,进程退出还在(有名) | 无 | 内核里,要 ipcrm 清 |
| 优先级 | 是(mq_send 可指定) |
否 | 是(msgsnd 的 mtype) |
| 多生产者 | 是 | 是 | 是 |
| 跨机器 | 否 | 是 | 否 |
| 受 sysctl 限制 | 是(/proc/sys/fs/mqueue/*) |
否 | 是(/proc/sys/kernel/msgmax、msgmnb) |
| 复杂度 | 中 | 高(要做可靠性) | 高(key 管理、清理) |
POSIX 消息队列在今天基本没有理由用,它的所有能力,UDS 加自定义协议都能做,而且更好调试,一条 strace 就能看全,出了事能复现,SysV 更不该用,ipcs 和 ipcrm 那套管理负担,加上进程崩溃后残留的段,是运维事故的经典来源,我在一台跑了六年的老机器上见过四十多个没人认领的段,加起来好几个 G,谁也不敢删,因为不知道哪个还有人在用,最后是停机窗口里才清掉的。
除非你在维护 2005 年的代码。
对照三,共享内存加信号量,对上其他所有方式。
共享内存最快的代价是它只解决数据在哪,不解决什么时候能读。
典型用法是配一对 POSIX 无名信号量。
1 | sem_t *full = sem_open("/mysem_full", O_CREAT, 0600, 0); |
这就是自己实现一个管道,你花了 200 行做到的,pipe() 一个调用就给了你,而且内核那段代码经过了三十年测试,你写的那 200 行只经过了你自己的三个下午,这中间差的不只是时间,是别人踩过的每一个坑都已经变成了注释和条件判断,而你的坑还全在前面等着,我第一次读 pipe 的实现时最惊讶的是它在里面处理了那么多种边角情况,其中一半我这辈子大概都遇不到。
所以共享内存的正确用法是,数据放共享内存,控制流用别的东西,常见的组合有三种。
- 数据在
MAP_SHARED,完成通知用eventfd或管道; - 数据在共享内存,请求用 UDS 发(只发偏移和长度);
- 环形队列本身放在共享内存,读写索引用原子操作(无锁)。
第三种最难写对,也最容易在压测里看起来完全正常,因为内存序的错只有在特定核数、特定编译优化下才暴露出来,而它暴露的方式通常不是崩溃,是某个值莫名其妙变成了旧值。(这条我到现在也不敢说自己会,只能说知道什么时候该请人 review。)
拆成两个进程,通常不是好主意
IPC 的成本不只是传数据,它还引入下面这些。
| 成本 | 说明 |
|---|---|
| 序列化 | 结构体要转成字节流,反序列化要校验 |
| 版本兼容 | 两个进程可能不同版本,协议必须向前兼容 |
| 错误传播 | 对面挂了怎么发现、怎么重连 |
| 可观测性 | 跨进程的调用链要额外埋点 |
| 部署 | 两个进程的生命周期要协调 |
| 调试 | 不能一把 gdb 全看了 |
如果你拆开的理由是 CPU 密集,正确答案通常是线程,不是进程,Linux 的线程是真的并行(多核),而进程间隔离带来的收益,往往抵不过上面这六条成本,这六条里任何一条没兜住,你得到的就不是隔离,是一个更难查的系统,因为故障从一个进程变成两个小时序,日志还分散在两台机器上,而时间戳精度只有秒,你在两台机器上对着两份日志拼一条链路,拼到一半就会发现两边时钟差了四百毫秒。
真正该拆进程的场景只有四种。
- 崩溃隔离(一个插件崩了不能带走主进程);
- 权限隔离(要
setuid到另一个用户); - 语言隔离(Python 调 C++ 服务,中间隔一层);
- 内存隔离(一个可能吃 20GB 的批处理,不能影响主服务)。
因为它更架构不是理由。
三种方式各测一遍吞吐
测三种方式的吞吐
搭一个 Unix domain socket 服务,客户端往它灌 512MB,看跑多久。
1 | python3 -c " |
同样大小走 TCP loopback,会略慢一点,多一层网络栈。
而共享内存没有发送这个动作,写完就完了,所以它的吞吐是无意义的,它只受内存带宽限制。
传一个文件描述符
这是 UDS 独有的能力,每次讲 IPC 我都会拿出来的例子,因为它把进程隔离这件事撕开了一个口子。
1 | import socket, os, array |
1 | received fd 3 -> b'myhost\n' |
接收方拿到的 fd 3 是它自己的 fd 号,指向同一个内核对象,两个进程可以同时从这个 fd 上读,共享同一份文件偏移。
我第一次跑这个例子的时候盯着 fd 3 看了很久,发送方那个 fd 是 4,接收方拿到的却是 3,号不一样,东西是同一个,这个细节后来在理解 socket 传 fd 的实现时帮了大忙,因为内核在这里做的不是复制 fd,而是在对方的 fd 表里新开一格,把同一个 file 结构的引用计数加一,所以两边的号必然不同,也不该相同。(当时我还干过一件蠢事,把收到的 fd 号硬编码在代码里,换台机器就崩了,因为没人保证内核给你的下一个空闲号永远是 3。)
看共享内存的实际占用
1 | ls -l /dev/shm/ |
如果 /dev/shm 里躺着一个 500MB 的文件,那 500MB 现在就占着内存(tmpfs),即使没有任何进程在用它。
这是共享内存会漏的最常见形式。
清理。
1 | # 看谁在用 |
检查遗留的 System V 段
1 | ipcs -m |
1 | ------ Shared Memory Segments -------- |
nattch 0 就是没人用,这些段会一直占着内存直到重启或者删掉。
注意 shmid 是这台机器上的编号,上面示例里的 12、13 是我那台的,到你自己机器上要先用 ipcs -m 看一遍,而且只能删 owner 是你自己的段,共享机上随手 ipcrm 有可能把别人的段删掉。
1 | # 先确认属主和 nattch,只处理自己的 |
在旧系统上,这一条能回收出几个 GB。
消息队列同理。
1 | ipcs -q |
共享内存那笔账,贵的地方不在搬
有人问共享内存是不是一定比其他 IPC 快,数据搬动确实快,但加上同步(信号量、原子操作、内存屏障)之后总成本不一定低,传 100 字节时 socket 反而更省心,因为你在共享内存那条路上要维护的东西比数据本身还多,而 socket 的排队和唤醒是内核替你做的,你只需要 read 和 write。也有人问 IPC 是不是越快越好,其实 IPC 的耗时通常在微秒级,而一次磁盘 IO 是毫秒级、一次网络往返是几十毫秒,在大多数系统里,IPC 根本不是瓶颈,你去优化一个占比不到百分之一的环节,收益还没压测噪音大。
还有人说 Unix domain socket 不能跨机器所以不如 TCP,可它在本地更快、更安全(文件权限就是访问控制)、还能传 fd,本地通信的默认选择应该是它,不是 TCP loopback,至于消息队列比 socket 可靠这个说法,内核消息队列确实有队列语义,但进程崩溃时队列里的消息直接丢掉,和 socket 缓冲一样,要真可靠得落盘。
SysV IPC 是标准这个说法更值得纠正,POSIX 版本才是标准(shm_open、mq_open、sem_open),SysV 是遗留接口,新代码没有理由用,信号只能传一个数字,不排队(同类信号会合并),也没有数据,它适合通知,不适合通信,而拆进程能提高可靠性这句话,只有在你已经实现了重连、超时、重试之后才成立,没这些,拆进程只是多了一个故障点。
最后一条,/dev/shm 不是虚拟的,它是 tmpfs,百分百占内存,这也是容器里 shm 只有 64MB 的原因,Docker 默认 /dev/shm 就是 64MB,Chrome 一跑就崩,得加 --shm-size。
两个问题决定选哪个
选型只看两个问题,跨不跨机器,要不要消息边界和排队。
本地进程通信的默认答案是 Unix domain socket,双向、有边界、能传 fd、能传凭据、能改成 TCP。管道适合一根线串两个命令的一次性数据流,不适合做服务接口。共享内存只在块大、访问勤、零拷贝是刚需时才划算,而且要自己管同步。
POSIX 消息队列和 SysV IPC 在现代代码里基本没有使用场景。信号是通知,不是通信。拆进程的成本很高,除非为了崩溃、权限、语言、内存这四种隔离,否则优先用线程。
这几把锤子摆完之后,你手上就有了一整套让进程协作的工具。
这套工具用完之后,压力会转移到一个地方。当数据不需要跨进程、只需要跨线程,问题就变成了两个线程同时改一个变量。而 Linux 里那个被叫做线程的东西,其实还是进程,只是跟别人共享了一点东西,正是这一点共享,决定了后面所有的锁。











