☰
Linux进程间通信入门:pipe、FIFO、文件与mmap共享内存选型实战
2026/10/27 15:32:14 网站建设 项目流程

1. 进程之间为什么不能直接"说话"

写过几年 Linux 应用层代码的人大概都遇到过一个瞬间:明明两个进程操作的是同一个变量名、同一个结构体,为什么一个改了另一个完全看不见?我第一次遇到这个问题时还很天真,以为是自己编译器优化出了问题,把-O2换成-O0重新跑了一遍,结果还是一样。后来才想明白,这与编译器没有任何关系,是进程地址空间隔离这件事在起作用。

每个进程在 Linux 里拿到的都是独立的虚拟地址空间,内核给每个进程维护一套页表,同一个虚拟地址在 A 进程里指向物理页帧 0x2A3,在 B 进程里完全可能指向 0x9F1,甚至压根没有映射。所谓的"同一个全局变量",只是在各自的地址空间里长得一模一样而已。这种隔离是操作系统安全模型的基石,但代价也很直白:你需要一种内核参与的、双方都认可的通道,才能把数据从一个进程搬到另一个进程。这就是 IPC,进程间通信(Inter-Process Communication)要解决的核心问题。

Linux 系统编程这块内容里,IPC 是绕不过去的分水岭。前面学完之后你顶多能在单个进程里打转,一旦跨进 IPC,你才真正开始处理"多个执行流协同"这种工程问题。管道 pipe、有名管道 FIFO、文件、共享存储映射 mmap,这四个东西是我认为入门阶段最该吃透的,因为它们覆盖了 IPC 的两大基本模型:字节流通道和共享内存。前者靠内核搬运数据,简单但不适合大数据量;后者靠映射同一块物理页,快得离谱但要自己处理同步。搞懂这两条路线的取舍,后面再学消息队列、信号量、套接字,理解成本会低非常多。

这篇内容适合三类人看:一是刚学完 fork、exec、文件 IO,准备系统学习 IPC 的;二是工作中被"父子进程怎么传数据""两个独立程序怎么通信"卡住想找现成方案的;三是想搞清楚 mmap 到底该怎么用、为什么总有人推荐又总有人说它坑多的。下面的代码全部可以拿去直接编译跑,参数我也会把怎么算的说清楚,不玩虚的。

1.1 地址空间隔离到底隔离了什么

把这件事讲透很有必要,不然后面理解 mmap 为什么能工作会很难受。进程的虚拟地址空间大致分成几个段:代码段 text、已初始化数据段 data、未初始化数据段 bss、堆 heap(向高地址长)、栈 stack(向低地址长),再加上一段内核映射区。每个进程都有这么一份布局,但物理内存里到底落在哪儿、共享不共享,由页表决定。

关键点在于:共享是可以存在的,但需要显式建立。fork 之后父子进程的页表项一度指向同一批物理页,采用写时复制(Copy-On-Write),一旦有一方写,内核才复制一份出来。所以 fork 之后父子进程的变量看似"继承"了,实际上一方改另一方看不到,因为写操作触发了复制。而 mmap 干的事情正好相反——它让两个本来独立的进程,主动把同一块物理页映射到各自的地址空间里,于是写操作不再触发复制,真正实现了"改一处,另一处可见"。

理解了这层,你就能明白为什么管道读出来的数据需要两次拷贝(用户态 → 内核缓冲区 → 另一个进程用户态),而共享内存只需要零次或一次(取决于是否落盘)。也就能明白为什么共享内存"快但危险"——内核不帮你做任何同步,两个进程同时写同一个位置,谁后写谁覆盖,数据竞争完全靠自己用信号量、原子操作或者锁来兜住。

提示:不要把"进程间共享变量"和"线程间共享变量"混为一谈。线程共享同一份地址空间,天然可见;进程不共享,必须走 IPC。很多初学者写出 bug 就栽在这个概念混淆上。

1.2 四种方案的选型判断表

选型这件事最忌讳"我记得有个函数叫 xx 就用它"。我一般按四个维度来判断:通信双方有没有亲缘关系、数据量大小、对延迟的敏感程度、需不需要自发同步。下面这张表是我自己在做技术评审时常用的对照,直接抄走就行。

方案亲缘关系要求典型数据量延迟量级同步负担适用场景
匿名管道 pipe必须有(fork 继承)KB 级,单次建议 ≤ 4KB微秒级内核自带流控父子进程、shell 管道、简单命令流
有名管道 FIFO无KB 级,同上微秒级内核自带流控两个独立程序、跨会话的简单通知
普通文件无任意大毫秒级(受磁盘影响)全靠自己加锁需要持久化、需要事后回看的场景
共享存储 mmap无MB 到 GB 级纳秒级(内存访问)完全自己负责高频大块数据、图像帧、实时缓冲

看这张表有个诀窍:先看数据量,再看延迟。如果你的数据是每次几十字节的控制命令,用 mmap 属于杀鸡用牛刀,而且你还得自己写同步逻辑,纯粹给自己找事;反过来如果你要传的是 1080P 的一帧图像(大约 6MB),用管道你就等着吧,内核缓冲区来回拷贝的开销足够让帧率掉一半。

2. 匿名管道 pipe:最轻量的血缘通信方案

pipe 是 IPC 里最古老也最省事的方案,Unix 哲学里"一切皆文件"的味道在它身上体现得淋漓尽致。你在 shell 里敲的ls | grep txt,中间那根竖线在进程层面就是一次pipe()调用加两次dup2()。它简单到几乎不需要配置,但也正因为它简单,限制条件一眼就能数完。

2.1 函数原型与内核实现的几个关键事实

函数签名短得可怜:

#include <unistd.h> int pipe(int pipefd[2]);

pipefd[0]固定是读端,pipefd[1]固定是写端。这个顺序千万别记反,我见过不少人第一次写就把方向搞错,然后程序卡死在 read 上,还以为是死锁。返回 0 表示成功,-1 表示失败并设置 errno,常见的是EMFILE(进程打开的 fd 数达到上限,默认 1024)和ENFILE(系统全局 fd 上限)。

管道在内核里对应的是一个pipe_inode_info结构,里面挂着一个环形缓冲区。这个缓冲区的大小不是随便设的,在 Linux 上默认是 64KB(65536 字节),你可以用fcntl(fd, F_GETPIPE_SZ)读出来验证,也能用F_SETPIPE_SZ在权限允许的范围内调整,上限受/proc/sys/fs/pipe-max-size约束,通常是 1MB。

这里有两个数字必须背下来,因为它们直接决定了你的程序会不会出诡异的 bug:

  • PIPE_BUF = 4096 字节。这是保证原子写的最大长度。也就是说,如果你一次write的数据量 ≤ 4096 字节,内核保证这次写要么全部成功、要么一个字节都不写,不会被其他进程的写操作插进来。超过这个长度,多个写者并发时数据可能交错。我在一个多进程日志采集的场景里就踩过这个坑:三个子进程往同一根管道写日志,每条日志平均 5KB,结果日志内容经常出现半截 A 半截 B 的"串行惨案"。
  • 64KB 缓冲区。当缓冲区写满后,write会阻塞(默认阻塞模式下),直到读端消费掉一部分。这意味着如果你在同一个进程里先写满 64KB 再去读,就会死锁——自己等自己。

2.2 一个完整可编译的父子进程通信实操

光说不练没用,下面是能直接跑的代码,我用gcc -Wall -o demo pipe_demo.c编译验证过的:

#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/wait.h> int main(void) { int fd[2]; if (pipe(fd) < 0) { perror("pipe"); exit(EXIT_FAILURE); } pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { /* 子进程:只读,先把写端关掉 */ close(fd[1]); char buf[256]; ssize_t n = read(fd[0], buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("[child] received: %s\n", buf); } else if (n == 0) { printf("[child] peer closed, got EOF\n"); } close(fd[0]); _exit(0); } else { /* 父进程:只写,先把读端关掉 */ close(fd[0]); const char *msg = "hello from parent"; ssize_t w = write(fd[1], msg, strlen(msg)); printf("[parent] wrote %zd bytes\n", w); close(fd[1]); wait(NULL); } return 0; }

代码本身不难,但里面有两个动作是必须做的,我在评审新人代码时最常挑出来的问题就是漏了它们。

第一个动作:关掉不用的一端。fork 之后父子进程各自持有一份fd[0]和fd[1],也就是总共有 4 个 fd 指向同一个管道。父进程要写,就必须关掉自己的fd[0];子进程要读,就必须关掉自己的fd[1]。为什么?因为管道的 EOF 判断规则是"所有写端 fd 都关闭后,读端 read 才返回 0"。如果你忘了关,即使父进程写完了、退出了,子进程那边还有一个属于它自己的fd[1]没关,read就永远不会返回 0,直接挂死。这个 bug 非常经典,现象是"程序不报错,就是不结束"。

第二个动作:子进程用_exit而不是exit。exit()会刷新 stdio 缓冲区、执行 atexit 注册的函数,而_exit()直接进内核退出。在 fork 出来的子进程里用exit(),父进程缓冲区里没刷新的内容可能被复制一份再刷一遍,输出就重了。这是 fork 与 stdio 混用时的老问题,虽然在这个简单例子里看不出差别,但养成习惯很重要。

2.3 管道的四条铁律与四个经典死锁

我总结了四条规则,只要守住这四条,pipe 用起来基本不会翻车:

  1. 只能用于有亲缘关系的进程。管道没有名字,别的进程根本不知道这个 fd 是什么,所以必须靠 fork 继承。想让两个毫无关系的程序通信,pipe 无能为力,得换 FIFO 或者别的。
  2. 半双工。数据只能单向流。想要双向对话,就得建两根管道,一根 A→B,一根 B→A。这一点和 socket 不一样,别搞混。
  3. 写满则阻塞,读空则阻塞。除非你把 fd 设成非阻塞。非阻塞模式下读空返回 -1 且 errno 为EAGAIN,写满也返回 -1 并设置EAGAIN,这个返回值必须检查,否则你会把"暂时没有"当成"出错了"。
  4. 写端全关则读端 EOF;读端全关则写端收到 SIGPIPE。第二条特别要命:如果读端已经关了,你还往里写,内核直接给你发SIGPIPE信号,默认动作是终止进程。很多人的程序"莫名其妙就死了",查半天发现是往一个已关闭的管道写了一笔。正确姿势是在写之前用signal(SIGPIPE, SIG_IGN)忽略掉,然后检查write返回的EPIPE错误。

死锁这块,我列四个真实遇到过的场景,对照着排查非常快:

死锁场景现象根因解法
单进程先写满 64KB 再读程序静默卡死缓冲区满,write 阻塞,永远走不到 read拆成两个进程,或用非阻塞 + 轮询
忘记关闭多余 fd读端永不返回 EOF写端 fd 计数没归零每端用完立即 close 不用的那一半
双向通信只建一根管道双方都在 read 等对方半双工,方向冲突建两根管道,明确各自方向
父进程 wait 在 write 之前子进程读不到东西顺序颠倒,读到 EOF先完成数据交互再 wait,或调整流程

提示:调试管道卡死最快的办法是cat /proc/<pid>/stack(需要权限)看内核栈,或者用strace -p <pid>看当前阻塞在哪个系统调用上。一看是read(3, ...停着不动,基本就锁定了方向。

3. FIFO 有名管道:给陌生人之间搭一座桥

pipe 的死穴是"必须 fork",可现实里大量的场景是两个毫不相干的程序要通信:一个采集程序把数据交给一个处理程序,两边可能是不同时间启动的、由不同脚本拉起的,甚至分属不同用户。这时候就轮到 FIFO 上场了。

3.1 FIFO 和 pipe 的本质差别就一个词:名字

FIFO 又叫有名管道(named pipe),它在文件系统里有一个真实可见的路径,用ls -l看类型是p(prerequisite,管道文件)。你完全可以在 shell 里手动敲:

mkfifo /tmp/demo_fifo ls -l /tmp/demo_fifo # prw-r--r-- 1 user user 0 Nov 1 10:00 /tmp/demo_fifo

注意它的文件大小显示为 0——FIFO 不占磁盘空间,它只是一个入口,真正的数据缓冲区还在内核里。这一点和普通文件有本质区别,别被ls的输出骗了。

正因为有名字,两个进程只要约定好路径,就能各自open同一路径拿到 fd,然后像读写文件一样读写它。内核看到这个 inode 是 FIFO 类型,就会把读写操作转发到管道的那套缓冲机制上。所以你可以把 FIFO 理解成"注册在文件系统里的管道",底层复用同一套pipe_inode_info,只是多了一层路径查找。

创建方式有两种,等价:

#include <sys/types.h> #include <sys/stat.h> /* 方式一:C 接口,mode 受 umask 影响 */ mkfifo("/tmp/demo_fifo", 0666); /* 方式二:命令行,效果一样 */ /* mkfifo /tmp/demo_fifo */

mode参数写 0666,实际权限会被进程 umask 削一刀(常见 umask 022,结果就是 0644)。如果你希望读写双方分属不同用户,记得把 mode 调成 0666 并临时改 umask,或者干脆用chmod()补一刀,否则另一边可能因为权限不够 open 失败,这个坑很隐蔽,报错信息只会告诉你Permission denied。

3.2 阻塞 open 的魔咒与"先读后写"的破法

FIFO 最让人抓狂的行为是:用O_RDONLYopen 一个 FIFO 会阻塞,直到有另一个进程用O_WRONLY打开它。反过来也一样。这是内核为了保证读写双方配对而设计的,但对不熟悉的人来说就是"我的程序启动就卡住不动了"。

一次实测的现象记录,我在两台终端上分别执行:

# 终端 A $ cat /tmp/demo_fifo # 内部是 open(O_RDONLY),立刻阻塞 # 终端 B $ echo "hello fifo" > /tmp/demo_fifo # open(O_WRONLY) 成功,A 立刻收到数据 hello fifo

A 在 B 执行前是完全静止的,stat显示状态是S(可中断睡眠)。这就是阻塞 open 的典型表现。

绕开它有几种做法,各有取舍:

  • O_NONBLOCK只读打开:立刻返回成功,即使没有写者。此时read在无数据时返回 -1,errno 为EAGAIN。这是写"常驻读取端"的标准姿势——服务端一般先启动,不能因为客户端没来就卡死。
  • 读写两端都用O_RDWR打开:Linux 上这是个"小技巧",因为管道两端都由自己持有,open 不会阻塞。但这么做有个副作用:你自己持着写端,就永远不会读到 EOF。只适合调试或者确实不需要 EOF 语义的场合。
  • 先启动写端再启动读端:只适用于一次性脚本,工程上不可靠。

我在一个数据转发的小工具里就用过第一种:

#include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <errno.h> #include <string.h> int main(void) { const char *path = "/tmp/demo_fifo"; /* 已存在就忽略错误,注意 EEXIST 不算失败 */ if (mkfifo(path, 0666) < 0 && errno != EEXIST) { perror("mkfifo"); return 1; } int fd = open(path, O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return 1; } char buf[512]; for (;;) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { fwrite(buf, 1, (size_t)n, stdout); fflush(stdout); } else if (n == 0) { /* 所有写端都关闭,通常意味着本轮的写者结束 */ printf("\n[peer closed]\n"); /* 常驻服务里这里不退出,继续等下一轮 */ usleep(100000); } else { if (errno == EAGAIN) { /* 暂时没数据,正常情况 */ usleep(50000); continue; } perror("read"); break; } } close(fd); return 0; }

这段代码里EAGAIN的处理是关键。很多人一看到read返回 -1 就perror退出,结果程序在空闲时疯狂报错。非阻塞模式下的EAGAIN是正常状态,不是错误,这个认知差是新手最容易栽的跟头之一。至于轮询间隔用usleep(50000)还是上去就用select/poll,取决于你对实时性的要求。如果延迟要求低于 50ms,轮询就不合适了,该换poll阻塞等待,把 CPU 让出去。

3.3 FIFO 的三个限制与它真正的定位

FIFO 虽然解决了"无亲缘"的问题,但它继承了 pipe 的所有限制:半双工、字节流无边界、单条消息超过 4096 字节不保证原子、数据不持久(写者退出后缓冲区里的数据可能丢,尤其是 reader 没读完的时候)。所以别对它有超出能力范围的期待。

我个人的定位是:FIFO 适合做"轻量的事件通知"和"命令通道",不适合做数据通道。举个例子,一个监控进程需要通知一个业务进程"配置变了,重新加载",发一个 8 字节的指令码,这种场景用 FIFO 简直完美——不用引入 socket、不用多开端口、mkfifo一行搞定,权限还能用文件权限管。但如果要传的是几百 MB 的数据流,那就老老实实走共享内存或者本地 socket,FIFO 会让你很难受。

还有一个细节值得说:FIFO 如果长期不用,文件会留在/tmp里,下次程序启动mkfifo就报EEXIST。所以创建时一定要判断errno == EEXIST并当作成功处理,别把它当错误退出。我见过有人每次启动都先unlink再mkfifo,结果两个实例同时跑的时候互相删对方刚建好的管道,场面相当混乱。正确做法是容忍已存在,或者用flock之类的机制保证单实例。

4. 文件做 IPC:能用,但要清楚自己在赌什么

"用文件完成进程间通信"这句话听起来很朴素——一个进程写文件,另一个进程读文件,就这么简单。它确实是最容易理解的方案,不需要任何特殊的 API,跨语言、跨机器(挂 NFS 的话)都能用。但我得先把话放这儿:用普通文件做 IPC,是把同步责任 100% 甩给了你自己,内核除了保证单次write的原子性(这个在不同文件系统上的保证力度还不一样),什么都不会帮你做。

4.1 三个必须正视的硬伤

第一个硬伤是没有阻塞语义。管道里没数据,read会阻塞等你;文件里没新内容,read直接返回 0(EOF),根本不知道对方是"还没写"还是"写完了"。这就逼着你引入轮询,或者靠文件大小、修改时间来判断。轮询的代价是什么?如果你用 10ms 间隔去stat一个文件,一天就是 864 万次系统调用,纯粹浪费 CPU。

第二个硬伤是部分写入。write(fd, buf, 1000000)不保证一次写完,返回值可能是 1000000 也可能是 65536,取决于内核缓冲区、文件系统、信号中断。你写循环补齐,那读端看到的就是不完整的数据。我做过一个日志汇总的小工具,初期就是"追写+追读",结果经常读到半条记录,日志格式全乱。后来在每条记录前加了固定长度的头部(记录总长度 + magic 数),读端先读头部再决定读多少,才算把这个问题按住。

第三个硬伤是并发写的覆盖。两个进程同时往同一位置写,或者一个在写一个在读,读到的内容完全不可预期。这时候必须上锁。Linux 提供flock()和fcntl()的F_SETLK两套机制,简单场景用flock就够:

#include <sys/file.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> int write_with_lock(const char *path, const void *data, size_t len) { int fd = open(path, O_WRONLY | O_CREAT, 0644); if (fd < 0) return -1; /* LOCK_EX 排他锁,阻塞直到拿到 */ if (flock(fd, LOCK_EX) < 0) { close(fd); return -1; } ssize_t n = write(fd, data, len); flock(fd, LOCK_UN); close(fd); return n == (ssize_t)len ? 0 : -1; }

注意flock是建议锁,只对同样调用了flock的进程有效,不配合的进程照样能写进去。所以这套机制成立的隐含前提是:所有参与者都遵守规则。在自己可控的几个程序之间,这个前提没问题;一旦涉及第三方程序,你就别指望了。

4.2 文件 + mmap 才是文件 IPC 的正确打开方式

纯read/write的文件 IPC 效率很低,因为每次都要走"用户态缓冲 → 内核页缓存 → 用户态缓冲"的拷贝。如果非要走文件,我强烈建议直接上 mmap,把文件映射进地址空间,读写就是内存操作,内核负责在msync或者换页时刷盘。

但这里有个更省事的选择:如果你只是想把 mmap 当共享内存用,根本不需要落盘。用/dev/shm(tmpfs)下的文件,或者干脆用MAP_ANONYMOUS | MAP_SHARED,效率比真实磁盘文件高一个数量级。用真实磁盘文件做共享内存有个容易忽略的代价:脏页回写会触发 IO,即使你只是想让两个进程交换数据,磁盘也在被无谓地写。我在一个嵌入式项目里见过有人拿 SD 卡上的文件做共享缓冲,结果卡寿命明显下降,后面改成 tmpfs 就解决了。

如果你确实需要数据落盘(比如断电后要能恢复),那么文件 + mmap 的组合就很合适:映射区当工作区,msync(addr, len, MS_SYNC)时刷盘。MS_SYNC会等到数据真正写到存储介质才返回,MS_ASYNC只是排进队列就返回。前者慢但可靠,后者快但断电可能丢。怎么选?看你的事故成本,配置文件这种丢了很麻烦的用MS_SYNC,临时缓存无所谓的用MS_ASYNC甚至不用显式调用。

对比项纯 read/write 文件文件 + mmaptmpfs + mmap
数据拷贝次数2 次1 次(缺页时)1 次(缺页时)
是否需要同步机制必须必须必须
持久化是是(配合 msync)否
大文件随机访问差(要 seek)极好(指针偏移)极好
断电安全性较高取决于 msync 策略无

5. 共享存储映射 mmap:快到飞起的代价

前面铺垫了这么多,终于到了重点。mmap 是我认为 Linux 系统编程里"威力最大、也最容易用错"的一个函数。用对了,进程间传数据的开销能压到接近内存拷贝;用错了,就是段错误、数据错乱、内存泄漏三件套。

5.1 六个参数逐个拆开讲

原型不长,但每个参数都有讲究:

#include <sys/mman.h> void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:期望的映射起始地址。填NULL让内核自己挑,这是 99% 的情况。填了非 NULL 值也只是"建议",内核可能不搭理你,而且你还得保证这个地址是页对齐的、不在已有映射区的冲突位置。除非你在做 JIT 或者特定布局的优化,否则永远填 NULL。
  • length:映射的字节数。内核以页为单位管理内存,x86-64 上页大小是 4096 字节。如果你传 5000,内核实际映射 8192 字节(两页)。访问第 5000 到 8191 字节会不会出错?不会段错误,但严格来说超出了你声明的范围,语义上不干净。我一般直接按页向上取整:len = (n + 4095) & ~4095,清爽。
  • prot:保护位,PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE的组合。注意这里有个陷阱:如果底层 fd 是以O_RDONLY打开的,你却请求PROT_WRITE,mmap会失败并返回MAP_FAILED(也就是(void *)-1)。必须两边匹配:fd 可写 +PROT_WRITE,才行。
  • flags:核心是MAP_SHARED和MAP_PRIVATE二选一。前者写入对其他映射同一区域的进程可见,并且会反映到文件;后者写入触发写时复制,只对自己可见。进程间共享必须用MAP_SHARED,用错了就是"为什么另一个进程读到的还是 0"的经典问题。另外MAP_ANONYMOUS表示不关联文件(此时 fd 传 -1),只用于有亲缘关系的进程之间共享,通常在 fork 之前建立。
  • fd:要映射的文件描述符。匿名映射时传 -1。
  • offset:文件内的偏移。必须是页大小的整数倍,传 100 会直接返回EINVAL。要映射文件中间某处,传offset = k * 4096。

返回值判断也有个坑:失败返回的是MAP_FAILED(即(void *)-1),不是NULL。我见过有人写if (!p) { perror(...); },结果映射失败后照样往下走,访问空指针直接崩。正确的写法只能是if (p == MAP_FAILED)。

5.2 一个共享结构体 + 原子标志位的完整案例

下面这个例子是我实际用过的简化版:父进程准备一块共享区,子进程往里写数据,写完置标志位,父进程轮询到标志位后读取。注意这里我故意用volatile加__atomic来演示,但真实工程里更稳妥的是配一个 POSIX 信号量(sem_open)或者无名信号量放在共享区里。

#include <sys/mman.h> #include <sys/wait.h> #include <unistd.h> #include <stdio.h> #include <string.h> #include <stdlib.h> typedef struct { volatile int ready; /* 0 = 未就绪, 1 = 已就绪 */ int value; char text[64]; } shared_t; int main(void) { /* 匿名共享映射,父子进程都能访问同一块物理页 */ shared_t *sh = mmap(NULL, sizeof(shared_t), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (sh == MAP_FAILED) { perror("mmap"); exit(EXIT_FAILURE); } sh->ready = 0; sh->value = 0; memset(sh->text, 0, sizeof(sh->text)); pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { /* 子进程:写入数据 */ sh->value = 42; snprintf(sh->text, sizeof(sh->text), "data from child"); /* 用 release 语义保证前面的写入对父进程可见 */ __atomic_store_n(&sh->ready, 1, __ATOMIC_RELEASE); _exit(0); } /* 父进程:等待标志位 */ int spin = 0; while (__atomic_load_n(&sh->ready, __ATOMIC_ACQUIRE) == 0) { if (++spin > 100000000) { fprintf(stderr, "timeout waiting child\n"); break; } usleep(1000); } printf("[parent] value=%d text=%s\n", sh->value, sh->text); wait(NULL); if (munmap(sh, sizeof(shared_t)) < 0) { perror("munmap"); } return 0; }

这段代码有几个细节值得掰开说。第一,MAP_ANONYMOUS | MAP_SHARED组合必须在 fork之前建立,这样父子进程才映射到同一块物理页。如果在 fork 之后各自 mmap 一次,那映射的是两块不同的匿名内存,再怎么共享也没用。第二,volatile只保证每次从内存读,不保证 CPU 层面的内存序,所以跨进程可见性要用__atomic的 acquire/release 语义。第三,纯自旋等待在真实场景中会把一个核跑满,这里usleep(1000)只是缓解,正经做法是上信号量或者eventfd。

如果是两个没有亲缘关系的进程,就不能用匿名映射了,必须借助文件:

/* 进程 A:创建并初始化 */ int fd = open("/dev/shm/demo_region", O_RDWR | O_CREAT, 0600); ftruncate(fd, sizeof(shared_t)); /* 关键!不调用会 SIGBUS */ shared_t *sh = mmap(NULL, sizeof(shared_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (sh == MAP_FAILED) { perror("mmap"); return 1; } /* 进程 B:只需要打开同一路径 */ int fd2 = open("/dev/shm/demo_region", O_RDWR); shared_t *sh2 = mmap(NULL, sizeof(shared_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd2, 0);

这里ftruncate是最容易漏的一步。文件长度为 0 的时候映射是合法的,但你一访问就会收到SIGBUS(不是SIGSEGV,注意区分!)。很多人第一次碰到SIGBUS完全懵,因为代码里根本没写错指针。原因就是映射区域超出了文件实际大小——mmap 映射的是文件当前长度,文件多长,你能安全访问的区域就有多大。

5.3 MAP_SHARED 与 MAP_PRIVATE 的实测差异

这两个 flag 的区别必须亲手验证一次才能记住。我做过的对照实验很简单:同一份代码,只改 flags,观察父进程能不能看到子进程的修改。

观察项MAP_SHAREDMAP_PRIVATE
子进程修改后父进程可见可见不可见(仍是初值)
是否触发写时复制否是,写入时分配私有副本
修改是否回写文件会(或需 msync 保证时机)不会
进程间通信用途唯一正确选项完全不能用
典型误用现象——"子进程明明改了,父进程读出来还是 0"

关于回写时机,还有一点要说清楚:MAP_SHARED的修改不是立刻落盘的,它写在页缓存里,由内核在合适的时候(比如msync、munmap、内存压力、或默认 30 秒左右的脏页回写周期)刷到磁盘。所以即使两个进程通过文件映射共享了内存,只要他们都还活着,读写就能立刻互相看到,跟有没有落盘无关。落盘只影响持久性和跨机器可见性。

5.4 同步机制:mmap 的必修课

mmap 最大的坑不在于函数怎么调,而在于它不提供任何同步。两个进程同时往同一个 int 上++,结果不是 1 而是 2 的情况可能发生,也可能只加了一次,完全看运气。这不是危言耸听,是真实存在的数据竞争。

解决办法有几种,按复杂度和可靠性排序:

  • POSIX 无名信号量放在共享区内:sem_init(&sh->sem, 1, 1),第二个参数1 表示进程间共享,这个参数传 0 只能用于线程,是常见错误。用完sem_destroy。
  • 命名信号量:sem_open("/demo_sem", O_CREAT, 0600, 1),适合无亲缘关系进程,需要想好怎么清理/dev/shm里的残留。
  • 原子操作:只适合简单的标志位、计数器。C11 的_Atomic或者 GCC 的__atomic_*都行,但要搞清楚 acquire/release 语义,否则在 ARM 平台上会因为乱序执行出问题(x86 的内存模型更严格,很多时候测不出来,换平台就炸)。
  • 文件锁:flock或者fcntl加锁,粒度粗但跨语言通用,适合低频操作。

我在一个视频帧传递的场景里用过共享内存 + 双缓冲 + 信号量:生产者写 buffer A 时消费者读 buffer B,写完后交换指针并sem_post。这样读和写永远不撞车,实测 1080P 帧(约 6MB)在两进程间的传递延迟能压到 1ms 以内,而同样的数据走管道要 8~10ms。数据量越大,差距越夸张。

5.5 清理与泄漏排查

munmap忘了调用会不会泄漏?技术上,进程退出时内核会回收所有映射,但映射本身在进程存活期间会一直占着地址空间和物理页。一个长期运行的服务如果每次请求都 mmap 一次却不 munmap,虚拟内存很快耗尽,cat /proc/<pid>/maps | wc -l会看到几千条记录。我的习惯是:每写一个 mmap 就立刻把 munmap 写在同一个函数的出口处,哪怕中途有 return 也要 goto 到统一的清理标签。

排查技巧就一个命令:

# 看某个进程的所有映射区 cat /proc/$$/maps | grep -i shm # 看系统层面的共享内存对象 ls -l /dev/shm/

/dev/shm下的文件是持久化的,进程退出不会自动删除,必须自己unlink。这个设计导致容器环境里经常堆积一堆没人管的共享内存文件,久而久之占满 tmpfs。我的做法是在sem_open或者shm_open成功之后立刻调unlink(对 POSIX 共享内存来说,unlink 只是删名字,已打开的 fd 和映射仍然有效),这样既保证了生命周期,又不会有残留。

6. 常见问题与排查技巧实录

这部分是我这些年踩坑踩出来的速查表,按"现象 → 可能原因 → 排查手段 → 解法"来组织,遇到问题直接对照,能省不少时间。

6.1 问题速查表

现象可能原因排查手段解法
读端永久阻塞不返回写端 fd 未关闭lsof -p <pid>看 fd 数量用完立即 close 不用的那端
管道写入后进程被杀读端已关闭,触发 SIGPIPEecho $?看是否为 141(128+13)忽略 SIGPIPE 并检查 EPIPE
mmap 访问收到 SIGBUS文件长度小于映射长度stat看文件 size先ftruncate到目标长度
mmap 返回 MAP_FAILEDfd 权限与 prot 不匹配 / offset 未对齐打印 errno,常见 EACCES、EINVAL权限对齐、offset 按 4096 取整
共享数据另一进程看不到用了 MAP_PRIVATE检查 flags 参数改 MAP_SHARED
数据写一半被覆盖单次写超过 PIPE_BUF统计单条消息长度拆包或者换共享内存
FIFO 打开就卡住阻塞 open 等待对端strace看停在 openat一端用 O_NONBLOCK
输出重复出现两遍fork 后用了 exit 而非 _exit检查子进程退出方式改成 _exit
sem_init 返回 EINVALpshared 参数传了 0查看 sem_init 第二参数改传 1 表示进程间共享
共享内存区越来越大/dev/shm 下的文件没清理ls -l /dev/shmopen 成功后立即 unlink

6.2 几条用血换来的经验

第一条,先用最小的 demo 验证通路,再往业务里塞。我见过太多人一上来就把 IPC 嵌进一个几千行的项目里,结果通信不通,根本分不清是 IPC 的问题还是业务逻辑的问题。正确做法是单写一个 30 行的最小程序,父子进程传一个 "hello" 过去,跑通了再接业务。这半个小时能省你两天。

第二条,同步机制永远比你想的更需要。刚开始用共享内存的人,十个里有九个觉得"读写速度那么快,不会撞上的"。实际上 CPU 跑到几 GHz,两个进程同时访问同一个位置的窗口大得很。我做压测的时候专门加了一个"故意让两个进程死循环对同一个 int 做 ++"的用例,跑十分钟必然出现计数丢失。所以只要是多进程共享,一律上同步,别赌概率。

第三条,注意平台差异。x86 的强内存模型会掩盖很多乱序问题,代码在开发机上跑得好好的,换到 ARM 板子上立刻出错。所以涉及无锁编程,__atomic的内存序参数必须写对,不能图省事全用__ATOMIC_RELAXED。我吃过这个亏,一个在 PC 上跑了三个月的程序,移植到嵌入式设备上偶发数据错乱,查了两周才发现是内存序的问题。

第四条,善用/proc而不是纯靠 printf。cat /proc/<pid>/maps看映射、ls -l /proc/<pid>/fd看打开的文件描述符、cat /proc/sys/fs/pipe-max-size看管道上限,这些信息比你在代码里加十行日志有用得多。特别是 fd 泄漏,ls -l /proc/<pid>/fd | wc -l一眼就能看出来涨没涨。

第五条,别把 IPC 当选型的终点。这四个方案覆盖了入门所需的全部基础,但真实工程里更常见的是 Unix domain socket(AF_UNIX)——它兼有 FIFO 的"无亲缘可用"和管道的"流式",还支持双向、支持传文件描述符、支持SOCK_SEQPACKET保留消息边界。如果你的通信需求开始变复杂(要双向、要传 fd、要区分消息边界),就该考虑升级方案了。我个人的经验是:简单通知用 FIFO,大数据用共享内存,剩下 80% 的场景上 Unix socket,这个比例在我经手的项目里基本成立。

最后分享一个我调试 IPC 问题时的固定动作:先确认两端都活着(ps),再确认 fd 状态正常(ls -l /proc/<pid>/fd),然后strace -f -e trace=read,write,openat,mmap挂上去看系统调用流。这套流程走一遍,九成的问题当场就能定位,比盯着代码瞪眼效率高太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询