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
2
sem_t *full  = sem_open("/mysem_full",  O_CREAT, 0600, 0);
sem_t *empty = sem_open("/mysem_empty", O_CREAT, 0600, 1);

这就是自己实现一个管道,你花了 200 行做到的,pipe() 一个调用就给了你,而且内核那段代码经过了三十年测试,你写的那 200 行只经过了你自己的三个下午,这中间差的不只是时间,是别人踩过的每一个坑都已经变成了注释和条件判断,而你的坑还全在前面等着,我第一次读 pipe 的实现时最惊讶的是它在里面处理了那么多种边角情况,其中一半我这辈子大概都遇不到。

所以共享内存的正确用法是,数据放共享内存,控制流用别的东西,常见的组合有三种。

  • 数据在 MAP_SHARED,完成通知用 eventfd 或管道;
  • 数据在共享内存,请求用 UDS 发(只发偏移和长度);
  • 环形队列本身放在共享内存,读写索引用原子操作(无锁)。

第三种最难写对,也最容易在压测里看起来完全正常,因为内存序的错只有在特定核数、特定编译优化下才暴露出来,而它暴露的方式通常不是崩溃,是某个值莫名其妙变成了旧值。(这条我到现在也不敢说自己会,只能说知道什么时候该请人 review。)

拆成两个进程,通常不是好主意

IPC 的成本不只是传数据,它还引入下面这些。

成本 说明
序列化 结构体要转成字节流,反序列化要校验
版本兼容 两个进程可能不同版本,协议必须向前兼容
错误传播 对面挂了怎么发现、怎么重连
可观测性 跨进程的调用链要额外埋点
部署 两个进程的生命周期要协调
调试 不能一把 gdb 全看了

如果你拆开的理由是 CPU 密集,正确答案通常是线程,不是进程,Linux 的线程是真的并行(多核),而进程间隔离带来的收益,往往抵不过上面这六条成本,这六条里任何一条没兜住,你得到的就不是隔离,是一个更难查的系统,因为故障从一个进程变成两个小时序,日志还分散在两台机器上,而时间戳精度只有秒,你在两台机器上对着两份日志拼一条链路,拼到一半就会发现两边时钟差了四百毫秒。

真正该拆进程的场景只有四种。

  • 崩溃隔离(一个插件崩了不能带走主进程);
  • 权限隔离(要 setuid 到另一个用户);
  • 语言隔离(Python 调 C++ 服务,中间隔一层);
  • 内存隔离(一个可能吃 20GB 的批处理,不能影响主服务)。

因为它更架构不是理由。

三种方式各测一遍吞吐

测三种方式的吞吐

搭一个 Unix domain socket 服务,客户端往它灌 512MB,看跑多久。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
python3 -c "
import socket, os, threading

path = '/tmp/bench.sock'
if os.path.exists(path): os.unlink(path)

srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(path); srv.listen(1)

def serve():
conn, _ = srv.accept()
total = 0
while True:
b = conn.recv(1 << 20)
if not b: break
total += len(b)
print('server received', total)

threading.Thread(target=serve, daemon=True).start()

import time
c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
c.connect(path)
t = time.time()
chunk = b'x' * (1 << 20)
for _ in range(512):
c.sendall(chunk)
c.shutdown(socket.SHUT_WR)
time.sleep(0.5)
print(f'UDS: 512MB in {time.time()-t:.3f}s')
os.unlink(path)
" 2>&1 | tail -3

同样大小走 TCP loopback,会略慢一点,多一层网络栈。

而共享内存没有发送这个动作,写完就完了,所以它的吞吐是无意义的,它只受内存带宽限制。

传一个文件描述符

这是 UDS 独有的能力,每次讲 IPC 我都会拿出来的例子,因为它把进程隔离这件事撕开了一个口子。

1
2
3
4
5
6
7
8
9
10
11
12
13
import socket, os, array

a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)

f = open('/etc/hostname')
a.sendmsg([b'\x00'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
array.array('i', [f.fileno()]))])

msg, ancdata, flags, addr = b.recvmsg(1, socket.CMSG_LEN(4))
for level, typ, data in ancdata:
if level == socket.SOL_SOCKET and typ == socket.SCM_RIGHTS:
fd = array.array('i', data)[0]
print('received fd', fd, '->', os.read(fd, 100))
1
received fd 3 -> b'myhost\n'

接收方拿到的 fd 3 是它自己的 fd 号,指向同一个内核对象,两个进程可以同时从这个 fd 上读,共享同一份文件偏移。

我第一次跑这个例子的时候盯着 fd 3 看了很久,发送方那个 fd 是 4,接收方拿到的却是 3,号不一样,东西是同一个,这个细节后来在理解 socket 传 fd 的实现时帮了大忙,因为内核在这里做的不是复制 fd,而是在对方的 fd 表里新开一格,把同一个 file 结构的引用计数加一,所以两边的号必然不同,也不该相同。(当时我还干过一件蠢事,把收到的 fd 号硬编码在代码里,换台机器就崩了,因为没人保证内核给你的下一个空闲号永远是 3。)

看共享内存的实际占用

1
2
ls -l /dev/shm/
df -h /dev/shm

如果 /dev/shm 里躺着一个 500MB 的文件,那 500MB 现在就占着内存(tmpfs),即使没有任何进程在用它。

这是共享内存会漏的最常见形式。

清理。

1
2
3
4
5
# 看谁在用
lsof /dev/shm/your_region

# 确认没进程用之后
rm /dev/shm/your_region

检查遗留的 System V 段

1
ipcs -m
1
2
3
4
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00000000 12 ark 600 4096 0
0x00000000 13 ark 600 1048576 0

nattch 0 就是没人用,这些段会一直占着内存直到重启或者删掉。

注意 shmid 是这台机器上的编号,上面示例里的 12、13 是我那台的,到你自己机器上要先用 ipcs -m 看一遍,而且只能删 owner 是你自己的段,共享机上随手 ipcrm 有可能把别人的段删掉。

1
2
3
4
5
# 先确认属主和 nattch,只处理自己的
ipcs -m | awk '$3 == ENVIRON["USER"] && $6 == 0 {print $2}'

# 确认无误后再删(把 SHMID 换成上面列出来的)
ipcrm -m SHMID

在旧系统上,这一条能回收出几个 GB。

消息队列同理。

1
2
ipcs -q
ipcrm -q <msqid>

共享内存那笔账,贵的地方不在搬

有人问共享内存是不是一定比其他 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 里那个被叫做线程的东西,其实还是进程,只是跟别人共享了一点东西,正是这一点共享,决定了后面所有的锁。