
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇
前言
大家好,我是写技术博客的程序员。今天这篇文章,我们来啃 Linux 进程间通信(IPC)的第一块硬骨头 ------匿名管道 ,并基于匿名管道亲手搭建一个可运行的进程池。
但在写代码之前,我想先跟大家聊一个贯穿整个 Linux 内核设计的核心思想:「先描述,再组织」 。这句话不是一句空话,它体现在 task_struct 里,体现在文件系统里,也体现在我们今天要讲的管道和进程池里。理解了这句话,你看任何内核数据结构都会觉得 "原来是一个套路"。
本文只讲匿名管道,命名管道(FIFO)、共享内存、消息队列、信号量会放在后续文章中。文末会简单预告下一篇的内容。
本文目录:
-
- 「先描述,再组织」------Linux 管理一切资源的总钥匙
-
- 进程间通信(IPC)概述
-
- 管道是什么:Unix 最古老的 IPC
-
- 匿名管道深度解析:从 API 到内核本质
-
- 管道读写规则与四种场景动手验证
-
- 从单管道到多进程:进程池的设计思想
-
- 进程池完整代码实现(五文件工程,带详细注释)
-
- 运行效果与关键细节拆解
-
- 总结与下篇预告
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 大致分三大流派:
- 管道 :Unix 祖传 IPC,简单可靠
- 匿名管道(pipe):只能用于有亲缘关系的进程
- 命名管道(FIFO):可用于无亲缘关系的进程
- System V IPC :AT&T 搞的一套标准,内核持久化
- System V 共享内存
- System V 消息队列
- System V 信号量
- 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 匿名管道的五大特点
- 只能用于有亲缘关系的进程:父子、兄弟等,因为要靠继承文件描述符来拿到管道
- 半双工通信:数据只能单向流动;要双向就得开两条管道
- 流式服务:数据是字节流,没有消息边界,读多写少、读少写多都正常
- 生命周期随进程:进程退出,管道占用的文件描述符被关闭,所有引用都没了管道就释放
- 内核自带同步互斥:一定条件下读写会阻塞,保证数据不会乱套
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 再次体会「先描述,再组织」
到这里,我们可以把内核的设计思想落地了:
- 先描述一条信道 :定义
Channel类- 里面装什么?写端文件描述符、对应子进程的 PID、信道名字
- 提供什么方法?发任务 Send、关闭 Close、获取信息等
- 再组织一堆信道 :用
vector<Channel>管理- 一个 Channel 对应一个子进程
- 派发任务时遍历 vector,轮询发给各个 Worker
内核用 struct + 链表管理进程,我们用 class + vector 管理信道。思路一模一样。
5.4 进程池的核心流程
- 初始化:循环 N 次,每次创建一条管道 → fork 子进程 → 子进程关写端、读管道;父进程关读端、保存写端
- 派发任务:Master 生成任务编号,轮询发给各个 Worker
- 销毁回收: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 全文总结
这篇文章我们从「先描述,再组织」这个核心思想出发,走完了匿名管道从原理到实战的全过程:
- 先描述,再组织 是 Linux 内核管理一切资源的通用思路
- 进程:
task_struct描述 + 链表 / 哈希表组织 - 文件 / 管道:
file+inode描述 + 文件描述符表组织 - 我们的进程池:
Channel描述一条信道 +vector组织所有信道
- 进程:
- 匿名管道 是最简单的 IPC,靠 fork 继承文件描述符实现父子通信
- 三步走:创建管道 → fork → 各关一端
- 半双工、字节流、生命周期随进程、内核同步互斥
- 管道读写有四种典型场景 ,核心是阻塞 / 非阻塞、对端关闭两种维度的组合
- 读空阻塞、写满阻塞
- 写端全关 read 返回 0,读端全关 write 触发 SIGPIPE
- 进程池 是匿名管道的经典应用,Master-Worker 架构
- 一个 Master + N 个 Worker,一人一条管道
- 初始化、派发、清理三阶段
- 靠关闭写端优雅通知子进程退出
8.2 下篇预告:命名管道(FIFO)
匿名管道很好用,但有一个致命限制:只能用于有亲缘关系的进程。如果两个完全不相关的进程想通信怎么办?
这就要用到命名管道(FIFO)------ 它在文件系统里有一个真实的文件名,任何进程只要 open 这个文件就能通信。
下一篇文章我们会讲:
mkfifo命令和mkfifo函数- 命名管道的打开规则(阻塞 / 非阻塞)
- 用命名管道实现文件拷贝
- 用命名管道实现 server-client 双向通信
- 匿名管道 vs 命名管道:一张表彻底分清
感兴趣的同学可以点个关注,我们下篇见。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流。有任何问题也可以在评论区留言,我会一一回复。