Linux下 进程间通信详解(一)管道、进程池与简单的Linux 进程间聊天室

欢迎来到我的频道 【点击跳转专栏】

码云链接 【点此转跳】

文章目录

  • [1. 进程间通信介绍](#1. 进程间通信介绍)
    • [1.1 进程间通信的目的](#1.1 进程间通信的目的)
    • [1.2 进程间通信的发展](#1.2 进程间通信的发展)
    • [1.3 进程间通信分类](#1.3 进程间通信分类)
    • [1.4 从进程通信的碎片化,看互联网行业标准的诞生逻辑](#1.4 从进程通信的碎片化,看互联网行业标准的诞生逻辑)
    • [1.5 如何实现进程间通讯](#1.5 如何实现进程间通讯)
  • [2. 管道](#2. 管道)
    • [2.1 匿名管道](#2.1 匿名管道)
      • [1. 匿名管道原理](#1. 匿名管道原理)
      • [2. 站在⽂件描述符⻆度-深度理解管道](#2. 站在⽂件描述符⻆度-深度理解管道)
      • [3. pipe(系统调用)](#3. pipe(系统调用))
      • [4. 站在内核⻆度-管道本质](#4. 站在内核⻆度-管道本质)
      • [5. 代码实例](#5. 代码实例)
      • [6. 匿名管道特性和情况总结](#6. 匿名管道特性和情况总结)
      • [7. 管道的应用场景(进程池)](#7. 管道的应用场景(进程池))
    • [2.2 进程池代码演示](#2.2 进程池代码演示)
      • [1. 核心设计思路解析](#1. 核心设计思路解析)
      • [2. 完整代码与展示](#2. 完整代码与展示)
      • [3. bug修改 难点突破](#3. bug修改 难点突破)
      • [4. 完整代码(采用方案3,上面完整代码采用方案1)已经上传gitee](#4. 完整代码(采用方案3,上面完整代码采用方案1)已经上传gitee)
    • [2.3 命名管道](#2.3 命名管道)
      • [1. 命名管道原理](#1. 命名管道原理)
      • [2. 创建一个命名管道(mkfifo)](#2. 创建一个命名管道(mkfifo))
      • [3. 匿名管道与命名管道的区别和相同点(有名管道的特性)](#3. 匿名管道与命名管道的区别和相同点(有名管道的特性))
    • [2.4 利用命名管道实现 Linux 进程间聊天室](#2.4 利用命名管道实现 Linux 进程间聊天室)
      • [1. unlink](#1. unlink)
      • [2. stat](#2. stat)
      • [3. 用open打开管道的一些重难点](#3. 用open打开管道的一些重难点)
      • [4. 测试代码与效果展示](#4. 测试代码与效果展示)

1. 进程间通信介绍

1.1 进程间通信的目的

  • 数据传输:一个进程需要将它的数据发送给另一个进程
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程回收)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

1.2 进程间通信的发展

  • 管道(文件级别)
  • System V进程间通信(一台机器内部,边缘化技术)
  • POSIX进程间通信(跨网络通信)

1.3 进程间通信分类

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC

  • 消息队列
  • 共享内存
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

一般操作系统,会有一个独立的通信模块------隶属于文件系统------IPC通信模块 其中有两套标准 system V && posix

1.4 从进程通信的碎片化,看互联网行业标准的诞生逻辑

进程的本质属性是独立性,但在实际应用中,无论是进程间交互还是网络数据传输,通信都显得至关重要。早期由于通信模块的设计相对灵活且实现方案多样,导致各大 Linux 发行版乃至其内部标准出现了严重的碎片化现象------大家"各自为战",缺乏统一的规范。

面对市面上层出不穷的通信方案,我们必须解决两个核心问题:(1)甄选出最合适的技术方案;(2)制定并推行一套强制性的统一标准。只有这样,才能确保不同的操作系统在设计底层逻辑时保持一致性,避免巨大的差异。

互联网行业标准的重要性不言而喻。这就好比华为手机与苹果手机虽然硬件和系统架构迥异,却依然能够实现无缝通信,这正是因为各个领域的行业巨头共同制定了互联互通的标准。标准的缺失往小了说是功能交互受阻,往大了说将直接阻碍互联网与物联网的构建! 因此,无论技术如何自由发展,其背后都必须有一套严格的标准作为支撑,否则便无法融入主流生态。

那么,这套标准究竟该如何制定?在现实中,许多机构和公司都提出了各自的通信方案。由于标准往往伴随着知识产权与专利费用(例如采用欧美标准需支付高昂的授权费),各方利益博弈激烈,互不相让。然而,行业标准的制定绝非儿戏,更不是谁都能主导的。想要成为规则的制定者,必须满足两大硬性条件:(1)具备极强的技术实力与深厚的行业威望;(2)拥有极其成熟的技术方案,能够以绝对的优势令同行信服。

在所有科技互联网公司中,有两句行业内都认可的话:

  1. 在传统稳定的技术下(如OS,客户端开发):三流公司做技术(外包),二流公司做产品(腾讯、小米),一流公司做标准(微软、苹果、华为)!!
  2. 对于新兴的技术产业(如AI):一流公司做技术,二流公司则是形成暂定标准!

1.5 如何实现进程间通讯

进程间通信本质:要想办法让不同的进程看到同一份资源(以特定形式存在的内存空间)!

而这个资源必须由OS提供!我们进程访问这个空间,本质上就是在访问操作系统!!

2. 管道

什么是管道:

  • 管道是类Unix中最古老的进程间通信的形式。
  • 我们把从一个进程连接到另一个进程的一个数据流称为一个 "管道"

2.1 匿名管道

1. 匿名管道原理

因为子进程可以做到和父进程看到同一份代码,所以可以尝试让父进程和子进程进行通信!!

在创建子进程时,操作系统确实需要拷贝父进程的 PCB(进程控制块)和文件描述符表。但这里有一个核心前提: 拷贝文件描述符表并不意味着在磁盘上创建了新的实体文件。这是因为操作系统的"进程管理模块"与"文件系统模块"是相互独立的------文件能否被打开是由操作系统内核决定的,进程作为系统资源的管理对象,并没有权限去干涉或强制要求文件系统的底层行为。
但是像是inode、操作表、文件内核缓冲区等无需给子进程进行拷贝,因为系统只需要加载一次!

管道!基于文件,进行内核级 进程间通讯!

但实际情况下:子进程会拷贝对应的struct file,但是指向的是同一个缓冲区!我们将这种基于文件的通讯方式称为管道!

而 管道 是类似于内核缓冲区一样的一段共享的固定大小的空间!


  1. 那么为什么要拷贝struct file呢?

管道的创建代码,其实本质是复用了内核文件部分的代码,在struct file 内部有mode(读写方式) pos(读写位置)属性,管道属于单向通信(为了简单 想做双向也可以 但是没必要)

因为是单向通信(一个,一个!),所以父子进程只能一个写,另一个只能读,而未来方便管理,所以会创建struct file

此外,常规的文件操作为了保证数据的持久化,最终必然会被刷新(同步)到物理磁盘上。但是我们希望数据仅在内存中流转、进行进程间通信而不写入磁盘,就必须引入一种特殊的机制------匿名管道 。它本质上是一种"内存级文件",通过这种设计,操作系统就能完美地将临时的内存数据交互与传统的磁盘文件存储区分开来。

管道是一个纯内存级的文件,不需要打开磁盘文件之类!没有路径,不需要文件名,所以叫匿名管道

2. 站在⽂件描述符⻆度-深度理解管道

  1. 打开同一个文件,都以读写同时打开的方式打开?
    假如以只读\只写 方式打开 子进程不就只能看到读或者写了吗?如果以读写打开 方便子进程看到 读写!
  2. 为什么后面要关闭对应的读写端,不关不行吗?
    答案是可以不关闭的!但是不建议,因为通讯是单向的,这么做容易误操作!
  3. 谁读谁写呢?谁决定?
    都可以读 都可以写 可以父读子写 也可以子读父写 我们可以自行控制!怎么控制 看下面

3. pipe(系统调用)

cpp 复制代码
#include <unistd.h>
功能:创建匿名管道
原型
int pipe(int fd[2]);
参数
fd[2]:这是一个长度为 2 的整型数组,作为输出型参数。
fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
返回值:成功返回0,失败返回错误代码

当函数调用成功后,内核会开辟一块专属的缓冲区,并通过 pipefd 数组返回两个文件描述符:

pipefd[0]:管道的读端 (read end),只能用于读取数据。

pipefd[1]:管道的写端 (write end),只能用于写入数据。

⚠️: 永远是从末尾追加!!


PIPE_BUF 是 Linux 管道(Pipe)通信中一个非常关键的系统常量,它定义了单次写入操作能够保持"原子性"的最大字节数

在绝大多数 Linux 系统中,PIPE_BUF 的默认大小是 4096 字节(即
4KB);我的机器是64KB(如下操作可看)

所谓"原子性",就是指一次写入操作要么完整地执行,要么完全不执行,中间绝不会被其他进程打断或穿插。PIPE_BUF

就是内核用来保障多进程并发写入时不发生数据混乱的"安全红线":

  • 写入数据 ≤ PIPE_BUF : 内核会保证这次写入是原子的。即使有多个进程同时向同一个管道写入数据,只要每次写入不超过 4096 字节,每个进程的数据块都会作为一个连续的整体存入管道,绝对不会和其他进程的数据交错混合。
  • 写入数据 > PIPE_BUF : 内核不再保证原子性。如果多个进程同时写入超过 4096 字节的大块数据,这些数据可能会被拆分成多个小片段,并且不同进程的数据片段可能会像拉链一样相互穿插,导致读取端收到错乱的数据。

可以把管道想象成一根多人共用的注水管,而 PIPE_BUF 就像是规定每个人单次倒水的"标准桶容量"(比如 4 升):

  • 如果你每次只倒一桶水(≤ PIPE_BUF),不管别人怎么插队,你这桶水一定是完整流进去的,不会断成两截。
  • 如果你非要一次性倒一吨水(> PIPE_BUF),由于水流太大时间太长,别人很可能在你倒水的过程中也往里面注水,最终导致管子里的水是你和别人的水混杂在一起的。

写一段代码给大家证明下(默认父进程一直不读(可以用sleep(1000)实现),这段为子进程):

cpp 复制代码
   close(pipefd[0]);
    int size=0;
    while(cnt)
    {
        char A ='A';
        write(pipefd[1],&A,1);
        size++;
        printf("%d\n",size);
    }
    exit(0);

结果:

最终最多只能写65536byte64kb 不过我这是ubuntu22.04的机器!

4. 站在内核⻆度-管道本质

看待管道,就如同看待⽂件⼀样!管道的使⽤和⽂件⼀致,和普通文件一样,在内核中也有对应的 struct file 结构体,但是管道的缓冲区无法写入磁盘。这么设计迎合了"Linux⼀切皆⽂件思想"。

内核在创建管道时,会专门伪造一个 inode。它会把 i_mode(文件模式)设置为 S_IFIFO( i_mode 本质上是一个 16位的二进制数,最高几位(通常是前4位)专门用来标识这个 inode 到底代表什么类型的文件,剩下的低位部分(主要是最后9位)就是我们最熟悉的 Linux 文件权限了 ),以此向整个系统声明:"我不是普通的磁盘文件,我是一个 FIFO(命名管道)或匿名管道!"

⚠️: 匿名管道没有 dentry!

补充:

标志位宏 对应的文件类型 常见示例/用途
S_IFIFO 管道文件 (FIFO) 匿名管道、命名管道
S_IFREG 普通文件 (Regular) .txt, .c, 图片等日常文件
S_IFDIR 目录 (Directory) 文件夹
S_IFLNK 符号链接 (Link) 快捷方式
S_IFCHR 字符设备 (Character) 键盘、串口 (/dev/tty)
S_IFBLK 块设备 (Block) 硬盘、U盘
S_IFSOCK 套接字 (Socket) 网络通信端点

5. 代码实例

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

int main()
{
    // 1. 创建管道
    int pipefd[2] = {0}; // 定义包含两个文件描述符的数组:pipefd[0]用于读,pipefd[1]用于写
    int n = pipe(pipefd); // 调用 pipe 系统调用创建管道
    if (n < 0) {
        perror("pipe"); // 如果返回值小于0,说明创建失败
        return 1;
    }
    
    printf("pipefd[0]: %d ,pipefd[1]:%d\n", pipefd[0], pipefd[1]); // 打印读写端的文件描述符编号
    
    // 2. fork 创建子进程
    pid_t id = fork(); 
    
    // 3. 子进程逻辑(充当写入端 Writer)
    if (id == 0) {
        close(pipefd[0]); // 子进程不需要读取,关闭管道的读端(良好的编程习惯)
        
        char *msg = "hello fcy";
        int cnt = 10;
        char outbuffer[1024]; // 准备一个缓冲区用于存放要发送的格式化字符串
        
        while (cnt) {
            // 将消息、计数器和当前子进程PID格式化拼接到 outbuffer 中
            snprintf(outbuffer, sizeof(outbuffer), "c->f# %s %d %d\n", msg, cnt--, getpid());
            // 将拼接好的字符串通过管道的写端(pipefd[1])写入内核管道缓冲区
            write(pipefd[1], outbuffer, strlen(outbuffer));
            sleep(1); // 每隔1秒发送一次,模拟周期性数据上报
        }
        exit(0); // 发送完毕,子进程正常退出
    }

    // 4. 父进程逻辑(充当读取端 Reader)
    close(pipefd[1]); // 父进程不需要写入,关闭管道的写端
    char inbuffer[1024]; // 准备一个缓冲区用于接收数据

    while (1) {
        // 从管道的读端(pipefd[0])读取数据。预留1个字节空间,防止后续添加字符串结束符时越界
        ssize_t n = read(pipefd[0], inbuffer, sizeof(inbuffer) - 1); 
        
        if (n > 0) {
            inbuffer[n] = '\0'; // 手动在读取到的有效数据末尾添加字符串终止符 '\0'
            printf("%s", inbuffer); // 将从子进程收到的消息打印到终端
        } else if (n == 0) {
            // read 返回 0,说明管道的写端已经全部关闭,数据流彻底结束
            break; 
        }
        // 如果 n < 0 则代表读取发生错误(此处省略错误处理以保持代码简洁)
    }

    // 5. 回收子进程资源,防止产生僵尸进程
    pid_t rid = waitpid(id, NULL, 0);
    (void)rid; // 强转 void,避免编译器提示"变量未使用"的告警
    
    return 0;
}

父进程先创建管道并 fork 出子进程,随后双方默契地关闭各自不需要的端口(子进程关读端、父进程关写端),形成一条"单行道";接着,子进程每隔一秒将格式化好的字符串通过 write 写入内核管道,而父进程则不断通过 read 从内核拉取数据,并在用户态缓冲区中严谨地手动添加 \0 结束符后打印出来,直到子进程发送完毕退出! 至此成功模拟了两个父子进程间的通讯!
效果:

6. 匿名管道特性和情况总结

特性总结:

  1. 管道是只能单向通信,即单工通信!
  2. 匿名管道只能用来进行 具有血缘关系的进程之间的通信(继承内核资源),常用于父子、兄弟间通信!
  3. 管道是面向字节流的!

即站在管道角度,并不关心 写入的是字符串还是图片或者音频,都是以字节传输的! 即假如一次性往管道写入了3行字符串,但是对管道来说 都是字节 可以一次性读完也可以读100次 一切按你的期望来 我想说明的是: 写的次数与读的次数并不正相关!

在现实中 发三次快递 你必须签收三次!这个叫数据报;自来水公司 一次性给你家供了1吨水 你按需接水 可以接好几次,这叫

  1. 打开的文件生命周期,随进程,管道也同理,因为管道也是文件的一种!

在文件的inode中有一个i_count的属性,每当有一个进程与该文件关联,则该属性+1;当一个关联进程关闭,则该属性-1,当减到0时候,则文件会自动释放

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

如果数据写入写了一半,此时另一端就开始读,就会出现读不完的情况,即数据并发访问出了问题! 所以必须要有互斥,即只有一个进程可以访问管道的资源同步即读写双方有一定顺序性,就像排队上公交一样!


情况总结:

  1. 子进程写的慢(管道为空),父进程只能阻塞等,等管道有数据,父进程才能读。
  2. 子进程写的快(管道为满),父进程不读,管道一旦被写满,子进程就必须被阻塞了!
  3. 读端在读,写端关闭,读端读完管道中剩余的数据,再读,就会读取""(空),read返回值为0,表明读取管道读到文件结尾!

此时子进程写一下一条数据后写端关闭,下面是父进程代码:

cpp 复制代码
    close(pipefd[1]);
    char inbuffer[1024];
    while (1)
    {
        inbuffer[0] = '\0';
        ssize_t n = read(pipefd[0], inbuffer, sizeof(inbuffer) - 1);
        printf("%s: %ld\n", inbuffer, n);
        sleep(1);      
    }

如果管道里没有数据,并且所有持有写端的进程都执行了 close(pipefd[1]) 或者直接退出了,此时你再调用 read,系统会立刻返回 0。

  1. 写端一直写,读端不读关闭fd,OS会直接杀掉写的进程!

这种杀死进程的方式,属于异常结束,父进程回收子进程的僵尸进程后,查看status信息就可以知道 子进程是怎么异常退出的:

此时退出信号为:13

当管道的读端已经关闭,而你的进程依然尝试向这个管道里写入(write/send)数据时,内核就会向你发送 SIGPIPE 信号杀死写端进程!

7. 管道的应用场景(进程池)

我们可以通过控制父进程写的方式 来控制对应子进程完成对应的任务,我们称其为:进程池

即类似预制菜一样 提前准备好,当我想吃的时候直接加热即可!这种模式可以大大提高相应效率!!所谓的池化技术就是对资源的预先创建

2.2 进程池代码演示

1. 核心设计思路解析

在深入代码之前,我们先来拆解一下这个进程池的整体架构。它的核心思想是"预创建 + 复用",避免频繁创建和销毁进程带来的巨大系统开销。

  1. 整体架构:主从式
  • 父进程(Master):充当管理者。它负责预先创建好一组子进程,并通过管道向它们派发任务。
  • 子进程(Worker):充当打工者。它们在启动后会进入一个死循环,阻塞等待父进程通过管道下发的任务指令,拿到任务后执行,然后继续等待下一个任务。
  1. 通信机制:匿名管道
  • 父进程在 fork 创建子进程之前,先调用 pipe() 创建管道。
  • fork 之后,父子进程共享文件描述符表。此时约定:父进程关闭读端保留写端,子进程关闭写端保留读端
  • 这样就建立了一条单向通信通道:父进程写(下发任务码),子进程读(接收任务码)。
  1. 负载均衡策略:轮询(Round-Robin)
  • 父进程维护了一个 Channel 容器,里面装着所有子进程的通信管道。
  • 在派发任务时,采用简单的轮询算法index++ % size),依次将任务均匀地分发给每一个子进程,防止某个子进程累死,而其他子进程闲死。
  1. 退出机制
  • 当父进程决定关闭进程池时,它会主动关闭所有管道的写端
  • 子进程在 read 管道时会发生阻塞。一旦读到返回值为 0(即对端关闭了写端,触发了对应程序,),子进程就知道该下班了,于是跳出循环并调用 exit 正常退出。
  • 最后父进程调用 waitpid 回收所有子进程的资源,防止产生僵尸进程。

2. 完整代码与展示

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <functional>
#include <cstdlib>
#include <ctime>
#include <sys/wait.h>

///////////////// 模拟的子进程要完成的任务 (业务逻辑层) /////////////////
// 定义几个模拟耗时操作的函数,实际开发中可以是数据库操作、网络请求等
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;
}

// 使用 typedef 定义函数指针类型,统一任务函数的格式:无参无返回值
typedef void (*task_t)(); 

// 任务映射表:通过数组下标(任务码)来索引具体的任务函数
task_t tasks[4] = {syncDisk, Download, PrintLog, UpdateStatus};

///////////// 进程池相关 (核心管理层) /////////////////
enum
{
  OK,
  PIPE_ERROR,
  FORK_ERROR
};

// 全局常量:预设进程池中子进程的数量
const int g_processnum = 5;

// 使用 using 定义回调函数类型,用于解耦父进程和子进程的执行逻辑
using cb_t = std::function<void(int)>;

// ==========================================
// 子进程的入口函数 (Worker 的工作循环)
// ==========================================
void DoTask(int fd)
{
  while (1)
  {
    int task_code = 0;
    // 阻塞读取管道中的数据,这里假设传输的是一个 int 类型的任务码
    ssize_t n = read(fd, &task_code, sizeof(task_code));
    
    if (n == sizeof(task_code))
    {
      // 读取成功,根据任务码在任务表中找到对应的函数并执行
      if (task_code >= 0 && task_code < 4)
      {
        tasks[task_code](); 
      }
    }
    else if (n == 0)
    {
      // read 返回 0 表示管道对端(父进程)关闭了写端,遇到了 EOF
      // 这意味着父进程要求结束,子进程也应该退出了
      std::cout << getpid() << ":task quit ..." << std::endl;
      break;
    }
    else
    {
      // 读取失败,处理错误
      perror("reading failed!");
      break;
    }
  }
}

// ==========================================
// 进程池类封装 (Master 的管理中心)
// ==========================================
class ProcessPool
{
private:
  // 内部类:Channel (信道)
  // 作用:封装父进程与单个子进程之间的通信管道及子进程信息
  class Channel
  {
  public:
    Channel(int wfd, pid_t pid) : _wfd(wfd), _sub_pid(pid)
    {
      _sub_name = "sub-channel-" + std::to_string(_sub_pid);
    }
    
    // 向管道写入任务码(下发任务)
    void Write(int index)
    {
      ssize_t n = write(_wfd, &index, sizeof(index));
      (void)n;
    }
    
    // 关闭管道的写端(用于通知子进程退出)
    void ClosePipe()
    {
      std::cout << "关闭wfd" << _wfd << std::endl;
      close(_wfd);
    }
    
    std::string Name()
    {
      return _sub_name;
    }
    
    // 回收子进程资源,防止僵尸进程
    void Wait()
    {
      pid_t rid = waitpid(_sub_pid, nullptr, 0);
      (void)rid;
      std::cout << "回收子进程" << _sub_name << std::endl;
    }
    
    // 调试用的打印函数
    void PrintfInfo()
    {
      printf("wfd: %d, who: %d, channel name %s\n", _wfd, _sub_pid, _sub_name.c_str());
    }
    
    ~Channel()
    {
    }
    
    // 成员变量
    int _wfd;       // 管道的写端文件描述符(父进程持有)
    pid_t _sub_pid; // 对应子进程的 PID
    std::string _sub_name; // 通道名称(用于调试)
    int cnt;        // 预留字段,可用于扩展权重等负载均衡策略
  };

  // 组织所有 Channel 的容器,相当于管理了所有的子进程
  std::vector<Channel> channels;

  // 创建进程和管道的核心逻辑
  void CreateProcessChannel(cb_t cb)
  {
    // 循环创建指定数量的子进程
    for (int i = 0; i < g_processnum; i++)
    {
      int pipefd[2] = {0};
      // 1. 创建匿名管道
      int n = pipe(pipefd);
      if (n < 0)
      {
        std::cerr << "pipe create error" << std::endl;
        exit(PIPE_ERROR);
      }
      
      // 2. 创建子进程
      pid_t id = fork();
      if (id < 0)
      {
        std::cerr << "fork error" << std::endl;
        exit(FORK_ERROR);
      }
      else if (id == 0)
      {
        // ================= 子进程逻辑 =================
        close(pipefd[1]); // 子进程不需要写,关闭写端
        cb(pipefd[0]);    // 调用传入的回调函数(即 DoTask),开始工作循环
                          // 注意:cb 执行完意味着子进程要退出了
        exit(OK);         // 确保子进程执行完任务后正常退出
      }
      else
      {
        // ================= 父进程逻辑 =================
        close(pipefd[0]); // 父进程不需要读,关闭读端
        
        // 将新创建的管道写端和子进程PID封装成 Channel 对象存入容器
        // 使用 emplace_back 直接在容器内构造对象,避免拷贝开销
        channels.emplace_back(pipefd[1], id);
        
        std::cout << "创建子进程:" << id << "成功..." << std::endl;
      }
    }
  }

  // 负载均衡策略1:轮询选择子进程
  int SelectChannel()
  {
    static int index = 0; // 静态变量,记录上一次选中的位置
    int selected = index;
    index++;
    index %= channels.size(); // 保证下标在容器范围内循环
    return selected;
  }

  // 随机选择一个任务码
  int SelectTask()
  {
    int itask = rand() % 4; // 随机生成 0~3 的任务码
    return itask;
  }

  // 将任务发送给指定的子进程
  void SendTask2Salver(int itask, int index)
  {
    // 边界检查,防止越界
    if (itask >= 4 || itask < 0) return;
    if (index < 0 || index >= channels.size()) return;
    
    // 通过对应的 Channel 写入任务码
    channels[index].Write(itask);
  }

public:
  ProcessPool()
  {
    // 设置随机数种子,结合时间和 PID 保证随机性
    srand((unsigned int)time(NULL) ^ getpid());
  }

  // 初始化进程池
  void Init(cb_t cb)
  {
    CreateProcessChannel(cb);
  }

  // 优雅退出进程池
  void Quit()
  {
    // 步骤1:让所有子进程退出
    // 原理:父进程关闭所有管道的写端,子进程 read 会读到 0 (EOF),从而自动退出
    for (auto &channel : channels)
    {
      channel.ClosePipe();
    }

    // 步骤2:回收子进程资源
    // 遍历所有 Channel,调用 waitpid 等待子进程结束
    for (auto &channel : channels)
    {
      channel.Wait();
    }
  }

  // 调试函数:打印所有 Channel 的信息
  void Debbug()
  {
    for (auto &c : channels)
    {
      c.PrintfInfo();
    }
  }

  // 运行进程池的主控制循环
  void Run()
  {
    int cnt = 10; // 模拟发送 10 个任务
    while (cnt--)
    {
      std::cout << "-----------------------" << std::endl;
      
      // 1. 随机挑选一个任务
      int itask = SelectTask();
      std::cout << "itask: " << itask << std::endl;
      
      // 2. 通过轮询选择一个子进程 (Channel)
      int index = SelectChannel();
      std::cout << "index: " << index << std::endl;

      // 3. 将任务码发送给指定的子进程
      SendTask2Salver(itask, index);
      printf("发送 %d to %s\n", itask, channels[index].Name().c_str());
      
      sleep(1); // 稍微停顿一下,方便观察输出
    }
  }
};

int main()
{
  // 0. 实例化进程池对象
  ProcessPool pp;
  
  // 1. 初始化:创建管道和子进程,并将 DoTask 作为子进程的执行入口
  pp.Init(DoTask);

  // 2. 父进程开始派发任务(演示负载均衡)
  // 正常情况下,这里可以是接收网络请求,然后将请求转化为任务派发给子进程
  pp.Run();

  // 3. 释放和回收所有资源(关闭管道,回收子进程)
  pp.Quit();
  
  return 0;
}

效果:

3. bug修改 难点突破

这是我们原本的回收写法:

cpp 复制代码
// 1. 让所有子进程退出
    for (auto &channel : channels)
    {
      channel.ClosePipe();
    }

    // 2. 回收子进程
    for (auto &channel : channels)
    {
      channel.Wait();
    }

cpp 复制代码
for (auto &channel : channels)
    {
      channel.ClosePipe();
      channel.Wait();
    }

在回收部分 如果这么写 会直接卡住 为什么呢?

卡在关闭wfd4直接不动了:

  1. 管道的退出机制
    在子进程的 DoTask 函数中,它通过一个 while(1) 循环不断调用 read(fd, ...) 从管道读取任务。只有当 read 返回值为 0 (表示遇到了 EOF,即管道的写端全部被关闭)时,子进程才会跳出循环并执行 exit 退出。
  2. 文件描述符的"意外"继承
    当父进程通过 for 循环创建多个子进程时,会发生以下情况:
  • 创建第1个子进程(PID: A)时,建立管道1。
  • 创建第2个子进程(PID: B)时,建立管道2。但此时,子进程B会完整拷贝父进程当前的文件描述符表 。这意味着,子进程B不仅拥有管道2的读写端,还顺带继承了父进程手里管道1的写端
  • 以此类推,越靠后创建的子进程,它的文件描述符表中就捏着越多前面管道的写端副本。
  1. 死锁是如何发生的?
    当运行那段有 Bug 的代码时:
cpp 复制代码
for (auto &channel : channels) {
    channel.ClosePipe(); // 父进程关闭当前管道的写端
    channel.Wait();      // 父进程阻塞等待对应的子进程退出
}
  • 假设父进程处理第一个子进程(PID: A)。父进程关闭了管道1的写端,然后调用 Wait() 开始傻等子进程A退出。
  • 但是,子进程A在 read 管道1时,发现管道1的写端引用计数并没有归零!因为后面创建的子进程B、C、D... 的文件描述符表里,都备份着管道1的写端。
  • 只要还有一个写端没关,子进程A的 read 就会认为"可能还有人会给我发数据",于是继续死死地阻塞等待,永远不会返回 0,也就永远不会退出。
  • 结果就是:父进程在 Wait() 处永远等不到子进程A的退出信号,程序直接卡死(死锁)。

可以在/proc/进程的pid中查看其fd具体对应文件证明:

解决方案1:分两次循环
cpp 复制代码
// 1. 先让所有子进程退出
for (auto &channel : channels) {
    channel.ClosePipe(); // 父进程一口气关掉自己手里所有的写端
}
// 2. 回收子进程
for (auto &channel : channels) {
    channel.Wait();
}

虽然其他子进程继承了前面管道的写端,但当这个循环跑完,父进程已经关闭了自己持有的所有写端。对于最后一个子进程来说,它对应的管道写端彻底没了(因为它没有后续的子进程去继承它的管道),它会率先读到 0 并退出。随着各个子进程陆续退出,它们继承的那些"多余写端"也会被操作系统自动释放,所有子进程都能顺利退出。

解决方案2:倒序回收
cpp 复制代码
int end = channels.size() - 1;
while (end >= 0) {
    channels[end].ClosePipe(); // 从最后一个子进程开始关
    channels[end].Wait();
    end--;
}

最后创建的子进程(比如子进程E),它是"最干净"的,它只继承了属于它自己的那个管道的写端,没有继承任何后续进程的管道。所以,当我们倒着先关闭并等待最后一个子进程时,它能立刻感知到写端关闭并退出。当子进程E退出后,它继承的其他管道写端(如果有)也会随之消失。这样像推多米诺骨牌一样,倒着一个个回收,也能完美避开死锁。

解决方案3: 在子进程刚创建的时候就关闭复制下来的历史fd
cpp 复制代码
pid_t id = fork();
      if (id < 0)
      {
        std::cerr << "fork error" << std::endl;
        exit(FORK_ERROR);
      }
      else if (id == 0)
      { 
        //通过前面父进程的写端fd数组,来关闭历史fd,因为子进程只影响的是自己的fd表
        if(!channels.empty())
        {
          for(auto & channel:channels)
          {
            channel.ClosePipe();
          }
        }

      // child
        close(pipefd[1]); // read
        cb(pipefd[0]);    // 回调:让子进程调用出去,回调完成,还会回来
        exit(OK);         // 根本不会执行后续代码,执行完自己的DoTask函数后,自己就退出了
      }

加上代码后:

  • 进程A只持有管道1的读端。
  • 进程B只持有管道2的读端(主动扔掉了管道1的写端)。
  • 进程C只持有管道3的读端(主动扔掉了管道1、2的写端)。

这个也是最优解!

4. 完整代码(采用方案3,上面完整代码采用方案1)已经上传gitee

码云链接 【点此转跳】

lesson35/ProcessPool/ProcessPool.cc

2.3 命名管道

  • 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
  • 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
  • 命名管道是一种特殊类型的文件

与匿名管道不同,它是有文件名和路径的!

1. 命名管道原理

命名管道和匿名管道的原理相同: 都是通过让我们对应的父子进程看到通信资源,采用的是让父子继承的方案。

至于怎么让毫不相干 的不同进程看到同一资源?只要 让不同进程访问同一个路径下的同一个文件即可!而这个特殊文件就叫管道文件 !核心目标就是:让不同进程实现通信!

问题来了:如果再来一个进程,和当前进程打开了同一个文件,怎么保证打开的是同一个文件?

我们标识文件本质 通过 路径+文件名 = 唯一inode 所以只要访问同一路径下的同一个文件即可!

2. 创建一个命名管道(mkfifo)

  • 命名管道可以从命令⾏上创建,命令⾏⽅法是使⽤下⾯这个命令:
shell 复制代码
$ mkfifo filename

命名管道也可以从程序⾥创建,相关系统调用函数有:

cpp 复制代码
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename,mode_t mode)
  • const char *filename :指定要创建的 FIFO 文件的路径和名称(例如 "./my_pipe""/tmp/fifo_test")。
  • mode_t mode :设置该文件的访问权限(使用八进制数表示,如 06660644)。【 受unmask影响!】

返回值 :成功返回 0;失败返回 -1 并设置全局变量 errno

3. 匿名管道与命名管道的区别和相同点(有名管道的特性)

对比维度 匿名管道 (Anonymous Pipe) 命名管道 (Named Pipe / FIFO)
创建方式 调用 pipe() 函数创建 调用 mkfifo() 函数或 mkfifo 命令创建
打开方式 无需手动打开,pipe() 直接返回读写文件描述符 必须像普通文件一样,使用 open() 通过路径名打开
本质区别 仅在于"创建"与"打开"的方式不同 仅在于"创建"与"打开"的方式不同
后续语义 一旦打开,两者的 I/O 操作具有完全相同的语义 一旦打开,两者的 I/O 操作具有完全相同的语义
  1. 数据流向一致 :都是半双工通信(单向数据流)。数据只能从写端(write fd)流入,从读端(read fd)流出。如果需要双向通信,两者都需要建立两个管道。
  2. 读写规则一致 :都遵循 先进先出(FIFO) 的原则,传输的是无消息边界的字节流。
  3. 阻塞机制一致
    • 当管道为空时,read 会默认阻塞,直到有数据写入。
    • 当管道写满时,write 会默认阻塞,直到有数据被读出。
    • 读端关闭写端写 : 内核会向写端发送 SIGPIPE 信号导致进程直接杀死。
    • 写端关闭读端读 : 读完管道里剩下的数据后,再次调用 read() 会立刻返回 0(相当于读到了文件结尾 EOF)。
    • 这些特性都和匿名管道相同!!
  1. 原子性一致 :在写入数据量不超过 PIPE_BUF管道的极限大小)时,两者的写入操作都具有原子性(多进程同时写入不会发生数据交错)。

简单来说,你可以把命名管道理解为 "带了一个文件系统路径名字的匿名管道"。这个名字让它们突破了匿名管道只能在"有亲缘关系进程(如父子进程)"间通信的限制,使得任意两个独立的进程只要知道这个路径,就能像在同一个父子进程中一样进行通信。

2.4 利用命名管道实现 Linux 进程间聊天室

1\2\3 部分为代码所需的 前置知识

unlink 的核心作用是从文件系统中删除一个文件名字与inode的映射关系。使用前需要包含 <unistd.h> 头文件。

⚠️:不能删目录!

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

int unlink(const char *pathname);
  • 参数 pathname:你要删除的文件、管道或软链接的路径。
  • 返回值 :成功返回 0;失败返回 -1 并设置全局变量 errno

unlink 最精髓的地方。它在底层并不是直接把文件数据从硬盘上抹掉,而是执行了以下两步操作:

  1. 将文件的"硬链接计数"减 1
  2. 如果链接计数变为 0,且当前没有任何进程打开该文件,那么系统才会真正释放文件占用的磁盘空间,彻底删除文件。

这就引出了一个非常重要的特性: 如果一个文件已经被某个进程打开了(持有文件描述符 fd),此时调用 unlink 删除它,文件并不会立刻消失!

  • 目录上的文件名会立刻消失(你在文件夹里看不到这个文件了)。
  • 但是,已经打开该文件的进程依然可以继续正常地读写字节流 ,直到该进程主动 close 关闭文件描述符,或者进程退出,文件数据才会被操作系统真正回收。

之前的 mkfifo(创建命名管道)。由于命名管道会在文件系统中留下一个实实在在的"管道文件",当你的程序结束或不再需要这个管道时,必须把它清理掉,否则下次运行可能会因为 EEXIST(文件已存在)而报错。
这时候就需要用到 unlink!

2. stat

在 C/C++ 开发中,当你需要判断文件是否存在、获取文件大小、查看权限或时间戳时,就需要用到 stat 函数。

使用前需要包含 <sys/stat.h> 头文件:

cpp 复制代码
#include <sys/stat.h>

int stat(const char *path, struct stat *buf);
  • 参数 path:目标文件或目录的路径字符串。
  • 参数 buf :一个指向 struct stat 结构体的指针,用于接收查询到的文件属性。
  • 返回值 :成功返回 0;失败返回 -1 并设置全局变量 errno

核心数据结构:struct stat

stat 函数会把文件的元数据填充到这个结构体中。以下是你在开发中最常关注的成员变量:

成员变量 含义说明
st_size 文件的总大小(以字节为单位)
st_mode 文件类型(普通文件、目录等)和访问权限
st_uid / st_gid 文件所有者用户ID和所属组ID
st_nlink 文件的硬链接数量
st_ino 文件的 inode 编号(文件系统中的唯一标识)
st_atime 文件内容最后被访问的时间 (Access Time)
st_mtime 文件内容最后被修改的时间 (Modify Time)
st_ctime 文件元数据(如权限、所有者)最后被改变的时间 (Change Time)

常用于:

  • 判断文件是否存在 :调用 stat,如果返回 -1 且 errno == ENOENT,则说明文件不存在。(我们这里用的就是这个功能!
  • 判断是文件还是目录 :通过宏来判断 st_mode。例如 S_ISREG(st_mode) 判断是否为普通文件,S_ISDIR(st_mode) 判断是否为目录。
  • 获取文件大小 :直接读取 st_size 字段。
cpp 复制代码
#include <sys/stat.h>
#include <iostream>

int main()
 {
    struct stat fileStat;
    // 获取当前目录下 test.txt 的信息
    if (stat("test.txt", &fileStat) == 0)
     {
        std::cout << "文件大小: " << fileStat.st_size << " 字节" << std::endl;
        
        if (S_ISREG(fileStat.st_mode)) 
        {
            std::cout << "这是一个普通文件" << std::endl;
        } else if (S_ISDIR(fileStat.st_mode)) 
        {
            std::cout << "这是一个目录" << std::endl;
        }
    }
     else 
     {
        perror("stat failed");
    }
    return 0;
}

3. 用open打开管道的一些重难点

如果不加任何特殊标志,直接使用 open(fifo_path, O_RDONLY)open(fifo_path, O_WRONLY),系统会强制要求 读写两端必须"配对成功" 才能继续往下执行。

  • 读端先 open (O_RDONLY) : 如果此时没有写端打开这个管道,读端的 open 会一直卡住(阻塞),直到有另一个进程以写方式打开了该管道,它才会返回成功的文件描述符。
  • 写端先 open (O_WRONLY) : 同理,如果此时没有读端打开这个管道,写端的 open 也会一直卡住,直到有另一个进程以读方式打开了该管道。

这种机制其实是一种天然的进程同步手段,保证了通信双方在开始传输数据前都已经准备就绪。但如果在复杂的启动脚本中,很容易因为两边互相等待而导致程序看起来像"死锁"了一样卡住。


如果你不想让 open 卡住,可以在打开时加上 O_NONBLOCK 标志。此时 open 的行为会发生很大变化:

  • 非阻塞读端 (O_RDONLY | O_NONBLOCK)open立刻成功返回,不管有没有写端存在。
  • 非阻塞写端 (O_WRONLY | O_NONBLOCK)open 也会立刻返回。但如果此时没有读端 存在,open 会直接返回 -1 失败 ,并将全局变量 errno 设置为ENXIO(表示 No such device or address, 意即没人读,你写了也没意义)。

4. 测试代码与效果展示

加餐: .hpp是啥?

.h文件通常只写函数声明,.hpp可以函数声明和定义放在一块!


写了一个最基础的 Linux 进程间聊天室。它利用命名管道(FIFO)在两个完全独立的程序之间搭了一座桥:一个程序负责在后台死守管道等着收消息,另一个程序负责把你键盘敲进去的话塞进管道里传过去。这样一来,哪怕它们是两个毫不相干的进程,也能实现你在这头打字、那头立马就能收到的单向实时通信效果。
Pipe.hpp:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 默认管道文件名
const std::string gcommfile = "./fifo";

// 定义打开管道的模式常量
#define ForRead 1    // 读端模式
#define ForWrite 2   // 写端模式

class Fifo
{
public:
    // 构造函数:初始化管道文件名、权限模式以及文件描述符
    Fifo(const std::string &commfile = gcommfile) 
    : _commfile(commfile), _mode(0666), _fd(-1)
    {
    }

    // 创建有名管道 (FIFO)
    // 如果管道文件不存在则创建;若已存在则直接返回
    void Build()
    {
        if (IsExists())
            return;
        
        umask(0); // 清除文件掩码,确保创建的管道具有预期的 0666 权限

        int n = mkfifo(_commfile.c_str(), _mode);
        if (n < 0)
        {
            std::cerr << "mkfifo error: " << strerror(errno) << " errno: " << errno << std::endl;
            exit(1);
        }
        std::cerr << "mkfifo success: " << strerror(errno) << " errno: " << errno << std::endl;
    }

    // 打开管道文件
    // mode: ForRead 为只读,ForWrite 为只写
    // 注意:默认的 open 操作是阻塞的。比如读端先打开时,会一直卡住直到有写端也来打开这个管道
    void Open(int mode)
    {
        // 在通信没有开始之前,如果读端打开,写端如果没打开,读端open就会阻塞,直到write打开!
        // 为什么?区分对端写关闭的情况或者读关闭
        if(mode == ForRead)
            _fd = open(_commfile.c_str(), O_RDONLY);
        else if(mode == ForWrite)
            _fd = open(_commfile.c_str(), O_WRONLY);
        else
        {}

        if(_fd < 0)
        {
            std::cerr << "open error: " << strerror(errno) << " errno: " << errno << std::endl;
            exit(2);
        }
        else
        {
            std::cout << "open file success" << std::endl;
        }
    }

    // 向管道中写入数据
    // msgin: 待发送的字符串消息
    void Send(const std::string &msgin)
    {
        ssize_t n = write(_fd, msgin.c_str(), msgin.size());
        (void)n; // 抑制编译器关于未使用变量的警告
    }

    // 从管道中读取数据
    // msgout: 用于接收读取到的数据的字符串指针
    // 返回值: >0 表示实际读到的字节数;0 表示读到 EOF(通常意味着写端关了);-1 表示出错了
    int Recv(std::string *msgout)
    {
        char buffer[128];
        // 为什么要-1?为了预留一个字节给字符串结束符 '\0',防止后面手动添加时越界
        ssize_t n = read(_fd, buffer, sizeof(buffer)-1); 
        if(n > 0)
        {
            buffer[n] = 0; // 手动添加 C 语言风格的字符串结束符
            *msgout = buffer;
            return n;
        }
        else if(n == 0)
        {
            return 0; // 返回 0 表示 read 到了 EOF,通常意味着写端已经关闭
        }
        else
        {
            return -1; // 返回 -1 表示读取过程中发生了系统错误
        }
    }

    // 删除管道文件
    // 调用 unlink 系统调用将管道文件从文件系统中移除
    void Delete()
    {
        if (!IsExists())
            return;
        int n = unlink(_commfile.c_str());
        (void)n;
        std::cout << "Unlink " << _commfile << std::endl; 
    }

    ~Fifo()
    {
    }

private:
    // 判断管道文件是否已经存在
    // 存在返回 true,否则返回 false
    // 这里特意用 stat 而不是 open,因为 open 管道会触发阻塞或改变其内部状态
    bool IsExists()
    {
        // 不敢使用open判断,和管道的特点有关
        struct stat st;
        int n = stat(_commfile.c_str(), &st);
        if (n == 0)
        {
            // std::cout << "file exists" << std::endl;
            return true;
        }
        else
        {
            errno = 0;
            // std::cout << "file not exist exists: " << errno << std::endl;
            return false;
        }
    }

private:
    std::string _commfile; // 管道文件的路径名
    mode_t _mode;          // 创建管道时的权限位
    int _fd;               // 打开管道后获得的文件描述符
};

服务端:

cpp 复制代码
#include "Pipe.hpp"

int main()
{
    // 创建 && 打开管道
    Fifo pipefile;
    pipefile.Build();     // 创建有名管道文件(如果已存在则跳过)
    pipefile.Open(ForRead); // 以只读方式打开管道(这里会阻塞,直到有写端来连接)
    
    std::string msg; // 用于存放接收到的消息

    // 进入死循环,持续读取客户端发来的数据
    while (true)
    {
        int n = pipefile.Recv(&msg); // 从管道中读取数据
        if(n > 0)
            std::cout << "Client Say# " << msg << std::endl; // 打印接收到的内容
        else
            break; // n <= 0 说明读到了 EOF(写端关闭了)或者出错了,直接退出循环
    }

    pipefile.Delete(); // 通信结束,清理并删除管道文件

    return 0;
}

客户端:

cpp 复制代码
#include "Pipe.hpp"

int main()
{
    Fifo fileclient;
    // 以只写方式打开管道(这里会阻塞,直到有读端来连接)
    fileclient.Open(ForWrite);

    // 进入死循环,持续从键盘获取输入并发送
    while(true)
    {
        std::cout << "Please Enter@ ";
        std::string msg;
        std::getline(std::cin, msg); // 读取用户输入的整行内容
        fileclient.Send(msg); // 将消息写入管道发送给读端

     // 如果用户输入 quit,就主动退出循环,结束通信
        if (msg == "quit") break;
    }
    return 0;
}

效果展示:

相关推荐
hhhh明1 小时前
ubuntu22.04 桌面可视化(vncserver+novnc 方式)
linux·运维·服务器
‎ദ്ദിᵔ.˛.ᵔ₎1 小时前
Linux 权限
linux
拳里剑气1 小时前
Linux:权限
linux·学习方法
ole ' ola1 小时前
Linux DDR内存使用情况
linux·运维·服务器
CingSyuan1 小时前
华为/长江计算 国产信创服务器:基于 BMC 远程 KVM 安装操作系统
运维·服务器·kylin
Kingairy1 小时前
Linux 机器信任关系
linux·运维·服务器
m0_737302581 小时前
OpenClaw:打破对话边界,能够实操设备的开源自主 AI 智能体
服务器
流浪0012 小时前
Linux系统篇(一):从零入门操作系统:冯诺依曼体系到进程的完整理解
linux·运维·服务器
大湿兄啊啊啊2 小时前
MID360S调试
java·服务器·前端