Linux:进程池制作(匿名管道版本 & 定名管道版本)

[复制链接]
发表于 2026-10-3 22:36:55 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

每次来任务才 fork 一个进程,创建成本会直接摊到业务延迟上。进程池的思路正相反:启动前先批量造好一批工作进程,任务来了直接分发给空闲进程执行。这篇用匿名管道实现一个进程池,并顺带讲清管道管理、fd 泄漏和子进程退出这三个最容易翻车的地方。

一、为什么要进程池

创建进程是要花时间的,fork 一次内核要做进程描述符、页表、缓冲区等一系列复制动作(现代内核用写时拷贝把成本压得很低,但反复创建销毁依然可观)。如果任务本身只有毫秒级,创建进程的开销就会盖过任务本身。

进程池的做法是:进程启动时先初始化一批子进程,平时它们阻塞在管道读端等着,来任务时父进程把「任务码」写进某个子进程对应的管道,子进程读到就执行,执行完继续等下一次。这些提前创建好的进程集合就是进程池。

二、整体框架:先描述,再组织

父进程要同时知道:往哪根管道写、对应哪个子进程、这个子进程叫什么。所以第一步是给「一根管道 + 一个 worker」做个结构体,再用 vector 装起来,便于快速随机访问。

#define NUM 5          // 任务进程个数

int number = 1;         // 管道编号

class channel
{
public:
    channel(int fd, pid_t id)
        : ctrlfd(fd)
        , workerid(id)
    {
        name = "channel-" + std::to_string(number++);
    }

public:
    int ctrlfd;
    pid_t workerid;
    std::string name;
};

主流程就三步:

int main()
{
    std::vector<channel> channels;   // 用 vector 管理管道

    // 1、创建管道,创建进程,指定工作进程的工作方式
    CreaterChannel(&channels);

    // 2、主进程向工作进程发送任务
    const bool g_always_loop = true;
    SendTask(channels, !g_always_loop, 10);

    // 3、回收资源:关闭写端,子进程自动退出
    ReleaseChannels(channels);
    return 0;
}

三、创建管道与子进程(匿名管道版)

父进程循环 NUM 次,每次先建管道再建子进程,然后关掉双方不需要的读写端,形成一条单向通道。核心代码:

void CreaterChannel(std::vector<channel>* channels)
{
    std::vector<int> old;      // 记录子进程继承的多余读端
    for (int i = 0; i < NUM; i++)   // 创建 NUM 个工作进程
    {
        // 1. 创建管道
        int pipefd[2];
        int n = pipe(pipefd);
        assert(n == 0);
        (void)n;    // 防止编译器报警

        // 2. 创建子进程
        pid_t id = fork();
        if (id < 0)
        {
            perror("fork");
            return;
        }

        // 3. 形成单向通信
        if (id == 0)     // 子进程,也就是工作进程
        {
            if (!old.empty())    // 将子进程继承父进程的多余读端全部关闭
            {
                for (auto rfd : old)
                {
                    close(rfd);
                }
            }
            close(pipefd[1]);
            dup2(pipefd[0], 0);  // 把读端重定向到标准输入
            work();              // 子进程执行任务
            exit(0);
        }

        // 父进程
        close(pipefd[0]);
        channels->push_back(channel(pipefd[1], id));  // 记录写端与 worker 信息
        old.push_back(pipefd[1]);
    }
}

3.1 那个必须处理的 fd 泄漏

简单「关掉不需要的读写端」是不够的,因为 fork 出来的子进程会继承父进程当时打开的所有 fd。后面创建的子进程又继承了前面子进程的 fd,一层层传下去,最后每个子进程手里都攥着一堆并行的管道读端,导致管道一直无法彻底关闭,资源泄漏。

解决办法是:父进程每建一个写端就记进 old,新子进程一诞生,就把它从父进程继承来的多余读端全部 close 掉。这一步是进程池能不能长期稳定运行的关键。

四、工作进程怎么干活

子进程循环从标准输入(已经被 dup2 到管道读端)读 4 字节的任务码:读到就校验任务码、执行任务;读返回 0 说明父进程写端全关了,管道写端闭合,read 返回 0,子进程该退出了。

void work()
{
    while (1)
    {
        int code = 0;
        ssize_t n = read(0, &code, sizeof(code));
        if (n == 0)
        {
            // 主进程写端退出,子进程也退出
            break;
        }
        else if (n > 0)
        {
            if (!init.CheckSafe(code))   // 检查任务码是否合法
                continue;
            init.RunTask(code);          // 执行任务
        }
        else
        {
            // nothing
        }
    }
}

约定父进程一次只写 4 字节、子进程一次只读 4 字节,任务码就是一个 int,这样天然不会粘包,也不用关心半包。

五、发任务与资源回收

父进程遍历 channels,按策略(轮询或随机)挑一个 worker,往它的 ctrlfd 写一个任务码即可。用一个开关变量控制是「一直发」还是「发够 N 次就停」:

SendTask(channels, !g_always_loop, 10);

收尾时把父进程这边的写端逐个关掉。写端全部关闭后,管道读端会读到 0,上面 work() 里的循环自然退出,子进程跟着退出,父进程再 wait 回收,整套进程池干净退场。

六、换成命名管道版怎么改

  • 管道从匿名(pipe(),靠 fd 继承)换成命名管道(mkfifo(),靠路径),父子进程不需要有亲缘关系也能通信。
  • 描述符管理从「继承后逐个 close」变成「按路径打开、用完按路径关闭」,泄漏风险小很多。
  • 结构体里 ctrlfd 的来源改为 open(fifoPath, O_WRONLY),其余框架、任务码校验、work() 循环完全不用动。

所以两种版本的差异只在「通道怎么建」,职责划分(描述 channel、组织 vector、派发任务、回收资源)一模一样。

七、踩坑清单

  • 不关继承的多余读端 → 子进程永不退出、fd 与管道资源泄漏,长时间运行必然出问题。
  • 忘了 dup2 到 0,work() 里写死 read(0, ...) 就会读到终端输入而不是管道。
  • 任务码定长但用了字符串,长度不固定就得自己做粘包处理,直接用 int 最省事。
  • 父进程主流程退出前必须先关写端,否则子进程会一直阻塞在 read 上变成僵尸。
  • 进程池大小别盲目开大:worker 太多只是增加上下文切换开销,一般和 CPU 核数、任务类型(CPU 密集 / IO 密集)挂钩来定。

qidao123.com企服评测-应用市场 编辑提示

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表