ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux进程间通信详解:匿名管道与FIFO原理及实战

Linux进程间通信详解:匿名管道与FIFO原理及实战 说句实话进程间通信这个话题每次面试都绕不开实战里更是天天见。前面十几篇我们一路把文件、进程、内存这些基础打牢了今天终于要碰这块硬骨头。进程间通信IPC在Linux系统编程里是个经典大户内容多、坑也多所以拆成上下两篇来讲。这篇先聚焦最基础也最常用的管道匿名管道pipe和命名管道FIFO把关键原理、代码实践、阻塞规则和典型陷阱讲透。信号、共享内存、消息队列这些留给下篇。不过你不用担心学完这篇你已经能理解日常命令行里ls | grep背后的机制也能写出像模像样的多进程协作程序。讲透一个东西最好的办法是先回答那个“为什么”。为什么进程间需要通信为什么不能直接用全局变量这两个问题想明白了后面所有机制都是顺理成章。1. 为什么需要进程间通信独立进程之间的协作难题1.1 进程地址空间隔离全局变量为什么不可见先从最基础的事实说起。Linux里的每个进程都运行在独立的虚拟地址空间中这意味着父进程fork出来的子进程虽然看起来“拷贝”了父进程的内存映像但两者之后各自修改自己的数据互不影响。你可以做个最简单的实验定义一个全局变量int counter 0fork之后让子进程给counter加100然后父子进程各自打印counter。结果一定是父进程打印0子进程打印100。为什么会这样因为fork采用的是写时拷贝COW技术虚拟内存页在物理内存中先共享一旦某个进程要写这块页内核就给这个进程单独复制一份物理页。这下两个进程各看各的谁也不知道对方改了什么。这就产生了一个核心矛盾进程在地址空间上被刻意隔离这是保护机制防的是进程互相踩踏内存但真实业务又需要多个进程协同配合。要打破隔离必须借助内核提供的通道。这些通道就是进程间通信机制。1.2 一张地图看清Linux的IPC家族Linux下的IPC手段不止一种很多初学者容易混淆我建议先在心里建立一张地图学任何机制都往这张图上挂。IPC机制数据模型传输方向是否需同步典型场景匿名管道字节流单向不需要父子进程、命令行管道命名管道(FIFO)字节流单向不需要两个不相干进程信号事件通知单向不需要通知进程“发生了某件事”共享内存内存块双向需要信号量大批量数据交换消息队列消息单元双向不需要按消息传递的数据套接字字节流/数据报双向不需要本机或跨网络这张表里管道和FIFO是入门门槛最低的因为它们的语义直观、API简单。共享内存虽然快但你要自己处理互斥稍不注意就是数据错乱。信号量不是用来传数据的它是用来做同步的。我们按从易到难的顺序先把管道和FIFO啃下来。1.3 本篇覆盖范围管道与FIFO管道是Unix哲学的典型产物——小而美一条命令的输出可以作为另一条命令的输入。这个思想贯穿整个Unix设计即使今天你在shell里敲cat file | grep keyword背后也是管道机制在工作。这里要说明一下管道有个天生的限制它是单向的。如果两个进程需要双向通信得建两条管道一条管一个方向。这一点在后面的双管道示例里你会看到。至于FIFO它是管道的一种“文件系统化”版本专门解决匿名管道只能用于有亲缘关系进程的问题。两个独立的进程只要都愿意打开同一个FIFO文件就能建立通信。2. 匿名管道最简单也最常用的IPC2.1 管道的基本概念与pipe()函数你可以把管道想象成一根真实的水管一端进水一端出水。数据从写端流入从读端流出顺序完全一致。内核在内存中维护一个有大小限制的环形缓冲区这就是管道的本质。创建管道只需要一个函数#include unistd.h int pipe(int pipefd[2]);这个函数接收一个长度为2的int数组成功后pipefd[0]是读端描述符pipefd[1]是写端描述符返回0失败返回-1。为什么返回两个fd而不是一个因为管道是单向的你没有别的办法只用单个fd同时表达“只能读”和“只能写”两种能力内核干脆给你两个独立的文件描述符。默认情况下fd[0]只能读fd[1]只能写不能反着来。用一个生活类比帮你记住管道就像一条传送带你站在传送带一端放东西另一端的人取东西。传送带不会原路返回东西也不会回传到起点。2.2 标准用法pipe-fork-关闭-读写管道创建出来以后当前进程实际上拥有了管道的读写两端。要让另一个进程也能用这根管道必须经过fork。标准步骤几乎是固定套路我建议你背下来调用pipe()创建管道得到fd[0]和fd[1]调用fork()创建子进程父进程关闭不需要的一端通常是读端fd[0]子进程关闭另一端通常是写端fd[1]父进程通过写端写入数据子进程通过读端读取数据通信结束后双方各自关闭自己的fd为什么第3步如此关键因为如果不关闭不需要的端内核判断“管道里数据没了”的依据就会出错。具体来说读端read要返回0EOF必须满足“写端全部关闭”这个条件。如果读进程自己还握着写端不关内核会认为写端可能还会来写数据于是read就一直阻塞着不返回。这个坑在下文会专门展开。来看一个最简单的父子进程通信示例#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/wait.h int main(void) { int fd[2]; pid_t pid; char buf[128]; if (pipe(fd) -1) { perror(pipe); exit(EXIT_FAILURE); } pid fork(); if (pid -1) { perror(fork); exit(EXIT_FAILURE); } if (pid 0) { // 子进程只读关闭写端 close(fd[1]); ssize_t n read(fd[0], buf, sizeof(buf) - 1); if (n 0) { buf[n] \0; printf(子进程收到: %s\n, buf); } close(fd[0]); exit(EXIT_SUCCESS); } else { // 父进程只写关闭读端 close(fd[0]); const char *msg hello from parent; write(fd[1], msg, strlen(msg) 1); close(fd[1]); wait(NULL); } return 0; }你注意一下我在子进程里close了fd[1]、在父进程里close了fd[0]。这就是前面说的标准步骤。很多人一开始不以为意等程序卡死才意识到这两行close简直是救命稻草。2.3 内核视角管道其实是一个有大小限制的缓冲区管道在内核中是一个环形缓冲区。每个管道在创建时会被分配一个默认大小的缓冲区Linux下默认通常是65536字节64KB这个值可以通过fcntl的F_SETPIPE_SZ来调整。“环形”这两个字值得琢磨。读写双方各自维护自己的位置读端读过的位置可以被重新写入。当缓冲区满了写进程的write会阻塞直到读进程读走一部分数据腾出空间。反之当缓冲区空了读进程的read会阻塞直到写进程写入新数据。这种阻塞行为正是管道能实现同步的根本原因——它天然地让生产和消费的速度自动匹配。举个真实场景父进程需要向子进程发送一个几MB的文件内容。如果你一次性write一个几MB的块write会因为缓冲区不够而阻塞直到子进程陆续读走数据。也就是说不需要你手动做“分块发送”逻辑内核的阻塞机制把这件事替你办了。但你也别高兴太早这种自动节流在某些场景下会造成死锁后面第5章我们会细聊。2.4 管道读写规则与阻塞行为速查我根据实际经验给你整理一份“管道行为速查表”什么时候阻塞、什么时候返回、什么时候收到信号一目了然行为条件结果read阻塞管道为空且仍有写端打开read挂起直到有数据read返回0管道为空且所有写端已关闭读端读到EOF认为对方关闭write阻塞管道已满write挂起直到有空间write写入部分管道剩余空间不足返回实际写入的字节数写端写数据时读端已关闭读端fd已全部关闭内核发SIGPIPE默认终止写进程写入PIPE_BUF管道缓冲区中原子写不会和其他数据交错写入PIPE_BUF管道缓冲区中可能被拆分多写者时可能交错这里面有两个最重要的结论第一read返回0代表对方关闭了写端而不是“暂时没数据”第二写端不关闭读端就永远等不到EOF。这两个结论是排查管道问题时的第一性原理。3. 命名管道FIFO让不相干的进程也能通信3.1 匿名管道的局限与FIFO的诞生匿名管道有一个硬伤它必须通过fork传递文件描述符所以你只能在有亲缘关系的进程之间使用。两个完全没有关系、各自独立启动的进程要怎么通信FIFOFirst In First Out就是干这个的。它在文件系统中创建一个特殊文件任何进程只要知道这个文件的路径就能通过open打开它然后像使用普通管道一样使用它。因为这个文件是真实存在于文件系统里的所以两个毫无血缘关系的进程也能借助它建立连接。你可以用mkfifo命令创建mkfifo /tmp/myfifo ls -l /tmp/myfifo你会看到文件类型那一列是p这就是命名管道。3.2 FIFO的创建与打开规则在程序里创建FIFO需要调用mkfifo函数#include sys/types.h #include sys/stat.h int mkfifo(const char *pathname, mode_t mode);第一个参数是文件路径第二个是权限位比如0644。返回值0表示成功-1表示失败。需要注意如果指定的文件已经存在mkfifo会失败并设置errno为EEXIST所以工程代码里一般要先判断一下。FIFO的打开规则和普通文件完全不同这一点是新手最懵的地方。对于一个FIFO文件调用open的时候如果没有对应的另一端也在打开open会阻塞住。进程A调用open(fifo, O_RDONLY)会阻塞直到有一个进程以O_WRONLY方式打开了这个FIFO进程B调用open(fifo, O_WRONLY)会阻塞直到有一个进程以O_RDONLY方式打开了这个FIFO这种设计逻辑很清晰通信是成对建立的你没有另一端光自己开着没有意义。3.3 一个完整的服务端/客户端示例我用一个最简单但完整的例子告诉你FIFO怎么用。服务端负责创建FIFO、读数据客户端负责打开FIFO、写数据。服务端代码#include stdio.h #include stdlib.h #include unistd.h #include sys/stat.h #include sys/types.h #include fcntl.h #include string.h #define FIFO_PATH /tmp/myfifo int main(void) { if (mkfifo(FIFO_PATH, 0644) -1) { if (errno ! EEXIST) { perror(mkfifo); exit(EXIT_FAILURE); } } printf(服务端等待客户端连接...\n); int fd open(FIFO_PATH, O_RDONLY); if (fd -1) { perror(open); exit(EXIT_FAILURE); } char buf[256]; ssize_t n; while ((n read(fd, buf, sizeof(buf) - 1)) 0) { buf[n] \0; printf(服务端收到: %s\n, buf); } close(fd); unlink(FIFO_PATH); return 0; }客户端代码#include stdio.h #include stdlib.h #include unistd.h #include fcntl.h #include string.h #define FIFO_PATH /tmp/myfifo int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, 用法: %s 消息\n, argv[0]); exit(EXIT_FAILURE); } int fd open(FIFO_PATH, O_WRONLY); if (fd -1) { perror(open); exit(EXIT_FAILURE); } write(fd, argv[1], strlen(argv[1]) 1); close(fd); return 0; }编译运行时要先启动服务端再启动客户端。服务端open会阻塞在等待客户端打开客户端一open两边同时返回然后客户端写入数据服务端read收到数据并打印。这个例子还有一点值得注意客户端每次打开FIFO、写入、关闭服务端的read就会返回一次数据然后继续读取。当客户端写入后关闭了写端服务端read会收到EOF吗不会因为服务端自己还保留着FIFO的读端。真正的EOF判断是“所有写端全部关闭”客户端关闭了它的写端但如果未来还有新的客户端打开FIFO来写服务端依然能读到。这里最容易犯的错误是误以为“没有新数据了”就等于“管道关闭了”对FIFO来说并不是这样。3.4 非阻塞打开与工程注意事项在实际工程里阻塞式open有个很麻烦的问题如果对方一直不来你的程序就会一直卡在open调用上。想避免这种情况可以在open时指定O_NONBLOCK。以O_RDONLY | O_NONBLOCK方式打开FIFO如果还没有写端打开open会立即成功不阻塞以O_WRONLY | O_NONBLOCK方式打开FIFO如果还没有读端打开open会返回-1errno为ENXIO这种模式适合你写一个后台守护进程时使用不想让它因为等一个客户端而无限期挂起。处理方法是先非阻塞打开如果打开失败就继续做别的事稍后再重试。还有一些工程上的细节要提醒你FIFO文件在你unlink之前会一直占用文件系统路径如果你不清理重复启动服务端会撞上EEXIST。我在上面代码里对EEXIST做了容忍但对实际生产环境你还需要仔细考虑是“复用已有FIFO”还是“清理旧FIFO重新创建”视业务而定。另外FIFO本身不持久化数据数据被读走就没了它只是进程通信的桥梁不是存储工具。4. 管道实战从单向通信到双向通信4.1 单向管道父子通信示例第2章的示例已经演示了最简单的单向通信——父进程写、子进程读。这里我想补充一个更实用的点父进程如何等待子进程处理完成后再继续。在上一个示例代码中父进程在写完数据后调用wait(NULL)它会一直等待子进程退出。这种“先通信再回收”的顺序在实战中经常出现。你可以把它理解成父进程派一个工人子进程去干活工人干完活、把结果交回来父进程才继续自己的事。如果你恰好需要父进程等子进程结束后再读取“子进程处理后的结果”那就要小心了父进程在write之后不能直接close写端然后read因为close写端会触发子进程收到EOF但父进程自己也要把读端保留下来才能读取结果。这个场景单个匿名管道做不到必须用两个管道。4.2 双管道实现全双工通信管道是单向的要实现双向通信最直接的办法是建两条管道。一条管父进程到子进程一条管子进程到父进程。我写一个经典示例父进程发一条命令给子进程子进程把字符串转成大写后返回。#include stdio.h #include stdlib.h #include unistd.h #include string.h #include sys/wait.h int main(void) { int to_child[2], from_child[2]; if (pipe(to_child) -1 || pipe(from_child) -1) { perror(pipe); exit(EXIT_FAILURE); } pid_t pid fork(); if (pid -1) { perror(fork); exit(EXIT_FAILURE); } if (pid 0) { // 子进程从 to_child 读往 from_child 写 close(to_child[1]); close(from_child[0]); char buf[128]; ssize_t n read(to_child[0], buf, sizeof(buf) - 1); if (n 0) { buf[n] \0; for (int i 0; buf[i]; i) { if (buf[i] a buf[i] z) { buf[i] - 32; } } write(from_child[1], buf, strlen(buf) 1); } close(to_child[0]); close(from_child[1]); _exit(EXIT_SUCCESS); } else { // 父进程往 to_child 写从 from_child 读 close(to_child[0]); close(from_child[1]); const char *cmd hello world; write(to_child[1], cmd, strlen(cmd) 1); close(to_child[1]); // 关键告诉子进程不会再写 char result[128]; ssize_t n read(from_child[0], result, sizeof(result) - 1); if (n 0) { result[n] \0; printf(父进程收到结果: %s\n, result); } close(from_child[0]); wait(NULL); } return 0; }这里有两个关键点你写代码时一定要留意第一父进程写完to_child后必须close(to_child[1])。如果不关闭子进程的read会一直阻塞因为内核认为写端可能还有数据要来。这里我说的“写端”不只是父进程持有的写端而是所有进程持有的写端拷贝。只要管道上还有任何一个写端打开着read就等不到EOF。第二父进程读结果时子进程写完之后也要关闭from_child的写端否则父进程的read同样会卡住。这也是为什么我在子进程写完数据后立即调用close(from_child[1])。这种“用完就关fd”的习惯是写管道程序最重要的肌肉记忆。4.3 生产消费场景中的管道用法管道天然适合生产者和消费者模型。生产者进程往里写数据消费者进程从里面读数据内核的阻塞机制会让他们自动协调步调。比如一个进程不断从传感器采集数据写入管道另一个进程负责解析和存储两个进程解耦管道就是它们之间的缓冲区。这里我给你一个可复用的设计建议生产者和消费者之间传的数据一定要有“消息边界”。因为管道是字节流它不保证一次read就能读到一条完整消息。你最好自己设计协议比如固定长度头、或者以换行符分隔记录。不然消费者读到的可能是半条消息、两条消息的拼接程序很容易就写乱了。这个坑在真实项目中非常常见。很多人第一次用管道传“结构化消息”时都是直接write(里的结构体指针)然后对面read出来直接用。这样做在小数据量、单次写完的情况下能跑通但一旦数据量大、多次写入就会遇到消息被拆分的问题。我的建议是在协议层面用“长度前缀”或者“定长记录”来统一消息格式不要赌内核会一次性把你写入的字节完整送达。5. 管道程序最容易踩的坑与定位方法5.1 忘记关闭不用的fd导致read永远阻塞这个坑太经典了几乎每个写管道程序的人都栽过。症状是子进程的read永远不返回程序卡住不动。但是你又看不到任何报错进程既不退出也不崩溃。原因就是我前面反复强调的read要返回EOF必须等管道上所有写端全部关闭。如果子进程自己还持有fd[1]的拷贝那“写端全部关闭”这个条件永远不会满足。我看过不少初学者的代码父进程fork之后子进程的代码里只close了fd[0]却没close fd[1]然后read就永远阻塞着。或者是父进程自己忘了close fd[0]导致父进程在wait子进程时子进程的read也等不到EOF。排查思路其实很简单用一个调试器或者strace看一下进程卡在哪个系统调用上。如果看到进程阻塞在read(3, ...)那就去看fd 3是不是管道读端再看还有没有别的进程持有这个管道的写端。5.2 写端关闭后被SIGPIPE杀掉另一个高频事故是读端已经关闭了写进程还在继续write。这时候内核会向写进程发送SIGPIPE信号而SIGPIPE的默认行为是终止进程。很多服务端程序莫名其妙地退出日志里什么错误都没有其实就是死在了SIGPIPE上。为什么会这样回想我前面给你的速查表读端全部关闭后再向管道写数据就没有任何意义了内核选择用信号通知写进程“别写了没人要了”。默认直接杀掉简单粗暴。如果你想在代码里优雅处理可以忽略SIGPIPE信号然后自己处理write时遇到的EPIPE错误码#include signal.h signal(SIGPIPE, SIG_IGN); // 忽略SIGPIPE这样write调用返回-1errno为EPIPE你就能在业务逻辑里做兜底处理。在很多网络服务框架中你都会看到先signal(SIGPIPE, SIG_IGN)这行代码道理就在这里。5.3 管道写满和PIPE_BUF原子性的影响前面提到过Linux管道缓冲区默认64KB。当你往管道里写的数据量超过这个值时write会阻塞直到读者读走一部分数据。这个行为本身不是bug但如果你同时有多个写进程往同一个管道写数据就必须关注写入的原子性。POSIX规定如果单个write写入的字节数不超过PIPE_BUFLinux上通常是4096字节这个写入操作就是原子的。也就是说多个写进程同时写时内核会保证这4096字节不会被其他写进程的数据穿插打乱。但一旦写入超过PIPE_BUFwrite就不保证原子性了多个写进程的数据可能交错在一起。这个规则在FIFO多客户端场景中很关键。假设两个客户端同时往同一个FIFO写数据如果它们各自write的数据都在PIPE_BUF以内服务端读到的数据不会出现“A的前半段B的后半段”这种混乱交错。如果超过PIPE_BUF服务端就必须自己处理消息分帧否则数据就乱了。设计协议时把单条消息控制在PIPE_BUF以内可以省掉很多麻烦。5.4 用strace和lsof定位卡死问题产品代码里的管道问题往往比教科书示例复杂得多。进程A卡住、进程B卡住、谁在等谁、fd谁没关这些信息光靠肉眼很难看清。我推荐两个定位利器。strace是跟踪系统调用的神器。假设你怀疑进程卡在某个管道操作上直接看它阻塞在哪个系统调用strace -p pid如果看到read(3, 会一直阻塞在这里。接下来你用lsof查一下这个进程打开了哪些fdlsof -p pidlsof输出里会列出fd 3对应的文件路径。如果是管道通常会显示类似pipe:[123456]的信息。看到管道节点号后可以再查还有谁持有同一个管道节点lsof | grep 123456这样你就能找出谁还握着这个管道的写端问题就一目了然了。我再分享一个我的实际经验有一次一个服务进程卡死strace显示它阻塞在read上lsof确认它是一个管道读端。我搜了下整个系统里哪些进程持有这个管道节点发现竟然是一个已经在僵尸状态下的子进程还占着写端。这个子进程退出了但父进程没有wait回收它导致fd一直没释放。僵尸进程虽然不能再执行代码但它占用的文件描述符并没有随进程退出自动全部关闭——准确地说进程退出时内核会关闭fd但那是在进程进入僵尸状态之前完成的而某些情况下这个关闭时机和父进程回收的配合会出现各种奇怪的竞态。那次问题最后是让父进程及时调用wait回收子进程解决的。所以说管道问题不只是管道本身的问题还经常牵扯到父进程的子进程管理。还有一个小技巧查看/proc/pid/fdinfo/fd可以看到管道缓冲区的当前状态cat /proc/1234/fdinfo/3里面会有pos、flags、mnt_id等信息虽然不能直接看到缓冲区内容但有时候能帮你确认这个fd是不是管道、当前的读写偏移在什么位置对定位问题很有帮助。结尾管道这块内容我这次讲得很细代码也给你了但真正的理解还得靠你自己上手跑一跑。我建议你把第4章的双管道示例改一改比如让子进程处理完数据后再返回一批更长的数据然后观察read和write的阻塞行为你就会对“管道写满”“EOF判断”这些概念有肌肉记忆。老实说管道这个机制看起来简单但它的价值远超你写demo时的感受。很多系统工具、容器编排工具、后台服务里的父子进程协作底层都是基于管道在传数据和控制命令。你把这些规则吃透后面学信号、共享内存、epoll这些更复杂的机制会轻松很多。下篇我会接着讲信号和System V IPC到时候见了。
返回列表