—— 让无关进程共享同一段内存的秘密)
引言在上一篇文章中我们详细拆解了匿名管道的实现它利用fork()之后父子进程共享文件描述符的特性巧妙地将同一块内核缓冲区“塞”到了两个有亲缘关系的进程面前。但 Linux 世界里的进程千千万许多时候我们需要让两个毫无关系的进程也能互相传递数据——比如一个独立运行的服务端和客户端。这种需求是如何被满足的答案就是命名管道Named Pipe也称 FIFO。这篇文章将带你深入内核从文件系统中的特殊节点到pipe_inode_info环形缓冲区的共享彻底弄懂“有名字的管道”究竟是如何工作的。一、匿名管道的天生局限回忆一下匿名管道它通过pipe()返回两个文件描述符只有在调用fork()之前创建管道子进程才能继承到指向同一个struct file的描述符。这种机制决定了匿名管道只能用于父子进程或兄弟进程之间。一旦你需要跨无亲缘关系的进程或者想要让一个持久的服务进程与动态启动的客户端通信匿名管道就无能为力了。于是命名管道应运而生。它在文件系统中拥有一个可见的文件名任何进程只要知道这个路径就能打开它进而找到同一块内核缓冲区。二、初识命名管道一个“永远为0字节”的特殊文件我们可以用命令创建一个命名管道mkfifomypipe# 或者 mknod mypipe pls -l会显示出这样一个文件prw-r--r-- 1 user user 0 Aug 1 12:00 mypipe注意最前面的p代表这是一个 FIFO 类型的文件。并且大小永远显示为 0 字节——因为它不存储任何持久化的数据它只是内核中一块缓冲区的“入口”。在代码中等价的操作是系统调用#includesys/types.h#includesys/stat.hintmkfifo(constchar*pathname,mode_tmode);mkfifo()只在文件系统中创建了一个带S_IFIFO标志的 inode此时内核中并不存在任何管道缓冲区。真正的重头戏发生在open()的时候。三、打开 FIFO 的内核过程从文件路径到共享缓冲区假设现在有两个完全不相关的进程 A 和 BA 打算写B 打算读。它们分别调用// 进程A写端intfd_wopen(mypipe,O_WRONLY);// 进程B读端intfd_ropen(mypipe,O_RDONLY);内核在open系统调用中会通过路径找到对应的inode然后判断其类型为S_IFIFO于是调用专门处理命名管道的函数fifo_open()。接下来发生的事情是理解命名管道的核心。3.1 首次打开分配管道缓冲区对于每一个 FIFO inode其内部有一个指针i_pipe即struct pipe_inode_info *在刚创建时它是NULL。当进程 A 以写方式打开 FIFO 时fifo_open()检查inode-i_pipe发现为空于是调用内核函数alloc_pipe_info()分配一个全新的管道缓冲区结构并将其地址赋给i_pipe。接着内核创建一个只写的struct file指向这个缓冲区返回描述符给进程 A。关键点在于管道的实体环形缓冲区是被存放在 inode 内部的而 inode 又是通过文件路径找到的。当进程 B 随后用只读方式打开同一个路径时内核会找到同一个 inode此时i_pipe已经不为空于是直接复用这个已有的管道缓冲区再创建一个只读的struct file返回给进程 B。至此进程 A 的写struct file和进程 B 的读struct file都指向了同一个pipe_inode_info就如同匿名管道在父子进程中的情形一样。但这一次两个进程之间没有任何亲缘关系。3.2 阻塞等待“先到先等”的开门规则命名管道默认遵循同步打开规则如果一方打开时另一方还没有来那么打开操作会阻塞直到对方也打开了同一 FIFO。这是为了防止数据写入时没有接收方。进程 A 调用open(..., O_WRONLY)如果没有读进程打开写打开会阻塞在fifo_open()中进入i_pipe的等待队列。进程 B 调用open(..., O_RDONLY)内核发现写端已等在那边或者刚刚到来就会唤醒双方最终双双返回文件描述符。如果想打破这一规则可以使用O_NONBLOCK标志写端O_WRONLY | O_NONBLOCK若无读端open()会立即返回失败errno为ENXIO。读端O_RDONLY | O_NONBLOCK即便没有写端读打开也会成功返回但后续read()会立即返回 0 或阻塞取决于缓冲区状态。3.3 多个进程打开同一 FIFO命名管道可以同时被多个写进程或多个读进程打开。只要是通过同一个路径找到同一个 inode它们全部共享同一个pipe_inode_info。内核负责在pipe_write()和pipe_read()中处理互斥与同步这一点我们马上会看到。四、深入内核数据结构pipe_inode_info与环形缓冲区无论是匿名管道还是命名管道一旦进入读写阶段使用的核心结构是同一个pipe_inode_info。这里我们重点剖析它的构成。structpipe_inode_info{structmutexmutex;// 互斥锁保证读写互斥wait_queue_head_trd_wait;// 读等待队列wait_queue_head_twr_wait;// 写等待队列unsignedinthead;// 写索引下一次写入的缓冲区槽位unsignedinttail;// 读索引下一次读取的缓冲区槽位unsignedintmax_usage;// 缓冲区槽位总数unsignedintring_size;// 环大小unsignedintreaders;// 当前读端打开计数unsignedintwriters;// 当前写端打开计数structpipe_buffer*bufs;// 环形缓冲区数组...};管道缓冲区并不是一大块连续的字节序列而是由struct pipe_buffer结构组成的循环数组每个pipe_buffer管理一个内存页structpipe_buffer{structpage*page;// 指向物理内存页unsignedintoffset;// 页内数据起始偏移unsignedintlen;// 有效数据长度...};这种设计的好处是数据在管道内以页为单位离散存放写入和读取只需要修改head/tail索引和页的使用情况内存拷贝次数少效率很高。环形队列的操作逻辑写入pipe_write()从head指向的槽位开始将用户数据拷贝到对应的页中必要时分配新页移动head。如果head追上tail说明缓冲区满写进程阻塞。读取pipe_read()从tail指向的槽位取出数据移动tail。如果tail追上head说明缓冲区空读进程阻塞。整个过程受mutex保护保证了临界区的互斥。等待队列rd_wait和wr_wait则用于在没有数据或空间不足时让进程休眠等条件满足后被唤醒。五、读写操作与同步互斥的内核细节由于命名管道的读写路径与匿名管道完全一致最终都进入pipe_read和pipe_write我们直接在此基础上总结它带来的语义。5.1 读操作的阻塞与 EOF当管道非空时read()取出数据并返回读到的字节数。当管道为空但writers 0还有写端打开时读进程会进入rd_wait等待队列休眠直到数据到来。当管道为空且writers 0所有写端都已关闭时read()立即返回 0表示到达 EOF。5.2 写操作的阻塞与 SIGPIPE当管道未满时write()写入数据并返回。当管道已满且readers 0时写进程进入wr_wait等待队列休眠直到空间被读出。当readers 0所有读端都已关闭内核会向写进程发送SIGPIPE信号默认终止进程同时write()返回-EPIPE错误。5.3 原子性与 PIPE_BUF管道保证当一次写入的数据量小于等于PIPE_BUFLinux 上通常为 4096 字节时多个进程的写操作绝不会出现穿插。也就是说如果进程 A 写 1000 字节“AAAA…”进程 B 写 1000 字节“BBBB…”读出的数据要么是完整的 1000 个 A要么是完整的 1000 个 B不会出现“AAABBBA”之类的乱序。这一保证由pipe_write内部的互斥锁实现。六、管理管道文件unlink与stat除了核心的mkfifo、open、read、write和close在实际编写使用命名管道的程序时我们经常还需要处理管道文件的清理和状态检查。这里介绍两个关键的系统调用unlink和stat。6.1 删除管道文件节点unlinkunlink()系统调用从文件系统中删除一个目录项也就是解除文件名与 inode 的链接。#includeunistd.hintunlink(constchar*pathname);对于命名管道unlink(mypipe)会使mypipe这个文件名从目录中消失后续进程无法再通过该路径打开这个 FIFO。但已经打开该管道的进程不受影响。因为unlink删除的只是目录项内核中的struct inode和i_pipe环形缓冲区仍然存在引用计数保护着它们。只有当所有持有描述符的进程都调用了close()缓冲区才会被真正回收。这一点很像普通文件删除文件名并不等于删除文件数据本身。因此一个常见的编程模式是服务进程在mkfifo并open后立刻调用unlink。这样即使服务意外退出文件系统中也不会残留一个无用的 FIFO 节点而已经打开的客户端依然可以正常通信。示例// 创建一个 FIFO打开后立即删除其目录项mkfifo(/tmp/mypipe,0666);intfdopen(/tmp/mypipe,O_RDONLY);unlink(/tmp/mypipe);// 文件名已删除但管道仍然有效// 客户端仍然可以用 fd 进行读写 ...当然如果你想让管道节点持久保留供后续使用就不必在打开后立即unlink而是在程序结束或需要清理时调用。6.2 检查文件状态与类型statstat()系统调用可以获取一个文件的详细元数据包括其类型、权限、大小、inode 号等。#includesys/stat.hintstat(constchar*pathname,structstat*statbuf);通过stat我们可以在打开之前安全地判断一个路径是否真的是 FIFO避免对普通文件误操作。结构体stat的st_mode字段包含了文件类型信息可以使用宏S_ISFIFO()来检测。示例打开前做类型检查。structstatsb;constchar*pathmypipe;if(stat(path,sb)-1){perror(stat);// 文件可能不存在可以根据需要调用 mkfifo}else{if(S_ISFIFO(sb.st_mode)){printf(%s 是一个命名管道\n,path);// 安全地打开}else{fprintf(stderr,%s 已存在但不是 FIFO拒绝操作\n,path);}}另一个常见用途是在mkfifo前先用stat检查文件是否已存在。若不存在则创建若已存在且为 FIFO则直接使用若是其他类型则报错退出防止意外覆盖或破坏已有文件。结合unlink和stat我们就可以健壮地管理命名管道的生命周期用stat探测状态和类型用mkfifo创建用open接入数据流用close释放描述符最后用unlink清理文件系统节点。这些调用一起构成了命名管道完整的用户态使用闭环。七、命名管道的特点总结结合内核实现和日常使用我们可以系统归纳出命名管道的所有重要特性跨无关进程通信通过文件系统路径定位 inode不相干的进程只要约定好文件名就能共享同一个管道缓冲区。这是与匿名管道最本质的区别。文件名为入口数据不持久FIFO 在文件系统里有一个持久的名字删除需用unlink但它本身只是一个寻路标识。数据完全存在于内存的环形缓冲区中进程终止后数据消失文件大小始终为 0。半双工通信同匿名管道一样数据流只能单向流动。若要双向通信必须创建两个 FIFO。面向字节流无消息边界多次写入的数据会被合并读取端无法知道单次写入的边界。应用层需自行做消息定界。自带同步互斥与阻塞机制管道的空/满状态会引发读/写进程的自动阻塞写端全关会向读端发送 EOF读端全关会向写端发送 SIGPIPE。多进程并发读写时内核保证原子性。生命周期由内核引用计数管理只要还有任何一个进程持有 FIFO 的文件描述符读端或写端打开pipe_inode_info就继续存在。当最后一个引用关闭内核释放缓冲区。文件系统节点本身需要显式unlink才能彻底移除。八、匿名管道 vs 命名管道何时用哪个特性匿名管道命名管道创建方式pipe()mkfifo()open()是否有文件名无有可存在于磁盘目录适用进程仅亲缘进程父子、兄弟任意进程双向通信需两个管道需两个 FIFO数据存储内存环形缓冲区内存环形缓冲区生命周期随文件描述符消失缓冲区随引用计数文件名持久直至unlink典型应用shell 管道ls | grepC/S 架构本地进程通信如果你的场景是fork()后立即通信匿名管道更轻量。如果需要独立启动的服务进程与客户端通信或者两个根本不相关的进程需要对话命名管道就是不二之选。希望这篇文章能让你对命名管道的理解不再停留在“就是有名字的管道”而是清晰地看到那一行文件路径是如何在内核深处架起一道连接任意进程的数据桥梁。