
先说一个我早年间遇到的真实场景一台采集服务器上跑了四个分析进程每隔几秒就要从主进程手里取一批日志数据。最开始我图省事直接用文件落地加轮询结果不仅因为文件锁搞得调度顺序乱还白白多了很多磁盘IO。后来老老实实回到Linux系统编程的进程间通信IPC体系里重新把管道、消息队列、信号量、共享内存、信号、套接字这些手段理了一遍才把方案稳定下来。回头看IPC不是一条两条API的堆叠而是不同场景下的不同取舍。这篇文章把我从实际项目里沉淀下来的理解整理成文对于正在学Linux系统编程、准备面试题或者正为多进程协作发愁的开发者应该都有点参考价值。1. 管道内核里那条最简单的字节水管1.1 管道的基本模型一对文件描述符管道是Linux IPC里最朴素的一种实现。它本质上就是内核缓冲区加上两个文件描述符一个负责写一个负责读。调用pipe(fd)之后你会得到一个长度为2的整型数组fd[0]是读端fd[1]是写端。所有写入写端的数据都会按先进先出的顺序在内核缓冲区里排队直到被读端取走。这里有个初学者很容易忽略的点管道里没有消息边界它是纯粹的字节流。发送方连写两句话接收方可能一次读走全部也可能一次只读到其中一部分。所以你在设计管道协议时必须自己划定消息边界要么用固定长度要么用换行符要么自己定义包头。这一点和后面要讲的消息队列有本质区别。#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/wait.h int main(void) { int fd[2]; if (pipe(fd) -1) { perror(pipe); exit(EXIT_FAILURE); } pid_t pid fork(); if (pid 0) { /* 子进程关闭读端只写 */ close(fd[0]); write(fd[1], hello, 5); close(fd[1]); exit(0); } else { /* 父进程关闭写端只读 */ close(fd[1]); char buf[64]; ssize_t n read(fd[0], buf, sizeof(buf) - 1); buf[n] \0; printf(parent got: %s\n, buf); close(fd[0]); wait(NULL); } return 0; }上面这段代码演示了父子进程间最经典的通信方式。但真正写代码时比功能更重要的是记住要关掉不需要的一端。fork之后文件描述符表会被复制一份父子进程其实各自持有fd数组里的两个描述符。如果四个描述符都不关闭内核里管道对象的引用计数就不会归零很容易出现read永久阻塞的挂死现象。这个坑我在刚学的时候踩过不止一次后来养成了习惯fork完第一件事就是close自己不需要的那一端再考虑怎么传数据。1.2 管道的阻塞、缓冲区和SIGPIPE管道的读写默认是阻塞式的。写端写入速度远大于读端消耗速度时缓冲区一旦填满write调用就会阻塞直到读端取走数据腾出空间。反过来读端已经关闭再往写端写数据进程会收到SIGPIPE信号默认动作是直接终止进程。实际项目里SIGPIPE会导致程序莫名其妙退出而且不带任何core dump提示。我在写网络转发模块时遇到过对端断开连接结果自己进程被干掉。后来统一在进程启动时加了一句signal(SIGPIPE, SIG_IGN)把信号忽略掉write返回-1、errno为EPIPE再根据业务逻辑决定是重试还是清理连接。关于缓冲区大小Linux下管道默认容量通常是64KB左右具体可以通过fpathconf(fd, _PC_PIPE_BUF)查询单次写入的原子性上限。注意_PC_PIPE_BUF和总容量是两个概念前者保证写入不超过这个字节数时原子性不受干扰。做生产级代码时数据量一旦超过原子写范围就要警惕写入交叉的问题。1.3 命名管道让没有亲缘关系的进程也能对话普通管道只能在父子进程或兄弟进程之间用因为它依赖fork继承文件描述符。两个完全没有亲缘关系的进程也想用管道就得靠命名管道FIFO。命名管道通过mkfifo命令或mkfifo函数创建在文件系统里表现为一个特殊文件。它不占用磁盘空间数据仍然在内核缓冲区里但两个进程可以通过同一个路径名打开它。#include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h int main(void) { const char *path /tmp/myfifo; mkfifo(path, 0644); pid_t pid fork(); if (pid 0) { int fd open(path, O_WRONLY); write(fd, from fifo, 9); close(fd); _exit(0); } else { int fd open(path, O_RDONLY); char buf[64]; ssize_t n read(fd, buf, sizeof(buf) - 1); buf[n] \0; printf(read: %s\n, buf); close(fd); unlink(path); } return 0; }这个例子里有个隐蔽的行为值得记住open一个FIFO只读端时进程会阻塞直到有另一个进程以写模式打开它。也就是说读写两端必须同时就绪open调用才会返回。实际用的时候最好把两个open放在两个进程里否则单进程内先开读端再开写端自己就把自己阻塞死了。1.4 我对管道的实战评价管道在IPC里效率不算高一次数据传递至少经过两次用户态与内核态的拷贝写入时从用户空间复制到内核缓冲区读取时再从内核缓冲区复制到用户空间。但它的优势是接口简单、天然阻塞、不需要额外同步原语。对于线程模型简单、并发量不大、数据量在几十KB以内的父子进程协作管道往往是最快能上手的方案。我现在的习惯是能不用文件落地就不用能用管道解决的问题就不急着上共享内存。管道作为最基础的IPC是理解后面所有机制的地基。2. 消息队列让数据自带信封和地址2.1 SysV消息队列的核心API消息队列把通信粒度从字节流提升到了消息。每条消息由两部分组成一个正整数类型的mtype一段不超过msgsz字节的正文mtext。接收方不仅可以按顺序取消息还能指定取哪一种类型相当于给数据加了一层轻量级路由。SysV消息队列的关键函数有四个msgget创建或获取队列msgsnd往队列放消息msgrcv从队列取消息msgctl做控制操作比如删除队列、查询属性。#include stdio.h #include stdlib.h #include string.h #include sys/ipc.h #include sys/msg.h struct msgbuf { long mtype; char mtext[128]; }; int main(void) { int msqid msgget(IPC_PRIVATE, IPC_CREAT | 0666); if (msqid -1) { perror(msgget); return 1; } struct msgbuf msg { .mtype 1, .mtext hello msg }; if (msgsnd(msqid, msg, strlen(msg.mtext) 1, 0) -1) { perror(msgsnd); return 1; } struct msgbuf rcv; ssize_t n msgrcv(msqid, rcv, sizeof(rcv.mtext), 0, 0); if (n -1) { perror(msgrcv); return 1; } printf(received: %s\n, rcv.mtext); msgctl(msqid, IPC_RMID, NULL); return 0; }注意msgsnd传入的长度是消息正文的长度不是整个struct msgbuf的长度。这是新手最容易写错的地方。如果传了sizeof(struct msgbuf)内核会认为消息正文比你实际想发的大可能会报EINVAL错误。2.2 消息类型就是最原始的路由规则msgrcv的第四个参数msgtyp表面上看是个整数实际上是一套路由规则msgtyp 0取队列中第一条消息。msgtyp 0取第一条mtype等于msgtyp的消息。msgtyp 0取队列中第一条mtype小于等于msgtyp绝对值的消息。这带来一个很实用的效果你可以在同一个队列里混放普通消息和紧急消息用mtype区分接收方就能按需处理。我在一个任务分发模块里就是这么干的任务类型1是普通任务类型2是取消指令处理进程优先去取类型2的消息逻辑非常清晰完全不需要额外维护优先级链表。2.3 消息队列的边界和常见误区消息队列看起来很美好但有几个边界必须清楚。第一消息正文长度受内核参数msgmax限制很多Linux发行版默认是8192字节超过就发不出去。第二整个队列的总字节数受msgmnb限制满了以后msgsnd默认会阻塞你可以传递IPC_NOWAIT让调用立刻返回。第三消息队列是一种内核对象进程退出后不会自动清空必须由某个进程显式调用msgctl(msqid, IPC_RMID, NULL)删除否则会一直残留严重时会占满内核消息槽。使用IPC_PRIVATE作为键值时创建的队列只能被fork出来的子进程访问不相关的进程无法通过msgget拿到同一个队列。要让任意两个进程共享队列就需要通过ftok函数根据某个路径名生成一致键值或者直接约定一个固定键值。但键值固定会带来冲突风险实际项目里更稳妥的做法是先创建再通过其他方式把msqid传出去。Linux上还有一个msgget(IPC_PRIVATE)创建的私密队列通过/proc/sysvipc/msg临时检查的方法调试时可以看。2.4 我为什么没有重度依赖消息队列说实话消息队列在现代C/C项目里用得越来越少了。原因是内核态和用户态之间多次拷贝的问题它一样存在mtype的灵活性又不如用一个自定义协议头包在socket消息里那么通用。它比较适合的场景是老系统改造、短期内不想引入socket依赖、多进程发消息频率不高的传统架构。如果你是准备面试消息队列的API和上面这几个限制是必须背下来的如果是做新项目我会建议你直接把眼光放到后面的共享内存和UNIX域套接字上。3. 信号量给共享资源装红绿灯3.1 信号量不是锁是计数器很多人在学信号量时把它当成互斥锁这是个常见误解。信号量的本质是一个内核计数器它允许设定一个初始值比如3然后进程每次申请资源时对计数器减1每次释放资源时加1。只有当计数器变成0时再申请的进程才会被阻塞。这跟互斥锁那种非0即1的排他语义是不同的。信号量的P操作荷兰语proberen减1和V操作verhogen加1在SysV接口里统一由semop完成真正别扭的地方在于人机交互。SysV信号量编程比管道和消息队列要抽象不少因为它把一个信号量集合semaphore set作为一个对象来管理里面可以包含多个信号量计数。3.2 SysV信号量完整流程创建、PV、销毁#include stdio.h #include stdlib.h #include sys/ipc.h #include sys/sem.h #include unistd.h union semun { int val; struct semid_ds *buf; unsigned short *array; } arg; int main(void) { int semid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); if (semid -1) { perror(semget); return 1; } arg.val 1; if (semctl(semid, 0, SETVAL, arg) -1) { perror(semctl); return 1; } struct sembuf p {0, -1, 0}; struct sembuf v {0, 1, 0}; pid_t pid fork(); if (pid 0) { /* 子进程申请、使用、释放 */ if (semop(semid, p, 1) -1) { perror(semop p); } printf(child in critical section\n); sleep(1); semop(semid, v, 1); _exit(0); } else { semop(semid, p, 1); printf(parent in critical section\n); sleep(1); semop(semid, v, 1); wait(NULL); semctl(semid, 0, IPC_RMID); } return 0; }struct sembuf的三个字段分别是信号量在集合里的编号、操作数正负决定加或减、标志位。标志位可以是0、IPC_NOWAIT、SEM_UNDO的组合。SEM_UNDO是我特别要强调的一个选项它会让内核记住进程对信号量的原始调整量进程崩溃退出后内核自动把未完成的操作撤销回来从而避免其他等待的进程永远卡死。3.3 为什么SEM_UNDO如此关键考虑一个场景进程A申请了信号量进入临界区结果在第5行代码处段错误崩了。如果没有SEM_UNDO信号量计数还停在0进程B即使逻辑完全正确也会一直阻塞在P操作上。这属于典型的死锁不是由代码设计错误造成而是由异常退出造成的情况。带上SEM_UNDO内核在进程退出时会自动把计数加回去B就能继续走了。不过SEM_UNDO也有副作用它基于进程级调整记录如果一个进程故意做多次P操作又异常退出内核会把整个进程的历史调整量全部回滚可能让计数超过初始值。实际问题不大但面试时如果能说出这个利弊权衡明显比背定义强。3.4 信号量清理一个容易被忽视的资源泄漏点信号量和消息队列一样是内核对象不会随进程退出自动销毁。我用ipcs -s查过一台长期运行的服务器发现几万个残留信号量基本都是某个服务每次启动创建了一套IPC_PRIVATE信号量退出时忘了删。长期积累的结果就是系统可用的信号量集合被占满新服务创建失败。排查和清理的手段很直接ipcs -k查看键值ipcrm -s semid删除指定的信号量集。生产工程里正确姿势是在进程退出路径、退出信号处理、以及容错代码里都加上semctl(semid, 0, IPC_RMID)的调用宁多勿漏。另一种做法是启动时先尝试删除同键值的旧对象再创建新对象防止异常重启后残留冲突。4. 共享内存追求极致吞吐的共享白板4.1 为什么共享内存是速度之王管道和消息队列每次传递数据内核都要做一次用户态到内核态、内核态到用户态的完整拷贝。共享内存的原理则完全不同通过mmap或SysV共享内存接口把同一段物理内存分别映射到不同进程的虚拟地址空间一个进程写入数据另一个进程直接就能读到不需要借助系统调用完成数据搬运。数据拷贝次数从两次变成零次吞吐自然高出一大截。所以只要涉及大数据量、高频交互、低延迟诉求共享内存几乎总是首选。比如日志采集、视频帧共享、数据库引擎里的缓冲池交换都是它的典型战场。4.2 mmap与SysV共享内存的底层差异拿到Linux里讲共享内存至少要分清两个流派mmap和SysV共享内存。mmap最常用因为它和文件系统关系密切。它可以mmap一个普通磁盘文件也可以使用MAP_ANONYMOUS创建一个纯粹的内存映射区。fork之后MAP_SHARED的匿名映射区会被父子进程共享。SysV共享内存走的是shmget/shmat这套接口把一段内存作为一个内核对象来管理通过键值获取和文件没有直接关系。从底层实现看现代Linux上SysV共享内存实际上也挂载在tmpfs文件系统上和mmap的匿名映射没有本质区别只是接口和管理方式不同。选型时我的判断标准是需要用文件语义做持久化、或要在进程组之间共享某个文件内容用mmap需要按SysV风格用键值管理、并希望查看ipcs -m这类传统工具用SysV共享内存。4.3 一个父子进程共享内存的最小实例#include stdio.h #include string.h #include sys/mman.h #include unistd.h #include sys/wait.h int main(void) { char *ptr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (ptr MAP_FAILED) { perror(mmap); return 1; } pid_t pid fork(); if (pid 0) { /* 子进程稍作等待再读取父进程写入的数据 */ sleep(1); printf(child reads: %s\n, ptr); _exit(0); } /* 父进程写入共享内存 */ snprintf(ptr, 4096, hello from parent); wait(NULL); munmap(ptr, 4096); return 0; }这段代码里MAP_SHARED是核心它保证写在映射区里的修改对另一个进程可见。如果只写成MAP_PRIVATE写操作会触发写时复制子进程读到的还是修改前的旧数据行为完全不一样。这个细节值得刻进脑子里。4.4 共享内存必须搭配信号量的三个原因共享内存快但它的快是有代价的内核完全不提供任何同步机制。同一个位置进程A在写进程B在读谁先谁后没人管。因此实践中几乎总要把共享内存和信号量绑定使用。第一互斥。保证临界区同一时间只有一个进程在写防止数据被撕成两半。第二可见性。可以让进程在完整写入之后再释放信号量读进程拿到信号量后才去读确保不会读到半截数据。第三计数与唤醒。用计数信号量做生产者和消费者的节奏控制比如缓冲区里每放入一条记录就V一下消费者P一下再取天然形成了有界队列的同步语义。我见过不少直接全裸用共享内存的代码本地压测没问题一旦并发上来就出现数据错乱。原因几乎都是忘了加信号量或者加了信号量但忘了把SEM_UNDO打开。所以我把共享内存信号量当成一对固定搭档来看提到前者必然想到后者。5. 信号内核主动递过来的异步纸条5.1 信号到底是干什么的前面的管道、消息队列、共享内存本质都是进程主动去取数据。信号则完全不同它是内核或另一个进程主动向目标进程投递的一个异步通知。进程无法预测信号到达的时间只能提前注册好处理函数等着。常见信号里SIGINT是CtrlC触发SIGTERM是kill命令默认发送的终止信号SIGCHLD用于通知父进程你的子进程状态变了SIGSEGV是非法内存访问。信号本身不携带大数据它的价值是事件发生这个事实比如该退出子进程结束了定时器到了。5.2 用sigaction而不是signal注册信号处理的接口有两个早年的signal()和更完备的sigaction()。signal()在不同Unix系统上行为差异很大处理函数注册后是否需要重新注册在不同的实现里都不一样。我推荐直接用sigaction因为它把行为定义得更清楚还支持控制标志。#include stdio.h #include signal.h #include string.h #include unistd.h static volatile sig_atomic_t running 1; static void on_signal(int signo) { running 0; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler on_signal; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGINT, sa, NULL); sigaction(SIGTERM, sa, NULL); while (running) { write(STDOUT_FILENO, working...\n, 11); sleep(1); } return 0; }SA_RESTART表示当进程正在阻塞在某个慢系统调用上时收到信号后是否自动重启该系统调用。开启了SA_RESTARTread这类阻塞调用就不会因为信号返回EINTR错误能减少很多无谓的循环重试。但有些场景比如你想让阻塞的read立刻醒来做清理就不加这个标志反而要让阻塞调用被中断通过返回EINTR来处理。5.3 信号处理函数里的雷区信号处理函数里能做的事极其有限。很多人习惯在里面调printf、malloc、甚至是sprintf这是非常危险的行为。原因是信号随时可能中断主线代码比如主线正执行到malloc内部正在操作分配器的内部链表此时信号到来处理函数里再调用malloc就会重复进入同一个非重入函数轻则数据错乱重则死锁。安全原则是信号处理函数里只设置volatile sig_atomic_t类型的全局标志或者用write往管道里写一个字节把真正的逻辑交回主循环处理。我在项目里常用的是self-pipe trick信号处理函数里向一个pipe写端写入一个字节主循环阻塞在读端收到字节后再决定做什么。这和前面管道章节的知识刚好串起来了。另外一个很实际的经验是处理SIGCHLD时要在处理函数里循环调用waitpid(-1, NULL, WNOHANG)直到返回0否则多个子进程同时退出时只回收一个剩下的会变成僵尸进程。6. 套接字与全局选型从本地通信到跨主机6.1 Unix域套接字本机高可靠的通信通道Unix域套接字AF_UNIX常常被初学者忽略但它其实是我在工作中用得最多的本地IPC方式。它和网络套接字共用同一套socket接口只不过地址是一个文件系统路径不经过TCP/IP协议栈。它既能提供流式连接SOCK_STREAM也能提供数据报SOCK_DGRAM还天然支持一次一读写的语义边界比管道更灵活。下面是一个最简服务端骨架#include stdio.h #include string.h #include sys/socket.h #include sys/un.h #include unistd.h int main(void) { int lfd socket(AF_UNIX, SOCK_STREAM, 0); struct sockaddr_un addr {0}; addr.sun_family AF_UNIX; strcpy(addr.sun_path, /tmp/ipc.sock); unlink(/tmp/ipc.sock); bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 16); int cfd accept(lfd, NULL, NULL); char buf[128]; ssize_t n read(cfd, buf, sizeof(buf)); printf(server got: %.*s\n, (int)n, buf); close(cfd); close(lfd); unlink(/tmp/ipc.sock); return 0; }客户端只需要socket、connect然后用write发送数据就能和服务端对上。因为不需要网络协议栈本地通信延迟非常低吞吐能力也不差而且支持所有的IO多路复用模型比如epoll。对于想要一个可靠、有序、多路复用、又容易调试的本地IPC方案Unix域套接字几乎是无脑选择。有个小坑是sun_path长度限制。Linux上这个字段长度一般只有108字节左右路径过长会直接导致bind失败。所以设计socket文件路径时要么统一放在像/tmp这种短目录下要么自己做好前缀压缩。6.2 六种IPC方式怎么选才不后悔聊完各种手段回到选型问题。我整理了一张对比表基本能覆盖大多数日常决策通信方式数据模型是否需要同步典型吞吐跨主机复杂度管道无边界字节流否低到中不支持低消息队列按类型寻址的消息否中不支持中信号量计数器协调用本身就是同步不传数据不支持中共享内存原始内存块必须配合信号量高不支持中信号异步事件通知单向推极低不支持低Unix域套接字流或数据报自带阻塞语义中到高不支持中网络套接字流或数据报自带阻塞语义取决于网络支持高选型的核心逻辑很简单先看通信双方是否在同一台机器不在同一台就直接上网络套接字。同一台机器上如果只是简单通知或同步优先管道和信号如果数据量大、频率高共享内存加信号量如果想保持代码结构清晰、后续还要扩展成跨机方案Unix域套接字是上升空间最大的选择。在具体项目里我不会把所有能力极端化。比如日志采集这条链路我最终选的是共享内存信号量组合因为日志量实在太大socket协议本身的存储和转发开销扛不住。但在任务分发模块里我反而用回了Unix域套接字因为它天然支持多个客户端同时连接还可以用epoll统一管理事件不用自己去设计复杂的消息分发表。6.3 写在最后的几点实操体会如果只让我留三条经验给后来的开发者我会说第一不要在进程退出时省略清理工作IPC对象和信号量的残留比内存泄漏更隐蔽排查起来也更痛苦。第二共享内存永远不要裸奔信号量不是可选项是必选项。第三多读内核文档和方法论比背函数签名更值钱尤其是了解每种IPC方式的阻塞、缓冲、生命周期控制在什么位置才能真正应对生产环境里的疑难杂症。另外如果你还没法把几种方式的区别彻底拎清建议去终端里敲一遍echo xxx | more的管道原理再用strace跟一下pipe、msgget、mmap、semget这些系统调用的返回值眼见为实。把基础机制亲手摸过一遍之后再看面试题里所谓的请你说说Linux进程间通信方式就不再是背书而是复盘自己做过的事。