进程间通信与进程池实现详解

一个管道能装多少数据?我把它灌爆了

你说管道有容量上限,上限是多少?你说写满了会阻塞,阻塞是什么样子?你说读端关了写端会被系统杀掉,杀掉的时候信号编号是多少?

这些不是"知识点"。这些是你可以亲手跑出来的东西。

跑出来,你就真的搞清楚管道了。跑不出来,你只是记住了"管道有大小限制""管道支持进程同步"这两句话------命名不等于理解


先把管道灌爆

上节课我们把管道的原理讲完了。创建管道,fork,关掉不需要的读写端,父子进程单向通信------这套动作我们写过。但有两个场景还没验证:

  1. 管道能被写满吗?容量到底多大?
  2. 写满之后,写端会怎样?

代码很简单。我们拿上节课的测试代码,做一件事:让子进程一直写,父进程不读。

cpp 复制代码
// 子进程:写端
int cnt = 0;
while (true)
{
    char c = 'a';
    write(fd, &c, 1);          // 每次写一个字节
    printf("cnt: %d\n", ++cnt); // 打印写了多少个
}

父进程呢?父进程拿着读端文件描述符,不关,但也不读。进程也不退出。文件描述符合法,我就是不读。

编译,跑起来。

bash 复制代码
$ make
$ ./test
cnt: 1
cnt: 2
cnt: 3
...
cnt: 65536

停住了。

65536。除以 1024,等于 64

这个管道的容量是 64KB。

不同系统不一样。有人测出来过 8KB,有人测出来过 16KB。你现在用的系统比较新,内核 6.8,管道大小就是 64KB。

但这不是重点。重点是:为什么到 65536 就不写了?

因为写满了。


两个结论,一次实验全搞定

第一,管道有容量上限。在我们的系统里是 64KB。

第二,读端不读,写端一直写------写满之后,写进程被阻塞

操作系统把这个写进程拉到一边去睡觉了。管道内部自己实现了进程同步:满了就不准你再写,空了就不准你再读。

这跟两个独立进程各搞各的数组完全不一样。你往数组里写,我往数组里读,谁也不管谁,读出来的可能是垃圾。管道里不存在这种事。

就这么回事。

管道是字节流------读写次数不需要匹配

还有一个现象在上节课被注意到了,但值得单独拎出来说。

你写 5 次,每次写 1 个字符。读端呢?读 1 次,一次读 1024 字节。结果一次就把 5 个字符全读上来了。

cpp 复制代码
// 写端:写 5 次
for (int i = 0; i < 5; i++)
{
    write(fd, "a", 1);
    sleep(1);
}

// 读端:读 1 次,大缓冲区
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));  // n = 5

这就是面向字节流。管道不管你怎么切------你写 5 次,读 1 次,数据不丢。你写 1 次 100 字节,读 20 次每次 5 字节,也不丢。

如果是两个人各搞各的数组------你写你的,我读我的------不会有这种效果。读出来的可能是垃圾、可能是半截数据、可能空读。管道内部自己做了这件事。

这跟文件一模一样。 你写 10 次,每次写 1 字节,lseek 回开头,一次读 10 字节------文件不也是这么干的吗?管道的本质就是文件。


IPC 到底是个什么东西------名字不重要,核心就一句话

三个问题:是什么、为什么、怎么办

上节课花了很长时间搭背景。再捋一遍。

是什么? 进程有独立性,两个进程想通信很困难。需要一种技术,让一个进程把自己的数据发送给另一个进程。这就叫进程间通信 。英文缩写 IPC:Inter-Process Communication。

换个问法,不用"进程间通信"这个词,还能说清楚吗?能------就是两个独立运行的程序之间互相传数据。

为什么要有? 进程不是孤立个体。未来需要协同------一个进程控制另一个进程的启停、生死。所以 IPC 的目标是:数据传输、数据共享、事件通知、进程控制。

怎么办? 不管什么方法,核心共性就这一句话------

必须想办法让不同的进程看到同一份资源。

管道的做法:利用文件系统的特性,让父子进程通过 fork 继承同一个文件描述符,从而看到同一个管道文件。大部分管道代码其实是在写"如何让两个进程看到同一份资源",真正通信那部分就一句 write 和一句 read

这就是 IPC 的核心指导思想。后面不管学哪种 IPC------共享内存、消息队列、信号量------都逃不开这句话。

顺便说下标准这回事

IPC 技术有标准。System V 是一种(老一辈的),POSIX 是另一种(现在用的多)。

标准是干什么的?不是限制你的,是限制写操作系统那些人的。你们这些写操作系统的人,函数叫什么、返回值参数长什么样,都给我设计成一样的。原理不类似不要紧,表现特征必须一致。

为什么能定标准?因为计算机世界到处都是标准。

你买的电脑是组装机------内存条用谁的?主板用谁的?CPU 用谁的?硬盘用谁的?不同的厂商,做出的产品竟然能组合起来,形成一台能跑的电脑。

凭什么?因为硬件级别有标准。USB 接口、PCIe 插槽、内存插槽------大家都在按标准做。硬件有标准,软件自然也有标准。在 IPC 这个细分领域,System V 就是其中一套标准。

甚至你教材里学的这些知识------进程有独立性、进程调度用时间片分片、进程切换------这些全部都是标准。标准本质上就是一种共识,而知识就是共识。

这就是为什么操作系统课上学的东西,在 Linux 下是这个概念,在 Windows 下也是这个概念------不是巧合,是标准。你将来进了公司,和老程序员之间也是在靠标准建立共识、靠知识体系对齐认知。

这跟华为定 5G 标准、未来还会有 AI 标准、自动驾驶标准是一个道理------标准代表统一性,规避不同带来的问题。

但标准不是一蹴而就的。一开始大家摸索了很久,复用文件系统的代码做出了管道。管道不属于 System V,它是另一种独立的 IPC 技术------最早的一种。


管道的四种死法

想想看,一个管道,一头写一头读,总共能出几种状况?

我们已经验证了两种正常情况

  • 写端慢(甚至不写)→ 读端阻塞等
  • 读端慢(甚至不读)→ 写端写满后阻塞

下面两种是异常情况------不是通信异常,是"不跟你通信了"的退出场景。

场景三:写端说"我不写了,也不玩了"

写端不光不写,还把写端文件描述符关了,自己退出了。

读端怎么办?

cpp 复制代码
// 写端:写一个字符,等5秒,然后关闭走人
write(fd, "a", 1);
sleep(5);
close(fd);
break;
cpp 复制代码
// 读端:一直读
char buf[1024];
while (true)
{
    ssize_t n = read(fd, buf, sizeof(buf));
    printf("read returned: %zd\n", n);
    if (n == 0)
    {
        printf("读到文件结尾了\n");
        break;
    }
    sleep(1);
}

跑起来:

bash 复制代码
$ ./test
read returned: 1    # 读到了那个 'a'
# ... 等了5秒 ...
read returned: 0    # 读到 EOF
读到文件结尾了

结论:写端关闭后,读端会把管道里剩余数据全部读完,然后再读就返回 0。

read 返回 0 是什么意思?end of file,读到文件结尾。管道也是文件,所以如果你把写端关了------等于告诉系统"从此往后再也没人往里写了"------读端把所有剩余数据取走之后,自然就读到文件结尾。这合理吗?非常合理。

场景四:读端说"我不听了,走了"

这回换过来。写端一直在写,读端不读了,而且把读端文件描述符关了。

这就像你一直在说话,对方不光不听了,转身就走了。你什么感受?

站在操作系统的角度:

读端关了,这个单向通信的管道里没有人读了 。你写端往管道里写数据,没人读------这意味着你在浪费时间和空间。操作系统不会干这种蠢事。你写端还要写?直接杀掉。

怎么杀?通过信号

cpp 复制代码
// 子进程(写端):每隔1秒写一次
while (true)
{
    write(fd, "a", 1);
    printf("写了第 %d 次\n", ++cnt);
    sleep(1);
}
cpp 复制代码
// 父进程(读端):读一次,等3秒,关掉读端
char buf[1024];
read(fd, buf, sizeof(buf));  // 读一次
sleep(3);
close(fd);                   // 关掉读端!
break;

然后父进程用 waitpid 等子进程退出,获取退出信息。

我们需要提取子进程的退出信号。之前学进程控制时讲过:进程退出的状态信息中,低 7 位是退出信号(core dump 标志在第 8 位),次低 8 位是退出码。

cpp 复制代码
int status = 0;
waitpid(child_pid, &status, 0);

int exit_code = (status >> 8) & 0xFF;    // 退出码
int exit_signal = status & 0x7F;          // 退出信号

printf("子进程退出码: %d, 退出信号: %d\n", exit_code, exit_signal);

编译运行:

bash 复制代码
$ make
$ ./test
写了第 1 次
写了第 2 次
写了第 3 次
写了第 4 次    # 前4次被父进程一次全读了(字节流,读写次数可以不匹配)
写了第 5 次
写了第 6 次
写了第 7 次    # 此时父进程关了读端
# 子进程被杀

子进程退出码: 0, 退出信号: 13

13 号信号

13 号信号叫什么?SIGPIPE。操作系统往一个已经没有读者的管道里写数据时,给写进程发送 SIGPIPE,默认动作是终止进程。


这张表,记住就行了

场景 写端 读端 现象
正常1 慢/不写 读端阻塞等待
正常2 慢/不读 写满后写端阻塞
异常1 关闭 读完剩余数据,read 返回 0
异常2 关闭 写端收到 SIGPIPE(13),被杀

五种特征,四种场景,齐了。

还有一个特征值得单独提:管道的生命周期随进程。管道的本质是文件,如果相关的进程都退出了,管道文件自动释放。你不需要手动删它------进程活着它就在,进程死了它就没了。跟普通文件不一样。

信号是怎么干掉进程的------顺便看一眼 kill 和 signal

场景四里说"操作系统会通过信号杀掉写进程"。说都说了,不如把发信号和收信号跑一遍。

信号这个话题还没正式讲,但代码不等人------先跑起来再说。

发信号:mykill

一个最简单的信号发送工具。跟 shell 里的 kill -9 <pid> 一个意思,只不过这次是自己调用 kill() 系统调用:

cpp 复制代码
#include <iostream>
#include <string>
#include <signal.h>
#include <unistd.h>

void Usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " signumber pid\n\n";
}

// ./mykill signumber pid
// ./mykill 9 1234
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        return 1;
    }
    int signumber = std::stoi(argv[1]);
    pid_t pid = std::stoi(argv[2]);

    std::cout << "send " << signumber << " to " << pid << std::endl;
    int n = kill(pid, signumber);
    (void)n;

    return 0;
}

用法:

bash 复制代码
$ ./mykill 13 208967   # 给 PID 208967 发 SIGPIPE
send 13 to 208967

就一个 kill(pid, signumber) 调用,操作系统把指定信号投递给目标进程。

收信号:testsig

那收到信号的进程怎么处理?默认行为之外,你可以注册自己的处理函数:

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

// sig: 表示的是收到的信号编号
void handlersig(int sig)
{
    std::cout << "哈哈, 我正在处理一个信号, pid: " << getpid()
              << " sig number: " << sig << std::endl;
}

int main()
{
    // 一口气注册 1~31 号信号的处理函数
    for (int signo = 1; signo <= 31; signo++)
        signal(signo, handlersig);

    std::cout << "我是一个进程, pid: " << getpid() << std::endl;
    sleep(1);

    int a = 10;
    a /= 0;  // 触发 SIGFPE (8号信号, 浮点异常)

    return 0;
}

跑起来:

bash 复制代码
$ ./testsig
我是一个进程, pid: 123456
哈哈, 我正在处理一个信号, pid: 123456 sig number: 8

几个关键点:

  • signal(signo, handler) 注册一个信号处理函数。收到该信号时,不执行默认动作,改为调用你的 handler
  • 除了自定义函数,还可以传 SIG_DFL(恢复默认行为)或 SIG_IGN(忽略信号)。
  • a /= 0 触发 8 号信号 SIGFPE。正常情况进程会直接挂掉,但因为我们注册了 handler,它走了自定义逻辑。
  • SIGKILL(9) 不能捕获也不能忽略------这是硬规则。

回到管道的场景四:写端往一个没有读端的管道写数据,操作系统给写端发 13 号信号 SIGPIPE。默认行为是终止进程------所以写进程直接没了。如果你给写进程提前注册了 SIGPIPE 的 handler,比如用 signal(13, handlersig),那结果就不一样了:进程不会死,而是走进你的处理函数。

你最好自己跑一下试试。把进程池里子进程的 worker 加上 signal(SIGPIPE, handler),然后让父进程关读端------看子进程是直接退出还是走进你的 handler。


你每天敲的那个 |,到底干了什么

以前学 shell 的时候见过这个:

bash 复制代码
$ who | grep whp | wc -l

用竖线连接起来的三个命令。当时说"这叫管道"。后来知道了,每个命令都是一个进程,shell(bash)是它们的父进程。

那么问题来了:这个 | 底层到底干了什么?

做个小实验。用 sleep 替代那些瞬间跑完的命令,看得清楚:

bash 复制代码
$ sleep 10000 | sleep 20000 | sleep 30000 &

然后查进程:

bash 复制代码
$ ps axj | head -1 && ps axj | grep sleep

输出(关键部分):

复制代码
PPID     PID      PGID     SID   TTY    TPGID    STAT  ...  COMMAND
2074986  2078046  2078046  2074986 pts/0 2078046  S+    ...  sleep 10000
2074986  2078047  2078046  2074986 pts/0 2078046  S+    ...  sleep 20000
2074986  2078048  2078046  2074986 pts/0 2078046  S+    ...  sleep 30000

三个进程的 PPID(父进程 ID)都是 2074986。而 2074986 是谁?

bash 复制代码
$ ps | grep 2074986
2074986  pts/0  Ss  0:00 -bash

这三个进程的老爹都是 bash。它们是兄弟进程,具有血缘关系。

所以,你在命令行上敲的 sleep 10000 | sleep 20000 | sleep 30000,shell 做了这些事:

  1. 解析命令行,发现有 两个竖线 → 需要连接 三个进程 → 需要创建 两个管道
  2. 创建一个管道,fork 两次,第一个子进程的标准输出重定向到管道写端,第二个子进程的标准输入重定向到管道读端
  3. 再创建一个管道,同样的套路连接第二个和第三个进程
  4. 每个子进程内部关闭自己不需要的读写端
  5. 父进程(bash)关闭所有管道的读写端,继续做命令行解释

竖线 | 的本质就是匿名管道。

如果你自己写过一个 mini shell,想在里头支持管道,逻辑是一样的:

  • 解析有多少个命令 → 管道数 = 命令数 - 1
  • 先创建管道,再连续 fork
  • 每个子进程关闭不需要的读写端,通过 dup2 重定向标准输入/输出
  • 父进程关闭所有管道端

今天我们不再去改以前写的 shell 了------那个代码的教学意义在于理解 shell 原理,不在于把管道那段写得多漂亮。我们换个更有价值的东西来写。


进程池:把厨子提前雇好

先聊池化

你们学 C++ 的时候应该见过内存池。vector 扩容的时候,你只需要 1 个字节,它给你扩两倍容量。为什么?

因为向操作系统申请内存要走系统调用。系统调用成本高------操作系统要检查哪块内存空闲、空间不够还得做交换腾挪、分配好了才返回。

一次多要点,下次就别烦操作系统了。

这就是池化技术。吃饭的类比:

你去餐厅说要吃西红柿炒鸡蛋。大厨说:"行,你等着,我去菜市场买鸡蛋,买西红柿,回来给你做。"再过分一点:"你等着,我家老母鸡下蛋了,地里西红柿长出来了,我再给你炒。"

------这顿饭你要等多久?

后来厨子学聪明了。昨天炒了一堆西红柿炒鸡蛋没吃完,冻冰箱里。你今天说要吃,他从冰箱里拿出来加热一下,直接上桌。这就叫预制菜。

提前把准备工作做好,有任务直接处理,不需要临时创建。 这就是池化------目的是减少处理工作之前的成本,提高效率。

进程池同理:提前创建一批子进程,有任务直接交给它们处理,不用每次来了任务再 fork。

进程池怎么控制子进程?

核心思路非常直接------用管道。

看一下板书上的结构:

复制代码
                close(fd)
  Master ──────────→ ┌─Pipe 0─┐ → Slave 0: while(true) { read(fd_0); 执行代码 }
  (父进程)           ├─Pipe 1─┘ → Slave 1: while(true) { read(fd_1); 执行代码 }
                     ├─Pipe 2── → Slave 2: while(true) { read(fd_2); 执行代码 }
                     └─Pipe 3── → Slave 3: while(true) { read(fd_3); 执行代码 }

父进程(Master) 做的事:

  1. 创建一个管道
  2. fork 一个子进程
  3. 父子分别关掉不需要的读写端------父保留写端,子保留读端
  4. 重复 N 次,就有了 N 个管道 + N 个子进程

所有子进程(Slave) 都在一个 while(true) 死循环里,各自 read 自己的管道。

现在,如果父进程不向任何一个管道写数据------所有子进程的 read 都会阻塞在那里。因为写端不写,管道空了,读端就阻塞。

父进程想点哪个子进程干活?往对应的管道里写数据就行了。

这个子进程的 read 一返回,它就跑去执行一段代码(比如上传、下载、打印),执行完回到循环顶部继续 read------管道又空了,又阻塞了。

父进程通过管道控制所有子进程的启停。 想让你跑就写数据,不想让你跑就不写。这不就是进程控制吗?

换个问法,不用"进程间通信"这个词,还能说清楚吗?能------你的进程通过一根管子告诉我的进程"该干活了"。


任务码:我要让你干什么

子进程被唤醒之后,它怎么知道该干什么?

我们需要一个任务表 。最简单的方式:一个函数指针数组。需要 #include <functional> 引入 std::function

cpp 复制代码
#include <functional>

using task_t = std::function<void()>;

void printLog()    { std::cout << "我是一个打印日志的任务" << std::endl; }
void download()    { std::cout << "我是一个下载任务" << std::endl; }
void readMySQL()   { std::cout << "我是一个访问数据库的操作" << std::endl; }
void writeRedis()  { std::cout << "我是一个访问 Redis 的操作" << std::endl; }

std::vector<task_t> g_tasks = { printLog, download, readMySQL, writeRedis };

数组有下标:0 是打日志,1 是下载,2 是读数据库,3 是写 Redis。

这个数组定义成全局变量。 为什么?fork() 之后子进程会拷贝父进程的整个地址空间------所有全局变量、堆、栈全复制一份(写时拷贝)。所以子进程天然就能访问父进程定义好的 g_tasks,不需要任何 IPC 传过去。父进程准备好任务表,所有子进程共享看到------这在进程池里叫"共识"。

父进程往管道里写一个整数(下标),子进程读到这个整数,拿它去数组里索引,执行对应的方法。

这个整数就是任务码(task code)。

cpp 复制代码
// 父进程发送任务
int taskCode = 2;  // "读数据库"
write(wfd, &taskCode, sizeof(int));

// 子进程接收并执行
int code;
ssize_t n = read(rfd, &code, sizeof(int));  // 每次读4字节
if (n == sizeof(int) && code >= 0 && code < g_tasks.size())
{
    g_tasks[code]();  // 执行任务
}

管道是面向字节流的,写四个字节读四个字节,没问题。


忙闲不均的问题

父进程一直给同一个管道写任务码:

复制代码
Slave 0: 干活、干活、干活、干活...(忙死)
Slave 1: 等...
Slave 2: 等...
Slave 3: 等...(闲死)

这跟你家过年一样------你妈在厨房里忙得不可开交,你在房间里打游戏,你爷爷奶奶动不了。一家四五口人,四个人闲得不行,一个人忙得不行。

多进程的并发优势没体现出来。 我们要做的是负载均衡------把任务均匀地撒到不同的管道里,让每个子进程压力差不多。

三种策略:

  1. 随机数rand() % num_channels,时间久了大致均衡
  2. 轮询:第一次给你,第二次给他,第三次给他......一圈完了从头再来
  3. 计数器:记录每个管道历史上发了多少任务,优先发给历史负载最小的

我们选轮询------最简单,也最容易验证。

轮询逻辑就三行:

cpp 复制代码
int selectChannel()
{
    int choice = _nextChannel;                        // 先保存当前选中的
    _nextChannel = (_nextChannel + 1) % _channels.size(); // 自增后取模,保证下标不越界
    return choice;                                    // 返回之前保存的
}

构造函数里把 _nextChannel 初始化为 0。每次调用,先返回当前值,再自增取模。第一次选 0,第二次选 1,...绕一圈回到 0。

让任务名字可读:taskToString

打印日志时,光看到数字 0 1 2 3 不够直观。写个小函数把任务码转成字符串:

cpp 复制代码
std::string taskToString(int code)
{
    switch (code)
    {
        case LOG_TASK:      return "打日志";
        case DOWNLOAD_TASK: return "下载任务";
        case MYSQL_TASK:    return "读数据库";
        case REDIS_TASK:    return "写Redis";
        default:            return "未知任务";
    }
}

发送时日志就变成这样:

复制代码
发送任务 [打日志] (0) 给 channel-226259-5
发送任务 [下载任务] (1) 给 channel-226260-6
发送任务 [写Redis] (3) 给 channel-226261-7

一目了然。


把代码写成对象:Channel 和 ProcessPool

先描述,再组织

站在父进程的角度,它手里的写端不止一个------它要管理 N 个管道。管道在 for 循环里创建,pipefd 是个临时变量,循环一结束就没了。子进程把它拷贝走没问题(每个子进程只关心自己的读端),但父进程必须把所有的写端保存下来

最简单的做法:把所有写端文件描述符存到一个 vector<int> 里。但信息量太小------你只知道文件描述符,不知道它对应哪个子进程,不知道它叫什么名字。

这就像相亲。媒婆给你拿一堆照片,每张照片背后有基本信息------你才好挑。你要是只拿到一堆编号,连谁是谁都不知道,怎么选?

父进程也一样。它手里不止一个管道------有的写了 10 个任务,有的写了 20 个,有的是新创建的,有的可能要释放。父进程需要知道:这个管道是写给哪个子进程的?名字叫什么?历史负载多少?

先描述:把"一个管道在父进程视角的全部信息"封装成一个类。

设计类的时候顺便说个参数传递约定:输出型参数用指针,输入型参数用 const 引用,输入输出型参数用引用。以后代码都按这个规矩来,不用每次纠结是传值还是传引用。

cpp 复制代码
class Channel
{
public:
    Channel(int wfd, pid_t subprocessId)
        : _wfd(wfd)
        , _subprocessId(subprocessId)
        , _name("channel-" + std::to_string(subprocessId) + "-" + std::to_string(wfd))
    {}

    int fd()          const { return _wfd; }
    pid_t subPid()    const { return _subprocessId; }
    std::string name() const { return _name; }

    void close()      { if (_wfd >= 0) { ::close(_wfd); _wfd = -1; } }
    void wait()       { waitpid(_subprocessId, nullptr, 0); }

    void sendTask(int taskCode) { ::write(_wfd, &taskCode, sizeof(int)); }

private:
    int _wfd;
    pid_t _subprocessId;
    std::string _name;
};

再组织:用 vector<Channel> 管理。

cpp 复制代码
class ProcessPool
{
public:
    ProcessPool(int numProcesses) : _num(numProcesses), _nextChannel(0) {}

    void create()
    {
        for (int i = 0; i < _num; i++)
        {
            int pipefd[2];
            pipe(pipefd);
            pid_t id = fork();
            // 实际代码里这里应该检查 pipe() 和 fork() 的返回值
            // pipe 失败用 perror 报错然后 exit(2)
            // fork 失败 exit(3)

            if (id == 0)  // 子进程
            {
                close(pipefd[1]);  // 关写端
                worker(pipefd[0]); // 进入工作循环
                close(pipefd[0]);
                exit(0);
            }
            else  // 父进程
            {
                close(pipefd[0]);  // 关读端
                _channels.emplace_back(pipefd[1], id);  // 保存写端 + 子进程PID
            }
        }
    }

    void stop()
    {
        // 关闭所有写端 → 子进程读完管道内剩余数据后 read 返回 0 → 子进程退出
        for (auto &ch : _channels) ch.close();
        // 回收所有子进程
        for (auto &ch : _channels) ch.wait();
    }

    void postTask(int taskCode)
    {
        int idx = selectChannel();  // 选一个子进程
        _channels[idx].sendTask(taskCode);
    }

private:
    int selectChannel()
    {
        int choice = _nextChannel;
        _nextChannel = (_nextChannel + 1) % _channels.size();  // 轮询
        return choice;
    }

    void worker(int rfd)
    {
        while (true)
        {
            int code = 0;
            ssize_t n = read(rfd, &code, sizeof(int));

            if (n == sizeof(int))
            {
                if (code >= 0 && code < g_tasks.size())
                    g_tasks[code]();   // 执行任务
            }
            else if (n == 0)
            {
                break;  // 写端关了,退出
            }
            else
            {
                break;  // 读异常,退出
            }
        }
    }

    int _num;
    int _nextChannel;
    std::vector<Channel> _channels;
};

这段代码有一个非常容易看漏的点:create() 里的 for 循环,只有父进程在跑。

fork() 返回 0 → 进入 if (id == 0) 分支 → 子进程调用 worker(rfd)worker 里是 while(true),读不到 0 就不会 break子进程永远走不到 exit(0),更走不到下一次循环。

fork() 返回 > 0 → 进入 else 分支 → 父进程关读端、把写端存入 _channels父进程继续 for 循环的下一次迭代,创建下一个管道 + 子进程。

循环结束的时候,父进程手里攒着 N 个管道的写端,而 N 个子进程各自蹲在 read() 上等任务。

多进程编程跟单线程最大的不同就是这个------fork 之后,代码分叉了。 同一段 create() 代码,前半段父子一起跑(pipe() 之后到 if/else 之前),fork() 返回那一刻分道扬镳:子进程走进 if,父进程走进 else,之后再也不会交汇。你要心里时刻清楚"当前这行代码是哪个进程在执行"。

关键细节:父进程退出 → 写端关闭 → 子进程读返回 0 → 子进程退出

让子进程退出,只需要父进程 close 自己的写端。 子进程把管道内剩余数据读完,再 read 就返回 0,break 退出死循环。

这就是管道场景三的直接应用。不是什么高级技巧,就是管道的特性本身。

条件编译:让一个文件既能测试又能被 include

上面的代码里,main 函数只是测试用的。未来你把这个 ProcessPool 当成一个类给别人用,main 函数就是多余的。

经典做法:条件编译套一层。

cpp 复制代码
#ifdef MAIN
int main(int argc, char *argv[])
{
    // ... 测试代码 ...
}
#endif

然后在 Makefile 里:

makefile 复制代码
processpool: processpool.cc
    g++ -DMAIN -o $@ $^

编译时 -DMAIN 定义了 MAIN 宏,main 函数就生效。如果去掉 -DMAIN,这段 #ifdef MAIN ... #endif 里的代码被预处理器直接裁掉------文件变成一个纯的头文件/实现文件,不再包含 main,可以被其他 .cc 文件 #include 进去用。

等以后你把 ProcessPool 拆成 .h + .cc 的时候,这个测试用的 main 就可以完全移走了。


测试:从标准输入发任务

cpp 复制代码
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " process_number\n";
        return 1;
    }

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

    // std::make_unique 需要 C++14
    auto pool = std::make_unique<ProcessPool>(num);
    pool->create();

    // 从键盘读任务码,派发给进程池
    while (true)
    {
        std::cout << "请输入任务码: ";
        int code;
        std::cin >> code;

        if (code < 0 || code >= (int)g_tasks.size())
        {
            std::cout << "任务码错误,请重新输入\n";
            continue;
        }
        pool->postTask(code);
    }

    pool->stop();
    return 0;
}

编译,启动 5 个子进程:

bash 复制代码
$ ./processpool 5
请输入任务码: 0
请输入任务码: 1
请输入任务码: 3
请输入任务码: 1
请输入任务码: 1
请输入任务码: 1
请输入任务码: 1

输出可以看到,任务按轮询顺序被分配:

  • 0 号任务发给 Channel 0 (PID 226259)
  • 1 号任务发给 Channel 1 (PID 226260)
  • 3 号任务发给 Channel 2 (PID 226261)
  • 1 号任务发给 Channel 3 (PID 226262)
  • 1 号任务发给 Channel 4 (PID 226263)
  • 1 号任务回到 Channel 0 (PID 226259)
  • ......

轮询生效了。每个子进程轮流干活。

批量测试:造 50 个随机任务

cpp 复制代码
void generateRandomTasks(std::vector<int> &out, int count)
{
    srand(time(nullptr) ^ getpid());
    for (int i = 0; i < count; i++)
    {
        int code = rand() % g_tasks.size();
        out.push_back(code);
        usleep(12345);  // 让随机数差异化
    }
}

然后在 main 里:

cpp 复制代码
std::vector<int> taskCodes;
generateRandomTasks(taskCodes, 50);

for (int code : taskCodes)
{
    pool->postTask(code);
    usleep(500);  // 每个任务间隔0.5ms
}

运行:

bash 复制代码
$ ./processpool 5

50 个随机任务依次通过管道分发给 5 个子进程,每个子进程读取、执行、打印,轮询均衡。

进程池的生命周期可视化

想看到整个进程池从出生到死亡的全过程,用个监控脚本:

bash 复制代码
$ while true; do ps axj | head -1 && ps axj | grep processpool; sleep 1; done

然后代码里加点延时,让每个阶段停留几秒:

cpp 复制代码
// main 中
sleep(5);                          // 阶段0:只有一个父进程在跑
std::cout << "父进程开始创建进程池..." << std::endl;
pool->create();
sleep(2);                          // 阶段1:父进程 + N个子进程
pool->stop();
// stop() 里先 close 再 wait ------ 中间有僵尸阶段
sleep(3);                          // 阶段3:只剩父进程

配合监控脚本,你会看到三个阶段:

阶段 进程数 状态
create 前(sleep 5秒) 1 只有父进程
create 后(sleep 2秒) 1 + N 父进程 + N 个子进程(全部阻塞在 read)
close 后、wait 前 1 + N(僵尸) 子进程都变成了 <defunct> 僵尸进程
wait 后(sleep 3秒) 1 所有子进程被回收,只剩父进程

僵尸阶段是因为你先把所有管道写端 close 了------子进程 read 返回 0,退出变成僵尸------然后父进程还没来的及 wait。 这时候 ps 能看到 <defunct> 标记的僵尸进程。等 wait 循环跑完,它们就消失了。

这也验证了:不是 close 完子进程就立马没了的。close 只是让子进程退出(变成僵尸),回收靠 wait

另一个极端:如果父进程不 wait 就直接退出了,子进程会变成孤儿 ,被 init(PID=1)收养。init 会帮它们 wait,所以孤儿不会一直僵尸。但只要父进程活着又不 wait,子进程就是僵尸------这就是为什么进程池的 stop() 必须先 close 再 wait,一个都不能少。


代码里悄悄修过一个 bug

在写 create() 函数的时候,最开始有一段代码长这样:

cpp 复制代码
int n = pipe(pipefd);   // pipe 的返回值
// ...
int n = fork();         // 又把 n 赋值给 fork 的返回值!
if (n == 0)
{
    // 子进程
}
else if (n > 0)
{
    // 父进程
}
else
{
    // fork 失败
}

编译能过,但运行时子进程创建逻辑全乱套了------pipe 的返回值被 fork 的返回值覆盖了,而后续判断 n == 0 时,用的是覆盖后的值。

修法很简单:变量名分开。

cpp 复制代码
int n = pipe(pipefd);
// ...
pid_t id = fork();
if (id == 0)     // 子进程
else if (id > 0) // 父进程
else             // fork 失败

变量名重复使用是多进程代码里最常见的坑。 贴下来拷贝上一段代码的时候,顺手就忘了改。我也搞错过这个东西。


停下来,回答一个问题

有同学问:

一个管道,如果一方不写也不关闭,那读的时候也会返回 0 吗?

不会。

不写但不关,本质就是"写的慢的极端情况"。读端会永远阻塞在 read 那里------直到对端写数据,或者对端把写端关了。

四种场景再背一遍:

写端 读端 结果
不写也不关 读端永久阻塞
不读也不关 写满后写端阻塞
关了 读完剩余数据,read 返回 0
关了 写端收到 SIGPIPE(13),被系统杀掉

记住就行。


一个藏着的小 BUG

回收子进程的时候,有三种写法:

写法一:先关完,再收完(正确)

cpp 复制代码
void stop()
{
    for (auto &ch : _channels) ch.close();   // 先关所有写端
    for (auto &ch : _channels) ch.wait();    // 再回收所有子进程
}

写法二:倒着关+收(正确)

cpp 复制代码
void stop()
{
    for (int i = _channels.size() - 1; i >= 0; i--)
    {
        _channels[i].close();
        _channels[i].wait();
    }
}

写法三:正着关一个收一个(卡死)

cpp 复制代码
void stop()
{
    for (auto &ch : _channels)
    {
        ch.close();
        ch.wait();   // 卡在这里!
    }
}

三种写法的现象:

写法 结果
先全部 close,再全部 wait 正常退出
倒序 close + wait 正常退出
正序 close + wait 父进程卡死

为什么正序会卡死?这跟文件描述符的分配机制和管道的引用计数有关。

每次 fork 之后,子进程继承了父进程的整个文件描述符表 。父进程虽然关掉了第一个管道的读端,但它在循环中继续 fork 时,后面创建的子进程也会继承前面管道对应的文件描述符。换句话说,子进程之间可能持有别的管道的文件描述符

当你正着 close 第一个管道的写端然后立即 wait

  • 第一个子进程的 read 返回 0 的前提是:所有持有该管道写端的进程都关了它
  • 但第二个、第三个子进程可能因为继承关系,还持有第一个管道写端的引用
  • 所以第一个子进程的 read 不会返回 0 → 它不退出 → 父进程 wait 永远等不到

倒着回收为什么行?因为倒着回收时,后面的子进程先被关了它们的写端,释放了对前面管道的引用,所以前面子进程的 read 能顺利返回 0。

这里我没完全搞清楚所有细节------文件描述符继承在多次 fork 中的精确行为还需要进一步验证。你最好自己写一个小例子跑一下,看每个子进程打开的文件描述符到底有哪些。

但核心道理是这个:你关了一个管道的写端,不代表所有持有写端的进程都关了它。 管道靠引用计数活着,只要还有进程拿着另一端,read 就不会返回 0。


这篇东西到底说了什么

如果你只记住三个东西:

  1. 管道不是魔法------它是文件,有自己的容量(常见 64KB),写满阻塞,读空阻塞,一端关了另一端能感知到(read 返回 0,或者收到 SIGPIPE 信号 13)。

  2. shell 的 | 就是匿名管道------bash 创建管道,fork 兄弟进程,重定向标准输入输出,关掉不需要的端口。你每天都用,只是没意识到底层是怎么回事。

  3. 进程池用管道控制子进程------父进程写数据 = 唤醒子进程;父进程关写端 = 让子进程退出。中间加上任务码和轮询,就有了一个能用的多进程任务处理框架。

最后一件事:进程间通信的核心指导思想。不管你用管道、共享内存、消息队列还是信号量,归根结底就一句话------

必须想办法让不同的进程看到同一份资源。

管道的做法是:利用文件系统的特性,让父子进程通过 fork 继承同一个文件描述符,从而看到同一个管道文件。大部分管道代码其实是在写"如何让两个进程看到同一份资源",真正通信那部分就一句 write 和一句 read

就这么回事。