做Linux后端开发或者嵌入式开发的朋友,早晚会遇到一个场景:你写了两个进程,一个负责采集数据,另一个负责处理入库,这两个进程怎么配合?或者干脆就是面试,对面直接抛一句"说说进程间通信有哪几种方式",别小看这个问题,它能从原理问到实战,再从实战问到坑,能拦住一大半人。
进程间通信(IPC)的概念在操作系统里算是核心考点。很多人能背出"管道、消息队列、共享内存、信号量、套接字"这些名词,但真让他现场写一段代码,让两个进程把一个字符串传过去,就卡壳了。这篇文章我整理了业界公认的七种主流IPC方式:匿名管道、命名管道FIFO、信号、消息队列、共享内存、信号量、socket。每一种都配上可运行的C语言示例和实际开发中会遇到的坑。建议收藏,以后当工具手册用。
1. 先看战场:为什么进程通信比线程通信麻烦
1.1 进程隔离是安全的基石,也是通信的障碍
要理解IPC,先得理解一个根本矛盾。现代操作系统给每个进程分配了独立的虚拟地址空间,进程A的地址空间里存了什么,进程B是看不见也摸不着的。一个进程崩溃了,不会被另一个进程的内存错误直接带崩,这是隔离设计带来的安全性。
但坏处也随之而来。线程之间要通信太简单了,共享同一份地址空间,定义一个全局变量,A线程写入,B线程读取,完事。顶多加个锁保护一下。进程呢?天然就是"异地",你没法直接读取另一个进程的变量,必须依靠内核或者操作系统提供的某种"中间媒介"来传递信息。
这个"中间媒介"就是IPC的本质。七种方法看似花样繁多,其实都是围绕一个问题:如何让两个隔离的地址空间交换数据或同步状态。有的方法走内核缓冲区,比如管道和消息队列;有的方法直接映射物理内存,比如共享内存;有的方法干脆走网络协议栈,比如socket。理解了媒介是什么,你就理解了IPC的底层逻辑。
1.2 七种方法的家族谱和适用场景
先用一张表把七种方法的大致定位看清楚。这不是全部参数,但足够让你知道"什么场景该往哪边想"。
| 方法 | 媒介 | 方向 | 数据量 | 是否支持进程无关通信 | 是否跨主机 |
|---|---|---|---|---|---|
| 匿名管道(pipe) | 内核环形缓冲区 | 单向 | 小到中等 | 否,只能父子进程 | 否 |
| 命名管道(FIFO) | 内核缓冲区文件 | 单向 | 小到中等 | 是 | 否 |
| 信号(signal) | 内核信号表 | 单向通知 | 几乎不传数据 | 是 | 否 |
| 消息队列(msg queue) | 内核链表 | 双向 | 小到中等,有上限 | 是 | 否 |
| 共享内存(shm) | 物理内存映射 | 双向 | 大,吞吐量最高 | 是 | 否 |
| 信号量(semaphore) | 内核计数器 | 同步,不传数据 | 无 | 是 | 否 |
| 套接字(socket) | 网络协议栈/内核缓冲区 | 双向 | 大 | 是 | 是 |
从这张表能读出几个关键点。管道和socket有"血缘关系"的天然限制,匿名管道只能fork出来的进程用,但socket既能同机也能跨主机。信号本质不是用来传数据的,它传的只是一个"事件发生"的通知。信号量更特殊,它连通知都算不上,它管的是"能不能进入临界区"。共享内存是最快的信息交换方式,但同时也是同步问题最多的一种,一般要和信号量搭配使用。
后面每一章就按这个坐标系展开。先讲最基础的管道,再讲System V三件套,最后讲socket,最后给出一套可落地的选型思路。
2. 管道与FIFO:最简单的字节流通道
2.1 匿名管道pipe:父子进程里的"自来水管道"
匿名管道是所有IPC方式中最直观的一个,特别像现实中的自来水管道:数据从一端流进,从另一端流出,方向固定,只此一家。
pipe()系统调用会返回两个文件描述符,分别对应管道的读端和写端。注意,它是在进程内部先创建好管道,然后通过fork()创建子进程,子进程会继承这两个文件描述符。父进程如果要发数据给子进程,就关闭自己的读端,保留写端;子进程则关闭写端,保留读端。这样数据就顺着管道从父进程流向子进程。
下面这段代码演示了父子进程通过管道传递一行字符串:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> int main() { int fd[2]; pid_t pid; char buf[128] = {0}; if (pipe(fd) == -1) { perror("pipe"); return 1; } pid = fork(); if (pid == -1) { perror("fork"); return 1; } if (pid == 0) { // 子进程:关闭读端,只保留写端 close(fd[0]); const char *msg = "hello from child"; write(fd[1], msg, strlen(msg) + 1); close(fd[1]); _exit(0); } else { // 父进程:关闭写端,只保留读端 close(fd[1]); ssize_t n = read(fd[0], buf, sizeof(buf)); printf("parent received (%zd bytes): %s\n", n, buf); close(fd[0]); wait(NULL); } return 0; }这段代码里有几个细节值得多说一句。子进程最后用的是_exit(0)而不是exit(0),因为exit会刷新标准I/O缓冲区并执行atexit回调,在fork出来的子进程里这么做容易导致缓冲区内容被写两次,尤其当父进程也有未刷新的缓冲数据时。_exit直接进入内核,干净利落。另外,父子进程关闭不需要的那一端非常重要,如果不关,会导致对方永远读不到数据的结尾。
2.2 命名管道FIFO:两个独立进程的对话
匿名管道的最大限制是必须要有亲缘关系。如果我想让两个没有关系的进程通信,比如一个长期运行的后台服务和一个命令行工具,匿名管道就使不上劲了。这时需要命名管道FIFO。
FIFO在文件系统里有一个真实路径,用mkfifo()或者命令mkfifo /tmp/myfifo创建。它看起来是个文件,但内容不落盘,数据仍然只存在于内核缓冲区里。两个不相关的进程,只要约定好同一个路径,就能通过这个特殊文件交换数据。
FIFO最值得注意的行为是打开时的阻塞特性。以只读方式打开一个FIFO时,如果没有写端打开,open()会阻塞在那里;以只写方式打开时,如果没有读端,同样会阻塞。这个行为经常让新手莫名其妙,程序"卡住"了,其实是在等对方。
写端的代码:
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <sys/stat.h> #include <unistd.h> int main() { const char *path = "/tmp/myfifo"; // 如果已存在也没关系,忽略重复创建错误 mkfifo(path, 0666); // 以只写方式打开,会阻塞,直到有读端打开 int fd = open(path, O_WRONLY); if (fd == -1) { perror("open"); return 1; } const char *msg = "hello fifo"; write(fd, msg, strlen(msg) + 1); close(fd); // 清理临时文件 unlink(path); return 0; }读端的代码:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> int main() { const char *path = "/tmp/myfifo"; // 以只读方式打开,会阻塞,直到有写端打开 int fd = open(path, O_RDONLY); if (fd == -1) { perror("open"); return 1; } char buf[128] = {0}; ssize_t n = read(fd, buf, sizeof(buf)); printf("reader got %zd bytes: %s\n", n, buf); close(fd); return 0; }运行方式要先启动读端,再启动写端。因为在打开FIFO时双方要互相"碰头"。如果你想避免这种阻塞行为,可以在open()时使用O_NONBLOCK标志,但要注意,非阻塞模式和阻塞模式的语义区别很大,非阻塞模式下没有对方时open会立即返回失败,你需要在代码里处理好重试逻辑。
2.3 管道的三个经典坑
管道写多读少的场景,写端向一个读端已关闭的管道写入数据,内核会向写进程发送SIGPIPE信号,这个信号的默认动作是终止进程。很多服务程序莫名其妙挂掉,排查半天发现是SIGPIPE造成的。处理办法是在初始化时调用signal(SIGPIPE, SIG_IGN)忽略它,然后通过write的返回值判断管道是否已经断开。
管道默认是阻塞I/O。如果读端从一个没有任何写端的空管道读取,read会返回0,表示读到EOF;如果管道里没数据但写端仍存在,读端会阻塞。父进程如果不关闭自己的写端,子进程读数据时永远等不到EOF,程序就"假死"了。这就是为什么管道操作里"关闭不需要的端"是铁律。
管道缓冲区大小默认是64KB(通过proc文件系统可以查到)。管道塞满时写端会阻塞,直到读端消费数据。如果你要在管道里传输大于缓冲区尺寸的数据,读写双方必须严格按照"边写边读"的节奏来,否则就会形成互相等待的死锁。这是很多多线程管道程序的隐藏bug来源。
3. 信号:不传数据、只发通知的异步机制
3.1 信号的本质与常用信号
信号可以理解成软件层面的中断。硬件中断是CPU收到外部设备的通知,信号则是内核向进程发送的通知。比如你按下Ctrl+C,内核会向前台进程组发送SIGINT;进程访问非法内存,内核发送SIGSEGV;你在终端执行kill <pid>,实际就是通过系统调用发送指定的信号。
信号最大的特点就是"轻"——它不携带数据。你可以通知对方"某个事件发生了",但没法告诉对方具体发生了什么。因此信号不是为数据传输设计的,它主要用于事件通知、异常处理、超时控制这类场景。
开发中最常用的信号包括:SIGUSR1和SIGUSR2,这是预留给你自定义使用的两个信号;SIGCHLD,子进程状态变化时父进程会收到;SIGTERM,终止请求,通常用于优雅退出;SIGKILL和SIGSTOP则特殊,默认行为不可被替换也无法被捕获,这是内核最后的强制手段。
3.2 实战:用SIGUSR1实现父子进程通知
下面的程序演示了一个典型的场景:父进程fork出子进程,子进程进入等待状态,父进程发送SIGUSR1给子进程,子进程收到信号后执行自定义的处理函数并退出。
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <sys/wait.h> void handler(int sig) { if (sig == SIGUSR1) { printf("child: caught SIGUSR1, now exit\n"); _exit(0); } } int main() { pid_t pid = fork(); if (pid == 0) { // 子进程注册信号处理函数 signal(SIGUSR1, handler); while (1) { pause(); } } else { sleep(1); kill(pid, SIGUSR1); wait(NULL); printf("parent: child exited\n"); } return 0; }这里有两个细节要特别注意。信号处理函数里的printf其实是不严谨的,严格来说应该只调用异步信号安全函数(async-signal-safe functions),printf不在这个列表里。小演示程序这么写没问题,但在生产环境里,信号处理函数里最好只做volatile sig_atomic_t类型的变量赋值,或者调用write这类安全函数,复杂的业务逻辑应该放到主循环里处理。
pause()让进程挂起等待信号。这是一种很朴素的同步方式。更精细的控制可以用sigwait配合sigprocmask,把信号的接收从"异步打断"变成"同步等待",这在多线程程序里更安全。
3.3 信号处理函数的禁区
信号处理是很多隐蔽bug的重灾区,这里说几条硬经验。
不要在里面调用malloc、printf、strcpy这类非异步安全函数。原因很简单:信号可能在你主程序执行到半路时突然插入,如果主程序正好在调用malloc维护堆结构,信号处理函数里又调用malloc,堆结构就被搞坏了。这个bug极其随机,可能跑几个月才出现一次。
全局变量在信号处理函数和主循环之间共享时,必须声明为volatile sig_atomic_t。普通变量可能被编译器优化进寄存器,主循环读它时不知道信号处理函数已经改了内存中的值。
注意不要滥用信号实现定时器或者业务触发。信号发送频率过高会拖慢系统,而且不同操作系统对信号的实现细节有差异,可移植性不佳。如果只是"同一个进程内"要异步通知,优先考虑eventfd、自管道或者epoll,这些机制更可控。
4. System V三件套:消息队列、共享内存、信号量
4.1 消息队列:带类型的报文通道
消息队列比管道"高档"的地方在于,它传递的是一个一个有类型的消息块,而不是纯粹的字节流。发送方可以指定消息类型,接收方可以按类型取消息。这就好比你给快递贴了标签,同城的按城市分拣,不用全部堆在一起。
消息队列使用三个核心系统调用:msgget创建或获取队列,msgsnd发送消息,msgrcv接收消息。消息的结构要自己定义,首成员必须是long mtype类型,后面跟数据。
下面用fork演示父子进程通过消息队列通信:
#include <stdio.h> #include <string.h> #include <sys/ipc.h> #include <sys/msg.h> #include <sys/wait.h> #include <unistd.h> struct msgbuf { long mtype; char mtext[128]; }; int main() { key_t key = ftok("/tmp/msg.tmp", 66); int msqid = msgget(key, IPC_CREAT | 0666); pid_t pid = fork(); if (pid == 0) { struct msgbuf rcv; memset(&rcv, 0, sizeof(rcv)); // 取类型为1的第一条消息 ssize_t n = msgrcv(msqid, &rcv, sizeof(rcv.mtext), 1, 0); printf("child received (%zd bytes): %s\n", n, rcv.mtext); _exit(0); } else { struct msgbuf snd; snd.mtype = 1; strcpy(snd.mtext, "hello from parent"); msgsnd(msqid, &snd, sizeof(snd.mtext), 0); wait(NULL); // 清理消息队列,否则内核会一直留着 msgctl(msqid, IPC_RMID, NULL); } return 0; }消息队列有一组必须知道的约束。msgrcv的第四个参数msgtyp有多种语义:等于0取队列里第一条消息;大于0取指定类型的消息;小于0则取类型小于等于其绝对值的消息中最小的那一条。第五个参数设为IPC_NOWAIT时,如果队列为空或没有匹配类型的消息,会立刻返回,而不是阻塞。
4.2 共享内存:吞吐量最大的通信方式
如果说前面几种方式都是"绕道内核传数据",共享内存就是"直接上门串门"。系统把一段物理内存映射到多个进程的虚拟地址空间,大家直接读写同一块内存,不走内核转发。少了两次数据拷贝,吞吐量和延迟都是七种方法里最优的。
原理可以用门牌号类比。shmget负责申请一块共享内存并分配一个id,shmat把这段内存挂到当前进程的地址空间,然后返回一个指针。你往这个指针指向的地方写数据,另一个进程通过自己的指针读到数据,就这么直接。
共享内存的实战代码:
#include <stdio.h> #include <string.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/wait.h> #include <unistd.h> int main() { key_t key = ftok("/tmp/shm.tmp", 66); int shmid = shmget(key, 4096, IPC_CREAT | 0666); pid_t pid = fork(); if (pid == 0) { char *addr = shmat(shmid, NULL, 0); strcpy(addr, "hello from child via shm"); shmdt(addr); _exit(0); } else { wait(NULL); // 简单同步:等子进程写完 char *addr = shmat(shmid, NULL, 0); printf("parent read: %s\n", addr); shmdt(addr); // 删除共享内存段 shmctl(shmid, IPC_RMID, NULL); } return 0; }上面这段代码虽然能跑通,但用的是wait(NULL)确保子进程先写完再让父进程读。真实项目里两个进程的生命周期不会这么规整,你不能靠自己掐时间。正确做法是给共享内存配一把锁,也就是下面要讲的信号量。数据写入完成后通过信号量或者标志位通知读者,而不是盲目sleep。
共享内存的另一大隐患是它不会随进程退出而自动销毁。进程崩溃后,共享内存段还挂在系统里,时间长了就会积累一堆废弃段。排查系统资源时可以用ipcs -m查看,ipcrm -m <id>手动清理。
4.3 信号量:不传数据,只管同步
信号量经常和共享内存一起出现。你要说它是"通信方式",其实不准确,它传不了业务数据,它传递的是"资源是否可用"的状态。信号量本质是一个内核维护的整型计数器,支持两种原子操作:P操作(等待、减一)和V操作(释放、加一)。
P操作等价于"尝试占用资源",如果计数器为0,进程就睡觉,等别人释放;V操作等价于"释放资源",计数器加一并唤醒等待者。多个进程用这个计数器协调,就能避免同时写共享内存造成的混乱。
演示代码:
#include <stdio.h> #include <sys/ipc.h> #include <sys/sem.h> union semun { int val; struct semid_ds *buf; unsigned short *array; }; void P(int semid, int index) { struct sembuf op = {index, -1, 0}; semop(semid, &op, 1); } void V(int semid, int index) { struct sembuf op = {index, 1, 0}; semop(semid, &op, 1); } int main() { key_t key = ftok("/tmp/sem.tmp", 66); int semid = semget(key, 1, IPC_CREAT | 0666); union semun su; su.val = 1; semctl(semid, 0, SETVAL, su); // 模拟临界区保护 P(semid, 0); printf("critical section, do something important\n"); V(semid, 0); semctl(semid, 0, IPC_RMID); return 0; }注意一点,union semun在某些glibc版本里不会自动定义,需要手动声明,上面已经帮你加上了。semctl(semid, 0, SETVAL, su)用来把信号量初始化为1,这就是一把典型的互斥锁:同一时刻只有一个进程能通过P操作拿到资源。
信号量的第二个常见用途是生产者和消费者场景。这时候通常需要两个信号量,一个表示缓冲区是否为空,一个表示缓冲区是否为满,配合使用才能达成同步。比这里演示的单一互斥锁复杂一些,但思路是一样的。
4.4 System V IPC的公共坑
这三个方法用的都是Ipc对象,不是普通的文件描述符,很多人在这里踩坑。
ftok生成的key值冲突是高频问题。ftok依赖一个存在的文件路径和一个项目ID,如果两个不相关的进程用了同一个路径和ID,又碰巧key值相同,就可能互相对上暗号,读到对方的队列或共享内存。建议项目里统一约定路径,并且在代码中加一层自己的ID校验字段。
创建System V对象时用的是IPC_CREAT,如果对象已经存在,它会直接复用。如果你想要"不存在才创建,存在就报错"的语义,需要加上IPC_EXCL标志。类似文件打开时的O_CREAT|O_EXCL组合。
资源不释放是生产环境的常见事故。消息队列、共享内存、信号量都是内核持久化的对象,进程退出不会自动清理。很多服务在启动时创建,正常退出时销毁,可一旦异常崩溃,对象就残留了。多次重启之后,系统里积累了大量无用IPC对象,查看方法用ipcs,清理用法ipcrm(-q对应消息队列,-m对应共享内存,-s对应信号量)。
5. socketpair与Unix域套接字:把网络能力搬到本地
5.1 socketpair:天生一对的双向管道
你可能已经发现了,前面说的管道是单向的。想要双向通信,要么建两个管道,要么就用socketpair。它在功能上可以理解成"双口的管道",创建出来就是一对已经连接好的socket,数据可以从任意一端发往另一端。
关键优势是,socketpair创建的句柄天然支持双向传输,并且也支持fork。父子进程fork之后,各自保留一个fd,通信方向和协议由AF_UNIX和SOCK_STREAM指定,完全走本地内核,不经过网络协议栈。
代码示例:
#include <stdio.h> #include <string.h> #include <sys/socket.h> #include <sys/wait.h> #include <unistd.h> int main() { int sv[2]; char buf[128] = {0}; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1) { perror("socketpair"); return 1; } pid_t pid = fork(); if (pid == 0) { close(sv[0]); write(sv[1], "hello from child", 17); close(sv[1]); _exit(0); } else { close(sv[1]); ssize_t n = read(sv[0], buf, sizeof(buf)); printf("parent received (%zd bytes): %s\n", n, buf); close(sv[0]); wait(NULL); } return 0; }socketpair还有一个常见的隐藏用途:实现进程间的"优雅唤醒"。主进程阻塞在epoll上等待事件,如果想让它退出或者执行某个操作,就通过socketpair的一端写入一个字节,另一端注册进epoll,马上就能唤醒。这种方式比信号更可控、更安全,是Linux服务端编程里的经典技巧。
5.2 Unix域套接字:无亲缘关系进程也能通信
socketpair毕竟还是要求进程有亲缘关系,否则没法共用那一对fd。要做本地任意进程之间的通信,就需要走通用道路:先创建一个监听socket,然后走一遍"监听、连接、收发"的流程,和网络TCP编程几乎一样,唯一区别是地址不是一个IP:端口,而是一个本地文件路径。
这种方式叫做Unix域套接字(Unix Domain Socket,UDS),在同一个主机上性能很高,并且因为不经过真正的网络栈,它不会经过网卡。
服务端核心代码片段:
#include <stdio.h> #include <string.h> #include <sys/socket.h> #include <sys/un.h> #include <unistd.h> int main() { const char *path = "/tmp/uds.sock"; unlink(path); // 清理历史残留 int fd = socket(AF_UNIX, SOCK_STREAM, 0); struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strcpy(addr.sun_path, path); bind(fd, (struct sockaddr *)&addr, sizeof(addr)); listen(fd, 8); while (1) { int conn = accept(fd, NULL, NULL); char buf[128] = {0}; read(conn, buf, sizeof(buf)); printf("server got: %s\n", buf); close(conn); } return 0; }客户端只需要把connect的目标地址指向相同的文件路径,然后send数据即可。这里就不再展开完整代码了,整体跟TCP Socket几乎一样,把sockaddr_in换成sockaddr_un就行。
值得注意的一点是本地地址长度问题。sockaddr_un结构里的sun_path长度有限制,不同系统的UNIX_PATH_MAX不一样,Linux下通常是108字节。路径太长会直接bind失败,报"invalid argument"。所以Unix域套接字的路径尽量放短,放在/tmp或者/var/run下的短目录里比较安全。
5.3 本地socket性能与注意事项
很多人觉得本地socket是不是比TCP慢,其实相反。Unix域套接字不需要IP路由、不经过网卡、不处理TCP重传,性能非常高。在Linux本机通信场景,UDS的吞吐甚至比回环地址的TCP好不少,延迟也低得多。这也是为什么nginx、MySQL、Redis这些高性能服务,在本机通信时都愿意用Unix域套接字。
但UDS有几个坑要记住。第一个是连接上限。listen的backlog参数只规定了未完成与已完成连接的队列总长度,真实并发连接还受进程fd数量的限制,ulimit -n需要提前调大。第二个是权限问题。socket文件在文件系统上是可被ls -l看到的,它的访问权限就是普通文件的权限,如果不希望其他用户连上来,要设置好目录权限。第三是清理问题。程序崩溃后,socket文件会残留在磁盘上,重启服务时如果直接bind,可能报Address already in use,所以服务启动第一步通常unlink(path)。
6. 七种方法怎么选:权衡表与实战避坑经验
6.1 一张表看清七种方法的边界
前面讲完了七种方法的原理和代码,现在把它们摆在同一张桌子上做对比,这节是全文最有用的一张"工资条"。收藏文章其实就是收藏这张表。
| 选型维度 | pipe/FIFO | signal | 消息队列 | 共享内存 | 信号量 | socket/UDS |
|---|---|---|---|---|---|---|
| 核心用途 | 字节流传输 | 事件通知 | 报文传输 | 大批量数据共享 | 同步互斥 | 双向流/跨主机 |
| 传输量 | 中 | 极小 | 中,有上限 | 大 | 无 | 大 |
| 是否需要同步机制 | 阻塞自带背压 | 不需要 | 阻塞自带背压 | 必须配合信号量 | 本身就是同步机制 | 阻塞自带背压 |
| 进程关系 | pipe需亲缘,FIFO不需要 | 不要求 | 不要求 | 不要求 | 不要求 | socketpair需亲缘,UDS不要求 |
| 跨主机能力 | 不支持 | 不支持 | 支持,但需自行扩展 | 不支持 | 不支持 | TCP支持 |
| 生命周期管理 | 随进程关闭自动释放 | 无持久资源 | 内核对象,需手动删除 | 内核对象,需手动删除 | 内核对象,需手动删除 | fd随进程关闭 |
从这张表你可以发现一个规律:凡是走"文件描述符"或者"文件系统路径"的方式(pipe、FIFO、socket),生命周期都好管理,因为fd随进程退出自动关闭;凡是走System V核心对象的方式(消息队列、共享内存、信号量),都需要你手动清理。这是System V三件套在工程实践中最大的心智负担。
6.2 选型思考路径
实际工作中我每次定IPC方案,基本按下面这套路径思考,先回答三个问题。
第一个问题:对方进程和我是父子关系吗?如果只是fork出来的进程,优先考虑匿名管道或socketpair。它们实现最简单,不需要处理key、不需要手动清理资源、随进程退出自动回收。
第二个问题:数据量多大,实时性要求多高?如果数据量很大,比如每秒MB级别的日志采集,共享内存是最优解,但必须接受"你得自己管同步"的事实,信号量会被拉来一起干活。如果数据量不大,偶尔来一条几十上百字节的控制指令,消息队列或者FIFO足够了,阻塞特性本身就帮忙做了背压,不会无限堆积。如果是高频小文件的本地传输,UDS更合适,实现简单又支持双向。
第三个问题:需不需要跨主机?如果需要跨主机,前面所有本地IPC全部出局,只能用socket。Linux下的TCP或者Unix域套接字就是统一的编程模型,本地用UDS,跨主机用TCP,应用层代码能最大程度复用。
6.3 资深开发者的几条实战心得
在真实系统里跑过几年进程间通信之后,有几条经验是从文档里翻不出来的,这里一并写了。
第一,能用文件描述符的地方别用System V对象。这里的"能"指功能满足的情况下。fd模型和epoll配合得天然流畅,我好几次排查线上内存和句柄泄漏,发现根因都是Signal V IPC对象没有清理。而fd模型出问题后进程退出就自动回收了,心智负担小得多。
第二,大流量通信的最终结局大概率是共享内存加无锁队列。我见过不少项目,刚开始用消息队列,性能不够就换UDS,UDS还是不够,最后回归共享内存。共享内存加无锁环形队列,再加上CAS做同步,单机吞吐可以做到非常夸张。但这个组合对开发和测试的功底要求高,能用UDS扛住就先用UDS。
第三,调试IPC问题必备三个命令:strace跟踪系统调用,ipcs查看System V对象,lsof -U查看Unix域套接字连接。很多难以复现的"假死"问题,多半是阻塞在某个IPC调用上,strace一抓就对得上号了。
第四,信号这个方案尽量往后放。它能传的信息量太小,处理函数限制又多,维护成本不低。现代Linux下如果需要异步通知,可以优先考虑eventfd,配合epoll使用非常顺手,语义比信号清晰得多。不过它虽然是文件描述符,但严格来说超出了"七种经典IPC"的范畴,我也只是提一嘴,作为你进阶路上的一个延展知识点。
进程间通信这个话题,说深了可以写一本书,说浅了就是上面这七板斧。文章里所有代码我都尽量保持最小可运行状态,你复制到Linux环境下用gcc编译就能跑。跑通之后再自己改一改,换成你自己的业务场景,体会会比看十遍理论都深。