Linux IPC机制详解:管道、共享内存与消息队列避坑选型

发布时间:2026/9/30 16:36:27
Linux IPC机制详解:管道、共享内存与消息队列避坑选型 1. 一次阻塞到凌晨的排查IPC选错到底要付多少代价凌晨一点半我盯着一个应该早就退出的采集进程它在read()上纹丝不动。上游进程明明已经处理完数据并exit(0)了下游却像被施了定身术既不报错也不返回。当时第一反应是死锁加了gdbattach 上去看调用栈卡在read上参数是那个管道 fd。折腾了四十分钟之后我才反应过来上游 fork 出来的那个后台子进程还攥着管道写端没撒手。写端没全关EOF 就永远不来读端也就永远等下去。这就是 Linux 进程间通信IPC最典型的性格它给你极其丰富的工具箱——管道、FIFO、共享内存、消息队列、信号量、信号、Unix domain socket、eventfd、memfd——但每一样都有自己的脾气选错工具或者少写一行关闭逻辑代价可能就是一次深夜加班。IPC 本身不是把数据从 A 送到 B这么简单它真正解决的问题是三件事数据怎么传、谁先谁后的顺序怎么保证、对方死了或者卡住了我怎么办。很多教程只讲第一件剩下两件全靠你在生产环境里用故障去换。这篇内容我打算把 Linux IPC 这套东西按机制原理—代码骨架—真实坑点—选型逻辑的顺序捋一遍。适合已经会写基本 Linux C 程序、但在多进程架构里踩过或者即将踩坑的开发者如果你平时用 Python、Go 或者 Java也能看懂因为这些机制的语义是跨语言通用的只是封装层不同。全程我会尽量给出可以直接抄的代码片段以及一份我自己的选型对照表。我要先讲清楚一个贯穿全文的判断标准没有任何一种 IPC 是最好的只有在特定约束下最合适的。低延迟、高吞吐、跨主机、可持久化、天然支持多对多——这些需求两两之间往往是冲突的你的任务不是找银弹而是在约束条件下做减法。下面就按从最轻到最重的顺序拆。2. 管道与FIFO内核里那条只有64KB的传送带2.1 匿名管道fork之后的那根脐带管道是所有 IPC 里最古老、最简单、也最容易想当然的一个。pipe()返回两个 fd一个读端一个写端本质上就是内核里一块环形缓冲区配上一对读写指针。它最典型的使用姿势是父进程先pipe()再fork()子进程继承这两个 fd然后各自关掉自己不需要的那一端。// 父进程写子进程读 int fds[2]; if (pipe(fds) 0) { perror(pipe); exit(1); } pid_t pid fork(); if (pid 0) { close(fds[1]); // 子进程不需要写端必须关掉 char buf[256]; ssize_t n; while ((n read(fds[0], buf, sizeof(buf))) 0) { // 处理数据 } close(fds[0]); _exit(0); } close(fds[0]); // 父进程不需要读端 write(fds[1], hello, 5); close(fds[1]); // 写完必须关否则子进程永远等不到 EOF waitpid(pid, NULL, 0);这里有两个必须关的动作对应我开头踩的那个坑子进程要关掉写端父进程写完要关掉写端。原因是read()返回 0也就是 EOF的触发条件不是缓冲区空了而是所有持有写端的进程都关闭了写端。只要还有一个进程攥着写端不放——哪怕它根本没写过数据——read()就会继续阻塞。这一点在 fork 出多级子进程、或者用system()/popen()时会变得非常隐蔽因为那些后台进程很可能悄悄继承了你的 fd。顺便说一个相关的细节open系列默认继承的 fd 在exec之后仍然保留。如果你用popen起了个 shell 脚本脚本里又nohup了一个守护进程那这个守护进程就继承了你管道写端的副本你后面所有read都可能被它拖住。解决办法有两个一是创建 fd 时加O_CLOEXECpipe2(fds, O_CLOEXEC)二是在 fork 之后手动关闭。我现在的习惯是无脑加 O_CLOEXEC因为在复杂的模块依赖里你很难靠人工审查保证每个分支都关了。2.2 管道缓冲区大小与PIPE_BUF的原子性边界管道缓冲区在 Linux 上的默认容量是 65536 字节16 个内存页可以通过fcntl(fd, F_SETPIPE_SZ, size)调整上限受/proc/sys/fs/pipe-max-size约束普通用户一般最多能调到 1MB 左右。缓冲区满了会怎样如果写端是阻塞模式write()会挂起如果是O_NONBLOCK返回 -1 并置errno为EAGAIN。这是所有带背压的流式传输的基础。真正需要刻进肌肉记忆的是PIPE_BUF这个值。Linux 上它是 4096 字节。它的含义是当写入长度小于等于 PIPE_BUF 时这次写是原子的——多个进程往同一个管道写数据不会互相穿插。一旦超过这个值内核可能分多次搬运别的进程的写入就可能插到中间。如果你在做多生产者场景比如多个 worker 往一个统计管道里打点消息一旦超过 4KB就必须自己在应用层加锁或者改用带有消息边界的机制消息队列、SEQPACKET socket。还有一个隐形的坑read()不保证一次读满你请求的字节数。管道是字节流不是消息流内核只是有多少给多少。如果你写了 100 字节两次读端可能一次读到 200也可能分三次读完。所以应用层必须自己处理粘包和拆包常见做法是加长度前缀或者干脆约定用\n分隔。这一点是我见过新手翻车最多的地方很多人以为管道像消息队列一样有消息边界。2.3 命名管道FIFO把管道挂到文件系统上匿名管道有个硬伤只有在有亲缘关系的进程之间才能用。两个毫无关系的进程想通信就得用 FIFO也就是命名管道。mkfifo(/tmp/myfifo, 0666)之后那个路径就变成了一个特殊文件任何有权限的进程都能像打开普通文件一样open()它。mkfifo /tmp/myfifo # 终端 A cat /tmp/myfifo # 终端 B echo hello from B /tmp/myfifoFIFO 的打开语义有个很容易踩的细节以只读方式open()一个 FIFO 会阻塞直到有进程以写方式打开它反之亦然。这在单进程测试时会让人以为程序卡死了。想避开就加O_NONBLOCK此时只读打开会立刻返回成功只写打开如果没人读会返回ENXIO。FIFO 还有一个常年存在的顽疾残留文件。进程异常退出时不会自动清理 FIFO 文件下次启动mkfifo就会报EEXIST。正确的做法是启动时先unlink再创建并且要处理好两个进程同时启动的竞态——一般用一个 mkdir 锁目录或者 flock 来串行化初始化过程。2.4 实战中三个容易翻车的点第一SIGPIPE。读端全部关闭后还继续往管道写内核会给写进程发SIGPIPE默认行为是直接杀掉进程。很多人调试时发现程序莫名其妙没了就是这个原因。标准做法是在程序启动早期就signal(SIGPIPE, SIG_IGN)然后自己处理write返回的EPIPE。网络编程里这条几乎是必备操作。第二半关闭。管道不能只关一半方向再继续用另一半它只有整体关闭。如果你的协议里需要我发完了但还要收管道就不合适得用 socket。第三跨进程传输大量数据。管道的两次用户态—内核态拷贝写一次读一次在高吞吐场景下是明显瓶颈。100MB/s 以上的数据流管道很快就会成为 CPU 占用的主要来源这时候就该考虑共享内存了。3. 共享内存性能天花板也是同步地狱3.1 共享内存为什么快省掉的到底是哪一次拷贝要理解共享内存的价值先得理解其他 IPC 慢在哪。管道、消息队列、socket 的数据路径都是发送方用户态 → 内核缓冲区 → 接收方用户态数据被搬了两趟。共享内存的思路是内核分配一块物理内存然后同时映射进两个进程的虚拟地址空间发送方直接往这块内存写接收方直接读中间不经过任何系统调用的搬运。严格说这不是零拷贝而是把两次拷贝压缩成一次生产者写数据的那一次仍然存在但它写的就是双方共用的那片内存消费者拿到的指针直接指向它。对于大块数据几百 KB 到几 GB 都不稀奇这个差距是数量级的。代价同样巨大内核不再帮你做任何同步。管道有缓冲区满/空的天然流控共享内存没有。你写完一段数据对方怎么知道写完了两个进程同时写同一块区域怎么办这些全靠你自己设计同步协议。所以我一直有个观点共享内存的难点从来不是映射那几行代码而是它后面跟着的同步方案。3.2 mmap匿名映射与shm_open两条路径父子进程之间传数据最省事的是匿名映射size_t size 4096; int *shm mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (shm MAP_FAILED) { perror(mmap); exit(1); } shm[0] 0; pid_t pid fork(); if (pid 0) { shm[0] 42; // 子进程写入 _exit(0); } waitpid(pid, NULL, 0); printf(%d\n, shm[0]); // 父进程能看到 42关键在MAP_SHARED如果写成MAP_PRIVATE那就是写时复制双方各改各的谁也看不见谁。这个字母之差排查起来极其费劲因为程序不报错只是数据不同步。无亲缘关系的进程要用 POSIX 共享内存走shm_open加ftruncate加mmap三步int fd shm_open(/my_shm, O_CREAT | O_RDWR, 0666); ftruncate(fd, size); // 必须先定大小否则 mmap 会被 SIGBUS 干掉 void *p mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // ... 使用 munmap(p, size); close(fd); shm_unlink(/my_shm); // 用完删名字注意ftruncate那一步。共享内存对象创建时长度为 0如果你跳过它直接mmap然后访问程序会收到SIGBUS而不是友好的错误码——这是因为映射建立时对象是空的一旦访问超出实际文件大小的页内核找不到对应的后备存储。这个 bug 我第一次遇到时对着非法地址的报错查了半天最后才意识到是ftruncate漏了。3.3 System V与POSIX两代API的取舍Linux 上共享内存有两条并行的路线老一代 System V 的shmget/shmat/shmdt和新一代 POSIX 的shm_open/mmap。它们的对比如下维度System V (shmget)POSIX (shm_open)命名方式整数 key需 ftok 生成字符串名形如 /name生命周期独立于文件系统需 ipcrm 清理表现为 /dev/shm 下的文件大小设定创建时指定创建后 ftruncate权限控制创建时指定 mode走文件系统权限颗粒度更好可移植性传统 Unix 广泛支持较新部分老系统缺失与 mmap 的整合需要 shmat不支持 offset 灵活映射天然就是 mmap映射灵活我的建议很直接新项目一律用 POSIX 那套。理由不是它更快而是它把共享内存统一成了文件心智模型权限、清理、映射偏移这些事都能复用你已经熟悉的文件操作经验。System V 那套现在还在维护基本都是历史包袱——我在一个十几年的老系统里见过ipcs -m列出几十个没人记得用途的段谁都不敢删因为不知道哪个进程还在用。3.4 同步才是真正的难点信号量与futex共享内存裸用一定会出问题。最基础的同步需求是生产者写完消费者才能读。朴素做法是加个标志位// 生产者 shm-data value; __sync_synchronize(); // 内存屏障防止编译器/CPU 重排 shm-flag 1;这里必须插内存屏障否则编译器可能把flag 1重排到data value之前CPU 也可能乱序执行消费者就会读到标志已置位但数据还是旧的的状态。这是无锁编程的入门坑也是我认为共享内存最反直觉的地方——你写的代码顺序不代表实际执行顺序。有锁的方案更稳妥。POSIX 提供了专门为共享内存设计的信号量sem_t *sem sem_open(/my_sem, O_CREAT, 0666, 1); sem_wait(sem); // P 操作计数减一为 0 则阻塞 // 临界区 sem_post(sem); // V 操作计数加一唤醒等待者对于进程间共享的未命名信号量必须用sem_init(sem, 1, 1)第二个参数pshared1表示进程间共享。这个参数如果写成 0信号量只在当前进程内有效多进程场景下形同虚设而且不会报错——又是一个静默失效的坑。Linux 特有的futex快速用户态互斥是更底层的原语glibc 的 pthread mutex 就是基于它实现的。直接用它的人不多但理解它的思路有价值大部分情况下不需要进内核只有在真正发生竞争时才通过FUTEX_WAIT陷入内核睡眠。这是 Linux 上高性能同步的关键设计思想。实践中你用PTHREAD_PROCESS_SHARED属性的 pthread mutex 就够了pthread_mutexattr_t attr; pthread_mutexattr_init(attr); pthread_mutexattr_setpshared(attr, PTHREAD_PROCESS_SHARED); pthread_mutexattr_setrobust(attr, PTHREAD_MUTEX_ROBUST); // 防止持锁进程崩溃导致死锁 pthread_mutex_init(shm-mutex, attr);PTHREAD_MUTEX_ROBUST这个属性值得单独提一句。共享内存上的互斥锁最怕的就是持锁进程突然崩了锁永远不释放其他进程集体饿死。robust 属性让内核在持锁进程死亡时把锁标记为EOWNERDEAD让下一个pthread_mutex_lock的调用者知道上一个主人死了你去收拾残局你可以在那里把共享数据恢复成一致状态。这个机制我在一个需要长期运行的采集服务里用过效果很好省掉了一大堆进程崩溃后必须整体重启的运维麻烦。3.5 /dev/shm残留和权限那些事POSIX 共享内存的实体在/dev/shm/下是个 tmpfs数据在内存里重启即消失。这既是优点不怕脏数据也是隐患不能持久化。如果进程崩溃没调shm_unlink名字会一直留在那里下次O_CREAT打开的是旧对象里面可能残留上次的数据长度也可能不对。典型的症状是程序重启后行为诡异改代码也没用。排查命令就一个ls -l /dev/shm/ # 看有哪些残留 ipcs -m # System V 的段 ipcrm -m shmid # 手动删除另外注意/dev/shm的大小受 tmpfs 默认限制通常是物理内存的一半一个几百 GB 的共享内存请求会直接失败。这个值可以通过挂载参数调整。4. 消息队列被严重低估的带边界的字节流4.1 消息边界才是消息队列的核心价值如果我只能用一个词概括消息队列跟管道的区别那就是边界。管道是字节流消息队列是消息流你mq_send一条 100 字节的消息对方mq_receive拿到的就是完整的一条 100 字节不会多也不会少更不会两条粘一起。相册.book#include mqueue.h mqd_t mq mq_open(/my_queue, O_CREAT | O_RDWR, 0666, NULL); struct mq_attr attr; mq_getattr(mq, attr); // 默认 mq_maxmsg10, mq_msgsize8192 printf(maxmsg%ld, msgsize%ld, curmsgs%ld\n, attr.mq_maxmsg, attr.mq_msgsize, attr.mq_curmsgs); mq_send(mq, task-1, 7, 0); // 最后参数是优先级 char buf[8192]; unsigned int prio; ssize_t n mq_receive(mq, buf, sizeof(buf), prio);默认配置是最多 10 条消息每条最大 8192 字节这个默认值在真实项目里几乎一定要改。你可以在mq_open时通过 attr 指定但要注意两个上限受/proc/sys/fs/mqueue/msg_max和msgsize_max约束普通用户改不了。容量给太大还有个副作用消息队列占用的内存不归你的进程统计出问题时用top看不出来得用ipcs或者/proc/sys/fs/mqueue相关文件排查。4.2 优先级机制和它的适用边界消息队列的第二个特色是优先级。mq_send的最后一个参数越大优先级越高mq_receive永远先拿优先级最高的消息。这看起来很美但有个反直觉的点相同优先级之间是 FIFO不同优先级之间是严格抢占。这意味着如果你给某个高优先级消息持续投喂低优先级的消息可能永远排不上队。我在一个日志采集系统里见过这个问题——错误日志被设成高优先级结果正常日志在高峰期全被饿死最后不得不给正常日志也做限流。所以优先级要慎用它适合表达控制指令 业务数据这类层次分明的场景不适合表达业务内部的重要性差异。4.3 阻塞策略与mq_notifymq_receive默认是阻塞的队列空就一直睡。可以用O_NONBLOCK打开空了返回EAGAIN。但如果你想让消息到达时主动通知而不是轮询或阻塞POSIX 提供了mq_notify注册一个信号或者线程回调队列从空变成非空时触发一次。注意mq_notify的通知是边沿触发的。它只在队列由空转非空的那一刻通知一次如果你的处理函数没有把队列读空后续消息不会再次触发通知。所以标准用法是在回调里while (mq_receive(..., O_NONBLOCK) 0)一直读到EAGAIN为止然后重新注册mq_notify。这套机制对写事件循环的人来说不太友好因为信号处理函数里不能做复杂操作后面会讲Linux 上更推荐的做法是配合eventfd或者直接用 Unix domain socket 代替。4.4 System V消息队列还剩什么价值msgget/msgsnd/msgrcv这一套比 POSIX 版本更老但有一个 POSIX 没有的特性msgrcv可以按消息类型选择性地接收而不只是按优先级。这在某些需要按类型分发处理的多消费者场景里挺方便不用自己做类型路由。不过我还是那句话新项目优先 POSIX。System V 消息队列最大的问题是清理ipcs -q里堆积的队列不会自动消失进程崩了队列还在重启应用会发现旧消息还在里面等着被消费行为完全不可预期。用 POSIX 的好处是它在/dev/mqueue下有名有姓ls一下就能看个大概。5. 信号与信号量名字很像用途完全不同5.1 信号最轻量也最容易丢信号是 Linux 上最异步的 IPC——它不传数据除了sigqueue能带一个整数只传一个编号含义是发生了一件事。它的优势是轻量、无需建立任何连接进程随时能用kill(pid, SIGUSR1)通知另一个进程。但它有三个硬伤每一个都能让程序出诡异 bug。第一个是不排队。同一信号连续来两次如果进程还没处理第一次第二次会被丢弃最终只处理一次。内核里每个信号只维护一个 pending 位不是队列。要做计数通知必须用实时信号SIGRTMIN起的那些它们是排队的或者干脆用eventfd。第二个是天然竞态。如果你用冰箱里有信号就处理任务这种模式检查信号和进入睡眠之间有一个窗口期信号可能恰好落在这个窗口里丢掉。正统的解法是把信号先sigprocmask屏蔽处理完再解锁配合sigsuspend原子地等待。但更现代的做法是根本不这么设计。第三个是信号处理函数的限制。信号处理函数运行在任意时刻它调用的大多数函数都是不安全的不是可重入的。printf、malloc、free这些都不能在信号处理函数里用因为它们内部可能持有锁而信号可能正好打断了一个持锁的操作。安全做法是只在处理函数里设置一个volatile sig_atomic_t标志位主循环检测标志位再干活static volatile sig_atomic_t g_stop 0; void on_sigint(int sig) { g_stop 1; // 只做这一件事 } int main(void) { struct sigaction sa {0}; sa.sa_handler on_sigint; sigemptyset(sa.sa_mask); sigaction(SIGINT, sa, NULL); while (!g_stop) { // 主循环 } }关于sigaction里的sa_flagsSA_RESTART值得特别说不加它的时候被信号打断的慢系统调用比如read会返回 -1 并置EINTR加了之后内核会自动重启这些调用。哪个更好取决于你的场景——对于一个等数据的读循环你希望信号来了能立刻跳出那就别加SA_RESTART但要记得处理EINTR。我见过太多代码因为没处理EINTR而在偶发信号下读失败提前退出。5.2 signalfd把信号接入事件循环如果你的程序有epoll事件循环信号会是个麻烦它在任何地方打断你而你想让它变成一个事件。Linux 从 2.6.22 开始提供了signalfd可以把信号转成 fd直接扔进epollsigset_t mask; sigemptyset(mask); sigaddset(mask, SIGINT); sigaddset(mask, SIGTERM); sigprocmask(SIG_BLOCK, mask, NULL); // 必须先屏蔽否则信号走默认路径 int sfd signalfd(-1, mask, SFD_NONBLOCK | SFD_CLOEXEC); // 之后 epoll_ctl 把这个 sfd 加进去读 sfd 就是取信号这个模式的好处是彻底消灭了信号处理函数的可重入限制和竞态窗口所有逻辑集中在事件循环里跟我平时写网络代码的心智模型完全一致。前提是先屏蔽再创建 signalfd忘了屏蔽的话信号会走默认处理signalfd 那一路收不到又是一个静默失效。5.3 信号量它跟信号没有任何关系新手最容易混淆的一对概念就是信号和信号量。它们中文名只差一个字但机制、用途、API 全都不同。信号量是一个计数器用于控制同时有几个进程能进入临界区。计数大于 0 时sem_wait通过并减一等于 0 时阻塞等待直到别人sem_post加一唤醒。它最常见的用途有这么几类互斥初值设为 1就是一把二进制锁保护共享内存。限流初值设为 N控制最多 N 个 worker 同时消费某类资源。事件通知初值设为 0生产者sem_post消费者sem_wait天然实现你发我等的握手而且计数会累积不会像信号那样丢失。第三点经常被忽略信号量其实是一种很好用的不丢事件的信号。生产得快、消费得慢计数会一直涨消费者追上来之后能连续处理多次。相比之下信号只能告诉你有事发生具体几次说不清。POSIX 信号量分命名和未命名两种。命名信号量sem_open(/name, ...)存在于/dev/shm/sem.*跨无亲缘进程用未命名信号量sem_init(s, pshared, value)则适合放在共享内存里。清理上同样是命名信号量容易残留程序退出时记得sem_unlink。有个细节值得提醒sem_wait被信号打断会返回EINTR虽然会把信号量的值恢复但你的代码需要重试。写循环的时候把它包成一个要么成功要么彻底失败的辅助函数能省掉很多分散的重试逻辑。6. Unix domain socket与新一代轻量积木6.1 UDS的三种类型与凭据鉴权Unix domain socketUDS是我在本地多进程架构里用得最多的一种因为它把 socket 的丰富语义双向、消息边界、连接管理和本地通信的低开销结合在了一起。地址就是文件系统里的一个路径int fd socket(AF_UNIX, SOCK_SEQPACKET, 0); struct sockaddr_un addr {0}; addr.sun_family AF_UNIX; strncpy(addr.sun_path, /tmp/my.sock, sizeof(addr.sun_path) - 1); unlink(/tmp/my.sock); // 清理旧 socket 文件 bind(fd, (struct sockaddr *)addr, sizeof(addr)); listen(fd, 128);三种类型要按需选SOCK_STREAM是字节流跟 TCP 手感一样需要自己处理粘包SOCK_DGRAM保留消息边界但不保证顺序和可靠投递SOCK_SEQPACKET是我最推荐的——既保留消息边界又保证顺序和可靠非常适合自定义协议一条请求一条响应发过去接收端一次recv就能拿到完整消息不用写解析状态机。UDS 还自带一个超级实用的能力对端身份鉴权。通过SO_PEERCRED可以拿到连接对端的 pid、uid、gidstruct ucred cred; socklen_t len sizeof(cred); getsockopt(conn_fd, SOL_SOCKET, SO_PEERCRED, cred, len); printf(peer pid%d uid%d gid%d\n, cred.pid, cred.uid, cred.gid);这意味着一台机器上的服务可以只允许特定用户调用而且这个判断由内核提供无法伪造。很多系统级的本地服务比如系统日志、容器运行时都靠这个做权限控制。相比之下用 TCP 的 127.0.0.1 就只能靠 token 之类的应用层手段安全性差一层。6.2 抽象命名空间不落盘的socket路径UDS 默认要在文件系统里占一个路径这带来了跟 FIFO 一样的残留问题进程崩了 socket 文件还在下次bind报EADDRINUSE。Linux 提供了一个替代方案——抽象命名空间把地址的第一个字节设为\0这个 socket 就不落盘纯粹存在于内核里进程退出内核自动回收。addr.sun_path[0] \0; // 抽象命名空间 memcpy(addr.sun_path 1, my_abstract, 11); bind(fd, (struct sockaddr *)addr, sizeof(sa_family_t) 1 11);它的缺点是依赖 Linux 特有行为跨平台不可移植而且因为是匿名的ls看不到排查时得用ss -x或者/proc/net/unix。我的经验是一旦服务是长期运行的系统组件用抽象命名空间能省掉一大堆启动脚本里的清理逻辑如果需要在其他 Unix 上跑就乖乖用文件路径并且写unlink的容错逻辑。6.3 eventfd、timerfd、memfd为事件循环准备的积木这三样东西都是 Linux 特有的轻量设施共同点是都是 fd都能塞进 epoll非常适合在单线程事件循环里做进程间协作。eventfd是一个内核维护的 64 位计数器。write加值read取值并清零或减去指定值。它最常见的用法是唤醒工作线程往 eventfd 写一个 1事件循环里的 epoll 立刻返回主线程就知道有活干了。相比信号它不丢事件、不会打断系统调用、不需要处理EINTR。相比管道它只用一个 fd且读写都是 8 字节定长语义清晰。timerfd把定时器也变成了 fd。timerfd_create加timerfd_settime到点了那个 fd 变成可读读出来的是超时了几次。这样你的 epoll 循环可以统一处理网络事件、信号、定时器三类输入不用再纠结epoll_wait 的 timeout 该设多少这种问题。我在写高精度定时任务时基本都会用 timerfd 替代setitimer因为它能正确处理系统时间被调整的情况可以选择用CLOCK_MONOTONIC。memfd_create则是创建一个纯内存的匿名文件有 fd 但不在文件系统里可见。它最大的用途是跨进程传递大块内存一个进程创建 memfd 并mmap写入数据然后通过 UDS 把这个 fd 用SCM_RIGHTS传给另一个进程sendmsg附带辅助数据对方recvmsg拿到 fd 之后直接mmap数据一次都没被拷贝。这是容器和云原生场景里很经典的一种 IPC 模式。6.4 什么时候才轮到TCP出场上面这些机制都限定在同一台机器内。一旦需要跨主机选择就收敛到 TCP 或者更上层的消息中间件了。这时候要考虑的就不是哪种 IPC 快而是序列化格式、重连策略、超时重传、消息幂等等网络层问题——这些在本地 IPC 里基本不存在因为本地通信不会遇到网络分区和丢包。我一般的判断是先默认本地 IPC只有在明确需要分布式部署时才上网络层。反过来做的人往往会为了一台机器上的两个进程搭一套完整 RPC增加了运维复杂度和故障点而收益接近于零。7. 选型对照表与排查手段7.1 按场景挑工具把前面讲的东西汇总成一张表这是我实际做架构设计时会对照的场景特征推荐机制理由父子进程简单单向流式数据匿名管道零配置代码量最小大块数据、追求极致延迟POSIX 共享内存 信号量避免内核拷贝需要消息边界、优先级、多消费者POSIX 消息队列边界清晰天然流控本地多进程双向通信、需要鉴权Unix domain socket语义完整SO_PEERCRED事件循环里的唤醒与通知eventfd不丢事件可 epoll定时任务timerfd统一事件模型跨进程传大内存块memfd SCM_RIGHTS真正的零拷贝同一进程多线程条件变量/互斥锁无需内核介入跨主机TCP / 消息中间件唯一可行路径一个常见的误区是越复杂的机制越高级。实际上管道解决不了的问题共享内存往往能解决但共享内存引入的同步复杂度可能远超收益。我做过一个项目两个进程之间每秒传几十条小消息最开始用了共享内存加互斥锁代码三百多行还是偶尔出锁问题后来换成SOCK_SEQPACKET的 UDS代码缩到一百行出头性能完全够用稳定性反而上来了。在满足性能要求的前提下永远选语义最简单的那一个。7.2 组合拳共享内存加eventfd的实用搭配共享内存配信号量是经典组合但信号量有个小缺点每次sem_post/sem_wait都可能陷入内核。如果你只需要单向的数据就绪通知eventfd更轻。我的常用组合是这样共享内存放数据缓冲区用环形队列的结构。生产者写完往eventfd写一个计数。消费者epoll_wait收到事件读eventfd然后从共享内存取出数据。这个组合的好处是消费者可以完全挂在事件循环里不需要额外的消费者线程去阻塞等待。环形队列的读写指针必须是原子的uint64_t在 64 位平台上天然原子如果要用 32 位平台得用__atomic系列内建函数。环形队列有个必须处理的边界缓冲区满了怎么办。生产者不能覆盖未消费的数据所以要么阻塞等待要么丢弃要么扩容。这三者对应完全不同的业务语义必须在设计阶段想清楚而不是留到线上出问题时临时决定。7.3 排查IPC问题的三板斧本地 IPC 出问题时我通常按这个顺序排查第一板斧看对象存不存在。ipcs -m共享内存、ipcs -q消息队列、ipcs -s信号量、ls -l /dev/shm/、ls -l /dev/mqueue/。如果对象不在了说明程序崩了没清理或者创建的权限不对。第二板斧看fd和进程。lsof -p pid看进程打开了哪些 fd管道和 socket 都能看出来ls -l /proc/pid/fd/更直接管道会显示成pipe:[12345]两个进程如果指向同一个 inode 号那就是同一根管道。这个技巧在确认谁继承了不该继承的 fd时特别好用。第三板斧看系统调用。strace -f -e traceread,write,openat,mmap,clone把整个进程树的行为打出来卡在哪个调用上一目了然。我开头那个管道阻塞问题就是靠strace看到两个进程都在 read没有任何写操作才锁定了方向。注意-f一定要加否则子进程的系统调用看不到。还有一个容易被忽略的点IPC 对象的权限受 umask 影响。你用0666创建共享内存如果进程的umask是022最终权限就是644别的用户组的进程打不开。这类问题在单机测试时永远不会出现一到多用户环境就冒出来。稳妥的做法是显式umask(0)之后再创建或者创建后用fchmod校正。8. 我在多进程项目里被反复教育过的几件事8.1 fork之后fd的继承是新手的头号陷阱这是我最想强调的一点。fork()出来的子进程会继承父进程所有没有O_CLOEXEC的 fd包括管道、socket、eventfd、共享内存的句柄。这些继承的 fd 会让引用计数始终保持为正导致 EOF 永远不来、socket 永远不关闭、资源永远不会被回收。更要命的是这种 bug 往往不报错只是行为不符合预期排查成本极高。我的做法是三条一起上一是创建 fd 时无脑加O_CLOEXEC二是 fork 之后立即关闭子进程不需要的 fd不要嫌麻烦三是在exec之前把不需要继承的 fd 全部关掉。第三点在写一些嵌入式或者容器化的启动器时特别重要。8.2 崩溃后的残留必须由启动流程兜底任何依赖干净退出的 IPC 清理逻辑都是不可靠的因为进程可能被kill -9、可能段错误、可能被 OOM killer 干掉。启动时清理残留、运行中定期检查、退出时尽力清理——这三层里第一层才是真正有效的。具体做法共享内存在创建前先尝试shm_unlink一次忽略ENOENTUDS 和 FIFO 在bind/mkfifo之前先unlink并对失败情况做容错命名信号量同理。这样即使上次是崩溃退出本次启动也能拿到干净的状态。但要注意多实例场景如果两个实例同时启动互相unlink对方的东西就会出事。这时候需要一个基于文件锁或者目录的互斥机制来串行化初始化。8.3 一个关于心态的体会写了这么多年多进程程序我越来越觉得 IPC 的难度不在 API 本身——pipe、mmap、socket这些调用的用法半小时就能学会。难的是把并发时序想清楚。哪些操作必须原子、哪些状态可能不一致、对方在你操作的间隙死掉了怎么办、消息发出去之后多久没回应算超时——这些问题没有银弹只能一个一个地在设计阶段穷举。我的习惯是设计 IPC 协议时先画一张时序图标出每个可能被信号打断的点、每个可能失败的调用、每个可能残留的对象然后针对这些点逐个决定策略。看起来很啰嗦但比起在生产环境里用通宵排查一个偶发的死锁前期的这点投入实在划算。如果只能从这篇里拿走一句话那就是先想清楚同步和失败处理再挑通信机制。顺序反了选什么工具都会踩坑。