尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

进程间通信七种方式详解:从管道到共享内存的IPC实战指南

进程间通信七种方式详解:从管道到共享内存的IPC实战指南 做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收到外部设备的通知信号则是内核向进程发送的通知。比如你按下CtrlC内核会向前台进程组发送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 functionsprintf不在这个列表里。小演示程序这么写没问题但在生产环境里信号处理函数里最好只做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负责申请一块共享内存并分配一个idshmat把这段内存挂到当前进程的地址空间然后返回一个指针。你往这个指针指向的地方写数据另一个进程通过自己的指针读到数据就这么直接。共享内存的实战代码#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 SocketUDS在同一个主机上性能很高并且因为不经过真正的网络栈它不会经过网卡。服务端核心代码片段#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/FIFOsignal消息队列共享内存信号量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模型出问题后进程退出就自动回收了心智负担小得多。第二大流量通信的最终结局大概率是共享内存加无锁队列。我见过不少项目刚开始用消息队列性能不够就换UDSUDS还是不够最后回归共享内存。共享内存加无锁环形队列再加上CAS做同步单机吞吐可以做到非常夸张。但这个组合对开发和测试的功底要求高能用UDS扛住就先用UDS。第三调试IPC问题必备三个命令strace跟踪系统调用ipcs查看System V对象lsof -U查看Unix域套接字连接。很多难以复现的假死问题多半是阻塞在某个IPC调用上strace一抓就对得上号了。第四信号这个方案尽量往后放。它能传的信息量太小处理函数限制又多维护成本不低。现代Linux下如果需要异步通知可以优先考虑eventfd配合epoll使用非常顺手语义比信号清晰得多。不过它虽然是文件描述符但严格来说超出了七种经典IPC的范畴我也只是提一嘴作为你进阶路上的一个延展知识点。进程间通信这个话题说深了可以写一本书说浅了就是上面这七板斧。文章里所有代码我都尽量保持最小可运行状态你复制到Linux环境下用gcc编译就能跑。跑通之后再自己改一改换成你自己的业务场景体会会比看十遍理论都深。
返回列表