Linux(十三) 进程通信完全指南:匿名管道、进程池与命名管道

本文承接 Linux 文件系统与 I/O 全链路知识,遵循「原理 → 接口 → 特性 → 实战 → 踩坑」的学习路径,从内核实现到代码实战逐层拆解管道机制。管道是 Linux「一切皆文件」设计思想的典型体现,其底层完全复用 VFS、文件描述符、内核缓冲区等既有机制,是理解所有 IPC 机制的基础。

在 Linux 环境下进行多进程开发时,进程间通信(IPC,Inter-Process Communication)是绕不开的核心话题。操作系统为每个进程划定了独立的虚拟地址空间,由 mm_struct 管理并通过页表映射到物理内存,这种隔离在保障稳定性的同时,也使得进程与进程之间不能直接共享数据。

为此,内核提供了多种 IPC 机制,其中最基础、最经典的当属管道 。管道不仅是 Shell 中 | 符号的实现基础,也被广泛应用于各类多进程架构中。本文将从零开始,深入剖析匿名管道与命名管道的内核实现、特性细节,并结合完整的进程池示例和命名管道封装类,带你彻底掌握管道在 C++ 中的实战用法。

1. 进程间通信(IPC)概述

1.1 IPC 的本质:内核作为可信中转

进程地址空间的隔离是硬性的:用户态下,进程 A 无法直接访问进程 B 的内存,否则会触发段错误。所有 IPC 机制的本质,都是借助内核空间作为中转

  • 发送方把数据从用户态拷贝到内核缓冲区
  • 接收方把数据从内核缓冲区拷贝到用户态
  • 内核负责缓冲区的管理、同步、权限控制

不同 IPC 机制的差异,本质是内核缓冲区的组织形式、数据格式、同步方式不同。

1.2 Linux 主流 IPC 机制分类

分类 代表机制 核心特点 适用场景
字节流类 匿名管道、命名管道 面向字节流、单向、无消息边界、简单可靠 亲缘进程通信、简单数据传输、Shell 流水线
消息类 System V 消息队列、POSIX 消息队列 按消息存取、有边界、支持优先级 结构化消息传递
共享内存类 System V 共享内存、POSIX 共享内存、mmap 直接共享物理页、无需两次拷贝、速度最快 大数据量高频通信,需配合信号量同步
同步类 信号量、互斥锁、条件变量 不传输数据,仅做进程间同步互斥 配合共享内存使用,保护临界资源
网络通用类 Unix 域套接字 全双工、可靠、支持跨平台接口 本机跨进程复杂通信,可无缝迁移到网络
异步通知类 信号 异步事件通知,传输数据量极小 异常通知、进程终止通知

管道(特别是匿名管道)是最原始的 IPC 形式,由 Unix 最初引入,至今在命令行和程序中随处可见。它最大的优势是完全复用了文件系统的接口与抽象,学习成本极低,理解管道的细节,是深入学习其它 IPC 机制的基础。


2. 匿名管道(Anonymous Pipe)

匿名管道是最简单的 IPC 机制,只能用于具有亲缘关系的进程(父子、兄弟进程)之间,本质是内核中的一块环形缓冲区,以文件描述符的形式暴露给用户态。

2.1 内核本质:pipefs 虚拟文件系统

很多人只知道管道用 read/write 操作,却不知道它完全遵循 Linux「一切皆文件」的设计哲学:

  • 匿名管道属于 pipefs 虚拟文件系统,该文件系统只存在于内存中,不挂载到可见目录树,也没有磁盘对应。
  • 每个管道在内核中都对应一个独立的 inodestruct file 对象,完全复用 VFS 层的接口,因此可以像普通文件一样使用 readwriteclosefcntl 等系统调用。
  • 和普通文件的核心区别:管道的 inode 不关联磁盘数据块,数据只存在于内核环形缓冲区中,断电即失,且只能单向读写。

2.2 创建与基本用法

匿名管道通过系统调用 pipe() 创建,原型如下:

c 复制代码
#include <unistd.h>
int pipe(int fd[2]);

调用成功返回 0,失败返回 -1 并设置 errno。调用后 fd 数组会被填充两个文件描述符:

  • fd[0]读端,固定只读属性
  • fd[1]写端,固定只写属性

数据只能从写端流入、读端流出,严格遵循先入先出(FIFO)顺序。

典型通信模型:父子进程单向通信

匿名管道自身无法被非亲缘进程打开,因此标准用法是:父进程先创建管道,再 fork 子进程,子进程继承文件描述符表后,双方各自关闭不需要的一端

完整执行流程:

  1. 父进程调用 pipe(fd) 创建管道,获得读、写两个 fd
  2. 父进程 fork() 产生子进程,子进程的文件描述符表和父进程完全一致,也拥有这两个 fd
  3. 确定通信方向后,父子进程各自关闭无用的一端
    • 父写子读:父关闭 fd[0],子关闭 fd[1]
    • 子写父读:父关闭 fd[1],子关闭 fd[0]
  4. 双方通过 writeread 进行单向数据传输
  5. 通信结束后关闭剩余的文件描述符,内核回收缓冲区

最简父写子读示例:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe 创建失败");
        return 1;
    }

    pid_t pid = fork();
    if (pid == 0) {          // 子进程:负责读取
        close(pipefd[1]);    // 关闭自己不用的写端
        char buffer[128];
        ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            std::cout << "子进程[" << getpid() << "] 收到: " << buffer << std::endl;
        } else if (n == 0) {
            std::cout << "子进程读到 EOF,写端已全部关闭" << std::endl;
        }
        close(pipefd[0]);
        return 0;
    } else if (pid > 0) {    // 父进程:负责写入
        close(pipefd[0]);    // 关闭自己不用的读端
        const char *msg = "Hello from parent process!";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]);    // 写入完成必须关闭写端,子进程才会收到 EOF
        wait(nullptr);       // 等待子进程退出回收
        std::cout << "父进程通信结束" << std::endl;
    } else {
        perror("fork 失败");
        return 1;
    }
    return 0;
}

核心注意点:父进程写入完成后必须关闭写端 。只要还有任意一个写端处于打开状态,读端的 read() 就会认为可能还有数据到来,永远阻塞等待。

2.3 管道的核心特性与深层原理

2.3.1 半双工单向性

匿名管道是半双工通信:同一时间数据只能朝一个方向流动。

  • 若要实现双向通信,必须创建两条独立的管道,分别负责两个方向的数据传输。
  • 不建议同时使用同一管道的读写两端,虽然 Linux 下管道理论上可以同时读写,但 POSIX 标准不保证全双工,极易出现数据自读自写、逻辑混乱的问题。
2.3.2 面向字节流,无消息边界

管道和 TCP 一样属于字节流模型:数据是连续的字节序列,不保留发送边界。

  • 发送方分 3 次写入 5 字节、10 字节、8 字节,接收方可能一次全部读完 23 字节,也可能分两次、多次读取,拆分方式完全由内核缓冲区状态和读取时机决定。
  • 应用层必须自行约定消息格式,常见三种方案:
    1. 定长消息:每条消息固定字节数(比如固定 4 字节 int),读满固定长度才算一条消息,实现最简单。
    2. 长度前缀:消息前 2/4 字节表示本条消息长度,先读长度再读载荷。
    3. 特殊分隔符 :比如换行符 \n\0 作为消息边界,逐字节扫描判断。

本文进程池示例采用「定长 int 任务编号」方案,天然规避了消息边界问题,且写入大小远小于原子性阈值。

2.3.3 四种读写行为(阻塞模式下)

默认创建的管道是阻塞模式,读写行为有明确的规则,这是管道编程最容易踩坑的地方:

场景 read 行为 write 行为
缓冲区有数据 读取数据,返回实际读到的字节数 写入数据,返回实际写入的字节数
缓冲区为空 阻塞挂起,直到有数据写入或所有写端关闭 正常写入
缓冲区已满 正常读取 阻塞挂起,直到有数据被读出腾出空间
所有写端都已关闭 读完剩余数据后返回 0(EOF) ------
所有读端都已关闭 ------ 内核向写进程发送 SIGPIPE 信号,默认终止进程
关于 SIGPIPE 的详细说明

当管道所有读端都关闭后,进程继续调用 write() 会触发:

  1. 内核发送 SIGPIPE 信号给当前进程,默认动作为终止进程。
  2. 如果程序捕获/忽略了 SIGPIPEsignal(SIGPIPE, SIG_IGN)),则 write() 不会触发信号,直接返回 -1,errno 设置为 EPIPE

这是网络编程、管道编程中非常经典的问题:对端关闭连接后继续写,进程会意外退出。生产环境通常会忽略 SIGPIPE,通过 write 返回值做错误处理。

2.3.4 写入原子性与 PIPE_BUF

POSIX 标准明确规定:

当单次 write() 写入的数据量不超过 PIPE_BUF 时,写入操作是原子的。

  • 原子性含义:多个进程同时往同一个管道写入数据,每条 ≤ PIPE_BUF 的数据会连续存放,不会和其他进程的数据交错。

  • 超过 PIPE_BUF:内核可能会拆分写入,多进程并发时会出现数据穿插,消息完整性无法保证。

  • Linux 下 PIPE_BUF 的值为 4096 字节(4KB) ,可通过命令验证:

    bash 复制代码
    fpathconf . _PC_PIPE_BUF
    # 输出 4096

进程池场景中,父进程每次只写入 4 字节的 int,远小于 4096,即使多进程并发写也不会出现数据错乱。

2.3.5 管道容量与缓冲区
  • Linux 2.6 之后,匿名管道默认容量为 65536 字节(64KB)

  • 可以通过 fcntl 动态调整管道大小(需 root 权限,上限受系统参数限制):

    c 复制代码
    fcntl(fd, F_SETPIPE_SZ, 1024 * 1024);  // 设置为 1MB
    int size = fcntl(fd, F_GETPIPE_SZ);    // 获取当前管道大小
  • 系统最大管道容量由 /proc/sys/fs/pipe-max-size 控制。

  • 管道缓冲区在内核空间,采用环形队列实现:写指针到末尾后自动回到开头,读写指针重合时表示缓冲区空/满。

2.3.6 非阻塞模式

可以通过 fcntl 将文件描述符设置为非阻塞:

c 复制代码
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

非阻塞模式下行为变化:

  • read():缓冲区为空时不阻塞,直接返回 -1,errno = EAGAIN
  • write():缓冲区空间不足时,能写多少写多少,返回实际写入字节数;完全无空间时返回 -1,errno = EAGAIN

非阻塞通常配合 select/poll/epoll 多路复用使用,实现单进程管理多个管道。

2.4 经典场景:Shell 管道 | 的底层实现

Shell 中 ls | grep txt 这样的命令,底层完全基于匿名管道 + 文件描述符重定向实现,执行步骤:

  1. Shell 进程调用 pipe() 创建一条管道,得到读端和写端两个 fd。
  2. fork() 产生第一个子进程(执行 ls):
    • 关闭管道读端
    • 调用 dup2(pipefd[1], 1),把标准输出(fd=1)重定向到管道写端
    • 关闭原管道写端,execve 执行 ls 程序,所有输出自动写入管道
  3. fork() 产生第二个子进程(执行 grep):
    • 关闭管道写端
    • 调用 dup2(pipefd[0], 0),把标准输入(fd=0)重定向到管道读端
    • 关闭原管道读端,execve 执行 grep 程序,自动从管道读取数据
  4. 父进程关闭管道两端,等待两个子进程退出回收。

本质就是把前一个程序的标准输出,通过管道接到后一个程序的标准输入,完全复用了匿名管道和文件描述符重定向的机制,没有额外发明新接口。

2.5 双向通信:双管道实现

如果需要父子进程双向收发,必须创建两条管道,约定各自的读写方向:

cpp 复制代码
// 管道1:父写子读;管道2:子写父读
int pipe1[2], pipe2[2];
pipe(pipe1);
pipe(pipe2);

if (fork() == 0) {
    close(pipe1[1]);  // 子:关pipe1写端,保留读端
    close(pipe2[0]);  // 子:关pipe2读端,保留写端
    // 从pipe1读,往pipe2写
} else {
    close(pipe1[0]);  // 父:关pipe1读端,保留写端
    close(pipe2[1]);  // 父:关pipe2写端,保留读端
    // 往pipe1写,从pipe2读
}

3. 进程池:匿名管道的高级工程应用

进程池是一种经典的多进程架构:预先创建一批工作进程常驻内存,主进程通过 IPC 分发任务,避免频繁 fork 的开销,广泛应用于 Web 服务器、计算服务、数据处理框架中。

3.1 进程池的设计思想

为什么需要进程池?

fork() 创建进程有明显的开销:需要复制页表、复制文件描述符表、创建进程控制块,虽然有写时复制优化,但高频创建销毁依然会严重拖累性能。

进程池的核心价值就是进程复用:提前创建好 N 个工作进程,任务来了直接分配,用完不销毁,等待下一个任务。

为什么选择匿名管道作为通信方式?
  1. 天然适配:父子进程天然继承文件描述符,无需额外创建 IPC 资源。
  2. 一对一通道:每个子进程独立一条管道,任务分发不会冲突,不需要额外加锁。
  3. 接口简单 :直接使用 read/write,和普通文件操作一致,学习成本低。
  4. 天然半双工:任务只需要父→子单向分发,结果可通过其他管道或共享内存返回,完全匹配管道特性。

3.2 任务抽象:Task

任务类和进程池解耦,进程池只负责分发任务编号,具体执行逻辑由任务类定义,方便后续扩展新任务。

cpp 复制代码
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>

using Callback = std::function<void(int)>;

class Task {
private:
    // 内置四个示例任务,参数为执行进程的PID
    void run_cd(int pid)    { std::cout << "进程[" << pid << "] 执行 run 任务" << std::endl; }
    void read_cd(int pid)   { std::cout << "进程[" << pid << "] 执行 read 任务" << std::endl; }
    void write_cd(int pid)  { std::cout << "进程[" << pid << "] 执行 write 任务" << std::endl; }
    void delete_cd(int pid) { std::cout << "进程[" << pid << "] 执行 delete 任务" << std::endl; }

public:
    Task() {
        // 注册默认任务
        _cb.emplace_back(std::bind(&Task::run_cd,    this, std::placeholders::_1));
        _cb.emplace_back(std::bind(&Task::read_cd,   this, std::placeholders::_1));
        _cb.emplace_back(std::bind(&Task::write_cd,  this, std::placeholders::_1));
        _cb.emplace_back(std::bind(&Task::delete_cd, this, std::placeholders::_1));
    }

    // 支持动态添加自定义任务
    void add(Callback cb) { _cb.push_back(cb); }
    size_t size() const { return _cb.size(); }
    Callback operator[](size_t index) const { return _cb[index]; }

private:
    std::vector<Callback> _cb;
};

使用 std::function 作为任务载体,可以兼容普通函数、lambda、类成员函数,灵活性极高。

3.3 进程池核心实现

3.3.1 通道类 channel

封装父进程到单个子进程的通信通道,保存子进程 PID 和管道写端 fd。

cpp 复制代码
class process {
private:
    class channel {
    public:
        channel(pid_t pid, int write_fd) 
            : _pid(pid), _write_fd(write_fd) {}

        // 向子进程发送任务编号
        void send_task(int task_id) {
            write(_write_fd, &task_id, sizeof(task_id));
        }

        pid_t pid() const { return _pid; }
        int fd() const { return _write_fd; }

    private:
        pid_t _pid;       // 子进程 PID
        int   _write_fd;  // 父进程持有的管道写端
    };

    std::vector<channel> _channels;  // 所有子进程的通信通道
    std::vector<int> _all_write_fds; // 所有写端 fd,用于子进程批量关闭
    Task _task;
    int _process_num = 4;            // 默认进程数

    // 错误码定义
    enum { OK = 0, PROCESS_ERROR = 1, FORK_ERROR = 2 };
3.3.2 初始化:创建管道与子进程

init() 是进程池的核心初始化逻辑,循环创建管道 + fork 子进程。

cpp 复制代码
public:
    void init() {
        for (int i = 0; i < _process_num; i++) {
            int pipefd[2];
            if (pipe(pipefd) == -1) {
                perror("创建管道失败");
                exit(PROCESS_ERROR);
            }

            pid_t pid = fork();
            if (pid == -1) {
                perror("fork 失败");
                exit(FORK_ERROR);
            }

            if (pid == 0) {  // ========== 子进程 ==========
                // 关键:关闭所有继承来的写端,只保留自己的读端
                close_all_write_fds(pipefd[1]);
                worker_loop(pipefd[0]); // 进入工作循环
                exit(OK);
            } else {         // ========== 父进程 ==========
                close(pipefd[0]); // 父进程关闭读端,只保留写端
                _channels.emplace_back(pid, pipefd[1]);
                _all_write_fds.push_back(pipefd[1]);
            }
        }
        std::cout << "进程池初始化完成,共 " << _process_num << " 个工作进程" << std::endl;
    }

private:
    // 子进程关闭所有无关写端
    void close_all_write_fds(int current_write_fd) {
        close(current_write_fd); // 先关闭自己这条管道的写端
        for (int fd : _all_write_fds) {
            close(fd); // 关闭之前所有已创建的管道写端
        }
    }
为什么必须关闭所有无关写端?(高频踩坑点)

子进程会完整继承父进程的文件描述符表。如果不关闭其他管道的写端,会出现严重问题:

  • 当父进程关闭某条管道的写端后,子进程里还持有该写端的副本
  • 内核会认为该管道的写端还没全部关闭,读端 read() 永远不会返回 0(EOF)
  • 子进程会一直阻塞在读操作上,永远无法正常退出

因此子进程必须关闭所有无关的文件描述符,只保留自己需要的读端,确保管道的引用计数正确。

3.3.3 子进程工作循环

子进程阻塞等待任务,收到任务后执行对应回调,读到 EOF 则优雅退出。

cpp 复制代码
private:
    void worker_loop(int read_fd) {
        while (true) {
            int task_id = 0;
            ssize_t ret = read(read_fd, &task_id, sizeof(task_id));

            if (ret > 0) {
                // 校验任务编号合法性
                if (task_id >= 0 && task_id < (int)_task.size()) {
                    _task[task_id](getpid());
                } else {
                    std::cerr << "收到非法任务编号: " << task_id << std::endl;
                }
            } else if (ret == 0) {
                std::cout << "子进程[" << getpid() << "] 收到退出信号,正常退出" << std::endl;
                break;
            } else {
                perror("读取管道失败");
                break;
            }
        }
        close(read_fd);
    }
3.3.4 任务分发

父进程通过调度策略选择子进程,发送任务编号。

cpp 复制代码
public:
    // 随机调度策略:随机选一个子进程分发任务
    void run_demo(int task_count = 10) {
        for (int i = 0; i < task_count; i++) {
            int idx = rand() % _channels.size();
            int task_id = i % _task.size();
            _channels[idx].send_task(task_id);
            sleep(1); // 演示用,放慢节奏
        }
    }
常见负载均衡策略

示例中使用随机调度,生产环境可根据场景选择:

  1. 轮转调度(Round-Robin):按顺序依次分配,简单公平,适合任务耗时相近的场景。
  2. 最少任务调度:记录每个进程的待处理任务数,总是分配给负载最低的进程,适合任务耗时差异大的场景。
  3. 随机调度:实现简单,任务量大时统计上趋近均匀,适合轻负载场景。
  4. 亲和性调度:把同类任务固定分给同一个进程,利用 CPU 缓存,提升局部性。
3.3.5 资源回收(修复版)

原代码 bug 说明:recycle 中误用文件描述符调用 waitpidwaitpid 第一个参数必须是进程 PID,而非 fd。以下为修正后的完整实现。

cpp 复制代码
public:
    void recycle() {
        // 第一步:关闭所有管道写端,通知所有子进程退出
        for (auto& ch : _channels) {
            close(ch.fd());
        }

        // 第二步:逐个等待子进程退出,回收僵尸进程
        for (auto& ch : _channels) {
            pid_t pid = ch.pid();
            waitpid(pid, nullptr, 0);
            std::cout << "子进程 " << pid << " 已回收" << std::endl;
        }

        _channels.clear();
        _all_write_fds.clear();
        std::cout << "进程池资源已全部回收" << std::endl;
    }
};

这是管道编程的标准优雅退出范式:

关闭写端 → 读端收到 EOF → 子进程主动退出 → 父进程 wait 回收

3.4 进程池的进阶优化点

  1. 异常进程重启 :子进程异常崩溃时,父进程会收到 SIGCHLD 信号,可在信号处理函数中重新 fork 补全进程数,保证服务可用性。
  2. 动态扩缩容:根据任务队列长度,动态增加或减少工作进程数量,平衡资源占用和响应速度。
  3. 非阻塞 + 多路复用 :父进程用 epoll 管理所有管道,支持子进程回传执行结果,实现全双工通信。
  4. 任务队列:任务高峰期先放入队列,空闲进程自取,避免任务丢失。

3.5 进程池的优缺点

优点 缺点
避免频繁 fork 开销,响应速度快 进程间数据共享困难,需要额外 IPC
进程隔离,单个进程崩溃不影响整体 进程上下文切换开销比线程大
编程简单,调试方便,稳定性高 不适合超大规模并发(几百上千进程)
适合 CPU 密集型计算任务 不适合高频 IO 密集型任务

4. 命名管道(FIFO)

匿名管道只能用于亲缘进程,最大的限制就是没有名字,无法被无关进程打开。命名管道(FIFO) 通过在文件系统中创建一个特殊的设备文件,突破了亲缘关系的限制,任意有权限的进程都可以通过路径打开并通信。

4.1 内核本质

命名管道和匿名管道底层共用同一套 pipefs 内核实现,核心缓冲区、读写逻辑、原子性规则完全一致。唯一的区别是:

  • 匿名管道:没有文件名,只能通过 fork 继承 fd 传递
  • 命名管道:在文件系统中有一个可见的 inode 和文件名(FIFO 文件),进程可以通过 open() 按路径打开,拿到管道两端的 fd,之后和匿名管道用法完全相同

FIFO 文件只是一个「入口标识」,数据并不会写入磁盘,依然只存在于内核缓冲区中,和普通文件有本质区别。

4.2 创建与基本操作

创建方式
  1. 命令行创建

    bash 复制代码
    mkfifo my_fifo
    # 或 mknod my_fifo p
  2. 系统调用创建

    c 复制代码
    #include <sys/types.h>
    #include <sys/stat.h>
    int mkfifo(const char *pathname, mode_t mode);
    • pathname:FIFO 文件路径
    • mode:文件权限(和 open 的 mode 参数一致,会受 umask 影响)
    • 成功返回 0,失败返回 -1

创建完成后用 ls -l 查看,文件类型标识为 p

复制代码
prw-r--r-- 1 user user 0 6月 26 10:00 my_fifo

文件大小始终为 0,因为数据不存储在磁盘上。

4.3 打开行为详解

命名管道最特殊的就是 open 行为,默认阻塞模式下遵循「两端配对」规则:

  • 以只读方式 O_RDONLY 打开:会阻塞,直到另一个进程以写方式打开该 FIFO
  • 以只写方式 O_WRONLY 打开:会阻塞,直到另一个进程以读方式打开该 FIFO
设计原因

管道是单向通信的,只有一端打开没有任何意义。内核通过阻塞保证:open 返回成功时,通信的对端一定已经就绪,避免进程空等。

非阻塞模式下的打开行为

如果 open 时加上 O_NONBLOCK 标志:

  • 只读打开 O_RDONLY | O_NONBLOCK:立即成功返回,不等待写端
  • 只写打开 O_WRONLY | O_NONBLOCK:如果当前没有读端打开,直接返回 -1,errno = ENXIO

4.4 读写特性

打开成功后,读写行为、阻塞规则、原子性规则、SIGPIPE 规则和匿名管道完全一致

  • 面向字节流,无消息边界
  • ≤ PIPE_BUF 写入原子
  • 写端全关则读端返回 0
  • 读端全关则触发 SIGPIPE

4.5 优化后的 FIFO 封装类

以下为优化后的完整封装:

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <errno.h>

class FifoChannel {
public:
    explicit FifoChannel(std::string path = "./my_fifo") 
        : _fifo_path(std::move(path)) {}

    // 创建 FIFO 文件,已存在则不重复创建
    bool create(mode_t mode = 0666) {
        if (exists()) {
            return true;
        }
        umask(0); // 清除权限掩码,确保设置的权限生效
        if (mkfifo(_fifo_path.c_str(), mode) == -1) {
            std::cerr << "mkfifo 失败: " << strerror(errno) << std::endl;
            return false;
        }
        return true;
    }

    // 发送端:打开写端,循环发送输入内容
    void sender_loop() {
        int fd = open(_fifo_path.c_str(), O_WRONLY);
        if (fd == -1) {
            std::cerr << "打开写端失败: " << strerror(errno) << std::endl;
            return;
        }

        std::string line;
        std::cout << "请输入消息(输入 quit 退出):" << std::endl;
        while (std::getline(std::cin, line)) {
            if (line == "quit") {
                break;
            }
            // 按实际长度写入,包含末尾 \0 保证接收方可直接打印
            write(fd, line.c_str(), line.size() + 1);
        }

        close(fd);
        std::cout << "发送端退出" << std::endl;
    }

    // 接收端:打开读端,循环接收消息
    void receiver_loop() {
        int fd = open(_fifo_path.c_str(), O_RDONLY);
        if (fd == -1) {
            std::cerr << "打开读端失败: " << strerror(errno) << std::endl;
            return;
        }

        char buf[1024];
        std::cout << "等待接收消息..." << std::endl;
        while (true) {
            ssize_t len = read(fd, buf, sizeof(buf) - 1);
            if (len == 0) {
                std::cout << "写端关闭,接收结束" << std::endl;
                break;
            }
            if (len > 0) {
                buf[len] = '\0'; // 安全补零
                std::cout << "收到消息: " << buf << std::endl;
            } else {
                perror("读取失败");
                break;
            }
        }

        close(fd);
    }

    // 删除 FIFO 文件
    void remove() {
        if (exists()) {
            unlink(_fifo_path.c_str());
        }
    }

private:
    std::string _fifo_path;

    // 判断 FIFO 文件是否存在
    bool exists() {
        struct stat st;
        return stat(_fifo_path.c_str(), &st) == 0 && S_ISFIFO(st.st_mode);
    }
};
使用方式
cpp 复制代码
// 进程A:发送端
int main() {
    FifoChannel fifo;
    fifo.create();
    fifo.sender_loop();
    return 0;
}

// 进程B:接收端
int main() {
    FifoChannel fifo;
    fifo.receiver_loop();
    return 0;
}

4.6 命名管道的特性总结

  1. 持久性 :FIFO 文件节点会一直存在于文件系统,直到主动 unlink 删除;但内核缓冲区的数据会在所有进程关闭后释放。
  2. 单向性:和匿名管道一样是半双工,双向通信需要创建两个 FIFO。
  3. 多端访问:允许多个写进程、多个读进程同时打开;多写时 ≤ PIPE_BUF 保证原子性,多读时数据会被随机分配给某个读进程。
  4. 权限控制:遵循文件系统权限位,可以通过权限限制哪些进程可以访问。

4.7 匿名管道 vs 命名管道 对比表

对比项 匿名管道 命名管道 FIFO
标识方式 文件描述符,无名字 文件系统路径,有文件名
适用进程 仅亲缘进程(父子、兄弟) 任意有权限的进程
创建方式 pipe() 系统调用 mkfifo / mknod
内核实现 pipefs,内存缓冲区 同左,底层共用一套逻辑
数据存储 内核内存,不落地 同左
通信方向 半双工,单向 半双工,单向
字节流特性 是,无消息边界 是,无消息边界
写入原子性 ≤ PIPE_BUF 原子 同左
生命周期 所有引用关闭后销毁 文件节点永久存在,缓冲区随进程关闭释放
典型场景 Shell 管道、父子进程通信、进程池 无亲缘进程间简单通信、守护进程交互

4.8 适用场景

  • 两个独立程序之间的简单数据传输,比 socket 更简单,不需要处理网络地址、协议等。
  • 守护进程和控制台工具的交互。
  • 多进程架构中的简单通知、指令传递。
  • 不适合复杂结构化数据、高并发、需要全双工的场景,此时应优先用 Unix 域套接字。

5. 全文总结

管道是 Linux IPC 的基石,也是「一切皆文件」设计思想的最佳体现------内核把一块缓冲区抽象成文件,向上暴露统一的文件描述符接口,向下复用 VFS 整套框架,用极简的设计实现了可靠的进程间通信。

核心知识点回顾

  1. 匿名管道:内核环形缓冲区,只能亲缘进程使用,半双工、字节流、≤4KB 写原子,默认阻塞,是 Shell 管道和进程池的基础。
  2. 进程池:预创建进程复用,通过一对一管道分发任务,核心要点是正确关闭多余文件描述符、优雅退出、负载均衡。
  3. 命名管道:通过文件系统路径突破亲缘限制,底层和匿名管道共用实现,适合无关进程间的简单通信。

管道编程三大铁律

  1. 不用的一端一定要关:否则会导致 EOF 无法触发、资源泄漏、进程永久阻塞。
  2. 字节流要自己处理边界:不要假设 read 能刚好读到一条完整消息。
  3. 小数据保证原子性:单次写入控制在 PIPE_BUF 以内,避免多写进程数据交错。

掌握了管道的原理与工程用法,再去学习消息队列、共享内存、套接字等其他 IPC 机制时,就能快速抓住核心差异,构建起完整的 Linux 进程通信知识体系。