深入 Linux 内核:命名管道(FIFO)—— 让无关进程共享同一段内存的秘密

引言

在上一篇文章中,我们详细拆解了匿名管道的实现:它利用fork()之后父子进程共享文件描述符的特性,巧妙地将同一块内核缓冲区“塞”到了两个有亲缘关系的进程面前。但 Linux 世界里的进程千千万,许多时候我们需要让两个毫无关系的进程也能互相传递数据——比如一个独立运行的服务端和客户端。这种需求是如何被满足的?答案就是命名管道(Named Pipe),也称 FIFO

这篇文章将带你深入内核,从文件系统中的特殊节点,到pipe_inode_info环形缓冲区的共享,彻底弄懂“有名字的管道”究竟是如何工作的。


一、匿名管道的天生局限

回忆一下匿名管道:它通过pipe()返回两个文件描述符,只有在调用fork()之前创建管道,子进程才能继承到指向同一个struct file的描述符。这种机制决定了匿名管道只能用于父子进程或兄弟进程之间。一旦你需要跨无亲缘关系的进程,或者想要让一个持久的服务进程与动态启动的客户端通信,匿名管道就无能为力了。

于是,命名管道应运而生。它在文件系统中拥有一个可见的文件名,任何进程只要知道这个路径,就能打开它,进而找到同一块内核缓冲区。


二、初识命名管道:一个“永远为0字节”的特殊文件

我们可以用命令创建一个命名管道:

mkfifomypipe# 或者 mknod mypipe p

ls -l会显示出这样一个文件:

prw-r--r-- 1 user user 0 Aug 1 12:00 mypipe

注意最前面的p,代表这是一个 FIFO 类型的文件。并且大小永远显示为 0 字节——因为它不存储任何持久化的数据,它只是内核中一块缓冲区的“入口”

在代码中,等价的操作是系统调用:

#include<sys/types.h>#include<sys/stat.h>intmkfifo(constchar*pathname,mode_tmode);

mkfifo()只在文件系统中创建了一个带S_IFIFO标志的 inode,此时内核中并不存在任何管道缓冲区。真正的重头戏发生在open()的时候。


三、打开 FIFO 的内核过程:从文件路径到共享缓冲区

假设现在有两个完全不相关的进程 A 和 B,A 打算写,B 打算读。它们分别调用:

// 进程A(写端)intfd_w=open("mypipe",O_WRONLY);// 进程B(读端)intfd_r=open("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()会立即返回失败,errnoENXIO
  • 读端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_waitwr_wait则用于在没有数据或空间不足时让进程休眠,等条件满足后被唤醒。


五、读写操作与同步互斥的内核细节

由于命名管道的读写路径与匿名管道完全一致(最终都进入pipe_readpipe_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_BUF(Linux 上通常为 4096 字节)时,多个进程的写操作绝不会出现穿插。也就是说,如果进程 A 写 1000 字节“AAAA…”,进程 B 写 1000 字节“BBBB…”,读出的数据要么是完整的 1000 个 A,要么是完整的 1000 个 B,不会出现“AAABBBA”之类的乱序。这一保证由pipe_write内部的互斥锁实现。


六、管理管道文件:unlinkstat

除了核心的mkfifoopenreadwriteclose,在实际编写使用命名管道的程序时,我们经常还需要处理管道文件的清理和状态检查。这里介绍两个关键的系统调用:unlinkstat

6.1 删除管道文件节点:unlink

unlink()系统调用从文件系统中删除一个目录项,也就是解除文件名与 inode 的链接。

#include<unistd.h>intunlink(constchar*pathname);

对于命名管道:

  • unlink("mypipe")会使mypipe这个文件名从目录中消失,后续进程无法再通过该路径打开这个 FIFO。
  • 但已经打开该管道的进程不受影响。因为unlink删除的只是目录项,内核中的struct inodei_pipe环形缓冲区仍然存在,引用计数保护着它们。只有当所有持有描述符的进程都调用了close(),缓冲区才会被真正回收。

这一点很像普通文件:删除文件名并不等于删除文件数据本身。因此,一个常见的编程模式是:服务进程在mkfifoopen后,立刻调用unlink。这样即使服务意外退出,文件系统中也不会残留一个无用的 FIFO 节点,而已经打开的客户端依然可以正常通信。

示例:

// 创建一个 FIFO,打开后立即删除其目录项mkfifo("/tmp/mypipe",0666);intfd=open("/tmp/mypipe",O_RDONLY);unlink("/tmp/mypipe");// 文件名已删除,但管道仍然有效// 客户端仍然可以用 fd 进行读写 ...

当然,如果你想让管道节点持久保留供后续使用,就不必在打开后立即unlink,而是在程序结束或需要清理时调用。

6.2 检查文件状态与类型:stat

stat()系统调用可以获取一个文件的详细元数据,包括其类型、权限、大小、inode 号等。

#include<sys/stat.h>intstat(constchar*pathname,structstat*statbuf);

通过stat,我们可以在打开之前安全地判断一个路径是否真的是 FIFO,避免对普通文件误操作。结构体statst_mode字段包含了文件类型信息,可以使用宏S_ISFIFO()来检测。

示例:打开前做类型检查。

structstatsb;constchar*path="mypipe";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,则直接使用;若是其他类型,则报错退出,防止意外覆盖或破坏已有文件。

结合unlinkstat,我们就可以健壮地管理命名管道的生命周期:用stat探测状态和类型,用mkfifo创建,用open接入数据流,用close释放描述符,最后用unlink清理文件系统节点。这些调用一起构成了命名管道完整的用户态使用闭环。


七、命名管道的特点总结

结合内核实现和日常使用,我们可以系统归纳出命名管道的所有重要特性:

  1. 跨无关进程通信
    通过文件系统路径定位 inode,不相干的进程只要约定好文件名,就能共享同一个管道缓冲区。这是与匿名管道最本质的区别。

  2. 文件名为入口,数据不持久
    FIFO 在文件系统里有一个持久的名字(删除需用unlink),但它本身只是一个寻路标识。数据完全存在于内存的环形缓冲区中,进程终止后数据消失,文件大小始终为 0。

  3. 半双工通信
    同匿名管道一样,数据流只能单向流动。若要双向通信,必须创建两个 FIFO。

  4. 面向字节流,无消息边界
    多次写入的数据会被合并,读取端无法知道单次写入的边界。应用层需自行做消息定界。

  5. 自带同步互斥与阻塞机制
    管道的空/满状态会引发读/写进程的自动阻塞;写端全关会向读端发送 EOF,读端全关会向写端发送 SIGPIPE。多进程并发读写时内核保证原子性。

  6. 生命周期由内核引用计数管理
    只要还有任何一个进程持有 FIFO 的文件描述符(读端或写端打开),pipe_inode_info就继续存在。当最后一个引用关闭,内核释放缓冲区。文件系统节点本身需要显式unlink才能彻底移除。


八、匿名管道 vs 命名管道:何时用哪个?

特性匿名管道命名管道
创建方式pipe()mkfifo()+open()
是否有文件名有(可存在于磁盘目录)
适用进程仅亲缘进程(父子、兄弟)任意进程
双向通信需两个管道需两个 FIFO
数据存储内存环形缓冲区内存环形缓冲区
生命周期随文件描述符消失缓冲区随引用计数,文件名持久直至unlink
典型应用shell 管道ls | grepC/S 架构本地进程通信

如果你的场景是fork()后立即通信,匿名管道更轻量。如果需要独立启动的服务进程与客户端通信,或者两个根本不相关的进程需要对话,命名管道就是不二之选。


希望这篇文章能让你对命名管道的理解不再停留在“就是有名字的管道”,而是清晰地看到那一行文件路径,是如何在内核深处,架起一道连接任意进程的数据桥梁。