【Linux指南】进程通信系列(四):命名管道 FIFO —— 让陌生进程按名字见面

前两篇我们把匿名管道玩出了花:从内核里的 "披着文件外皮的内存",到进程池的 fd 泄漏排查。但匿名管道有一个与生俱来的天花板 ------ 只能用于亲缘进程 。原理我们说透了:它没有名字、没有路径,第三方进程根本 "找不到" 它,唯一入口是 fork 时的 fd 表拷贝。那两个素不相识的进程(比如一个日志服务和一个监控工具)想通信怎么办?这篇的主角登场:命名管道(FIFO) ------ 给管道起个名字,让任何进程都能凭这个名字打开同一份内核缓冲区。它还是第五篇共享内存的 "最佳搭子":共享内存裸奔没有同步机制,FIFO 恰好可以给它当 "信号灯" 用。

文章目录

    • [一、匿名管道的天花板与 FIFO 的破局](#一、匿名管道的天花板与 FIFO 的破局)
      • [1.1 回顾:为什么匿名管道走不出亲缘圈](#1.1 回顾:为什么匿名管道走不出亲缘圈)
      • [1.2 破局思路:给它一个 "名字"](#1.2 破局思路:给它一个 "名字")
      • [1.3 与匿名管道的本质关系](#1.3 与匿名管道的本质关系)
    • [二、创建 FIFO:命令行与系统调用](#二、创建 FIFO:命令行与系统调用)
      • [2.1 命令行创建](#2.1 命令行创建)
      • [2.2 系统调用创建](#2.2 系统调用创建)
      • [2.3 一个必须面对的问题:谁来删?](#2.3 一个必须面对的问题:谁来删?)
    • [三、打开 FIFO:open 的阻塞配对规则](#三、打开 FIFO:open 的阻塞配对规则)
      • [3.1 打开规则表](#3.1 打开规则表)
      • [3.2 创建 + 打开一步到位?](#3.2 创建 + 打开一步到位?)
    • [四、动手实验:文件拷贝版 FIFO](#四、动手实验:文件拷贝版 FIFO)
    • [五、实战:Server & Client 聊天程序](#五、实战:Server & Client 聊天程序)
      • [5.1 C 版本:最简 server-client](#5.1 C 版本:最简 server-client)
      • [5.2 C++ 封装版本:NamedFifo 类](#5.2 C++ 封装版本:NamedFifo 类)
    • [六、FIFO 的杀手锏应用:给共享内存当同步器](#六、FIFO 的杀手锏应用:给共享内存当同步器)
      • [6.1 问题的提出](#6.1 问题的提出)
      • [6.2 Wait / Signal 原语](#6.2 Wait / Signal 原语)
    • [七、五大特性对照:FIFO vs 匿名管道](#七、五大特性对照:FIFO vs 匿名管道)
    • 八、常见坑清单
    • 九、总结与下一篇预告
      • [9.1 本篇核心结论](#9.1 本篇核心结论)
      • [9.2 下一篇预告](#9.2 下一篇预告)

一、匿名管道的天花板与 FIFO 的破局

1.1 回顾:为什么匿名管道走不出亲缘圈

  • 匿名管道由 pipe() 创建,得到的是进程私有的两个 fd;
  • 它没有路径名,文件系统里不存在它的任何痕迹;
  • 想拿到它的 fd?只能靠 fork 拷贝 fd 表(或拷贝给兄弟进程)。

一句话:匿名管道的 "共享" 靠血缘继承,而继承只在 fork 那一刻发生。

1.2 破局思路:给它一个 "名字"

我们想在不相关的进程之间交换数据,可以让它们通过文件系统中一个可见的路径名 建立联系。这个路径名指向一种特殊类型的文件 ------ FIFO 文件 ,常被称为命名管道(named pipe)。

注意这里的措辞:FIFO 是 "一种特殊类型的文件 ",但上一句同时强调它是管道。它到底是什么?看下面这张对照图:

关键认知:FIFO 的文件名只是 "门牌号",数据依然存在内核内存缓冲区里,不落盘。 ls -l 看到的 FIFO "大小为 0" 正是这个原因 ------ 名字存在文件系统里,数据活在内存中。

1.3 与匿名管道的本质关系

先抛结论(这也是本篇最重要的一句话):

FIFO 与 pipe 之间唯一的区别是创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。

也就是说:同样的四种读写规则、同样的 64KB 容量与 PIPE_BUF 原子性、同样的五大特性(除了 "亲缘限制" 被解除)、同样可以设置 O_NONBLOCK。前两篇学的知识对 FIFO 全部适用 ,我们只需要重点研究它多出来的两件事:怎么创建 、怎么打开。

二、创建 FIFO:命令行与系统调用

2.1 命令行创建

bash 复制代码
$ mkfifo mypipe
$ ls -l mypipe
prw-r--r-- 1 user user 0 Oct  2 10:00 mypipe
 ^
 类型位 'p':pipe,这就是 FIFO 在文件系统中的身份证

第一个字符是 p(pipe),这是 FIFO 区别于普通文件 -、目录 d、链接 l 的类型标志。

2.2 系统调用创建

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *filename, mode_t mode);
// 成功返回 0,失败返回 -1(常见错误:EEXIST 已存在)
c 复制代码
int main()
{
    mkfifo("p2", 0644);
    return 0;
}

mode 参数与 open 的含义一致(受 umask 影响的实际权限 = mode & ~umask)。实践中常用 umask(0) 先把掩码清零,保证拿到的就是指定的 0666/0644。

2.3 一个必须面对的问题:谁来删?

FIFO 名字落在文件系统里,进程退出后它不会自动消失(对比匿名管道 "生命周期随进程")------ 这是它为了 "可见性" 付出的代价。所以服务端通常要在退出前清理:

c 复制代码
unlink("mypipe");   // 从文件系统中删除该名字

工程惯例:谁创建,谁 unlink (一般是 server 端),或者干脆在 shell 里用 rm mypipe 清理。忘了 unlink 的下场是下次运行 mkfifo 报 EEXIST ------ 这是 FIFO 的第一大常见坑。

三、打开 FIFO:open 的阻塞配对规则

如果说 mkfifo 只是 "挂牌",open 才是 "进门"。而 FIFO 的 open 有一套特殊的规则 ------ 读打开与写打开必须配对成功,否则阻塞。

3.1 打开规则表

设当前进程要 open 一个 FIFO:

打开目的 O_NONBLOCK 关闭(默认) O_NONBLOCK 打开
为读而打开 阻塞,直到有进程为写而打开该 FIFO 立刻返回成功
为写而打开 阻塞,直到有进程为读而打开该 FIFO 立刻返回失败 ,errno = ENXIO

用图理解这个 "配对握手":

这个规则其实非常符合直觉:管道是两端协作的资源,一端 open 而另一端不在,通信无从谈起 ------ 内核干脆让先到的一方等在 open 上 。还记得第三篇进程池里 Worker "阻塞在 read 上待命" 吗?FIFO 把 "待命" 提前到了 open 这一步,这带来了一个好处:建立连接的时序由内核保证,后启动的一方永远来得及加入。

(O_NONBLOCK enable 时写打开直接失败 ENXIO,语义是 "没人读还硬写,拒绝";而读打开仍成功,语义是 "先占好位置等写者来"。这个不对称在写服务端时有用。)

3.2 创建 + 打开一步到位?

严格来说 mkfifo 只创建不打开,之后必须 open。而实际代码中经常看到对同一进程来说 "创建与打开" 合并的写法 ------ server 端负责 mkfifo + open 读端,client 端只 open 写端。FIFO 与 pipe 的区别就在这里:pipe 是 "创建即打开"(一个系统调用拿到两个 fd),FIFO 是 "创建"(mkfifo)和 "打开"(open)分成两步、由不同进程分别执行 ------ 正是这两步的分离,让任意进程都能参与。

四、动手实验:文件拷贝版 FIFO

用 FIFO 在两个独立进程间拷贝文件,最能体现 "陌生进程通信":进程 1 读磁盘文件 abc 写入 FIFO,进程 2 读 FIFO 写入 abc.bak ------ 两个进程没有 fork 关系,仅凭 FIFO 名字协作。

写进程(读源文件 → 写 FIFO):

c 复制代码
// write_fifo.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>

#define ERR_EXIT(m) \
do { perror(m); exit(EXIT_FAILURE); } while(0)

int main()
{
    mkfifo("tp", 0644);

    int infd = open("abc", O_RDONLY);      // 打开源文件
    if (infd == -1) ERR_EXIT("open");

    int outfd = open("tp", O_WRONLY);      // 打开 FIFO 写端(阻塞等读者)
    if (outfd == -1) ERR_EXIT("open");

    char buf[1024];
    int n;
    while ((n = read(infd, buf, 1024)) > 0)
        write(outfd, buf, n);              // 磁盘 → 管道

    close(infd);
    close(outfd);
    return 0;
}

读进程(读 FIFO → 写目标文件):

c 复制代码
// read_fifo.c
// 头部同上
int main()
{
    int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (outfd == -1) ERR_EXIT("open");

    int infd = open("tp", O_RDONLY);       // 打开 FIFO 读端
    if (infd == -1) ERR_EXIT("open");

    char buf[1024];
    int n;
    while ((n = read(infd, buf, 1024)) > 0)
        write(outfd, buf, n);              // 管道 → 磁盘

    close(infd);
    close(outfd);
    unlink("tp");                          // 谁最后用完,谁负责删名字
    return 0;
}

运行时先启动读进程再启动写进程(或反过来都行 ------ open 的阻塞配对会自动把时序摆正),执行完 cmp abc abc.bak 验证一字不差。

两个观察点:

  1. 中间有一块数据从未落盘 :abc → 内核缓冲区 → abc.bak,FIFO 只过了个手。可以对比 ls -l tp 执行前后 ------ FIFO 的 "大小" 始终是 0;
  2. 写进程结束时 FIFO 读端收到 EOF :写进程 close 写端后,读进程的 read 返回 0,while 循环自然结束 ------ 匿名管道的规则原样生效。

五、实战:Server & Client 聊天程序

5.1 C 版本:最简 server-client

server 创建 FIFO 并从读端收消息;client 从写端发消息。这是 FIFO 的经典用法:

c 复制代码
// serverPipe.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#define ERR_EXIT(m) do{ perror(m); exit(EXIT_FAILURE); }while(0)

int main()
{
    umask(0);
    if (mkfifo("mypipe", 0644) < 0)      // server 负责 "挂牌"
        ERR_EXIT("mkfifo");

    int rfd = open("mypipe", O_RDONLY);  // open 阻塞,直到 client 打开写端
    if (rfd < 0)
        ERR_EXIT("open");

    char buf[1024];
    while (1)
    {
        buf[0] = 0;
        printf("Please wait...\n");
        ssize_t s = read(rfd, buf, sizeof(buf)-1);
        if (s > 0)
        {
            buf[s-1] = 0;               // client 写入的是 strlen(buf),含末尾 '\n',去掉它
            printf("client say# %s\n", buf);
        }
        else if (s == 0)                 // 所有写端关闭 → client 退出
        {
            printf("client quit, exit now!\n");
            exit(EXIT_SUCCESS);
        }
        else
            ERR_EXIT("read");
    }
    close(rfd);
    return 0;
}
c 复制代码
// clientPipe.c
#include <stdio.h>
#include <string.h>
// 头部同上

int main()
{
    int wfd = open("mypipe", O_WRONLY);  // 只 open,不创建 ------ server 已挂牌
    if (wfd < 0)
        ERR_EXIT("open");

    char buf[1024];
    while (1)
    {
        buf[0] = 0;
        printf("Please Enter# ");
        fflush(stdout);
        ssize_t s = read(0, buf, sizeof(buf)-1);   // 从键盘读
        if (s > 0)
        {
            buf[s] = 0;
            write(wfd, buf, strlen(buf));          // 写进 FIFO
        }
        else
            ERR_EXIT("read");
    }
    close(wfd);
    return 0;
}
bash 复制代码
$ gcc serverPipe.c -o serverPipe && gcc clientPipe.c -o clientPipe
$ ./serverPipe &          # 后台先起 server
$ ./clientPipe
Please Enter# hello
# server 侧输出:client say# hello

注意 server 循环里的 s == 0 分支:client 退出(写端关闭)后 server 读到 EOF、优雅退出。fork/pipe 的全部读写规则,在这里原封不动地重演 ------ 印证了 1.3 的那句话:"创建打开方式不同,语义相同"。

5.2 C++ 封装版本:NamedFifo 类

延续本系列的 C++ 封装风格,把 FIFO 的生命周期管理做成类(构造时创建、析构时可选清理),读写对 open 规则做薄封装:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

class NamedFifo
{
public:
    NamedFifo(const std::string &path, bool creator)
        : _fifoname(path), _fd(-1), _creator(creator)
    {
        if (_creator)
        {
            umask(0);
            int n = mkfifo(_fifoname.c_str(), 0666);
            if (n == 0)
                std::cout << "mkfifo success" << std::endl;
            else if (errno == EEXIST)   // 已存在不算错:允许重复启动
                std::cout << "fifo exists, reuse" << std::endl;
            else
                ERR_EXIT("mkfifo");
        }
    }

    bool OpenForRead()  { return OpenFile(O_RDONLY); }   // 阻塞等写者
    bool OpenForWrite() { return OpenFile(O_WRONLY); }   // 阻塞等读者

    void Write(const std::string &in)
    {
        ::write(_fd, in.c_str(), in.size());
    }

    int Read(std::string *out)
    {
        char buffer[1024];
        ssize_t n = ::read(_fd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            *out = buffer;
        }
        return n;      // >0 正常 / ==0 EOF / <0 错误
    }

    void Close() { if (_fd >= 0) { ::close(_fd); _fd = -1; } }

    ~NamedFifo()
    {
        Close();
        if (_creator)                       // 创建者负责摘牌
            ::unlink(_fifoname.c_str());
    }

private:
    bool OpenFile(int flags)
    {
        _fd = ::open(_fifoname.c_str(), flags);
        return _fd >= 0;
    }

private:
    std::string _fifoname;
    int _fd;
    bool _creator;      // 是否为创建方(决定谁 unlink)
};

封装的价值在析构函数:RAII 把 "谁创建谁删除" 变成自动行为 ,server 端 NamedFifo fifo(PATH, true) 走到作用域结尾自动摘牌,再也不怕忘 unlink。

六、FIFO 的杀手锏应用:给共享内存当同步器

6.1 问题的提出

先剧透第五篇的一个结论:System V 共享内存是所有 IPC 里速度最快的,但内核对它完全不提供同步与互斥保护 ------ 读写双方想什么时候写就什么时候写,想什么时候读就什么时候读,数据可能读到 "写了一半" 的状态。

而 FIFO 恰好相反:它速度一般,但自带阻塞语义 (空读阻塞、配对 open 阻塞)。让两者合作 ------ 共享内存搬数据,FIFO 传信号 ------ 就是一个经典设计:

text 复制代码
   Client (写方)                          Server (读方)
      │                                      │
      │  ① 把数据写进共享内存(裸写,飞快)        │
      │  ─────────────────────────────────►   │
      │                                      │
      │  ② 往 FIFO 写 4 字节 "信号"             │
      │  ──────────────┐    ┌──────────────►  │
      │                ▼    ▼                 │
      │              FIFO(内核缓冲区)          │
      │                                      │
      │                                      │ ③ 从 FIFO 读走信号 → 知道数据就绪
      │                                      │ ④ 从共享内存读数据(此刻数据是完整的)

信号的大小刻意选了 uint32_t(4 字节 < PIPE_BUF):信号内容无所谓,信号 "到来" 这个事件本身才是信息 ------ 4 字节既保证了原子性,又把 FIFO 的数据面压缩到最小,纯粹利用它的阻塞等待能力。

6.2 Wait / Signal 原语

把这套思路封装成两个函数(完整代码在第五篇展开,这里先看骨架):

cpp 复制代码
void Wait(int fd)     // 读方:等信号(FIFO 为空时 read 阻塞,天然实现等待)
{
    uint32_t temp = 0;
    ssize_t s = read(fd, &temp, sizeof(uint32_t));  // 阻塞直到写方发信号
    assert(s == sizeof(uint32_t));
    (void)s;
}

void Signal(int fd)   // 写方:发信号(4 字节原子写入)
{
    uint32_t temp = 1;
    ssize_t s = write(fd, &temp, sizeof(uint32_t));
    assert(s == sizeof(uint32_t));
    (void)s;
}

Server 循环里 Wait(fd) 阻塞着等 Client 写完共享内存后发信号,拿到信号才去读共享内存 ------ 用 FIFO 的阻塞补上了共享内存的同步缺口。这两个函数名是不是很眼熟?P、V 操作!它们的正式身份将在第七篇(信号量)登场,FIFO 版的 Wait/Signal 可以看作信号量思想的 "手工预演"。

这个设计还留了一个思考题给读者:Wait/Signal 用一根 FIFO 只能实现 "读方等写方" 的单向同步,如果写方还想等读方 "读完了再写下一批",该怎么加第二根 FIFO?(方向相反即可 ------ 这个 "双向握手" 问题,正是信号量同步机制的雏形。)

七、五大特性对照:FIFO vs 匿名管道

特性 匿名管道 FIFO
亲缘限制 只能亲缘进程 任意进程(凭路径名打开)
半双工 单向,双向要两条 相同,双向要两条
流式服务 字节流,无边界 相同
生命周期 随进程(内核对象随 fd 引用计数归零销毁) 名字随文件系统(需 unlink);缓冲区数据仍随打开它的进程
同步互斥 内核自带 相同
容量/原子性 64KB / PIPE_BUF 原子 相同
创建打开 pipe() 一步到位 mkfifo() 创建 + open() 打开(可跨进程分两步)
文件系统可见 不可见 可见(ls 能看到 p 类型文件)

可以看到,除了 "亲缘限制" 这一条被彻底解除、生命周期多了一个 "名字持久" 的维度,其余语义完全一致。学习成本几乎为零,收益是完全不同的使用场景 ------ 这就是 Unix 设计哲学中 "复用语义、扩展入口" 的典型例子。

八、常见坑清单

  1. 忘 unlink ------ 进程退出 FIFO 名字还在,下次 mkfifo 报 EEXIST。解法:RAII 封装(5.2 节析构函数)或启动时容忍 EEXIST 复用;
  2. server 先 open 写端却没人读 ------ 默认模式下 open 直接阻塞(好情况),O_NONBLOCK 下直接 ENXIO 失败(坏情况且容易被当成 "FIFO 坏了");
  3. 把 FIFO 当普通文件存数据 ------ 往 FIFO 里 "写文件" 的内容不会变大,ls -l 大小恒为 0;重定向 > mypipe 的输出全部流向了内核缓冲区,没人读就会阻塞在 write 上;
  4. 误以为 FIFO 数据落盘 ------ mkfifo 创建的文件不占数据块,断电丢失的是名字还是数据?名字还在(元数据),数据早随进程消失了。要落盘请用普通文件;
  5. 双向通信只开一根 FIFO ------ 单根 FIFO 半双工,两进程同时读写会把自己发出的数据读回来。双向 = 两根 FIFO(方向相反),这与匿名管道要两条管道是同一个道理。

九、总结与下一篇预告

9.1 本篇核心结论

  1. FIFO 的本质:文件系统里的一个路径名(门牌号)+ 内核内存缓冲区(真正的管道) ,ls 中类型位为 p;
  2. 与匿名管道唯一的区别是创建(mkfifo)与打开(open)的方式 ------ 打开后语义完全相同,四种读写规则、原子性、特性全部沿用;
  3. open 有阻塞配对规则:读/写打开必须双方到齐才返回(O_NONBLOCK 下读打开立即成功、写打开失败 ENXIO);
  4. 生命周期多一个维度:名字随文件系统持久,需要显式 unlink,工程上用 RAII 或 "创建方负责删除" 约定;
  5. 最有价值的组合拳:共享内存搬数据 + FIFO 传信号,Wait/Signal 两个原语补上共享内存的同步短板 ------ 这也是信号量思想的预演。

9.2 下一篇预告

FIFO 解决了 "陌生进程见面" 的问题,但它依然是字节流、依然要经过两次内核拷贝(写方用户态 → 内核 → 读方用户态)。有没有更快的路?下一篇《进程通信系列(五):System V 共享内存 ------ IPC 中的 "速度之王"》,我们直接在物理内存上划一块区域,把它同时映射进多个进程的地址空间 ------ 数据零拷贝,读写直达。快是有代价的:内核完全不管同步,一切保护要自己做,我们会用本篇留下的 Wait/Signal 组合把它治住。

相关推荐
段一凡-华北理工大学1 小时前
高炉能源系统智能化:热风炉、煤气与极致能效~系列文章03:热风炉烧炉与风温的决定因素
大数据·linux·人工智能·能源·热风炉·高炉能源优化
_小许_1 小时前
Linux 常用命令速查:CentOS 与 Ubuntu 双线对照
linux·ubuntu·centos
updayday8541 小时前
电脑换了硬盘、加了内存,资产台账却没更新 - Ping64 硬件资产管理
大数据·网络·数据库·安全·智能路由器
开开心心就好1 小时前
证件照排版打印工具,免费可自定义尺寸
服务器·开发语言·算法·随机森林·pdf·音视频·散列表
码农客栈2 小时前
mt7601u移植
linux·运维·服务器
2601_949847752 小时前
io-ts 跨端三方库仓颉适配全流程
linux·运维·ubuntu
a5986524 小时前
云手机怎么选?ARM服务器原生架构和X86模拟器差别很大
服务器·arm开发·智能手机
程序员清风12 小时前
数据处理脚本工程化:日志、配置、命令行与测试
服务器·开发语言·数据库
readhere12 小时前
从ZYNQ7000开发板,学会启动嵌入式Linux系统——入门学习:EBAZ4205篇目录
linux·启动·嵌入式linux·zynq7000·ebaz4205