手写进程池:基于匿名管道的 Master-Slave 架构实战

文章目录

  • [1. 引言:为什么需要进程间通信 (IPC)](#1. 引言:为什么需要进程间通信 (IPC))
    • [1.1 进程的独立性与协同工作的矛盾](#1.1 进程的独立性与协同工作的矛盾)
    • [1.2 IPC 的核心目的:数据传输、资源共享与进程控制](#1.2 IPC 的核心目的:数据传输、资源共享与进程控制)
  • [2. 核心原理:管道的本质](#2. 核心原理:管道的本质)
    • [2.1 管道不是磁盘文件:内核中的内存级缓冲区](#2.1 管道不是磁盘文件:内核中的内存级缓冲区)
    • [2.2 实现机制:`pipe()` 系统调用与文件描述符](#2.2 实现机制:pipe() 系统调用与文件描述符)
    • [2.3 通信基础:通过 `fork()` 继承文件描述符表](#2.3 通信基础:通过 fork() 继承文件描述符表)
    • [2.4 生命周期管理:随进程而生,由引用计数回收](#2.4 生命周期管理:随进程而生,由引用计数回收)
  • 3.基础实战:匿名管道的读写
    • [3.1 通信模型:单向通信(单工)与读写端约定](#3.1 通信模型:单向通信(单工)与读写端约定)
    • [3.2 代码演示:`pipe`、`fork`、`read`、`write` 的基本流程](#3.2 代码演示:pipeforkreadwrite 的基本流程)
    • [3.3 特性分析:面向字节流(读写次数不对等)](#3.3 特性分析:面向字节流(读写次数不对等))
  • [4. 深入理解:管道的四大特性与四种场景](#4. 深入理解:管道的四大特性与四种场景)
    • [4.1 特性一:自带同步与互斥机制](#4.1 特性一:自带同步与互斥机制)
      • [4.1.1 互斥:保证写入的原子性](#4.1.1 互斥:保证写入的原子性)
      • [4.1.2 同步:管道空则读阻塞,管道满则写阻塞](#4.1.2 同步:管道空则读阻塞,管道满则写阻塞)
    • [4.2 四种特殊情况(边界测试)](#4.2 四种特殊情况(边界测试))
      • [4.2.1 读端关闭,写端继续写 → `SIGPIPE` 信号](#4.2.1 读端关闭,写端继续写 → SIGPIPE 信号)
      • [4.2.2 写端关闭,读端继续读 → `read` 返回 0 / EOF](#4.2.2 写端关闭,读端继续读 → read 返回 0 / EOF)
      • [4.2.3 管道满:写端阻塞](#4.2.3 管道满:写端阻塞)
      • [4.2.4 管道空:读端阻塞](#4.2.4 管道空:读端阻塞)
  • [5. 进阶实战:基于管道的进程池 (Master-Slave)](#5. 进阶实战:基于管道的进程池 (Master-Slave))
    • [5.1 场景设计:Master 进程管理多个 Slave 进程](#5.1 场景设计:Master 进程管理多个 Slave 进程)
    • [5.2 架构思路:池化技术与一对一管道通信](#5.2 架构思路:池化技术与一对一管道通信)
    • [5.3 代码实现与修正](#5.3 代码实现与修正)
      • [5.3.1 `Channel` 类设计](#5.3.1 Channel 类设计)
      • [5.3.2 资源管理:使用智能指针避免对象切片](#5.3.2 资源管理:使用智能指针避免对象切片)
      • [5.3.3 任务分发:轮询策略与进程控制](#5.3.3 任务分发:轮询策略与进程控制)
    • [6. 总结](#6. 总结)
    • [6.1 管道的五种特性](#6.1 管道的五种特性)
    • [6.2 管道的四种情况](#6.2 管道的四种情况)

1. 引言:为什么需要进程间通信 (IPC)

1.1 进程的独立性与协同工作的矛盾

我们以前学习进程,独立性很强,一个一个学习。进程之间,要如何进行协同工作呢?进程通信就是研究进程如何协同工作的问题了。

父子之间数据可以共享,父进程的数据,子进程能看到。那为什么进程间还要通信呢?因为存在写时拷贝,父子无法互相传递数据。

1.2 IPC 的核心目的:数据传输、资源共享与进程控制

  1. 数据传输
  2. 资源共享
  3. 通知事件
  4. 进程控制

2. 核心原理:管道的本质

2.1 管道不是磁盘文件:内核中的内存级缓冲区

管道的本质就是让不同的进程看到同一份资源。

管道是纯内存级的文件,不需要打开磁盘文件,也就不需要路径+文件名确定文件。

2.2 实现机制:pipe() 系统调用与文件描述符

c 复制代码
int pipe(int pipefd[2]);
  • pipefd[0]:读端(r)
  • pipefd[1]:写端(w)
  • 成功返回 0,失败返回 -1
    内核会创建一个管道文件,并以读和写的形式打开。

2.3 通信基础:通过 fork() 继承文件描述符表

进程间通信怎么做?父进程往文件内核缓冲区中写入数据,子进程也能读取到。这个就是管道,基于文件仅从内核级进程间通信。

拷贝 struct file 的时候对我们的子进程,父子都要单独拷,文件不是单独拷的。

进程的本质是让其看到同一份资源。

细节:

  1. fork 的时候,file 要进行拷贝
  2. 管道是单向通信的,单工通信
  • 全双工通信:两人互骂的时候
  • 半双工通信:人类正常沟通
  • 单工通信:老师上课的时候
  1. 管道是一个纯内存级的文件,不需要打开磁盘文件,也就不需要路径+文件名

2.4 生命周期管理:随进程而生,由引用计数回收

父子进程都退出了,管道文件有没有被释放?到最后会被系统自动回收掉。换句话说,文件的生命周期随进程。

操作系统是怎样知道他要被回收的?inode 属性里面有一个 int ref_cnt 属性来进行标记,这个文件有没有人用。管道的生命周期是随进程的,该文件你不关,会自己回收。

3.基础实战:匿名管道的读写

3.1 通信模型:单向通信(单工)与读写端约定

  • 只读打开
  • 只写打开
    方便让子进程也能看到读和写。

为什么不关闭对应的读写段?不关闭其实也可以的,但是不建议,另一个你不用的,容易误操作。

管道是单向通信的,谁读谁写?子进程来读,父进程来写。

3.2 代码演示:pipeforkreadwrite 的基本流程

步骤:

  1. 创建管道文件
  2. fork 创建子进程
  3. 关闭读写段,子进程写,父进程读
  4. 子 → 父 通信
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    // 1. 创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe");
        return 1;
    }
    printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0], pipefd[1]); // 3, 4

    // 2. 创建子进程: 子进程写
    pid_t id = fork();
    if (id == 0)
    {
        // child
        close(pipefd[0]);
        char *msg = "Hello, World!";
        int cnt = 10;
        char outbuffer[1024];
        while (cnt)
        {
            snprintf(outbuffer, sizeof(outbuffer), "f->c# %s %d %d\n", msg, cnt--, getpid());
            write(pipefd[1], outbuffer, strlen(outbuffer));
            sleep(1);
        }
        exit(0);
    }

    // parent: 读
    close(pipefd[1]);
    char buf[1024] = {0};
    while (1)
    {
        ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0)
        {
            buf[n] = 0;
            printf("c->f# %s", buf);
        }
        else if (n == 0)
        {
            printf("child exit\n");
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }

    pid_t rid = waitpid(id, NULL, 0);
    (void)rid;
    return 0;
}

3.3 特性分析:面向字节流(读写次数不对等)

管道是面向字节流的。在管道的角度,它认为它写的全是字符。

写几个字符和你要读多少个字符没有正相关。文件也是字符流式的,知道是说某某流的,读写次数都不是正相关的。

4. 深入理解:管道的四大特性与四种场景

4.1 特性一:自带同步与互斥机制

管道通信,对于多进程而言,是自带互斥同步 机制的。

你的父进程在阻塞,问题在于你的父进程为什么在阻塞?

4.1.1 互斥:保证写入的原子性

管道通信,前提是让不同的进程看到同一份资源------文件 → 文件内核缓冲区 → 内存块。属于父子进程的共享资源。数据并发访问导致的问题。所以要对共享资源进行保护------互斥与同步。

  • 同步:访问数据具有一定的顺序性
  • 互斥:任何时候只允许一个人访问资源,这就是父进程卡住的原因

如何证明 :单次写入小于 4096 字节,没有人能来中断你。

4.1.2 同步:管道空则读阻塞,管道满则写阻塞

  • 如果子进程写的慢,父进程就要阻塞等,等管道有数据,父进程才能读
  • 如果子进程写的快,父进程不读,管道一旦被写满,子进程就必须被阻塞!不运行了,写满了,就应该要让父进程读了

管道为空,读就要阻塞,给写机会。管道为满,写就要阻塞,给读机会。

4.2 四种特殊情况(边界测试)

4.2.1 读端关闭,写端继续写 → SIGPIPE 信号

写端一直在写,读端不读关闭 fd。读端都关了,那你写是没有任何意义的。OS 不做浪费资源的事,OS 会直接杀掉写的进程。

如何证明:通过父进程回收其僵尸进程。

为什么要让子进程写?因为如果写出异常了父进程能回收异常。

退出码 13,操作系统是被异常杀掉的。

SIGPIPE :管道破裂,没有读端,写端继续写,OS 终止进程。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe");
        return 1;
    }
    printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0], pipefd[1]);

    pid_t id = fork();
    if (id == 0)
    {
        // child: 写端
        close(pipefd[0]);
        char ch = 'A';
        int size = 0;
        while (1)
        {
            write(pipefd[1], &ch, 1);
            size++;
            printf("child write %c to pipe, size=%d\n", ch, size);
        }
        exit(0);
    }

    // parent: 读端(不读,直接关闭)
    close(pipefd[1]);  // 关闭写端
    close(pipefd[0]);  // 关闭读端 → 读端关闭

    pid_t rid = waitpid(id, NULL, 0);
    (void)rid;
    return 0;
}

4.2.2 写端关闭,读端继续读 → read 返回 0 / EOF

读端在读,写端关闭。读端读完管道中剩余数据,再读就会读到 ""read 返回值为 0,表明读管道,读到文件结尾。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe");
        return 1;
    }
    printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0], pipefd[1]);

    pid_t id = fork();
    if (id == 0)
    {
        // child: 写端
        close(pipefd[0]);
        char *msg = "Hello, World!";
        int cnt = 5;
        char outbuffer[1024];
        while (cnt--)
        {
            snprintf(outbuffer, sizeof(outbuffer), "f->c# %s %d %d\n", msg, cnt, getpid());
            write(pipefd[1], outbuffer, strlen(outbuffer));
            sleep(1);
        }
        printf("write endpoint quit\n");
        close(pipefd[1]);  // 写端关闭
        exit(0);
    }

    // parent: 读端
    close(pipefd[1]);
    char buf[1024] = {0};
    while (1)
    {
        ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0)
        {
            buf[n] = 0;
            printf("c->f# %s\n", buf);
        }
        else if (n == 0)
        {
            printf("child exit, read return 0\n");
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }

    pid_t rid = waitpid(id, NULL, 0);
    (void)rid;
    return 0;
}

4.2.3 管道满:写端阻塞

子进程写的快,父进程不读,管道一旦被写满,子进程就必须被阻塞。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe");
        return 1;
    }
    printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0], pipefd[1]);

    pid_t id = fork();
    if (id == 0)
    {
        // child: 写端,疯狂写入
        close(pipefd[0]);
        char ch = 'A';
        int size = 0;
        while (1)
        {
            write(pipefd[1], &ch, 1);
            size++;
            printf("child write %c to pipe, size=%d\n", ch, size);
        }
        exit(0);
    }

    // parent: 读端(不读)
    close(pipefd[1]);
    char buf[1024] = {0};
    while (1)
    {
        sleep(100);  // 故意不读,让管道写满
        ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0)
        {
            buf[n] = 0;
            printf("c->f# %s\n", buf);
        }
        else if (n == 0)
        {
            printf("child exit\n");
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }

    pid_t rid = waitpid(id, NULL, 0);
    (void)rid;
    return 0;
}

4.2.4 管道空:读端阻塞

子进程写的慢,父进程就要阻塞等,等管道有数据,父进程才能读。

5. 进阶实战:基于管道的进程池 (Master-Slave)

5.1 场景设计:Master 进程管理多个 Slave 进程

完成一个进程控制一批进程的过程------Master-Slave 模式的进程池:预先将进程创建出来,用的时候直接用就好了。

所有的池化技术都是为了提高效率。

5.2 架构思路:池化技术与一对一管道通信

  1. 约定:统一 4 字节读写,任务码
  2. 如何选择一个进程呢?均匀分配,负载均衡
    • 轮询
    • 随机
    • 权重
      管道是自带同步的。

5.3 代码实现与修正

5.3.1 Channel 类设计

cpp 复制代码
```cpp
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <sys/wait.h>
#include <functional>
#include <ctime>
#include <cstdlib>
//================ 子进程要完成的任务 ===========================
void SyncDisk()
{
    std::cout << getpid() << ":刷新数据到磁盘任务" << std::endl;
    sleep(1);
}
void Download()
{
    std::cout << getpid() << ":下载数据到系统中" << std::endl;
    sleep(1);
}
void PrintLog()
{
    std::cout << getpid() << ":打印日志到本地" << std::endl;
    sleep(1);
}
void UpdateStatus()
{
    std::cout << getpid() << ":更新一次用户的状态" << std::endl;
    sleep(1);
}
typedef void (*task_t)();
task_t task[4] = {SyncDisk, Download, PrintLog, UpdateStatus};
enum
{
    OK = 0,
    PIPE_ERROR,
    FORK_ERROR
};
using cb_t = std::function<void(int)>;
//==================== 子进程的入口函数 ============================
void DoTask(int fd)
{
    while (true)
    {
        int task_code = 0;
        ssize_t n = read(fd, &task_code, sizeof(task_code));
        if (n == sizeof(task_code))
        {
            if (task_code >= 0 && task_code < 4)
            {
                task[task_code]();
                sleep(1); // 任务执行完稍作休息
            }
        }
        else if (n == 0)
        {
            std::cout << getpid() << ":task quit ..." << std::endl;
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }
}
const int gprocessnum = 5;
//==================== 进程池 ============================
class ProcessPool
{
public:
    class Channel
    {
    public:
        Channel(int wfd, pid_t pid)                                  
            : _wfd(wfd), _sub_pid(pid)
        {
            _sub_name = "sub-channel-" + std::to_string(_sub_pid);
        }
        void PrintInfo()
        {
            printf("wfd=%d, who=%d, channel name=%s\n",
                   _wfd, _sub_pid, _sub_name.c_str());
        }
        std::string Name()
        {
            return _sub_name;
        }
        void Write(int index)
        {
            ssize_t n = write(_wfd, &index, sizeof(index));
            (void)n;
        }
        void ClosePipe()
        {
            std::cout << "关闭wfd: " << _wfd << std::endl;
            close(_wfd);  
        }
        void Wait()
        {
            pid_t rid = waitpid(_sub_pid, nullptr, 0);
            (void)rid;
            std::cout << "回收子进程: " << _sub_pid << std::endl;
        }
        ~Channel() {}
    private:
        int _wfd;
        pid_t _sub_pid;
        std::string _sub_name;
    };
public:
    ProcessPool()
    {
        srand((unsigned int)time(nullptr) ^ getpid());
    }
    ~ProcessPool() {}
    void Init(cb_t cb)
    {
        CreateProcessChannel(cb);
    }
    void Run()
    {
	 int cnt = 10;
        while (cnt--)
        {
            std::cout << "------------------------------------------------" << std::endl;
            int itask = SelectTask();
            std::cout << "itask: " << itask << std::endl;
            int index = SelectChannel();
            if (index < 0)
            {
                std::cerr << "没有可用的子进程" << std::endl;
                break;
            }
            std::cout << "index: " << index << std::endl;
            printf("发送 %d to %s\n", itask, channels[index].Name().c_str());
            SendTaskToSlave(itask, index);
            sleep(1);
        }
    }
    void Quit()
    {
        // 先关闭所有管道写端 → 让所有子进程同时收到 EOF
        for (auto &ch : channels)
        {
            ch.ClosePipe();
        }
        // 再统一回收所有子进程
        for (auto &ch : channels)
        {
            ch.Wait();
        }
    }
    void Debug()
    {
        for (auto &c : channels)
        {
            c.PrintInfo();
        }
    }
private:
    int SelectTask()
    {
        return rand() % 4;
    }
    int SelectChannel()
    {
        if (channels.empty())
            return -1;
        static int index = 0;
        int selected = index;
        index = (index + 1) % channels.size();
        return selected;
    }
    void SendTaskToSlave(int itask, int index)
    {
        if (itask < 0 || itask >= 4)
            return;
        if (index < 0 || index >= (int)channels.size())
            return;
        channels[index].Write(itask);
    }
    void CreateProcessChannel(cb_t cb)
    {
        for (int i = 0; i < gprocessnum; i++)
        {
            int pipefd[2] = {0};
            if (pipe(pipefd) < 0)
            {
                std::cerr << "pipe create error" << std::endl;
                exit(PIPE_ERROR);
            }
            pid_t id = fork();
            if (id < 0)
            {
                std::cerr << "fork error" << std::endl;
                exit(FORK_ERROR);
            }
            else if (id == 0)
            {
                // 子进程:关闭所有历史管道写端
                for (auto &ch : channels)
                {
                    ch.ClosePipe();
                }
                close(pipefd[1]); // 关闭当前管道的写端
                cb(pipefd[0]);    // 执行回调函数(DoTask)
                exit(OK);
            }
            else
            {
                // 父进程:关闭当前管道的读端
                close(pipefd[0]);
                channels.emplace_back(pipefd[1], id);
                std::cout << "创建子进程:" << id << " 成功..." << std::endl;
            }
        }
    }
private:
    std::vector<Channel> channels;
};
//==================== main ============================
int    main()
{
    ProcessPool pp;
    pp.Init(DoTask);
    // pp.Debug();
    pp.Run();
    pp.Quit();
    return 0;
}

5.3.2 资源管理:使用智能指针避免对象切片

进程池中的 Channel 对象如果被复制,可能会导致 _wfd 被多个对象管理,关闭时出现重复关闭的问题。建议使用智能指针或禁止拷贝。

5.3.3 任务分发:轮询策略与进程控制

问题讨论:

  1. 如果父进程要求子进程退出,如果管道里面还有任务没有处理完,怎么办?
    • 子进程不会强制退出,子进程会将管道里面的任务消耗完了才会退出。
  2. 为什么全部关闭,在 wait 回收子进程呢?
    • 倒着关!最后一个子进程,wfd 只有一个,我们想要 1:1 的读写端。
  3. 函数回调调用完还会回来的。
    • DoTask 是回调函数,执行完会返回到子进程的主循环。

6. 总结

6.1 管道的五种特性

  1. 管道是只能单向通信,也就是单工通信
  2. 两个不相干的进程不能用匿名管道来通信。匿名管道只能用来进行具有血缘关系的父子通信(通过继承资源来实现的)
  3. 管道是面向字节流的
  4. 管道的生命周期随进程
  5. 管道通信是自带同步与互斥机制的

6.2 管道的四种情况

  1. 子进程写的慢,父进程就要阻塞等待
  2. 子进程写的快,管道写满,写端阻塞
  3. 写端关闭,读端读完剩余数据,read 返回 0
  4. 读端关闭,写端收到 SIGPIPE 信号,进程被终止

下一步:

管道通信是进程间通信的基础,但它只能实现单向通信。接下来我们将学习更强大的 IPC 机制------命名管道共享内存


欢迎大家批评指正!!!

相关推荐
happymagic2 小时前
vmware workstation Pro 虚拟机设定启停的时间段
运维·服务器·windows
寒水馨2 小时前
Linux下载、安装PowerShell-v7.6.4(附安装包powershell-7.6.4-linux-x64.tar.gz)
linux·跨平台·自动化运维·devops·命令行·powershell·脚本语言
组合缺一2 小时前
Solon 的 10 种 HTTP 服务器:改一行依赖,换一个引擎
java·服务器·网络协议·http·solon
lsh曙光2 小时前
scp和rsync远程同步
linux
阳明山水2 小时前
销量预测2026下半年:新架构、新基准与新共识
人工智能·深度学习·算法·机器学习·架构
NutShell Wang3 小时前
DeepSeek-V4-Flash正式版深度拆解:不换架构,后训练如何让Agent能力暴涨7倍
人工智能·ai·架构·agent·后训练·deepseek
Kina_C3 小时前
HAProxy 负载均衡算法全解析:从静态轮询到一致性哈希的实战指南
linux·算法·负载均衡·哈希算法·haproxy
~光~~3 小时前
【嵌入式linux学习_OV8858 bring up】L2_V4L2与Ov8858
linux·数据库·学习
Kina_C3 小时前
HAProxy 负载均衡实战:从安装配置到高级参数详解
linux·运维·负载均衡·haproxy
小程故事多_803 小时前
时序检索型AI知识库落地思考,从向量元数据到双架构工程化落地全梳理
人工智能·架构