你的进程池为什么卡死了?
先跑起来看看。
这段代码是一个进程池------父进程创建 N 个子进程,每个子进程通过匿名管道 接收父进程发过来的任务码(4 字节整数),查到对应函数后执行。父进程这边用轮询 做负载均衡:_next = (_next + 1) % channels.size(),依次挑一个子进程发任务。
cpp
// 任务:一个函数表,下标就是任务码
std::vector<std::function<void()>> tasks;
void LoadTasks() {
tasks.push_back(TaskA);
tasks.push_back(TaskB);
tasks.push_back(TaskC);
// ...
}
// channel:描述一个管道 + 子进程
class Channel {
int _writefd;
pid_t _pid;
std::string _name;
public:
void CloseWrite() { close(_writefd); }
void Wait() { waitpid(_pid, nullptr, 0); }
};
// 进程池
class ProcessPool {
std::vector<Channel> channels;
int _next;
public:
void Start(int num) {
for (int i = 0; i < num; i++) {
int pipefd[2];
pipe(pipefd); // ① 创建管道
pid_t id = fork(); // ② 创建子进程
if (id == 0) {
close(pipefd[1]); // 子进程关写端
// ③ 循环读 4 字节任务码,查表执行
while (true) {
int code = 0;
ssize_t n = read(pipefd[0], &code, 4);
if (n == 0) break; // 写端关闭,退出
if (n == 4) tasks[code]();
else break;
}
close(pipefd[0]);
exit(0);
}
// 父进程:关闭读端,只写
close(pipefd[0]);
channels.push_back(Channel(pipefd[1], id));
}
}
// 轮询选择下一个子进程
Channel& NextChannel() {
Channel& c = channels[_next];
_next = (_next + 1) % channels.size();
return c;
}
// 向选中的子进程发送任务码(必须是 4 字节)
void SendTask(int code) {
Channel& c = NextChannel();
write(c.GetWriteFd(), &code, 4);
}
void Stop() {
for (auto &ch : channels) {
ch.CloseWrite(); // 关一个
ch.Wait(); // 等一个
}
}
};
启动 10 个子进程,跑完任务后调 Stop()。
预期:10 个子进程依次退出,父进程回收完毕,程序结束。
实际:
bash
$ ./process_pool 10
$ ps ax | grep process_pool
# 10 个子进程全挂在那,一个没退
卡死了。见鬼。
画出来就清楚了
我们不急着修。先拿 3 个子进程当例子,一步步画文件描述符表。
第一轮
父进程有一个文件描述符表,012 已经被 stdin/stdout/stderr 占了。我们从 3 开始。
创建第一个管道 :pipe() 返回两个 fd------pipefd[0] 是读端,pipefd[1] 是写端。怎么记?0 像一张嘴(只管往里吸数据),1 像一支笔(只管往外写数据)。设本次分配:3 = 读端,4 = 写端。
父进程 fd 表:
0 → stdin
1 → stdout
2 → stderr
3 → 管道1 读端
4 → 管道1 写端
fork 第一个子进程:子进程以父进程的 fd 表为模板,写时拷贝一份。
子进程1 fd 表(继承自父进程):
0 → stdin
1 → stdout
2 → stderr
3 → 管道1 读端
4 → 管道1 写端
然后各自关掉不需要的端------父写子读:
-
父进程:
close(3),只留 4(写) -
子进程1:
close(4),只留 3(读)父进程 fd 表: 子进程1 fd 表:
0 → stdin 0 → stdin
1 → stdout 1 → stdout
2 → stderr 2 → stderr
3 → (空) 3 → 管道1 读端
4 → 管道1 写端 4 → (空)
完美。父通过 4 写,子通过 3 读。一条干净的单向信道。
第二轮
父进程继续循环。创建第二个管道 :pipe() 再分配两个 fd。此时 3 是空的(刚被 close 掉了),4 还占着。
所以新管道:3 = 读端,5 = 写端。
父进程 fd 表:
0 → stdin
1 → stdout
2 → stderr
3 → 管道2 读端 ← 新
4 → 管道1 写端 ← 旧,还开着!
5 → 管道2 写端 ← 新
fork 第二个子进程 。子进程以当前父进程的 fd 表为模板------把整个表拷贝过去。
子进程2 fd 表(继承自父进程):
0 → stdin
1 → stdout
2 → stderr
3 → 管道2 读端 ← 这有问题吗?没有,子进程2 需要读管道2
4 → 管道1 写端 ← 等等......这个是什么?
5 → 管道2 写端 ← 子进程2 不需要写
注意到了吗?子进程 2 不光拿到了自己管道(管道 2)的读写端,还拿到了管道 1 的写端!
因为 fork 拷贝的是整个 fd 表,不是只拷贝"刚刚创建的"那两个 fd。父进程的 4 号 fd 还指向管道 1 的写端,所以子进程 2 的 4 号 fd 也指向管道 1 的写端。
然后各自关不需要的:
-
父进程:
close(3)(管道 2 读端) -
子进程 2:
close(5)(管道 2 写端)父进程 fd 表: 子进程2 fd 表:
0 → stdin 0 → stdin
1 → stdout 1 → stdout
2 → stderr 2 → stderr
3 → (空) 3 → 管道2 读端
4 → 管道1 写端 4 → 管道1 写端 ← 这就是那条多余的腿
5 → 管道2 写端 5 → (空)
好了,现在管道 1 的写端有两个进程指着------父进程和子进程 2。
第三轮
父进程继续。创建第三个管道:3 空着,4、5 占着。所以新 fd:3 = 读端,6 = 写端。
父进程 fd 表:
0 → stdin
1 → stdout
2 → stderr
3 → 管道3 读端
4 → 管道1 写端
5 → 管道2 写端
6 → 管道3 写端
fork 第三个子进程。拷贝整个 fd 表。
子进程3 fd 表:
0 → stdin
1 → stdout
2 → stderr
3 → 管道3 读端 ← 需要
4 → 管道1 写端 ← 多余!
5 → 管道2 写端 ← 多余!
6 → 管道3 写端 ← 不需要(子进程只读)
各自关不需要的之后:
子进程3 fd 表:
3 → 管道3 读端
4 → 管道1 写端 ← 还指着呢
5 → 管道2 写端 ← 还指着呢
6 → (空)
规律出来了
如果创建的是 N 个子进程:
- 管道 1 的写端 :被 1(父进程)+ (N-1) 个后续子进程 = 共 N 个进程指向它
- 管道 2 的写端 :被 1(父进程)+ (N-2) 个后续子进程 = 共 N-1 个进程指向它
- 管道 k 的写端 :被 1(父进程)+ (N-k) 个后续子进程 = 共 N-k+1 个进程指向它
- 管道 N 的写端:只有父进程指着,1 个
内核维护的文件对象上有一个引用计数。每一个 fd 指向它,计数就加一。只有引用计数归零,文件才会真正关闭,读端才能读到 EOF(0)。
回到 Stop() 的问题
cpp
void Stop() {
for (auto &ch : channels) { // 从第一个管道开始
ch.CloseWrite(); // close(4) → 管道1 写端引用计数减 1
ch.Wait(); // waitpid 等待子进程1 退出
}
}
当父进程 close(4)(管道 1 的写端),引用计数从 N 减到 N-1。不为零。 所以管道 1 还没死。
子进程 1 的 read(3, ...)------管道里没数据了,但写端还有人指着(子进程 2、子进程 3 还指着呢),所以 read 不会返回 0,也不会返回 -1,它阻塞住了。
子进程 1 不退出 → waitpid(pid1) 等不到 → 父进程被挂起 → 整个程序卡死。
换个问法:管道什么时候才算"写端关闭"?当没有任何进程持有它的写端 fd 时。 父进程关了一个,还有一堆子进程指着,所以不算关。
为什么有些搞法能工作
搞法一:先关完所有的,再统一等
cpp
void Stop() {
for (auto &ch : channels)
ch.CloseWrite(); // 把所有管道的写端全关掉
for (auto &ch : channels)
ch.Wait(); // 再统一回收
}
能工作。因为当你 close 管道 N 的写端时,它的引用计数直接归零(只有父进程指着它),子进程 N 的 read 立即返回 0,子进程 N 退出。子进程 N 一退出,它身上所有 fd 都释放------包括它指着的管道 N-1 的写端。于是管道 N-1 的引用计数也降了......连锁反应,从后往前依次释放。
搞法二:倒着回收
cpp
void Stop() {
for (int i = channels.size() - 1; i >= 0; i--) {
channels[i].CloseWrite();
channels[i].Wait();
}
}
也能工作。和上面逻辑一样------先关最后一个管道(写端只有父进程指着),子进程立即退出释放资源,连锁效应向上传导。
搞法三:正着来,关一个等一个
这就是我们一开始的代码。不行。 第一个管道的写端还有子进程 2、3、4...N 指着,引用计数不为零,子进程 1 的 read 永远阻塞。
修好它
核心一句话:每个子进程,除了关掉自己管道不需要的那一端,还要把从父进程继承下来的、历史上其他管道的写端也全关掉。
具体怎么做?子进程在 fork 后能看到父进程的 channels vector(写时拷贝)。这个 vector 里保存了之前创建的每一个管道的写端 fd。遍历它,全部 close 掉。
cpp
// 在子进程的代码块里,关闭自己不需要的写端之后,加上这一段:
for (auto &ch : channels) {
ch.CloseWrite(); // 关掉历史上所有管道写端
}
为什么写时拷贝保证了安全性?子进程 fork 之后拿到的是父进程 channels 的一个副本。父进程后续向 channels 里 push_back 新的 channel,修改的是父进程自己的那份,不影响子进程已经拿到的副本。子进程这里随便关,不会把父进程刚创建的写端关掉(因为还没 fork 出这个子进程时,那个写端还不存在于副本里)。
第一次 fork:channels 为空,循环不执行。
第二次 fork:channels 里有管道 1 的写端,关掉。
第三次 fork:channels 里有管道 1 和管道 2 的写端,关掉。
每个子进程变成了干净的一个读端,没有多余的写端指着别的管道。
此时每个管道的写端只有父进程指着 。父进程 close 一个,引用计数归零,子进程 read 返回 0,退出,waitpid 立即返回。畅通无阻。
就这么回事。
命名管道:让陌生人也能聊天
匿名管道有个致命限制:只能父子/兄弟进程之间通信。 靠的是 fork 继承 fd 表,让双方看到同一个文件对象。
那如果我就是要让两个八竿子打不着的进程通信呢?比如两个终端里分别启动的程序。
核心原理:一个不刷磁盘的文件
回想一下普通文件被打开时的流程:
- 进程 A 调用
open("ipc.txt", O_WRONLY)→ 内核创建struct file,加载 inode 和内容到内核缓冲区,返回 fd 3 - 进程 B 调用
open("ipc.txt", O_RDONLY)→ 文件已经被打开过了,内核不会再加载一份,直接复用 inode 和缓冲区,创建新的struct file,返回 fd 3
现在 A 往 fd 3 里写,B 从 fd 3 里读。从逻辑上讲,这不就可以通信了吗?
问题是:普通文件会把数据刷到磁盘上。A 写了数据,刷到磁盘,B 再从磁盘读------中间过了外设 IO,慢,而且这本来就不是为通信设计的。
命名管道就是做了减法。 它也是一个磁盘上有名字的文件(ls -l 看到类型是 p),有自己的 inode------权限、时间戳、所属用户这些属性全都有,跟普通文件一模一样。但它做了一件事:写入内核缓冲区的数据,不刷新到磁盘。 数据留在内存里,等着另一方来读。对于操作系统来说,识别到文件类型是 p,就不执行"刷磁盘"那一步------裁减一个功能而已,编码并不难。
换个问法------它其实就是把匿名管道那一套内核结构,挂了一个磁盘上的路径名上去。两个不相干的进程,通过同一条路径 + 文件名,打开同一个管道文件,看到同一块内核缓冲区。让不同进程先看到同一份资源。
这就是命名管道。
一个关键的阻塞点:open 本身就会卡住
这一点和普通文件完全不同。对于命名管道:
- 当 server 以
O_RDONLY调open时,如果还没有任何进程以写方式打开同一个管道,open会阻塞,直到有写端出现。 - 反过来,client 以
O_WRONLY调open时,如果还没有读端,open也会阻塞。
换句话说,命名管道的 open 本身就是一种同步点 ------双方必须都到场,通信才能开始。这也是为什么必须先启动 server(让它卡在 open 上),再启动 client(让两边的 open 同时返回),通信才正式开始。
这一点和匿名管道不一样。匿名管道通过 fork 继承,父子双方拿到的 fd 天然就指向同一个文件对象,不存在"你先开我再开"的协调问题。
命令行先试一下
不用写代码,直接用 shell 就能验证。
bash
# 创建一个命名管道文件
$ mkfifo fifo
# 看一下
$ ls -l fifo
prw-rw-r-- 1 user user 0 Jun 22 10:00 fifo
# ↑ p = pipe,管道文件。大小始终是 0。
# 开一个终端,往里面写
$ while true; do echo "hello $(date)"; sleep 1; done > fifo
# 开另一个终端,从里面读
$ cat fifo
hello Mon Jun 22 10:00:01 CST 2026
hello Mon Jun 22 10:00:02 CST 2026
hello Mon Jun 22 10:00:03 CST 2026
...
在写入和读取期间,ls -l fifo 看到的文件大小始终是 0。数据没有落盘。
如果把读端 Ctrl+C 关掉,写端也会收到 SIGPIPE,bash 进程直接被干掉------和匿名管道的特性一模一样。
除了"让任意进程通信"这一点不同,命名管道的五种特性(字节流、单向、自带同步、随进程生命周期、可用于任意进程通信)和匿名管道完全一致。
这里把管道的四种经典读写场景和五种特性集中列一下,因为后面讲命名管道时全部复用。
管道的四种读写场景
拿父子进程的匿名管道举例------父写子读,单向。
| 场景 | 写端操作 | 读端操作 | 结果 |
|---|---|---|---|
| ① | 写得慢 / 暂停写(但不关) | 读得快,管道读空后继续 read |
读端阻塞,直到有数据或写端关闭 |
| ② | 一直写 | 一直不读 | 管道写满后写端阻塞,直到有空闲空间 |
| ③ | 不写了,且关闭写端 | 读端把剩余数据读完后再 read |
read 返回 0(读到文件结尾) |
| ④ | 一直写 | 读端关闭自己的读 fd | 操作系统向写进程发送 SIGPIPE,默认动作:杀死写进程 |
场景 ④ 就是上面命令行 demo 里把 cat 关掉后 bash 进程直接挂掉的原因------bash 往已关闭读端的管道里写,收到了 SIGPIPE 信号。
管道的五种特性
- 匿名管道仅限于有血缘关系的进程之间通信 (靠
fork继承 fd 表看到同一资源)。命名管道把这条变成了任意进程均可通信(靠文件路径看到同一资源)。 - 提供字节流服务------管道本质是一个以字节为单位的队列(先进先出),不存在消息边界。你写 5 个字节,对方可以分 3 次读,也可以 1 次读 5 个。接收方自己定协议。
- 生命周期随进程------所有引用管道的进程退出后,管道自动释放。
- 自带同步机制------场景 ① 和 ②:管道空时读端自动阻塞,管道满时写端自动阻塞。不需要额外加锁。
- 单向通信------一条管道只能一个方向流动。父写子读,或者父读子写。想双向就得建两条管道。
写成代码
两个独立编译的可执行程序,通过一个公共头文件约定好管道路径。
公共头文件 common.hpp:
cpp
#pragma once
#include <string>
const std::string FIFO_PATH = "./fifo";
服务端 server.cc------负责创建管道,以读方式打开,阻塞读取。
关于系统调用:mkfifo 就是 shell 命令 mkfifo 的底层实现,原型在 <sys/types.h> 和 <sys/stat.h> 里------
c
int mkfifo(const char *pathname, mode_t mode);
// 成功返回 0,失败返回 -1 并设置 errno
至于删除管道文件,Linux 里没有叫 rm 的系统调用。删文件用的是 unlink(man 2 unlink,头文件 <unistd.h>)------它在当前目录里去掉文件名和 inode 的映射关系,同时把 inode 的引用计数减一。引用计数归零时文件才真正从磁盘上清除。rm 命令和 unlink 系统调用做的事本质一样。
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include "common.hpp"
int main() {
// 1. 创建命名管道
// 如果不调 umask(0),创建的管道权限会是 664 而不是 666。
// 因为系统默认 umask(通常 0002)会屏蔽掉 "other" 的写权限位。
// umask(0) 把掩码清 0,mkfifo 才能原样使用 mode 参数。
//
// 你可以自己验证:先注释掉 umask(0),ls -l 看到的是 prw-rw-r--(664);
// 再加上 umask(0),重新编译运行,ls -l 看到 prw-rw-rw-(666)。
umask(0);
if (mkfifo(FIFO_PATH.c_str(), 0666) < 0) {
perror("mkfifo");
return 1;
}
std::cout << "server: pipe created\n";
// 如果你想让肉眼看到管道文件确实被创建出来了,可以在这里 sleep 几秒:
// sleep(5);
// 然后在另一个终端 ls -l,你会看到 prw-rw-rw- 1 ... ./fifo
// 等 server 退出后 unlink 删掉,文件就消失了。
// 2. 以只读方式打开管道文件
// 注意:命名管道用 open() 打开,只返回一个 fd(读或写,你选)。
// 这和 pipe() 不一样------pipe() 一次返回两个 fd(读 + 写)。
// 头文件:open 需要 <fcntl.h>,read/write/close 需要 <unistd.h>
int rfd = open(FIFO_PATH.c_str(), O_RDONLY);
if (rfd < 0) {
perror("open for read");
return 2;
}
std::cout << "server: pipe opened for reading\n";
// 3. 循环读
char buffer[1024];
while (true) {
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
std::cout << "client says: " << buffer << std::endl;
}
else if (n == 0) {
// 写端关闭,读到文件结尾
std::cout << "server: client closed write end, exiting\n";
break;
}
else {
perror("read");
break;
}
}
// 4. 关闭并删除管道文件
close(rfd);
unlink(FIFO_PATH.c_str());
return 0;
}
客户端 client.cc------不创建不删除,打开管道文件,发送键盘输入:
cpp
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "common.hpp"
int main() {
// 1. 以只写方式打开(管道已被 server 创建好了)
int wfd = open(FIFO_PATH.c_str(), O_WRONLY);
if (wfd < 0) {
perror("open for write");
return 1;
}
// 2. 从键盘读,写入管道
std::string line;
while (true) {
std::cout << "> ";
std::getline(std::cin, line);
write(wfd, line.c_str(), line.size());
// 注意:不要写 '\0'。管道是字节流,不是 C 字符串。
// '\0' 是 C 语言在内存里的字符串终结约定,和管道传输无关。
// 接收方读到了数据自己加 '\0',发送方只发有效字节。
}
close(wfd);
return 0;
}
Makefile------一次编译出两个可执行程序:
makefile
all: server client
server: server.cc common.hpp
g++ -o server server.cc -std=c++11
client: client.cc common.hpp
g++ -o client client.cc -std=c++11
clean:
rm -f server client fifo
跑起来:
bash
# 终端1:先启动 server(创建管道,阻塞等待读)
$ ./server
server: pipe created
server: pipe opened for reading
# 终端2:启动 client,输入消息
$ ./client
> 你好
> 吃了吗
> bye
# 终端1 同步显示:
client says: 你好
client says: 吃了吗
client says: bye
# Ctrl+C 关掉 client → server 的 read 返回 0 → server 退出
server: client closed write end, exiting
ps ax 能看到 server 和 client 是两个完全独立的进程,父进程各不相同。没有 fork 关系,没有血缘------但通信照常。
所以到底发生了什么?
三件事。
第一 ,fork 拷贝的是整个文件描述符表,不只是你刚创建的那两个 fd。每多创建一个子进程,它就会多继承一堆历史上其他管道的写端。这些多余的 fd 让管道引用计数降不到零,子进程的 read 永远阻塞。
第二 ,修法很简单:每个子进程在 fork 之后,遍历父进程传下来的 channels(写时拷贝的副本),把历史上所有管道的写端全 close 掉。干净了,引用计数控制权回到父进程手里,关一个收一个,畅通无阻。
第三 ,命名管道的原理和匿名管道一模一样------内核缓冲区、先看到同一份资源。唯一不同的是:它靠文件路径让不相干的进程找到对方,而不是靠 fork 继承。mkfifo 创建的就是一个披着文件名的管道,数据不落盘,留在内存里当队列用。