云计算百科
云计算领域专业知识百科平台

【Linux 系统篇(三十一)】通信(一):从管道原理到进程池实战

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐
个人主页:huangjin007_
🔥
文章收录专栏:Linux 内功修炼手册(系统篇)

总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇


Linux 系统篇(三十一) —— 从管道原理到进程池实战

文章目录

  • Linux 系统篇(三十一) —— 从管道原理到进程池实战
    • 一、进程间通信:为什么进程之间需要通信?
      • 1.1 先思考一个问题:两个进程,怎么才能交换数据?
      • 1.2 进程间通信要解决哪些问题?
      • 1.3 Linux 进程间通信是怎样发展起来的?
    • 二、管道:能不能让一个进程写,另一个进程读?
      • 2.1 从一个实际问题开始
      • 2.2 什么是管道?
      • 2.3 为什么说管道是一种字节流?
      • 2.4 管道分为哪几种?
        • 匿名管道
        • 命名管道
    • 三、匿名管道:pipe() 到底做了什么?
      • 3.1 先认识 pipe() 函数
      • 3.2 为什么创建一个管道,需要返回两个文件描述符?
    • 四、fork() 与匿名管道:父子进程为什么能够通信?
      • 4.1 先创建管道,再创建子进程
      • 4.2 父子进程通信时,为什么要关闭不使用的端?
      • 4.3 一个完整的父子进程管道程序
      • 4.4 父子进程为什么能够通过管道通信?
      • 4.5 为什么父进程能够读到 EOF?
      • 4.6 EOF 和 `waitpid()` 有什么区别?
    • 五、从文件描述符的角度理解管道
      • 5.1 文件描述符不等于真正的数据对象
      • 5.2 fork() 后,父子进程如何共享管道?
      • 5.3 为什么关闭一个文件描述符,不会影响另一个进程?
      • 5.4 文件描述符表和管道缓冲区有什么区别?
    • 六、从内核的角度理解管道
      • 6.1 管道中的数据存放在哪里?
    • 七、管道的读写规则
      • 7.1 管道为空时,read() 会怎样?
      • 7.2 管道写满时,write() 会怎样?
      • 7.3 所有写端关闭后,为什么 read() 会返回 0?
    • 八、管道的主要特点
      • 8.1 匿名管道通常用于相关进程之间通信
      • 8.2 管道提供字节流服务
      • 8.3 管道的生命周期与引用有关
      • 8.4 管道由内核协调读写
    • 九、用四种典型情况验证管道规则
      • 9.1 情况一:读端正常,写端正常
      • 9.2 情况二:写端正常,但管道为空
      • 9.3 情况三:写端关闭,读端正常
      • 9.4 情况四:读端关闭,写端正常
    • 十、从管道到进程池:为什么需要多个子进程?
      • 10.1 什么是进程池?
      • 10.2 为什么进程池需要管道?
    • 十一、任务管理器:如何根据编号执行任务?
    • 十二、Channel:管理父进程与子进程的通信
    • 十三、ChannelManager:如何轮流选择子进程?
    • 十四、ProcessPool:把多个组件组织起来
      • 14.1 Start():创建管道和子进程
      • 14.2 为什么子进程必须关闭继承来的旧写端?
      • 14.3 Work():子进程如何执行任务?
      • 14.4 Run():父进程如何派发任务?
      • 14.5 Stop():如何让子进程正常退出?
    • 结语:

一、进程间通信:为什么进程之间需要通信?

1.1 先思考一个问题:两个进程,怎么才能交换数据?

  一个程序运行起来,就会形成一个进程。每个进程都有自己的地址空间、代码、数据以及相应的运行状态。操作系统通过进程管理机制,让多个进程能够并发执行。

  例如,我们在 Linux 中打开两个终端,分别运行两个程序:

  • 进程 A:负责读取用户输入的数据。
  • 进程 B:负责处理数据,并将处理结果输出到屏幕上。

  现在问题来了:进程 A 读取到的数据,应该怎么交给进程 B?

  在单个进程内部,我们可以通过变量、数组、指针等方式传递数据。但两个独立进程之间,事情就没有这么简单了。

  原因在于,每个进程通常拥有独立的虚拟地址空间。

  假设进程 A 中存在一个变量:

int number = 100;

  进程 A 可以通过自己的地址空间访问这个变量,但进程 B 不能直接使用进程 A 中的普通指针,来访问同一个变量。

  因为这个地址属于进程 A 的虚拟地址空间。即使进程 B 中存在数值相同的虚拟地址,也不代表它们指向同一块物理内存。

  因此,我们需要一种机制,让两个进程能够按照一定的规则交换数据。

  这种机制,就是进程间通信(IPC,Inter-Process Communication)。

1.2 进程间通信要解决哪些问题?

  我们可以将其归纳为以下四类。

数据传输。最直接的需求:一个进程把数据交给另一个进程。比如 ps aux | grep nginx,ps 的输出要传给 grep。

资源共享。多个进程需要操作同一份数据,比如多个 worker 进程读取同一份配置,或者共享一块缓存。

通知事件。一个进程需要告诉另一个进程“某件事发生了”。最典型的是父进程需要知道子进程什么时候终止,才能去回收它,避免僵尸进程。

进程控制。有时候一个进程需要完全控制另一个进程的执行,比如调试器需要拦截被调试进程的信号、查看它的寄存器状态、单步执行。ptrace 就是这类需求的实现。

  这四类需求,对应的 IPC 机制各有侧重。管道擅长的是“数据传输”和一定程度的“通知事件”;共享内存擅长“资源共享”;信号擅长“通知事件”;而 socket 和信号量则各自覆盖更专门的场景。

1.3 Linux 进程间通信是怎样发展起来的?

  Linux 的 IPC 机制不是一口气设计出来的,而是经历了几个阶段的堆积。

第一代:管道和信号。Unix 最早期的 IPC 手段,简单粗暴,管道用于字节流传输,信号用于异步事件通知。这一代机制的问题也很明显:管道只能用于有亲缘关系的进程(匿名管道),信号能携带的信息几乎为零。

第二代:System V IPC。AT&T 在 System V Unix 中对早期 IPC 做了系统性的改进和扩充,形成了 System V 消息队列、System V 共享内存、System V 信号量这一套。它们在单个计算机内工作,接口风格统一,但生命周期管理和权限模型经常被吐槽——创建一个 IPC 对象后如果进程异常退出,对象可能残留在内核里。

第三代:POSIX IPC。POSIX 在 System V 的基础上重新设计了一套接口,包括 mq_open、shm_open、sem_open 等,设计上更接近文件操作,生命周期也更可控。Linux 从一开始就遵循 POSIX 标准,所以现代 Linux 上 POSIX IPC 是更推荐的选择。

  把这张分类图放在脑子里:

世代代表机制核心特点
早期 Unix 匿名管道、命名管道、信号 简单,但能力有限
System V 消息队列、共享内存、信号量 功能强,但生命周期粗糙
POSIX mq_*、shm_*、sem_*、互斥量、条件变量、读写锁 接口更干净,生命周期更可控

  本篇博客聚焦在管道上,但你会发现,管道的设计思想——“一切皆文件”——贯穿了整个 Linux IPC 体系。


二、管道:能不能让一个进程写,另一个进程读?

2.1 从一个实际问题开始

  假设现在有两个进程:

  • 进程 A 负责生产数据。
  • 进程 B 负责消费数据。

  我们希望进程 A 产生数据之后,进程 B 能够接收到这些数据。

  最直接的想法可能是:进程 A 把数据写入一个变量,进程 B 再从这个变量中读取。

  但是,普通变量位于进程各自的地址空间中。两个互相独立的进程,并不能直接通过同一个普通变量完成通信。

  那我们换一种思路。

  能不能在内核中准备一块用于暂存数据的区域?

  进程 A 将数据写进去,进程 B 再从里面读出来。

  这样,两个进程就不需要直接访问对方的普通变量,只需要按照统一的规则操作这块内核维护的区域。

  管道采用的就是类似的思路。

2.2 什么是管道?

  管道是 Unix 中最古老的进程间通信形式之一。

  我们可以将管道理解成一个由内核维护的、用于在进程之间传递字节流的通道。

  它有两个基本操作:

  • 写端:向管道中写入数据。
  • 读端:从管道中读取数据。

在这里插入图片描述

  进程 A 不需要知道进程 B 的内部实现,只需要把数据写入管道。

  进程 B 也不需要知道数据最初是怎么产生的,只需要从管道中读取数据。

  内核负责维护管道中的数据以及相应的读写状态。

2.3 为什么说管道是一种字节流?

  这是学习管道时非常重要的一个知识点。

  假设写端连续写入两次数据:

write(wfd, "hello", 5);
write(wfd, "world", 5);

  写端进行了两次 write() 调用,每次写入 5 个字节。

  读端可能一次读取 10 个字节,得到:

helloworld

  也可能先读取 5 个字节,得到:

hello

  然后再读取另外 5 个字节,得到:

world

  对于一般的管道读写,不能认为一次 write() 就必然对应一次 read()。

  例如,写端写入两个逻辑消息:

消息 A
消息 B

  读端可能一次就把两条消息的数据全部读出来,也可能只读到第一条消息的一部分。

  管道只负责按照字节顺序传递数据,并不理解这些字节在业务上代表什么。

  因此,管道是面向字节流的通信机制,而不是天然保留消息边界的通信机制。

2.4 管道分为哪几种?

  Linux 中最常见的管道有两种。

匿名管道

  匿名管道通常通过 pipe() 创建。

  它没有一个供其他进程通过文件路径直接打开的名称,一般由一个进程创建,然后通过 fork() 等方式将管道的文件描述符交给相关进程使用。

  例如,父进程创建管道,再创建子进程,父子进程就可以通过这个管道进行通信。

命名管道

  命名管道也叫 FIFO,可以通过文件系统中的路径找到。

  例如,可以使用 mkfifo() 创建一个命名管道:

mkfifo myfifo

  这样,两个没有亲缘关系的进程,也可以通过约定好的 FIFO 路径打开它并进行通信。

  本文先研究匿名管道。


三、匿名管道:pipe() 到底做了什么?

3.1 先认识 pipe() 函数

  Linux 为我们提供了 pipe() 系统调用,可以创建一个匿名管道。

头文件:

#include <unistd.h>

函数原型:

int pipe(int fd[2]);

  参数 fd 是一个包含两个整数的数组,用来接收创建成功后的两个文件描述符。

调用成功时:

  • fd[0] 表示管道读端。
  • fd[1] 表示管道写端。

返回值:

  • 成功返回 0。
  • 失败返回 -1,并设置 errno,用于说明失败原因。

例如:

int fds[2];

if (pipe(fds) == –1)
{
perror("pipe");
return 1;
}

  如果创建成功,fds 中就会保存两个文件描述符。

3.2 为什么创建一个管道,需要返回两个文件描述符?

  先回忆一下文件描述符的概念。

  在 Linux 中,文件描述符是一个非负整数,用于标识当前进程打开的文件或其他 I/O 对象。

例如:

int fd = open("test.txt", O_RDONLY);

  open() 成功后会返回一个文件描述符,后续可以通过 read(fd, …) 从文件中读取数据。

  管道也遵循类似的使用方式。

  不过,管道有两个不同的操作方向:一个用于读取,一个用于写入。

  因此,pipe() 创建成功后会提供两个文件描述符:

fds[0] // 读端
fds[1] // 写端

例如:

write(fds[1], "hello", 5);

  这行代码把 5 个字节写入管道。

随后:

char buffer[10];

ssize_t n = read(fds[0], buffer, sizeof(buffer));

  这行代码从管道中读取数据。

  可以发现,管道的使用方式与文件非常相似。

  普通文件可以通过 open() 打开,再通过 read() 和 write() 操作;匿名管道则通过 pipe() 获取读写端,再通过 read() 和 write() 操作。


四、fork() 与匿名管道:父子进程为什么能够通信?

4.1 先创建管道,再创建子进程

  前面已经知道,pipe() 可以创建管道。

  但问题来了:如果管道最初是由父进程创建的,子进程又怎么能够访问它?

答案是:fork()。

  在 Linux 中,父进程调用 fork() 创建子进程时,子进程会获得父进程文件描述符表中相应描述符的副本。

  这意味着,如果父进程在调用 fork() 之前已经创建了管道,那么子进程就可以继承管道读端和写端的文件描述符。

  因此,父子进程能够通过同一个管道对象进行通信。

  基本流程如下:

在这里插入图片描述

  需要注意的是,这里的“副本”并不是重新创建了一条完全独立的管道。

  父子进程拥有各自的文件描述符表,但对应的描述符引用着同一个底层管道对象。

  因此,父进程写入的数据,子进程可以读取;子进程写入的数据,父进程也可以读取。

  不过,如果父子进程都保留读写两端,就容易出现问题。例如,读端等待数据时,内核可能发现仍然有写端处于打开状态,从而无法判断写入方已经全部退出。

  因此,建立父子进程通信后,通常需要关闭双方不使用的文件描述符。

4.2 父子进程通信时,为什么要关闭不使用的端?

  假设我们希望实现这样的通信关系:

  • 子进程负责写入数据。
  • 父进程负责读取数据。

  那么,子进程只需要写端,父进程只需要读端。

  正确的做法是:

  子进程关闭读端:

close(fds[0]);

  父进程关闭写端:

close(fds[1]);

  为什么一定要关闭不使用的端?

  因为管道是否还有读者、是否还有写者,取决于内核对相关打开引用的管理,而不是取决于我们主观上认为哪个进程负责读、哪个进程负责写。

  例如,子进程虽然不打算读取管道,但如果它仍然保留读端,那么父进程关闭自己的读端之后,内核仍然可能认为管道还有读端存在。

  这会影响写端对“已经没有读者”的判断。

  同样,如果父进程不关闭写端,即使子进程已经退出,父进程也可能无法及时从管道读取到 EOF。

4.3 一个完整的父子进程管道程序

  下面通过一个简单的程序,演示父子进程如何利用匿名管道通信,以及父进程如何通过 EOF 判断写端已经全部关闭。

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
int fds[2];

if (pipe(fds) == –1)
{
perror("pipe");
return 1;
}

pid_t id = fork();

if (id == –1)
{
perror("fork");
close(fds[0]);
close(fds[1]);
return 1;
}

if (id == 0)
{
// 子进程负责写入
close(fds[0]);

const char* msg = "Hello, father!";
write(fds[1], msg, strlen(msg));

close(fds[1]);
_exit(0);
}

// 父进程负责读取
close(fds[1]);

char buffer[1024];
ssize_t n;

while ((n = read(fds[0], buffer, sizeof(buffer) – 1)) > 0)
{
buffer[n] = '\\0';
std::cout << "child say: " << buffer << std::endl;
}

if (n == –1)
perror("read");
else
std::cout << "管道读到 EOF,写端已经全部关闭" << std::endl;

close(fds[0]);
waitpid(id, nullptr, 0);

return 0;
}

4.4 父子进程为什么能够通过管道通信?

  整个过程可以概括为:

  • 父进程调用 pipe(fds),创建匿名管道,获得读端 fds[0] 和写端 fds[1]。
  • 父进程调用 fork(),子进程继承这两个文件描述符,因此父子进程能够访问同一个内核管道。
  • 子进程关闭读端,父进程关闭写端,明确各自的通信方向。
  • 子进程调用 write() 将数据写入内核管道缓冲区,父进程调用 read() 读取数据。
  •   管道通信的关键,是父子进程通过文件描述符访问同一个内核管道对象,而不是直接访问对方的内存。

    4.5 为什么父进程能够读到 EOF?

      read() 的返回值主要有三种情况:

    返回值含义
    大于 0 成功读取到数据
    等于 0 管道中的数据已经读完,并且所有写端都已关闭
    等于 -1 读取发生错误

      需要注意,子进程退出不代表父进程立即读到 EOF。如果管道中还有数据,父进程需要先将数据读完;如果还有其他进程持有写端,父进程也可能继续等待。

      因此,及时关闭不需要的文件描述符非常重要。

    4.6 EOF 和 waitpid() 有什么区别?

      二者解决的是不同的问题:

    • EOF:表示管道中的数据已经读完,并且所有写端都已关闭。
    • waitpid():等待子进程,并在适当情况下回收其退出资源。

      所以,父进程既要正确关闭文件描述符,也要使用 waitpid() 回收子进程。


    五、从文件描述符的角度理解管道

      前面我们知道,pipe() 创建管道,fork() 让子进程继承父进程的文件描述符。那么,为什么父子进程能够通过这些文件描述符访问同一个管道?

    5.1 文件描述符不等于真正的数据对象

      在 Linux 中,文件描述符可以理解为进程访问内核对象的入口编号。

    例如:

    int fd = open("test.txt", O_RDONLY);

      假设返回的文件描述符是 3。当我们调用 read(3, buffer, sizeof(buffer)) 时,内核会通过当前进程的文件描述符表找到对应的打开文件对象,再执行读取操作。

      因此,文件描述符只是一个编号,并不是文件内容或管道本身。

    5.2 fork() 后,父子进程如何共享管道?

      假设父进程调用 pipe(fds) 后得到:

    fds[0] = 3 // 读端
    fds[1] = 4 // 写端

      调用 fork() 后,子进程会继承这些文件描述符。可以简化理解为:

    在这里插入图片描述

      这里需要注意,父子进程拥有各自的文件描述符表,但相应的描述符引用着继承而来的底层打开对象,最终访问同一个内核管道。

      因此,子进程调用 write() 写入数据后,父进程就可以通过 read() 读取数据。

    5.3 为什么关闭一个文件描述符,不会影响另一个进程?

      例如,父进程执行:

    close(fds[1]);

      关闭的只是父进程自己的文件描述符,不会自动关闭子进程对应的描述符。

      如果子进程仍然持有写端,管道就仍然存在写端引用。只有所有写端都关闭,并且管道中没有剩余数据时,读端的 read() 才会返回 0。

      因此,判断管道是否还有写入方,不能只看某个进程是否退出,还要看是否有其他进程保留着写端。

    5.4 文件描述符表和管道缓冲区有什么区别?

    概念作用
    文件描述符 当前进程访问内核对象的入口编号
    文件描述符表 记录当前进程持有的文件描述符
    管道对象 管理管道的读写状态及相关内核数据
    管道缓冲区 暂存已经写入、但尚未读取的数据

    六、从内核的角度理解管道

    6.1 管道中的数据存放在哪里?

      假设子进程执行:

    write(wfd, "hello", 5);

      这 5 个字节不会直接写进父进程的普通变量,而是由内核管道机制接收,并暂存在内核维护的管道缓冲区中。

      随后,父进程执行:

    read(rfd, buffer, sizeof(buffer));

      内核再将管道中的数据复制到父进程提供的缓冲区。

      整个过程可以概括为:

    在这里插入图片描述

      父子进程拥有各自独立的虚拟地址空间,但可以通过系统调用,让内核帮助它们传递数据。

      管道通信的关键,是不同进程通过内核提供的共同通信对象交换数据,而不是直接访问对方的普通内存。


    七、管道的读写规则

    7.1 管道为空时,read() 会怎样?

      默认情况下,如果管道中没有数据,但仍有写端打开,阻塞模式下的 read() 会等待数据到来。

      如果将读端设置为非阻塞模式,那么在当前没有数据可读时,read() 通常返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

      但是,如果所有写端已经关闭,并且管道中的数据也已经读完,那么 read() 会返回 0,表示 EOF。

    7.2 管道写满时,write() 会怎样?

      管道缓冲区容量有限。如果写入方不断写入数据,却没有进程读取,缓冲区就可能被写满。

    • 阻塞模式: 空间不足时,write() 可能等待,直到读端释放空间。
    • 非阻塞模式: 如果当前无法写入,write() 通常返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。

      对于较大的写入请求,还可能出现只写入部分数据的情况,因此实际编程时需要检查返回值。

    7.3 所有写端关闭后,为什么 read() 会返回 0?

      假设子进程执行:

    write(wfd, "hello", 5);
    close(wfd);

      如果父进程读取时,管道中仍然有数据,就会先读取这些数据。等数据全部读完,并且所有写端都关闭后,后续的 read() 才会返回 0。

      因此,EOF 并不意味着某个子进程刚刚退出,而是表示管道中的数据已经读完,且不再有任何写端能够写入数据。

      如果其他进程仍然持有写端,即使最初写入数据的子进程已经退出,父进程也可能继续等待。


    八、管道的主要特点

    8.1 匿名管道通常用于相关进程之间通信

      匿名管道没有可以让任意进程直接通过文件系统路径打开的名称,因此最常见的方式是由父进程创建管道,再通过 fork() 让子进程继承文件描述符。

      不过,如果文件描述符通过其他方式传递给无亲缘关系的进程,匿名管道也可以用于它们之间的通信。

    8.2 管道提供字节流服务

      管道只负责传递字节,不会自动区分每次 write() 对应哪次 read()。如果需要传递一条条完整消息,通信双方必须约定数据格式。

    8.3 管道的生命周期与引用有关

      某个进程退出,并不意味着管道一定立即释放。只要仍有相关文件描述符保持打开,管道对象就可能继续存在。相关引用全部释放后,内核才能回收相应资源。

    8.4 管道由内核协调读写

      内核负责管理管道缓冲区,并根据读写状态处理阻塞和唤醒等操作。但管道并不能自动解决所有业务层面的同步问题,复杂场景仍然可能需要额外的同步机制。


    九、用四种典型情况验证管道规则

      理论讲完之后,我们可以回到实际代码中,验证前面的规则。

      这里重点考虑四种情况:

  • 读端正常,写端正常。
  • 写端正常,但读端暂时没有数据。
  • 写端关闭,读端继续读取。
  • 读端关闭,写端继续写入。
  • 9.1 情况一:读端正常,写端正常

      这是最普通的情况。

      写端执行:

    write(wfd, "hello", 5);

      读端执行:

    char buffer[100];

    ssize_t n = read(rfd, buffer, sizeof(buffer));

      只要数据成功写入,读端就可以读取相应的数据。

      不过,如果写端暂时没有数据可写,而读端使用阻塞模式调用 read(),读端就可能等待。

      因此,正常通信不仅要求管道存在,还需要写入方和读取方按照合理的流程协作。

    9.2 情况二:写端正常,但管道为空

      假设写端仍然打开,但是暂时没有向管道写入数据。

      此时读端调用:

    read(rfd, buffer, sizeof(buffer));

      如果是阻塞模式,读取操作会等待。

      如果是非阻塞模式,则通常返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

      这说明管道为空和管道已经结束是两种不同状态。

    • 管道为空,但仍有写端:后面还可能有数据。
    • 管道为空,所有写端关闭:已经到达 EOF。

    9.3 情况三:写端关闭,读端正常

      假设写端先写入:

    write(wfd, "hello", 5);

      然后执行:

    close(wfd);

      读端第一次读取时,仍然可能得到:

    hello

      数据被读完后,读端再次读取,就会得到:

    read(rfd, buffer, sizeof(buffer)) == 0

      这就是 EOF。

      所以,写端关闭不意味着已经写入的数据会被直接丢弃。

      读端仍然可以把缓冲区中的剩余数据读取完毕。

    9.4 情况四:读端关闭,写端正常

      假设所有读端都关闭了,但写端仍然尝试写入:

    write(wfd, "hello", 5);

      此时会产生 SIGPIPE 信号。

      如果采用默认处理方式,进程通常会终止。

      如果程序忽略或处理了 SIGPIPE,就可以通过 write() 的返回值检查错误。常见结果是返回 -1,并设置 errno 为 EPIPE。

      因此,写端在设计时不能假设读端永远存在。

      如果读端可能提前退出,写端就应该考虑如何处理这种情况,例如记录错误、停止发送数据、清理资源等。


    十、从管道到进程池:为什么需要多个子进程?

      前面我们已经学会了使用管道进行父子进程通信。但如果程序需要执行大量任务,例如打印日志、下载文件、上传数据,只依靠一个进程处理,就可能无法充分利用多进程并发执行的能力。

      这时,可以创建多个子进程,让父进程负责派发任务,子进程负责执行任务。这就是进程池的基本思想。

    10.1 什么是进程池?

      进程池就是提前创建一组工作进程,并让它们反复执行任务。

      它主要包含三个部分:

    • 父进程: 管理子进程,负责派发任务。
    • 子进程: 接收任务并执行。
    • 通信通道: 负责在父子进程之间传递任务信息。

      为什么不每次有任务时才创建子进程?

      因为创建和销毁进程需要一定的系统开销。对于任务较多的场景,提前创建并复用工作进程,可以减少重复创建进程的成本。

    10.2 为什么进程池需要管道?

      假设父进程创建了 3 个子进程:

    父进程
    / | \\
    管道1 管道2 管道3
    | | |
    子进程1 子进程2 子进程3

      父进程生成一个任务编号,例如 1,就可以通过对应管道将编号发送给子进程。

      子进程读取编号后,再执行相应任务。

      这里发送的是任务编号,而不是整个任务函数。父子进程通过约定好的编号对应关系,就能知道应该执行什么任务。

      为每个子进程建立独立管道,也方便父进程明确指定任务的接收者。


    十一、任务管理器:如何根据编号执行任务?

      进程池可以将任务函数统一保存起来,通过编号决定执行哪个任务。

    Task.hpp文件

    #pragma once

    #include<iostream>
    #include<vector>
    #include<ctime>

    typedef void (*task_t)();

    void PrintLog()
    {
    std::cout << "我是一个打印的任务…" << std::endl;
    }

    void DownLoad()
    {
    std::cout << "我是一个下载的任务…" << std::endl;
    }

    void UpLoad()
    {
    std::cout << "我是一个上传的任务…" << std::endl;
    }

    class TaskManager
    {
    public:
    TaskManager()
    {
    srand(time(nullptr));
    }

    void Register(task_t t)
    {
    _task.push_back(t);
    }

    int Code()
    {
    if(_task.empty())
    return –1;

    return rand() % _task.size();
    }

    void Execute(int code)
    {
    if(code >= 0 && code < _task.size())
    {
    _task[code]();
    }
    }

    private:
    std::vector<task_t> _task;
    };

      这里最重要的是三个函数:

    • Register():将任务函数保存到 vector 中。
    • Code():随机生成一个有效的任务编号。
    • Execute():根据编号调用对应的函数。

      例如,注册三个任务后,可以建立这样的对应关系:

    编号 0 → PrintLog
    编号 1 → DownLoad
    编号 2 → UpLoad

      父进程只需要向子进程发送编号 1,子进程就能执行 DownLoad()。

      为什么子进程也能执行这些函数?因为 fork() 后,子进程会继承父进程当时的进程内存状态,其中包括已经注册的任务列表。但父子进程的普通内存是相互独立的,后续修改不会自动同步。


    十二、Channel:管理父进程与子进程的通信

      每个子进程都对应一个通信通道。父进程需要记录管道写端和子进程 PID,因此可以使用 Channel 类统一管理。

    class Channel
    {
    public:
    Channel(int fd, pid_t id)
    : _wfd(fd), _subid(id)
    {
    _name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(_subid);
    }

    ~Channel() = default;

    void Send(int code)
    {
    size_t n = write(_wfd, &code, sizeof(code));
    (void)n; // 暂时不使用变量n的值
    }

    void Close()
    {
    if (_wfd > 0)
    {
    close(_wfd);
    _wfd = –1;
    }
    }

    void Wait()
    {
    pid_t rid = waitpid(_subid, nullptr, 0);
    (void)rid;
    }

    int FD() const { return _wfd; }
    pid_t SubId() const { return _subid; }
    const std::string &Name() const { return _name; }

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

    这里需要理解三个函数:

    • Send():将任务编号写入管道。
    • Close():关闭父进程持有的管道写端。
    • Wait():等待并回收对应的子进程。

      注意,关闭管道和回收子进程是两件不同的事。关闭写端可以让子进程在读完剩余数据后读到 EOF;waitpid() 则负责等待并回收子进程。

      实际工程中,Send() 还应检查 write() 的返回值,并处理写入失败等情况。


    十三、ChannelManager:如何轮流选择子进程?

      当进程池中有多个子进程时,父进程需要知道下一次应该向谁派发任务。

      可以使用 vector 保存所有通道,再通过轮询的方式依次选择:

    class ChannelManager
    {
    public:
    ChannelManager()
    : _next(0)
    {
    }

    ~ChannelManager() = default;

    void Insert(int wfd, pid_t subid)
    {
    _channels.emplace_back(wfd, subid);
    }

    Channel *Select()
    {
    if(_channels.empty())
    return nullptr;

    Channel *c = &_channels[_next];
    _next++;
    _next %= _channels.size();
    return c;
    }

    void PrintProcess()
    {
    for (const auto &channel : _channels)
    {
    std::cout << channel.Name() << std::endl;
    }
    }

    void StopSubProcess()
    {
    for (auto &channel : _channels)
    {
    channel.Close();
    std::cout << "关闭: " << channel.Name() << std::endl;
    }
    }

    void CloseInheritedWFds()
    {
    for (auto &channel : _channels)
    {
    channel.Close();
    }
    }

    void WaitSubProcess()
    {
    for (auto &channel : _channels)
    {
    channel.Wait();
    std::cout << "回收:" << channel.Name() << std::endl;
    }
    }

    private:
    std::vector<Channel> _channels;
    int _next;
    };

      假设有 3 个子进程,选择顺序就是:

    在这里插入图片描述

      这种方式叫作轮询(Round Robin)。

      它的优点是实现简单,能够依次向各个子进程分配任务。不过,它不会判断哪个子进程已经空闲,也不会考虑任务执行时间是否相同,因此属于基础的调度策略。


    十四、ProcessPool:把多个组件组织起来

      进程池的核心工作,就是创建子进程、建立管道、派发任务,并在结束时清理资源。

    14.1 Start():创建管道和子进程

      核心逻辑可以简化为:

    for (int i = 0; i < process_num; i++)
    {
    int pipefd[2];

    if (pipe(pipefd) == –1)
    return false;

    pid_t id = fork();

    if (id == 0)
    {
    close(pipefd[1]); // 子进程关闭当前写端
    Work(pipefd[0]); // 从读端接收任务
    close(pipefd[0]);
    _exit(0);
    }
    else if (id > 0)
    {
    close(pipefd[0]); // 父进程关闭当前读端
    manager.Insert(pipefd[1], id);
    }
    else
    {
    close(pipefd[0]);
    close(pipefd[1]);
    return false;
    }
    }

    这段代码展示了最核心的结构:

    • 父进程保留管道写端,用来发送任务编号。
    • 子进程保留管道读端,用来接收任务编号。
    • 每次循环创建一个子进程和一条独立管道。

      完整工程代码还需要在创建失败时清理之前创建的子进程和通道,不能只处理当前这条管道。

    14.2 为什么子进程必须关闭继承来的旧写端?

      假设父进程先创建子进程 1,再创建子进程 2。由于 fork() 会继承文件描述符,子进程 2 可能也持有管道 1 的写端。

      如果子进程 2 不关闭这个无关的写端,那么即使父进程和子进程 1 都关闭了管道 1 的写端,内核仍然认为管道 1 存在写端引用。

      结果就是:子进程 1 可能一直读不到 EOF,无法正常退出。

      因此,实际代码中的 CloseInheritedWFds() 就是用来关闭子进程继承来的、自己不需要的旧写端。

      原则:每个进程都应该及时关闭自己不使用的管道端,否则可能导致 EOF 无法出现,甚至让程序一直阻塞。

    14.3 Work():子进程如何执行任务?

      子进程的工作循环可以概括为:

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

    if (n == 0)
    break; // 所有写端关闭,且数据已经读完

    if (n == sizeof(code))
    task_manager.Execute(code);
    else
    break; // 简化示例,实际代码需要完善错误处理
    }
    }

      子进程不断读取任务编号,再调用 Execute() 执行对应任务。

      这里要注意,管道是字节流。真实工程代码还需要妥善处理短读、系统调用被信号中断等情况,不能直接把所有非完整读取都当作正常结束。

    14.4 Run():父进程如何派发任务?

      父进程的任务派发流程可以概括为:

    int code = task_manager.Code();

    Channel* c = channel_manager.Select();

    if (code >= 0 && c != nullptr)
    c->Send(code);

    整个过程就是:

  • 生成任务编号。
  • 轮询选择一个子进程。
  • 通过对应管道发送任务编号。
  • 子进程读取编号并执行任务。
  •   这里的 Run() 只负责派发任务,不代表任务已经执行完成。如果需要知道任务是否完成,还需要额外设计反馈机制。

    14.5 Stop():如何让子进程正常退出?

      进程池结束时,通常先关闭所有父进程持有的管道写端,再等待并回收子进程:

    void Stop()
    {
    manager.StopSubProcess(); // 关闭所有写端
    manager.WaitSubProcess(); // 等待并回收子进程
    }

      为什么不能直接先等待?

      因为子进程可能还阻塞在 read() 中。如果父进程没有关闭写端,子进程可能一直等不到数据或 EOF,父进程也就无法顺利等到它退出。

      所以,停止进程池的关键顺序是:

      关闭写端 → 子进程读到 EOF 并退出 → 父进程通过 waitpid() 回收子进程。

    ProcessPool 代码:

    class ProcessPool
    {
    public:
    ProcessPool(int num)
    : _process_name(num)
    {
    _tm.Register(PrintLog);
    _tm.Register(DownLoad);
    _tm.Register(UpLoad);
    }

    ~ProcessPool() = default;

    void Work(int rfd)
    {
    while (true)
    {
    int code = 0;
    size_t n = read(rfd, &code, sizeof(code));
    if (n > 0)
    {
    if (n < sizeof(code))
    continue;
    std::cout << "子进程[" << getpid() << "]执行任务:" << code << std::endl;
    _tm.Execute(code);
    }
    else if (n == 0)
    {
    std::cout << "子进程退出!" << std::endl;
    break;
    }
    else
    {
    std::cout << "读取错误!" << std::endl;
    break;
    }
    }
    }

    bool Start()
    {
    for (int i = 0; i < _process_name; i++)
    {
    int pipefd[2] = {0};
    pid_t n = pipe(pipefd);
    if (n < 0)
    {
    _cm.StopSubProcess(); // 关闭父进程保存的管道写端
    _cm.WaitSubProcess(); // 调用waitpid(),等待并回收子进程
    return false;
    }

    pid_t subid = fork();
    if (subid < 0)
    {
    // 清理当前创建的子进程
    close(pipefd[1]);
    close(pipefd[0]);

    // 关闭之前保存的所有父进程的写端
    _cm.StopSubProcess();

    // 等待并回收之前创建的子进程
    _cm.WaitSubProcess();

    return false;
    }
    else if (subid == 0)
    {
    // 子进程

    // 关闭从父进程继承过来的旧管道写端
    _cm.CloseInheritedWFds();

    close(pipefd[1]);
    Work(pipefd[0]);
    close(pipefd[0]);

    _exit(0);
    }
    else
    {
    close(pipefd[0]);
    _cm.Insert(pipefd[1], subid);
    }
    }
    return true;
    }

    void Debug()
    {
    _cm.PrintProcess();
    }

    void Run()
    {
    int taskcode = _tm.Code();
    if(taskcode < 0)
    {
    std::cerr << "没有可用的任务,无法派发" << std::endl;
    return;
    }

    Channel* c = _cm.Select();
    if (c == nullptr)
    {
    std::cerr << "没有可用的子进程,无法派发任务" << std::endl;
    return;
    }
    std::cout << "选择了一个子进程:" << c->Name() << std::endl;

    c->Send(taskcode);
    std::cout << "发送了一个任务码:" << taskcode << std::endl;
    }

    void Stop()
    {
    _cm.StopSubProcess();
    _cm.WaitSubProcess();
    }

    private:
    ChannelManager _cm;
    int _process_name;
    TaskManager _tm;
    };

      至此,进程池就将 pipe()、fork()、read()、write()、close() 和 waitpid() 这些知识点串联了起来。


    结语:

      今天的内容到这里就结束了,希望你能有所收获~

    干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux 系统篇(三十一)】通信(一):从管道原理到进程池实战
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!