Re:Linux 系统篇(三十五):进程间通信Chapter2:从匿名管道到进程池:Linux 进程间通信实战(深度理解「先描述,再组织」)


观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇


前言

大家好,我是写技术博客的程序员。今天这篇文章,我们来啃 Linux 进程间通信(IPC)的第一块硬骨头 ------匿名管道 ,并基于匿名管道亲手搭建一个可运行的进程池。

但在写代码之前,我想先跟大家聊一个贯穿整个 Linux 内核设计的核心思想:「先描述,再组织」 。这句话不是一句空话,它体现在 task_struct 里,体现在文件系统里,也体现在我们今天要讲的管道和进程池里。理解了这句话,你看任何内核数据结构都会觉得 "原来是一个套路"。

本文只讲匿名管道,命名管道(FIFO)、共享内存、消息队列、信号量会放在后续文章中。文末会简单预告下一篇的内容。

本文目录:

    1. 「先描述,再组织」------Linux 管理一切资源的总钥匙
    1. 进程间通信(IPC)概述
    1. 管道是什么:Unix 最古老的 IPC
    1. 匿名管道深度解析:从 API 到内核本质
    1. 管道读写规则与四种场景动手验证
    1. 从单管道到多进程:进程池的设计思想
    1. 进程池完整代码实现(五文件工程,带详细注释)
    1. 运行效果与关键细节拆解
    1. 总结与下篇预告

0. 「先描述,再组织」------Linux 管理一切资源的总钥匙

0.1 这句话到底在哪?就在进程 PCB 里

很多同学学操作系统时听过「进程是分配资源的基本单位」,但内核到底是怎么管理成千上万的进程的?

答案就是六个字:先描述,再组织。

  • 描述:用一个结构体把一个对象的所有属性记录下来,相当于给它建一张「户口本」。
  • 组织:用某种数据结构(链表、数组、哈希表、红黑树......)把这些结构体串起来,方便增删改查和调度。

对于进程来说:

  • 描述 的载体就是 task_struct,也就是我们常说的进程控制块(PCB)。里面装了什么?进程 ID、状态、优先级、地址空间指针、文件描述符表指针、信号处理函数、父进程子进程指针...... 凡是跟这个进程有关的信息,全部塞进这个结构体里。

  • 组织的方式就是内核里的各种链表和数据结构。比如运行队列用链表串起来,PID 用哈希表映射,父子关系用指针连成树状结构。

    内核视角下的进程管理:

    复制代码
    [task_struct]  [task_struct]  [task_struct]  [task_struct]
         |              |              |              |
         +------ prev/next 双向链表 ------+
                      组织
                      ↑
                先描述每个进程
                再把它们串起来

这就是「先描述,再组织」最经典的体现:先造结构体描述个体,再选数据结构组织整体。

0.2 为什么学管道也要先讲这个?

因为管道、文件、共享内存、消息队列......全都是同一个套路:

表格

资源类型 描述(结构体) 组织(数据结构)
进程 struct task_struct(PCB) 双向链表、运行队列、哈希表
普通文件 struct file + struct inode 目录项 dentry 树、inode 哈希表
管道 struct file + struct pipe_inode_info 文件描述符数组索引
共享内存 struct shmid_kernel ipc_ids 数组 + kern_ipc_perm
进程池信道 class Channel(我们自己写的) vector<Channel>

看懂了吗?内核怎么管理进程,我们写代码就怎么管理资源。 后面我们写进程池的时候,会先定义 Channel 类来描述一条通信信道,再用 vector 把一堆 Channel 组织起来 ------ 这就是把内核的设计思想,落到了我们的应用层代码里。

记住这句话:描述是 "是什么",组织是 "怎么放"。先描述清楚一个对象,再决定用什么数据结构把一堆对象管理起来。


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

1.1 为什么需要进程间通信

进程具有独立性,每个进程有自己的地址空间,一个进程看不到另一个进程的数据。但现实中我们经常需要进程之间协作:

  • 数据传输:一个进程把数据发给另一个进程
  • 资源共享:多个进程共用同一份资源
  • 事件通知:一个进程通知另一个进程 "某件事发生了"(比如子进程退出通知父进程)
  • 进程控制:比如 Debug 进程拦截被调试进程的异常

既然进程天然隔离,那就必须通过内核提供的公共媒介来交换数据 ------ 这就是进程间通信(IPC)。

1.2 Linux IPC 的发展与分类

Linux 的 IPC 大致分三大流派:

  1. 管道 :Unix 祖传 IPC,简单可靠
    • 匿名管道(pipe):只能用于有亲缘关系的进程
    • 命名管道(FIFO):可用于无亲缘关系的进程
  2. System V IPC :AT&T 搞的一套标准,内核持久化
    • System V 共享内存
    • System V 消息队列
    • System V 信号量
  3. POSIX IPC :更现代的标准,线程进程通吃
    • POSIX 共享内存、消息队列、信号量
    • 互斥量、条件变量、读写锁

本文聚焦匿名管道,这是 IPC 的入门第一课,也是最能体现「一切皆文件」思想的通信方式。


2. 管道是什么:Unix 最古老的 IPC

2.1 管道的直观理解

我们在 shell 里天天用管道:

复制代码
who | wc -l

这条命令干了什么?

复制代码
  who 进程                管道(内核缓冲区)              wc -l 进程
  输出数据  ────────────►  字节流  ────────────►  读入数据
  stdout                                        stdin
  • who 把当前登录用户信息写到标准输出
  • wc -l 从标准输入读数据并统计行数
  • 中间的 | 就是一个管道,由内核维护,充当两个进程之间的数据流通道

所以管道的定义很简单:把一个进程的输出连接到另一个进程输入的数据流,就叫管道。

2.2 管道的本质:内核缓冲区 + 一切皆文件

Linux 下管道有一个非常重要的设计哲学:把管道伪装成文件。

用户层看到的是两个文件描述符(一个读、一个写),用 read / write 操作,跟操作普通文件一模一样。但在内核里,管道对应的不是磁盘上的文件,而是一块内核缓冲区。

这就是著名的 「Linux 一切皆文件」 ------ 用统一的文件接口,操作不同的内核对象。底层通过 file_operations 函数指针表来分发,普通文件走磁盘 IO,管道走内核缓冲区读写。


3. 匿名管道深度解析:从 API 到内核本质

3.1 pipe 函数:创建匿名管道

头文件和原型:

复制代码
#include <unistd.h>

int pipe(int fd[2]);
  • 参数 :fd 是一个输出型参数,大小为 2 的整型数组
    • fd[0]:读端文件描述符
    • fd[1]:写端文件描述符
  • 返回值:成功返回 0,失败返回 -1 并设置 errno

调用 pipe 之后,当前进程会多打开两个文件描述符,分别对应管道的读端和写端。

3.2 最简单的例子:单进程读写管道

先看一个单进程的例子,从键盘读数据写入管道,再从管道读出来打印到屏幕。这能帮你快速熟悉 API。

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    int fds[2];       // 存放管道两端的文件描述符
    char buf[100];    // 数据缓冲区
    int len;

    // 1. 创建匿名管道
    if (pipe(fds) == -1) {
        perror("make pipe");
        exit(1);
    }

    // 2. 从标准输入读取数据,写入管道写端
    while (fgets(buf, 100, stdin)) {
        len = strlen(buf);
        // 写入管道写端 fds[1]
        if (write(fds[1], buf, len) != len) {
            perror("write to pipe");
            break;
        }
    }

    memset(buf, 0x00, sizeof(buf));

    // 3. 从管道读端 fds[0] 读取,写到标准输出
    if ((len = read(fds[0], buf, 100)) == -1) {
        perror("read from pipe");
    } else {
        write(1, buf, len);  // 1 就是 stdout
    }

    return 0;
}

但这个例子其实没什么实际意义 ------ 一个进程自己写自己读,还不如直接用内存拷贝。管道真正的价值在于跨进程通信 ,这就要配合 fork 来使用。

3.3 管道 + fork:父子进程通信的三步走

匿名管道只能用于有亲缘关系的进程(通常是父子进程)。原因很简单:管道没有名字,只能通过继承文件描述符的方式让子进程拿到管道两端。

核心流程分三步:

第一步:父进程创建管道
复制代码
  父进程的文件描述符表
  ┌───────────────────┐
  │ 0: stdin          │
  │ 1: stdout         │
  │ 2: stderr         │
  │ 3: fd[0] (读端)   │───┐
  │ 4: fd[1] (写端)   │───┤
  └───────────────────┘   │
                          ▼
                    内核管道缓冲区
第二步:父进程 fork 出子进程

子进程会继承父进程的文件描述符表,于是父子进程各自都有了读端和写端,指向同一个内核管道。

复制代码
        父进程                            子进程
  ┌───────────────────┐           ┌───────────────────┐
  │ fd[0] 读端        │───┐   ┌───│ fd[0] 读端        │
  │ fd[1] 写端        │───┤   ├───│ fd[1] 写端        │
  └───────────────────┘   │   │   └───────────────────┘
                          ▼   ▼
                    同一个内核管道
第三步:父子进程各自关闭不用的一端

管道是半双工的,数据只能单向流动。如果要父写子读,父进程就关掉读端,子进程就关掉写端。

复制代码
        父进程(写)                      子进程(读)
  ┌───────────────────┐           ┌───────────────────┐
  │ 关闭 fd[0]        │           │ 关闭 fd[1]        │
  │ fd[1] 写端        │──────────►│ fd[0] 读端        │
  └───────────────────┘           └───────────────────┘
                数据流向:父写 → 管道 → 子读

这就是匿名管道通信的标准范式:先创建管道,再 fork,最后各关一端。

3.4 父子进程通信代码示例

下面这段代码是最经典的匿名管道示例:子进程写 "hello",父进程读出来打印。

复制代码
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

// 错误处理宏:打印错误信息并退出
#define ERR_EXIT(m) \
    do { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while(0)

int main(int argc, char *argv[])
{
    int pipefd[2];

    // 1. 父进程创建管道
    if (pipe(pipefd) == -1)
        ERR_EXIT("pipe error");

    pid_t pid;
    // 2. fork 子进程
    pid = fork();
    if (pid == -1)
        ERR_EXIT("fork error");

    if (pid == 0) {
        // ========== 子进程:负责写 ==========
        close(pipefd[0]);          // 关闭读端,只保留写端
        write(pipefd[1], "hello", 5); // 写入管道
        close(pipefd[1]);          // 写完关闭写端
        exit(EXIT_SUCCESS);
    }

    // ========== 父进程:负责读 ==========
    close(pipefd[1]);              // 关闭写端,只保留读端
    char buf[10] = {0};
    read(pipefd[0], buf, 10);      // 从管道读取数据
    printf("buf=%s\n", buf);       // 输出:buf=hello

    return 0;
}

3.5 站在内核角度:管道的本质

为什么 read / write 既能操作普通文件,又能操作管道?

因为内核里有一层抽象:struct file + struct inode + file_operations。

复制代码
  用户层
    read(fd, buf, len)
        │
        ▼
  文件描述符数组 ──► struct file
                        │
                        ├─ f_mode  读写模式
                        ├─ f_pos   当前读写位置
                        ├─ f_count 引用计数
                        └─ f_op    函数指针表(file_operations)
                                    │
                                    ▼
                              对应的操作函数集
                          普通文件    管道    套接字
                          (磁盘IO) (内核缓冲区) (网络)
  • 普通文件的 f_op 指向 ext4 等文件系统的读写函数
  • 管道的 f_op 指向管道专属的读写函数,操作内核缓冲区
  • 套接字的 f_op 指向网络协议栈的收发函数

用户层看到的都是文件描述符,都是 read/write,但底层实现完全不同。这就是「一切皆文件」的精髓 ------ 用统一接口封装不同实现。

管道在内核里对应的是 struct pipe_inode_info,里面维护着环形缓冲区、读写位置、等待队列等。但对用户层透明,你只需要知道 "像操作文件一样操作管道" 就够了。

3.6 匿名管道的五大特点

  1. 只能用于有亲缘关系的进程:父子、兄弟等,因为要靠继承文件描述符来拿到管道
  2. 半双工通信:数据只能单向流动;要双向就得开两条管道
  3. 流式服务:数据是字节流,没有消息边界,读多写少、读少写多都正常
  4. 生命周期随进程:进程退出,管道占用的文件描述符被关闭,所有引用都没了管道就释放
  5. 内核自带同步互斥:一定条件下读写会阻塞,保证数据不会乱套

4. 管道读写规则与四种场景动手验证

管道的读写行为不是一成不变的,它跟「有没有数据」「缓冲区满没满」「对端关没关」「是否设置非阻塞」密切相关。

4.1 核心规则总览

表格

场景 阻塞模式(默认) 非阻塞模式(O_NONBLOCK)
管道空,读 read 阻塞,等数据来 read 返回 -1,errno = EAGAIN
管道满,写 write 阻塞,等数据被读走 write 返回 -1,errno = EAGAIN
所有写端关闭,读 read 返回 0(读到 EOF) read 返回 0
所有读端关闭,写 内核发 SIGPIPE 信号,写进程可能退出 同样触发 SIGPIPE

补充两条原子性规则:

  • 写入数据量 ≤ PIPE_BUF(Linux 下一般是 4096 字节)时,写入是原子的,不会穿插
  • 超过 PIPE_BUF,Linux 不再保证原子性,多进程写可能交错

4.2 四种经典场景验证

下面我们用代码逐个验证这四种情况。

场景一:写正常,读空(读阻塞)

读端去读一个空管道,会阻塞在这里,直到有数据写入。

复制代码
// 验证:读空管道会阻塞
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:5 秒后再写
        close(pipefd[0]);
        sleep(5);
        write(pipefd[1], "data", 4);
        close(pipefd[1]);
        exit(0);
    }

    // 父进程:立刻读(此时管道为空)
    close(pipefd[1]);
    printf("父进程开始读...\n");
    char buf[16] = {0};
    read(pipefd[0], buf, sizeof(buf)); // 这里会阻塞 5 秒
    printf("读到数据:%s\n", buf);

    wait(NULL);
    return 0;
}

现象:父进程打印 "开始读" 后卡住,5 秒后才打印读到的数据。这就是读阻塞。

场景二:读正常,写满(写阻塞)

管道缓冲区是有大小的(一般 64KB),写满之后再写就会阻塞,直到读端把数据读走腾出空间。

复制代码
// 验证:写满管道会阻塞
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:一直不读,5 秒后才读一次
        close(pipefd[1]);
        sleep(5);
        char buf[1024];
        read(pipefd[0], buf, sizeof(buf));
        close(pipefd[0]);
        exit(0);
    }

    // 父进程:拼命写,直到写满
    close(pipefd[0]);
    int count = 0;
    while (1) {
        write(pipefd[1], "A", 1);
        count++;
        printf("已写入 %d 字节\n", count);
        // 写满后 write 会阻塞在这里,不再继续打印
    }

    return 0;
}

现象:数字涨到管道容量大小后就停住了,5 秒后子进程读走一点,父进程才继续写一点。

场景三:写端关闭,读端正常读

当所有写端都关闭了,读端再读会读到 0,表示 EOF(对端已经写完了)。

复制代码
// 验证:写端关闭后 read 返回 0
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:写一点就关掉写端
        close(pipefd[0]);
        write(pipefd[1], "hello", 5);
        close(pipefd[1]); // 关闭写端
        exit(0);
    }

    // 父进程:一直读
    close(pipefd[1]);
    char buf[16] = {0};
    ssize_t n = read(pipefd[0], buf, sizeof(buf));
    printf("第一次读,n=%zd, content=%s\n", n, buf); // n=5

    // 再读一次,写端已经全关了
    n = read(pipefd[0], buf, sizeof(buf));
    printf("第二次读,n=%zd\n", n); // n=0,表示读到结尾

    wait(NULL);
    return 0;
}

这个特性非常重要------ 我们后面写进程池时,就是靠「父进程关闭所有写端」来通知子进程 "通信结束,你可以退出了"。

场景四:读端关闭,写端还在写

如果所有读端都关闭了,进程还去写管道,内核会给这个进程发 SIGPIPE 信号,默认行为是终止进程。

复制代码
// 验证:读端关闭后写会触发 SIGPIPE
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

void handler(int sig) {
    printf("收到信号 %d,进程即将退出\n", sig);
}

int main()
{
    // 捕获一下 SIGPIPE,方便观察
    signal(SIGPIPE, handler);

    int pipefd[2];
    pipe(pipefd);

    // 把读端关了(两个都关)
    close(pipefd[0]);

    // 尝试往没有读端的管道里写
    write(pipefd[1], "test", 4);
    printf("如果看到这句话,说明 write 没被信号打断\n");

    return 0;
}

现象:会打印 "收到信号 13",进程终止。这也是为什么写管道时一定要确保读端存在。

13 号信号就是 SIGPIPE。


5. 从单管道到多进程:进程池的设计思想

5.1 为什么需要进程池

如果我们有大量任务要处理,来一个任务 fork 一个进程行不行?

可以,但有问题:

  • fork 开销大:创建进程有成本,任务多了频繁创建销毁很浪费
  • 不好管理:进程数量失控,系统负载飙高

所以就有了进程池:预先创建好 N 个子进程,一直活着,有任务就派发给它们做,没任务就阻塞等待。这就是 "池化技术" 的思想,跟线程池、连接池一个道理。

5.2 进程池的整体架构

我们要实现的是 Master-Worker 模式:

复制代码
                    Master 进程(父进程)
                   /       |       \
                  /        |        \
           管道1       管道2       管道3
             |           |           |
          Worker1     Worker2     Worker3
         (子进程)  (子进程)  (子进程)
  • Master:负责任务派发,通过管道给 Worker 发任务编号
  • 每个 Worker:通过一条独立的匿名管道跟 Master 通信,收到任务就执行
  • 任务:用整数编号表示,Worker 内部根据编号查表调用对应函数

5.3 再次体会「先描述,再组织」

到这里,我们可以把内核的设计思想落地了:

  1. 先描述一条信道 :定义 Channel 类
    • 里面装什么?写端文件描述符、对应子进程的 PID、信道名字
    • 提供什么方法?发任务 Send、关闭 Close、获取信息等
  2. 再组织一堆信道 :用 vector<Channel> 管理
    • 一个 Channel 对应一个子进程
    • 派发任务时遍历 vector,轮询发给各个 Worker

内核用 struct + 链表管理进程,我们用 class + vector 管理信道。思路一模一样。

5.4 进程池的核心流程

  1. 初始化:循环 N 次,每次创建一条管道 → fork 子进程 → 子进程关写端、读管道;父进程关读端、保存写端
  2. 派发任务:Master 生成任务编号,轮询发给各个 Worker
  3. 销毁回收:Master 关闭所有写端 → Worker 读到 0 自动退出 → Master 用 waitpid 逐个回收

6. 进程池完整代码实现(五文件工程)

下面给出完整的可运行工程,一共 5 个文件,C++ 实现,带详细注释。

6.1 Channel.hpp ------ 描述一条通信信道

对应「先描述」的部分:一个 Channel 就是一条管道 + 一个子进程的抽象。

复制代码
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__

#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>

// 信道类:描述 Master 到一个 Worker 的通信管道
class Channel
{
public:
    // 构造:传入写端文件描述符 和 对应的子进程 pid
    Channel(int wfd, pid_t who) 
        : _wfd(wfd), _who(who)
    {
        // 生成一个便于调试的信道名字
        _name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
    }

    // 获取信道名称
    std::string Name()
    {
        return _name;
    }

    // 发送任务:往管道写端写一个整数(任务编号)
    void Send(int cmd)
    {
        write(_wfd, &cmd, sizeof(cmd));
    }

    // 关闭写端
    void Close()
    {
        close(_wfd);
    }

    // 获取子进程 pid
    pid_t Id()
    {
        return _who;
    }

    // 获取写端 fd
    int wFd()
    {
        return _wfd;
    }

    // 默认构造(供 vector 扩容使用)
    Channel()
    {}

private:
    int _wfd;             // Master 端的写端文件描述符
    std::string _name;    // 信道名称,方便调试
    pid_t _who;           // 这条信道对应的子进程 PID
};

#endif // __CHANNEL_HPP__

6.2 Task.hpp ------ 任务管理与 Worker 执行体

任务用整数编号表示,Worker 收到编号后查表执行对应的回调函数。

复制代码
#pragma once

#include <iostream>
#include <vector>
#include <functional>
#include <ctime>
#include <unistd.h>
#include <cstdlib>

// 任务类型:无参无返回值的函数对象
using task_t = std::function<void()>;

// 任务管理器:维护任务列表,支持随机选任务、执行任务
class TaskManger
{
public:
    TaskManger()
    {
        srand((unsigned int)time(nullptr));

        // 预先注册一批任务(模拟不同的业务逻辑)
        tasks.push_back([](){
            std::cout << "子进程[" << getpid() << "] 执行:访问数据库任务" << std::endl;
        });

        tasks.push_back([](){
            std::cout << "子进程[" << getpid() << "] 执行:URL 解析任务" << std::endl;
        });

        tasks.push_back([](){
            std::cout << "子进程[" << getpid() << "] 执行:加密计算任务" << std::endl;
        });

        tasks.push_back([](){
            std::cout << "子进程[" << getpid() << "] 执行:数据持久化任务" << std::endl;
        });
    }

    // 随机选一个任务编号
    int SelectTask()
    {
        return rand() % tasks.size();
    }

    // 根据编号执行任务
    void Excute(unsigned long number)
    {
        if (number >= tasks.size())
            return;
        tasks[number]();
    }

private:
    std::vector<task_t> tasks; // 任务表:下标 = 任务编号
};

// 全局的任务管理器实例
TaskManger tm;

// Worker 进程的工作函数:死循环从标准输入读任务编号并执行
// 为什么是标准输入?因为子进程里会把管道读端 dup2 到 0 号
void Worker()
{
    while (true)
    {
        int cmd = 0;
        // 从 0 号文件描述符(管道读端)读取任务编号
        ssize_t n = ::read(0, &cmd, sizeof(cmd));

        if (n == sizeof(cmd)) {
            // 读到完整的任务编号,执行
            tm.Excute(cmd);
        }
        else if (n == 0) {
            // 读到 0 说明写端全关了,通信结束,优雅退出
            std::cout << "pid: " << getpid() << " 收到退出信号,正常退出" << std::endl;
            break;
        }
        else {
            // 读出错
            perror("read error");
            break;
        }
    }
}

6.3 ProcessPool.hpp ------ 组织并管理整个进程池

对应「再组织」的部分:用 vector 把一堆 Channel 组织起来,提供初始化、派发、清理三大接口。

复制代码
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__

#include <iostream>
#include <string>
#include <vector>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <functional>

#include "Task.hpp"
#include "Channel.hpp"

// 工作函数类型
using work_t = std::function<void()>;

// 错误码
enum
{
    OK = 0,
    UsageError,
    PipeError,
    ForkError
};

// 进程池类
class ProcessPool
{
public:
    // 构造:指定进程数量 和 Worker 执行的回调
    ProcessPool(int n, work_t w)
        : processnum(n), work(w)
    {}

    // 初始化进程池:创建 N 个子进程 + N 条管道
    int InitProcessPool()
    {
        for (int i = 0; i < processnum; i++)
        {
            int pipefd[2] = {0};

            // 1. 创建一条管道
            int n = pipe(pipefd);
            if (n < 0)
                return PipeError;

            // 2. 创建子进程
            pid_t id = fork();
            if (id < 0)
                return ForkError;

            if (id == 0)
            {
                // ========== 子进程(Worker) ==========
                // 关闭之前已经创建的、属于父进程的写端
                // (子进程继承了父进程的所有 fd,历史管道的写端要关掉)
                for (auto &c : channels)
                {
                    c.Close();
                }

                close(pipefd[1]);          // 关闭当前管道的写端,只保留读端
                dup2(pipefd[0], 0);        // 把管道读端重定向到标准输入 0 号
                work();                    // 执行工作函数(死循环读任务)
                exit(0);                   // 工作结束退出
            }

            // ========== 父进程(Master) ==========
            close(pipefd[0]);              // 关闭读端,只保留写端
            channels.emplace_back(pipefd[1], id); // 把信道加入管理列表
        }
        return OK;
    }

    // 派发任务:轮询式地给每个 Worker 发任务
    void DispatchTask()
    {
        int who = 0;
        int task_count = 20; // 总共派发 20 个任务

        while (task_count--)
        {
            // 随机选一个任务编号
            int task = tm.SelectTask();

            // 轮询选择一个 Worker 信道
            Channel &curr = channels[who++];
            who %= channels.size();

            std::cout << "######################" << std::endl;
            std::cout << "发送任务 " << task << " 给 " << curr.Name()
                      << ",剩余任务:" << task_count << std::endl;
            std::cout << "######################" << std::endl;

            // 通过管道发送任务编号
            curr.Send(task);

            sleep(1); // 放慢节奏,方便观察
        }
    }

    // 清理进程池:关写端 → 等子进程退出 → 回收
    void CleanProcessPool()
    {
        // 逐个关闭写端 + 等待子进程
        for (auto &c : channels)
        {
            c.Close(); // 关闭写端,子进程 read 会返回 0,从而退出
            pid_t rid = ::waitpid(c.Id(), nullptr, 0);
            if (rid > 0)
            {
                std::cout << "子进程 " << rid << " 已回收成功" << std::endl;
            }
        }
    }

    // 调试打印:打印所有信道信息
    void DebugPrint()
    {
        for (auto &c : channels)
        {
            std::cout << c.Name() << std::endl;
        }
    }

private:
    std::vector<Channel> channels; // 信道列表(组织所有管道)
    int processnum;                // 进程数量
    work_t work;                   // Worker 执行的回调
};

#endif // __PROCESS_POOL_HPP__

6.4 Main.cc ------ 主入口

复制代码
#include "ProcessPool.hpp"
#include "Task.hpp"

// 使用说明
void Usage(std::string proc)
{
    std::cout << "Usage: " << proc << " 进程数量" << std::endl;
}

int main(int argc, char *argv[])
{
    // 参数检查
    if (argc != 2)
    {
        Usage(argv[0]);
        return UsageError;
    }

    int num = std::stoi(argv[1]);

    // 创建进程池,指定 Worker 为 Worker 函数
    ProcessPool *pp = new ProcessPool(num, Worker);

    // 1. 初始化进程池
    pp->InitProcessPool();

    // 2. 派发任务
    pp->DispatchTask();

    // 3. 清理并回收进程
    pp->CleanProcessPool();

    delete pp;
    return 0;
}

6.5 Makefile

复制代码
BIN=processpool
CC=g++
FLAGS=-Wall -std=c++11
LDFLAGS=-o

# 自动查找所有 .cc 文件
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o)

$(BIN):$(OBJ)
	$(CC) $(LDFLAGS) $@ $^

%.o:%.cc
	$(CC) $(FLAGS) -c $<

.PHONY:clean
clean:
	rm -f $(BIN) $(OBJ)

.PHONY:test
test:
	@echo $(SRC)
	@echo $(OBJ)

7. 运行效果与关键细节拆解

7.1 编译运行

复制代码
make
./processpool 3    # 启动 3 个 Worker 进程的进程池

运行效果大致如下:

复制代码
######################
发送任务 2 给 Channel-4-12345,剩余任务:19
######################
子进程[12345] 执行:加密计算任务
######################
发送任务 0 给 Channel-5-12346,剩余任务:18
######################
子进程[12346] 执行:访问数据库任务
...
(20 个任务轮流派发给 3 个进程)
...
pid: 12345 收到退出信号,正常退出
子进程 12345 已回收成功
pid: 12346 收到退出信号,正常退出
子进程 12346 已回收成功
pid: 12347 收到退出信号,正常退出
子进程 12347 已回收成功

7.2 几个必须搞懂的细节

细节一:为什么子进程要关闭历史管道的写端?

因为 fork 会继承父进程所有打开的文件描述符。创建第 2 个子进程时,第 1 条管道的写端也被子进程 2 继承了。如果不关,每条管道的写端引用计数就会大于 1,最后 Master 关闭写端时,子进程手里还握着写端,read 就永远等不到 0,子进程退不出来。

所以每条管道的所有写端都必须被关闭,读端才能读到 EOF。 这是管道编程最容易踩的坑之一。

细节二:为什么要 dup2 到 0 号文件描述符?

为了让 Worker 函数不用关心管道 fd 是多少,统一从标准输入读。这样 Worker 的代码更干净,也方便扩展(比如以后换成从网络读,Worker 不用改)。

细节三:为什么关闭写端就能让子进程退出?

这就是我们前面讲的「所有写端关闭,read 返回 0」。Master 把写端一关,子进程 read 就返回 0,Worker 函数检测到 n==0 就 break 退出循环,进程结束。

这是一种非常优雅的退出通知方式,不用额外发信号,靠管道本身的语义就能完成。

细节四:任务为什么用整数编号而不是直接传函数?

因为进程地址空间隔离,父进程的函数指针子进程用不了。所以用编号做约定,两边各自维护一张任务表,根据编号查表执行。这就是约定 + 查表的思路,也是分布式系统里常见的做法。


8. 总结与下篇预告

8.1 全文总结

这篇文章我们从「先描述,再组织」这个核心思想出发,走完了匿名管道从原理到实战的全过程:

  1. 先描述,再组织 是 Linux 内核管理一切资源的通用思路
    • 进程:task_struct 描述 + 链表 / 哈希表组织
    • 文件 / 管道:file + inode 描述 + 文件描述符表组织
    • 我们的进程池:Channel 描述一条信道 + vector 组织所有信道
  2. 匿名管道 是最简单的 IPC,靠 fork 继承文件描述符实现父子通信
    • 三步走:创建管道 → fork → 各关一端
    • 半双工、字节流、生命周期随进程、内核同步互斥
  3. 管道读写有四种典型场景 ,核心是阻塞 / 非阻塞、对端关闭两种维度的组合
    • 读空阻塞、写满阻塞
    • 写端全关 read 返回 0,读端全关 write 触发 SIGPIPE
  4. 进程池 是匿名管道的经典应用,Master-Worker 架构
    • 一个 Master + N 个 Worker,一人一条管道
    • 初始化、派发、清理三阶段
    • 靠关闭写端优雅通知子进程退出

8.2 下篇预告:命名管道(FIFO)

匿名管道很好用,但有一个致命限制:只能用于有亲缘关系的进程。如果两个完全不相关的进程想通信怎么办?

这就要用到命名管道(FIFO)------ 它在文件系统里有一个真实的文件名,任何进程只要 open 这个文件就能通信。

下一篇文章我们会讲:

  • mkfifo 命令和 mkfifo 函数
  • 命名管道的打开规则(阻塞 / 非阻塞)
  • 用命名管道实现文件拷贝
  • 用命名管道实现 server-client 双向通信
  • 匿名管道 vs 命名管道:一张表彻底分清

感兴趣的同学可以点个关注,我们下篇见。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流。有任何问题也可以在评论区留言,我会一一回复。

相关推荐
2301_777998341 小时前
ARP协议进阶:ARP缓存、RARP与ARP欺骗原理解析
linux·服务器·网络·网络协议·缓存
拆房老料1 小时前
【第四部分:Agentic 智能体设计模式】16. 一个 Prompt 搞不定复杂任务?试试 Prompt Chaining
java·前端·人工智能·设计模式·prompt
运维行者_2 小时前
应用性能监控怎么选?从调用链到用户体验的五个能力维度
运维·服务器·数据库·人工智能·mysql·性能优化·应用性能监控
张张123y2 小时前
后端第十四课:Agent 记忆系统(让 Agent 记住上下文和用户,跨会话不健忘)
java·大数据·人工智能·python·agent·记忆
考虑考虑6 小时前
nginx路由重写
运维·后端·自动化运维
曹牧6 小时前
C#:空合并运算符
java·前端·数据库
布吉岛的石头7 小时前
Java 程序员第 50 阶段2:预训练的目标函数:自回归语言建模本质
java·开发语言·人工智能·深度学习·回归·transformer
golang学习记7 小时前
IDEA 2026年的玩法已经完全不同
java·ide·intellij-idea
紧跟先前的步伐7 小时前
Go AI Agent 实战 05|Temperature 与生成参数:让模型输出可控
java·人工智能·golang