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

相关推荐
A小辣椒2 天前
TShark:Wireshark CLI 功能
linux
A小辣椒2 天前
TShark:基础知识
linux
AlfredZhao2 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334663 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪3 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩4 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言