《Linux系统编程》Linux 进程间通信之管道基础解析:从匿名管道原理到基于管道的进程池实现

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux操作系统从入门到实践》《Qt从入门到实践》

《算法题讲解指南》--优选算法

《算法题讲解指南》--递归、搜索与回溯算法

《算法题讲解指南》--动态规划算法

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一. 进程间通信基础认知](#一. 进程间通信基础认知)

[1.1 进程间通信的核心目的](#1.1 进程间通信的核心目的)

[1.2 进程间通信的发展与分类](#1.2 进程间通信的发展与分类)

[1.2.1 阶段一:管道](#1.2.1 阶段一:管道)

[1.2.2 阶段二:System V IPC](#1.2.2 阶段二:System V IPC)

[1.2.3 阶段三:POSIX IPC](#1.2.3 阶段三:POSIX IPC)

[1.2.4 IPC分类体系](#1.2.4 IPC分类体系)

[二. 管道的基础概念](#二. 管道的基础概念)

[2.1 管道的定义](#2.1 管道的定义)

[2.2 管道的核心特性](#2.2 管道的核心特性)

[三. 匿名管道](#三. 匿名管道)

[3.1 匿名管道的创建函数](#3.1 匿名管道的创建函数)

[3.2 匿名管道的简单使用示例:从键盘到屏幕](#3.2 匿名管道的简单使用示例:从键盘到屏幕)

[四. 基于 fork 的匿名管道跨进程通信](#四. 基于 fork 的匿名管道跨进程通信)

[4.1 fork 共享管道的核心原理](#4.1 fork 共享管道的核心原理)

[4.2 从文件描述符视角理解管道通信](#4.2 从文件描述符视角理解管道通信)

[4.3 子写父读的完整实战示例以及四个场景分析(重点)](#4.3 子写父读的完整实战示例以及四个场景分析(重点))

[五. 从内核视角看管道的本质](#五. 从内核视角看管道的本质)

[5.1 管道的内核数据结构](#5.1 管道的内核数据结构)

[5.2 管道的内核实现逻辑](#5.2 管道的内核实现逻辑)

[5.3 管道读写规则](#5.3 管道读写规则)

[5.3.1 阻塞模式(默认)](#5.3.1 阻塞模式(默认))

[5.3.2 非阻塞模式(O_NONBLOCK)](#5.3.2 非阻塞模式(O_NONBLOCK))

[5.3.3 原子性保证](#5.3.3 原子性保证)

[5.4 管道与普通文件的异同](#5.4 管道与普通文件的异同)

六、实践:进程池实现

[6.1 核心设计思路](#6.1 核心设计思路)

[6.2 通道封装(Channel.hpp)](#6.2 通道封装(Channel.hpp))

[6.3 任务管理(Task.hpp)](#6.3 任务管理(Task.hpp))

[6.4 进程池实现(ProcessPool.hpp)](#6.4 进程池实现(ProcessPool.hpp))

[6.5 主程序(Main.cc)](#6.5 主程序(Main.cc))

[6.6 编译运行](#6.6 编译运行)

七、核心要点总结

结束语


前言

在 Linux 系统中,进程拥有独立的地址空间,彼此无法直接交换数据,进程间通信(IPC)正是打破这种隔离、实现数据交互的关键技术。管道作为最古老、最基础的 IPC 手段,贴合"一切皆文件"的设计思想,尤其适合亲缘进程间的通信。本文将从文件描述符和内核视角深入解析匿名管道的底层原理,并在此基础上进一步探讨基于管道通信的进程池模型,拆解其如何通过预先创建子进程避免频繁创建销毁的开销,以及任务分发与协同机制。

一. 进程间通信基础认知

在学习管道之前,我们需要先明确进程间通信的核心目的和分类,建立对 IPC 技术的整体认知,这能帮助我们更好地理解管道的设计初衷和应用场景。

1.1 进程间通信的核心目的

在现代操作系统中,进程 是程序执行的基本单位 ,每个进程拥有独立的地址空间。这种设计带来了隔离性和安全性 ,但也带来了一个问题:进程之间如何交换信息和协同工作?

进程间通信的本质是实现进程间数据交互、资源共享和事件协同,具体可分为四个方面:

  • 数据传输:一个进程将自身数据发送给另一个进程,是最基础的 IPC 需求;
  • 资源共享:多个进程共享同一份系统资源(如文件、内存),提高资源利用率;
  • 通知事件:进程向其他进程发送事件通知,如子进程退出时通知父进程、进程完成任务后通知调度进程;
  • 进程控制:一个进程对另一个进程进行执行控制,如调试进程拦截目标进程的异常和陷入,实时获取其状态。

1.2 进程间通信的发展与分类

Linux 的 IPC 技术从 Unix 继承并不断发展,整体可分为三大类,管道是其中最基础的一类:

1.2.1 阶段一:管道

  • 管道 :包括 匿名管道(pipe)命名管道(FIFO),是最基础的 IPC 方式,基于文件系统实现,简单但功能强大,适用于亲缘关系进程;

1.2.2 阶段二:System V IPC

  • System V IPC :包括共享内存消息队列信号量,由 System V 系统引入,基于内核的 IPC 资源管理实现,生命周期随内核;

1.2.3 阶段三:POSIX IPC

  • POSIX IPC :遵循 POSIX 标准的 IPC 方式,是对 System V IPC 的改进,包括 POSIX 共享内存、消息队列、信号量等,具有跨平台兼容性,并且扩展了互斥量、条件变量、读写锁等

1.2.4 IPC分类体系

bash 复制代码
Linux IPC
├── 管道
│   ├── 匿名管道 (pipe)
│   └── 命名管道 (FIFO)
├── System V IPC
│   ├── 消息队列
│   ├── 共享内存
│   └── 信号量
└── POSIX IPC
    ├── 消息队列
    ├── 共享内存
    ├── 信号量
    ├── 互斥量
    ├── 条件变量
    └── 读写锁

管道作为最原始的 IPC 方式,虽然功能简单,但却是理解 Linux 进程间通信和文件系统的关键,也是实现其他复杂 IPC 的基础。

二. 管道的基础概念

2.1 管道的定义

管道是一种半双工 的数据流通信方式,本质是内核 中的一块缓冲区 ,它将一个进程的标准输出与另一个进程的标准输入相连,形成一条单向的数据流通道 。我们可以把管道理解为进程间的 "一根水管",数据从一端写入 ,从另一端读出,实现单向的通信。

在 Linux 命令行中,我们经常使用的管道符|就是管道的典型应用,例如who | wc -l:

  • who进程的标准输出被重定向到管道的写端;
  • wc -l进程的标准输入被重定向到管道的读端;
  • 内核中的管道缓冲区作为中间介质,完成两个进程间的数据传递。
  • 从上图中我们可以看出最后他们三个指令的父进程都是bash的,他们之间是具有血缘关系的进程

2.2 管道的核心特性

管道的设计贴合 Linux 一切皆文件的思想,其核心特性可总结为:

  • 半双工通信 :数据只能沿一个方向流动,若需双向通信,需创建两个管道;
  • 基于缓冲区 :管道的实质是内核缓冲区,数据写入后暂存于内核,直到被另一个进程读取;
  • 文件式操作 :管道通过文件描述符操作,读写接口与文件一致(read/write),符合 Linux 文件操作规范;
  • 亲缘进程专属:匿名管道仅支持具有共同祖先的亲缘进程(父进程与子进程、兄弟进程)间通信。

匿名管道的两个初步理解图:

三. 匿名管道

3.1 匿名管道的创建函数

cpp 复制代码
#include <unistd.h>

int pipe(int pipefd[2]);

函数参数

  • pipefd: 整型数组,是输出型参数,用于保存管道的读、写文件描述符:
    • pipefd0 管道的读端,仅用于读取管道中的数据;
    • pipefd1 管道的写端,仅用于向管道中写入数据。

返回值

  • 成功:返回 0;
  • 失败:返回 - 1,并设置 errno 表示错误原因。

注意 :调用pipe函数的进程会同时持有管道的读端和写端 ,若要实现两个进程间的单向通信,需要在进程创建后关闭各自无用的文件描述符,避免数据读写异常

3.2 匿名管道的简单使用示例:从键盘到屏幕

下面的示例实现了一个基础的匿名管道通信:从键盘读取数据写入管道,再从管道读取数据输出到屏幕,直观展示管道的读写操作。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void) 
{
    int fds[2];
    char buf[100];
    int len;

    // 创建管道
    if (pipe(fds) == -1) 
    {
        perror("make pipe");
        exit(1);
    }

    // 从标准输入读取数据
    while (fgets(buf, 100, stdin)) 
    {
        len = strlen(buf);

        // 写入管道
        if (write(fds[1], buf, len) != len) 
        {
            perror("write to pipe");
            break;
        }

        memset(buf, 0x00, sizeof(buf));

        // 从管道读取
        if ((len = read(fds[0], buf, 100)) == -1) 
        {
            perror("read from pipe");
            break;
        }

        // 写入标准输出
        if (write(1, buf, len) != len) 
        {
            perror("write to stdout");
            break;
        }
    }

    return 0;
}

该示例中,进程自身同时完成管道的写和读操作,虽然未实现跨进程通信,但清晰展示了管道的基本读写流程:通过fd1通过fd0,操作接口与普通文件完全一致。

四. 基于 fork 的匿名管道跨进程通信

匿名管道本身由单个进程创建 ,要实现跨进程通信 ,需要借助 fork 函数创建子进程 ------ 子进程会继承父进程文件描述符表 ,从而与父进程共享同一个管道的读、写端,这是匿名管道实现亲缘进程通信的核心原理。

4.1 fork 共享管道的核心原理

fork 函数创建的子进程会复制父进程的文件描述符表,包括父进程创建的管道读、写端文件描述符,因此父子进程会共享同一个内核管道缓冲区,实现数据互通。其核心步骤分为三步:

  • 父进程创建管道:父进程调用 pipe 创建管道,持有 fd0(读)和 fd1(写)两个文件描述符;
  • 父进程 fork 创建子进程:子进程继承父进程的文件描述符表,同样持有管道的 fd0 和 fd1
  • 关闭无用的文件描述符:根据通信方向,父、子进程分别关闭无用的读 / 写端,实现单向通信。

例如要实现父进程读、子进程写,则:

  • 父进程关闭写端fd1,仅保留读端fd0
  • 子进程关闭读端fd0,仅保留写端fd1

4.2 从文件描述符视角理解管道通信

从文件描述符的角度,我们可以更清晰地看到父子进程共享管道的过程,以父读子写为例:

步骤 1:父进程创建管道

父进程的文件描述符表中,0、1、2 分别为标准输入、标准输出、标准错误,pipe创建的管道分配到3(读端fd04(写端fd1

bash 复制代码
父进程:0(tty)  1(tty)  2(tty)  3(pipe读)  4(pipe写)
子进程:0(tty)  1(tty)  2(tty)  3(pipe读)  4(pipe写)

步骤 2:父进程 fork 创建子进程

子进程复制父进程的文件描述符表,此时父子进程的文件描述符 3、4 均指向同一个内核管道缓冲区。

bash 复制代码
父进程:0(tty)  1(tty)  2(tty)  3(pipe读)  4(pipe写)
子进程:0(tty)  1(tty)  2(tty)  3(pipe读)  4(pipe写)

步骤 3:关闭无用文件描述符

父进程关闭写端 4,子进程关闭读端 3,此时管道形成单向的 "子写父读" 通道 ,数据只能从子进程写入,父进程读出。

bash 复制代码
父进程:0(tty)  1(tty)  2(tty)  3(pipe读)        -
子进程:0(tty)  1(tty)  2(tty)        -        4(pipe写)

核心关键点 :父子进程的文件描述符指向同一个内核管道缓冲区 ,这是进程间能通过管道通信根本原因;关闭无用描述符则是为了保证通信的单向性,避免出现数据读写的混乱。

4.3 子写父读的完整实战示例以及四个场景分析(重点)

下面的示例实现了子进程向管道写入字符串,父进程从管道读取并打印的功能,是 "子写父读" 的标准实现:

场景1:写端慢,读端快------以慢的节奏来:当管道没有数据时,读端就要阻塞(等写)

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>

#define MAX 1024

void Child_write(int wfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        //场景1:写端慢,读端快------以慢的节奏来:当管道没有数据时,读端就要阻塞(等写)
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);
    }
}

void Father_read(int rfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        //场景1:写端慢,读端快------以慢的节奏来:当管道没有数据时,读端就要阻塞(等写)
        size_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = '\0';
            std::cout << "child say: " << buffer << std::endl;
        }
    }
}

int main()
{
    // 1、创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
    {
        std::cerr << "pipe error" << std::endl;
    }

    // 2、创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child
        // 3、关闭不需要的读写端,形成单向通信信道
        // father -> r , child -> w
        close(fds[0]);
        Child_write(fds[1]);

        // 子进程结束前端口全部关闭
        close(fds[1]);
        exit(0);
    }

    // 3、关闭不需要的读写端,形成单向通信信道
    // father
    close(fds[1]);
    Father_read(fds[0]);

    //waitpid(id, nullptr, 0);
    // 父进程结束前端口全部关闭
    close(fds[0]);
    return 0;
}

场景2:写端快,读端慢(读端会把写端写入的数据一次全部读上来:取决于缓冲区大小)------当管道满了的时候,写端就要阻塞等待(等读)

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>

#define MAX 1024

//子进程:w
void Child_write(int wfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        //场景2:写端快,读端慢(读端会把写端写入的数据一次全部读上来:取决于缓冲区大小)------当管道满了的时候,写端就要阻塞等待(等读)
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        printf("child:%d\n", cnt);//展示写了多少次数据到管道里面
    }
}

//父进程:r
void Father_read(int rfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        //场景2:写端快,读端慢(读端会把写端写入的数据一次全部读上来:取决于缓冲区大小)------当管道满了的时候,写端就要阻塞等待(等读)
        sleep(5);
        size_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = '\0';
            std::cout << "child say: " << buffer << std::endl;
        }
    }
}

int main()
{
    // 1、创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
    {
        std::cerr << "pipe error" << std::endl;
    }

    // 2、创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child
        // 3、关闭不需要的读写端,形成单向通信信道
        // father -> r , child -> w
        close(fds[0]);
        Child_write(fds[1]);

        // 子进程结束前端口全部关闭
        close(fds[1]);
        exit(0);
    }

    // 3、关闭不需要的读写端,形成单向通信信道
    // father
    close(fds[1]);
    Father_read(fds[0]);

    //waitpid(id, nullptr, 0);
    // 父进程结束前端口全部关闭
    close(fds[0]);
    return 0;
}

场景3:写端不写(close),读端继续------读完全部数据后,read就会读到返回值为0,表示文件结尾(不会阻塞等待)

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>

#define MAX 1024

//子进程:w
void Child_write(int wfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        // 场景3:写端不写(close),读端继续------读完全部数据后,read就会读到返回值为0,表示文件结尾(不会阻塞等待)
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(2);
        break; //break后,子进程就会把写端也进行关闭
    }
}

//父进程:r
void Father_read(int rfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        // 场景3:写端不写(close),读端继续------读完全部数据后,read就会读到返回值为0,表示文件结尾(不会阻塞等待)
        size_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = '\0';
            std::cout << "child say: " << buffer << std::endl;
        }
        else
        {
            std::cout << "n:" << n << std::endl;
            std::cout << "child 已退出,我也退出" << std::endl;
            break;
        }
    }
}

int main()
{
    // 1、创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
    {
        std::cerr << "pipe error" << std::endl;
    }

    // 2、创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child
        // 3、关闭不需要的读写端,形成单向通信信道
        // father -> r , child -> w
        close(fds[0]);
        Child_write(fds[1]);

        // 子进程结束前端口全部关闭
        close(fds[1]);
        exit(0);
    }

    // 3、关闭不需要的读写端,形成单向通信信道
    // father
    close(fds[1]);
    Father_read(fds[0]);

    //waitpid(id, nullptr, 0);
    // 父进程结束前端口全部关闭
    close(fds[0]);
    return 0;
}

场景4:读端不读(close),写端继续,OS直接杀死子进程,发送异常信号

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>

#define MAX 1024

//子进程:w
void Child_write(int wfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        // 场景4:读端不读(close),写端继续------写端再写入没有任何意义(OS不会做浪费时间和空间的事情),OS直接杀死子进程,发送异常信号
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        printf("child:%d\n", cnt);
        sleep(2);
    }
}

//父进程:r
void Father_read(int rfd)
{
    char buffer[MAX];
    int cnt = 0;
    while (true)
    {
        // 场景4:读端不读(close),写端继续------写端再写入没有任何意义(OS不会做浪费时间和空间的事情),OS直接杀死子进程,发送异常信号
        size_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = '\0';
            std::cout << "child say: " << buffer << std::endl;
        }
        break; //break后,父进程就会把读端也进行关闭
    }
}

int main()
{
    // 1、创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
    {
        std::cerr << "pipe error" << std::endl;
    }

    // 2、创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child
        // 3、关闭不需要的读写端,形成单向通信信道
        // father -> r , child -> w
        close(fds[0]);
        Child_write(fds[1]);

        // 子进程结束前端口全部关闭
        close(fds[1]);
        exit(0);
    }

    // 3、关闭不需要的读写端,形成单向通信信道
    // father
    close(fds[1]);
    Father_read(fds[0]);

    //waitpid(id, nullptr, 0);
    // 父进程结束前端口全部关闭
    close(fds[0]);

    // 场景4:读端不读(close),写端继续,OS直接杀死子进程,发送异常信号
    int status = 0; //位图
    int ret = waitpid(id, &status, 0);
    if(ret > 0)
    {
        printf("exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status&0x7F);
        //异常退出:退出码无意义(高8位比特位为0),低8位表示终止信号
    }
    return 0;
}

五. 从内核视角看管道的本质

从文件描述符视角,我们理解了管道的使用流程,而从内核视角 ,我们能看透管道的底层实现 ------ 管道的本质是内核中的一块缓冲区,由两个file结构体指向同一个inode ,贴合 Linux **"一切皆文件"**的设计思想。

5.1 管道的内核数据结构

在 Linux 内核中,管道的底层实现涉及三个核心数据结构:

  • **file结构体:**进程的文件描述符表中的每个项都指向一个file结构体,记录文件的操作方式、当前偏移量等信息;
  • **inode结构体:**用于描述文件的物理属性,管道的inode中保存了管道缓冲区的地址、大小、读写位置等核心信息;
  • 管道缓冲区:内核中的一块连续内存,是管道实际存储数据的地方。

对于匿名管道,父子进程的fd0fd1 会分别指向不同的file结构体 ,但这两个file结构体 最终会指向同一个inode结构体 ,而该inode指向内核中的管道缓冲区。

5.2 管道的内核实现逻辑

当进程对管道执行read/write操作时,内核的处理逻辑如下:

  • **写操作write(fd1, data, len):**内核将数据从进程地址空间复制到管道缓冲区,并更新inode中的写位置;
  • **读操作read(fd0, buf, len):**内核将管道缓冲区中的数据复制到进程地址空间,并更新inode中的读位置;
  • 缓冲区同步:内核会保证管道缓冲区的读写同步,若缓冲区为空,读操作会阻塞;若缓冲区满,写操作会阻塞。这个上面也有分析到一点。

简单来说,管道的读写操作本质是进程地址空间与内核缓冲区之间的数据拷贝 ,而两个进程共享同一个内核缓冲区 ,就实现了数据的跨进程传递

5.3 管道读写规则

5.3.1 阻塞模式(默认)

情况 读操作 写操作
没有数据可读 阻塞等待 -
管道已满 - 阻塞等待
所有写端关闭 返回 0(EOF) -
所有读端关闭 - 产生 SIGPIPE 信号

5.3.2 非阻塞模式(O_NONBLOCK)

情况 读操作 写操作
没有数据可读 返回 -1,errno=EAGAIN -
管道已满 - 返回 -1,errno=EAGAIN

5.3.3 原子性保证

写入数据量 ≤ PIPE_BUF:

  • Linux保证写入的原子性

写入数据量 > PIPE_BUF:

  • 不保证原子性
  • 可能与其他进程的数据交错

5.4 管道与普通文件的异同

管道的操作接口与普通文件一致,但二者在底层实现和使用上有明显区别,核心对比如下:

特性 管道 普通文件
存储介质 内核缓冲区 磁盘 / 块设备
生命周期 随进程(进程退出释放) 随文件系统(需手动删除)
数据读写 流式读写,不可随机访问 支持随机访问(lseek)
共享方式 仅亲缘进程通过文件描述符共享 所有进程可通过路径 / 文件描述符共享
数据持久化 不持久化(读出即删除) 持久化(数据保存在磁盘)

但二者的核心共性是都遵循 Linux 的文件操作模型,通过文件描述符file结构体inode结构体实现操作,这也是管道能复用文件读写接口的根本原因。

六、实践:进程池实现

6.1 核心设计思路

本进程池实现的核心逻辑:

  • 父进程创建指定数量的子进程,通过匿名管道与每个子进程建立单向通信(父写子读);
  • 父进程采用轮询策略 将任务分发给不同子进程,实现简单的负载均衡
  • 子进程循环读取管道中的任务码,执行对应任务;
  • 父进程通过关闭管道写端通知子进程退出,并回收所有子进程资源。

6.2 通道封装(Channel.hpp)

定义了ChannelChannelManager 两个类,用于管理基于管道的进程池:

Channel 封装单个子进程的写端 fd 和 pid,提供发送任务、关闭管道和等待回收的接口;ChannelManager 通过容器统一管理多个 Channel,并使用轮询算法实现任务的负载均衡分发。

cpp 复制代码
#pragma once

#include <iostream>
#include <unistd.h>
#include <string>
#include <vector>
#include <sys/wait.h>

// 先描述
//  通道类:管理单个子进程的通信管道和进程ID
class Channel
{
public:
    // 构造函数:初始化管道写端、子进程ID,生成通道名称
    Channel(int wfd, pid_t subid)
        : _wfd(wfd), _subid(subid)
    {
        _name = "Channel - " + std::to_string(_wfd) + " - " + std::to_string(_subid);
    }

    // 获取管道写端
    int Wfd() { return _wfd; }

    // 获取子进程ID
    pid_t Subid() { return _subid; }

    // 获取通道名称(调试用)
    std::string Name() { return _name; }

    // 发送任务给对应管道,使后续子进程接收
    void Send(int taskcode)
    {
        // 把随机分配的任务码写入管道文件,使其激活对应的子进程进行读取read操作
        ssize_t n = write(_wfd, &taskcode, sizeof(taskcode));
        (void)n; // 屏蔽未使用变量警告(实际场景应检查写操作是否成功)
    }

    // 关闭管道写端
    void Close()
    {
        if (_wfd > 0)
        {
            close(_wfd);
        }
    }

    // 等待子进程进行回收
    void Wait()
    {
        pid_t rid = (_subid, nullptr, 0);
        (void)rid; // 屏蔽未使用变量警告
    }

    ~Channel() {}

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

// 再组织
// 对所有创建的管道进行管理
class ChannelManager
{
public:
    ChannelManager()
        : _next(0)
    {
    }

    void Insert(int wfd, pid_t subid)
    {
        // Channel cn(wfd, subid);
        // _channels.push_back(cn);

        _channels.emplace_back(wfd, subid);
    }

    Channel &Select()
    {
        Channel &ch = _channels[_next]; // 获取对应的管道
        _next++;                        // 轮询选择子进程
        _next %= _channels.size();      // 防止越界
        return ch;
    }

    // 关闭所有管道的写端,使所有子进程read返回0退出
    void CloseSubProcess()
    {
        for (auto &channel : _channels)
        {
            channel.Close();
            std::cout << channel.Name() << " close success!" << std::endl;
        }
    }

    // 等待所有子进程进行回收处理
    void WaitSubProcess()
    {
        for (auto &channel : _channels)
        {
            channel.Wait();
            std::cout << channel.Name() << " wait success!" << std::endl;
        }
    }

    // 调试打印:输出所有通道信息
    void Print()
    {
        for (auto &channel : _channels)
        {
            std::cout << channel.Name() << " - " << channel.Wfd() << " - " << channel.Subid() << std::endl;
        }
    }

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

6.3 任务管理(Task.hpp)

任务管理层,通过 TaskManager 类将具体的任务函数(如打印日志、下载、上传、访问数据库)封装成统一的 std::function<void()> 类型并存储在 _task 容器中,提供 LoadTast() 加载所有任务、Code() 随机返回任务码(下标)以及 Execute(taskcode) 根据任务码调用对应函数的功能,使得进程池中的子进程只需拿到任务码就能执行相应的业务逻辑。

cpp 复制代码
#pragma once
#include <iostream>
#include <unistd.h>
#include <functional>
#include <vector>
#include <ctime>

// 包装器function:统一可调用对象的类型
using task_t = std::function<void()>;

// 具体任务1:打印日志(带子进程ID标识)
void PrintLog()
{
    std::cout << "我是一个打印日志的任务" << std::endl;
}

// 具体任务2:模拟下载
void DownLoad()
{
    std::cout << "我是一个下载任务" << std::endl;
}

// 具体任务3:模拟上传
void UpLoad()
{
    std::cout << "我是一个上传任务" << std::endl;
}

// 具体任务4:模拟访问MySQL
void ReadMysql()
{
    std::cout << "我是一个访问数据库的任务" << std::endl;
}

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

    // 加载所有任务
    void LoadTast()
    {
        _task.push_back(PrintLog); //将任务函数存放vector中,后续子进程通过_tm下标即可执行对应任务
        _task.push_back(DownLoad);
        _task.push_back(UpLoad);
        _task.push_back(ReadMysql);
    }

    //随机获取任务码
    int Code()
    {
        // 随机分配任务(任务码)(0~3)
        return rand() % _task.size();
    }

    //// 执行任务码对应的任务
    void Execute(int taskcode)
    {
        if(taskcode >= 0 && taskcode < _task.size())
        {
            _task[taskcode]();
        }
    }

    ~TaskManager()
    {}
private:
    std::vector<task_t> _task; // 存储所有可执行的任务
    // 实现将任务函数放入vector容器中,通过下标即可获取对应的函数进行调用
};

6.4 进程池实现(ProcessPool.hpp)

完整的基于管道通信的进程池,主要包含三个部分:

ProcessPool 类负责整体流程控制,通过 Start() 创建指定数量的子进程和匿名管道(父进程关闭读端保留写端,子进程关闭写端进入 Work() 循环阻塞读取);DispatchTask() 和 PushTask() 配合 ChannelManager 的轮询机制将任务码均匀分发到各子进程;Stop() 统一关闭所有管道写端,使子进程 read 返回 0 退出并被回收,从而实现进程池的生命周期管理。

cpp 复制代码
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__

#include "Channel.hpp"
#include "Task.hpp"
#include <cstdlib>

class ProcessPool
{
public:
    ProcessPool(int num)
        : _process_num(num)
    {
        _tm.LoadTast(); // 初始化进程池时加载所有的任务
    }

    // 子进程工作函数:循环读取管道中的任务码并执行
    // rfd:管道读端文件描述符
    void Work(int rwd)
    {
        //子进程一直等待获取任务码执行相应任务,只有当管道关闭,则子进程read返回0进行退出
        while (true)
        {
            int taskcode = 0;
            ssize_t n = read(rwd, &taskcode, sizeof(taskcode)); //当管道没有数据时则子进程堵塞等待
            if (n < 0)
            {
                // 读取失败
                std::cout << "读取错误" << std::endl;
                break; // break后退出Work函数,则子进程退出
            }
            else if (n == 0)
            { 
                // n为0说明读取文件结尾,则对应管道的写端close了,则子进程退出
                //std::cout << "子进程退出" << std::endl;
                break;
            }
            else
            {
                if (n != sizeof(taskcode))
                {
                    // 说明数据没有读取完整,重新读取
                    continue;
                }
                else
                {
                    // 读取成功且长度正确:执行任务
                    std::cout << "子进程[" << getpid() << "]接收一个任务码: " << taskcode << std::endl;
                    _tm.Execute(taskcode);
                }
            }
        }

        // 测试Work函数功能
        //  while(true)
        //  {
        //      std::cout << "我是子进程, 我的rfd是: " << rwd << std::endl;
        //      sleep(5);
        //  }
    }

    // 启动进程池(父进程执行):创建指定数量的子进程和管道
    void Start()
    {
        for (int i = 0; i < _process_num; i++)
        {
            // 1. 创建匿名管道
            int pipefd[2] = {0};
            int n = pipe(pipefd);
            if (n < 0)
            {
                perror("pipe");
                exit(2);
            }

            // 2. 创建子进程
            pid_t id = fork();
            if (id < 0)
            {
                perror("fork");
                exit(3);
            }
            else if (id == 0)
            {
                // 子进程
                close(pipefd[1]); // 子进程关闭写端(只读)

                Work(pipefd[0]); // 执行工作函数

                close(pipefd[0]);
                exit(0);
            }
            else
            {
                // 父进程
                close(pipefd[0]); // 父进程关闭读端(只写)
                // 创建通道对象并加入管理列表
                _cm.Insert(pipefd[1], id); // wfd subid
            }
        }
    }

    // 分配所有的任务给所有子进程执行
    void DispatchTask()
    {
        int cnt = 10;
        while (cnt--)
        {
            PushTask(cnt);
            usleep(1000000); // 模拟任务分发间隔(500ms)
        }
    }

    //分配随机任务给子进程
    void PushTask(int cnt)
    {
        // 1、随机选分配一个任务(任务码:下标):
        int taskcode = _tm.Code();

        // 2、选择一个信道(子进程),需要负载均衡的选择一个子进程->轮询,执行任务
        Channel &ch = _cm.Select(); // 获取对应管道
        std::cout << "选择了一个子进程" << ch.Name() << std::endl;
        // 3、通过任务码发送任务给选择的子进程执行
        ch.Send(taskcode);

        std::cout << "send " << taskcode << " to " << ch.Name() << ", 任务还剩: " << cnt << std::endl;
    }

    //退出进程池:关闭所有管道,回收子进程
    void Stop()
    {
        //1、关闭所有管道的写端
        _cm.CloseSubProcess();
        //2、等待所有子进程进行回收处理
        _cm.WaitSubProcess();
    }

    // 调试打印:输出所有通道信息
    void DebugPrint()
    {
        _cm.Print();
    }

private:
    ChannelManager _cm; //进程池管理ChannelManager
    int _process_num;   // 进程池大小(子进程数量)
    TaskManager _tm;    //进程池管理TaskManager
};

#endif

6.5 主程序(Main.cc

cpp 复制代码
#include "processpool.hpp"
#include <memory>
#include <string>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << "Usage: " << argv[0] << " process-num" << std::endl;
        return 1;
    }
    int num = std::stoi(argv[1]);

    // 1. 创建进程池对象(智能指针自动管理内存)
    std::unique_ptr<ProcessPool> pp(new ProcessPool(num));

    // 2. 启动进程池(创建子进程和管道)
    pp->Start();
    sleep(2);
    //测试管道
    // pp->DebugPrint();
    // sleep(100);

    // 3. 派发所有随机任务
    pp->DispatchTask();

    //4、停止进程池(回收资源)
    pp->Stop();
    return 0;
}

6.6 编译运行

Makefile:

bash 复制代码
BIN=processpool
CC=g++
FLAGS=-c -Wall -std=c++11
LDFLAGS=-o
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o)

$(BIN):$(OBJ)
	$(CC) $(LDFLAGS) $@ $^

%.o:%.cc
	$(CC) $(FLAGS) $<

.PHONY:clean
clean:
	rm -f $(BIN) $(OBJ)

运行示例:

七、核心要点总结

本文从进程间通信的基础出发,详细解析了 Linux 匿名管道的核心概念、创建 API、基于 fork 的跨进程通信实现,从文件描述符内核两个视角深入剖析了匿名管道的底层逻辑,最后实现简单的进程池,核心要点可总结为:

  • 管道是 Linux 最基础的 IPC 方式,本质是内核中的一块缓冲区,贴合**"一切皆文件"** 的设计思想,通过read/write接口操作;
  • 匿名管道通过pipe 函数创建,返回读、写两个文件描述符,需借助fork实现亲缘进程间通信,核心是子进程继承父进程的文件描述符表,共享同一个内核管道缓冲区;
  • 匿名管道通信的标准流程是:创管道→fork 子进程→关闭无用文件描述符→读写通信→关闭描述符,关闭无用描述符是保证单向通信的关键;
  • 从文件描述符视角,父子进程的文件描述符指向同一个内核管道缓冲区;从内核视角,管道是由两个file结构体指向同一个inode的内核缓冲区,读写操作是进程与内核缓冲区之间的数据拷贝;
  • 匿名管道是半双工的,仅支持亲缘进程间的单向通信,若需双向通信需创建两个管道,且其生命周期随进程,数据不持久化。

结束语

本文从 IPC 基础知识起步,循序渐进讲解管道原理,结合代码实例剖析匿名管道通信细节,再深入内核剖析底层架构与读写规则,最终落地完成管道版简单进程池的实现。从理论到实操完整梳理了管道相关知识点,既理清进程隔离与数据互通的底层逻辑,也借助进程池项目验证管道在并发场景的实用价值。至此全文内容完结,依托管道实现进程通信与任务调度的相关内容介绍完毕。

相关推荐
z200509301 小时前
【Linux学习】Linux中的进程程序替换
linux·服务器·学习
bush42 小时前
嵌入式linux学习记录四
linux·运维·学习
lihao lihao3 小时前
软硬链接
linux·运维·服务器
YY&DS3 小时前
Qt 嵌入 CEF 在 Linux 下必须设置 `QT_XCB_GL_INTEGRATION=xcb_egl才能加载网页
linux·开发语言·qt
辰风沐阳3 小时前
ThinkPHP8.1 + think-swoole 4.1 使用指南(保姆级教程)
linux·后端·swoole
mounter6254 小时前
迈向硬件级无缝热升级:Linux 内核 VFIO 与 IOMMU 持久化技术的演进之路
linux·服务器·内存管理·kernel
晚风吹红霞4 小时前
Linux软件包管理器详解 —— yum与apt的使用及软件生态
linux·运维·服务器
曦夜日长4 小时前
Linux系统篇,进程概念(一):计算机体系、操作系统的认识、程序的加载过程
linux·运维·网络