【Linux】进程间通信与进程池

1、进程间通信的目的

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

2、进程间通信的前提

进程具有独立性的虚拟地址空间,彼此无法直接访问对方内部的数据,无法直接通信,要实现进程间通信,需要让多个进程看到同一份资源该资源不属于任一进程的私有空间,该公共资源只能由操作系统内核提供,用户进程想要使用内核提供的公共资源,必须使用系统调用,系统调用本质上是函数,其命名、参数、返回值等由操作系统标准定义

3、匿名管道

3.1 管道的本质和创建逻辑

管道是一种基于文件的进程间通信的方式,本质是让父子进程通过继承机制共享同一个内核缓冲区

例如:父进程在fork()创建子进程时,进程的PCB和文件描述符表会拷贝一份给子进程,子进程拥有独立的副本,但内核文件资源inode、struct file 、内核缓冲区是全局唯一的,不会拷贝,父子的struct file 会指向同一个内核缓冲区

内核会创建一份特殊的内存文件,这个文件就是匿名管道,管道本质就是一种内存级文件,它不用往磁盘上进行刷新

首先父进程以读写的方式分两次打开一个文件,分两次是为了获得两个struct file 对象,让读写操作使用各自独立的指针,进程间不会相互影响,管道必须以读写模式打开,以便fork后父子进程均继承,为了形成单向通信通道,需要手动关闭不需要的文件描述符,若子进程只写,父进程只读,则子进程关闭读端fd[0] ,父进程关闭写端fd[1] ,不关闭可能会导致误操作,因此建议关闭

3.2 pipe()系统调用

pipe() 系统调用:

nasm 复制代码
int pipe(int pipefd[2]);
  • 参数pipefd[2]:输出型参数,函数内部给数组填值
  • pipefd[0] 是管道读端,pipefd[1] 是管道写端
  • 注意 :数组下标pipefd0中的0不是文件描述符数字0,文件描述符0、1、2在程序启动是已被占用,pipefd[0] 指的是管道的读端,pipefd[0] 值为3,pipefd[1] 是管道的写端,值为4
  • 返回值:成功返回0;失败返回-1,设置 errno

创建管道文件,fork创建子进程,关闭读写端,子进程只写,父进程只读

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string.h>
int main()
{
    // 创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe error");
        return -1;
    }
    printf("pipefd[0]=%d, pipefd[1]=%d\n", pipefd[0], pipefd[1]);

    // 创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child: w
        close(pipefd[0]); // 关闭读端
        char *msg = "hello, world";
        // write(pipefd[1], msg, strlen(msg));  // 不要+1,strlen不包含\0,管道也是文件
        int cnt = 10;
        char outbuffer[1024];
        while (cnt)
        {
            snprintf(outbuffer, sizeof(outbuffer), "f->c# %s %d %d", msg, cnt--, getpid());
            write(pipefd[1], outbuffer, strlen(outbuffer));
            sleep(1);
        }
        exit(0);
    }
    // 父进程 r
    close(pipefd[1]); // 关闭写端
    char buffer[1024];
    char inbuffer[1024];
    while (1)
    {
        ssize_t n = read(pipefd[0], inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            inbuffer[n] = 0; // 读到的内容是字符串,手动加\0
            printf("read: %s\n", inbuffer);
        }
        else if (n == 0)
        {
            printf("read EOF\n");
            break;
        }
        else
        {
            perror("read error");
            break;
        }
    }

    pid_t rid = waitpid(id, NULL, 0);
    (void)rid; // 防止警告

    return 0;
}

3.3 管道的五大核心特性

特性1:单向通信

管道只能一端读、一端写,如果想双向通信必须创建两个管道

特性2:只能用于有血缘关系的进程之间通信

父子进程、兄弟进程都可以,毫无关系的两个进程不能用匿名管道,因为匿名管道没有文件名,外部进程无法打开,靠fork()继承文件描述符,才能让多个进程看到同一个管道

特性3:面向字节流

管道传输的是一串无边界的字节流,写的次数和读的次数没有对应关系,管道不关心写入内容的语义,仅将其视为连续的字节序列,写入端一次可以写入大量数据,但读取可以分多次读取,这种无边界的读写模式称为"字节流"

特性4:管道生命周期跟随进程

管道作为内核中的打开文件,其生命周期与引用它的进程绑定,内核通过引用计数(如inode中的refcount)管理管道资源,每有一个进程关联,计数加1;进程退出时,计数减1,当引用计数降为0,操作系统自动释放管道内资源

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

默认状态管道读写是阻塞的

阻塞读:管道缓冲区为空,没有数据的时候,read() 会阻塞,进程休眠,直到有数据写入才返回

阻塞写:管道缓冲区大小有上限,Linux默认PIPE_BUF = 4096字节,管道缓冲区写满之后,write()会阻塞,等待读端把数据读走,腾出空间才能继续写

互斥:对于小于PIPE_BUF的写操作,内核保证其原子性,即写入过程不会被其他进程打断,防止数据被部分读取

同步:同步保证读写双方行为有顺序,没数据读端等待,缓冲区满写端等待

3.4 四种情况

  1. 子进程写的慢,父进程就要阻塞等待,等管道有数据,父进程才能读,管道为空,读就要阻塞,给写机会,管道为满,写就要阻塞,给读机会
  2. 子进程写的快,父进程不读,管道一旦被写满,子进程就必须阻塞了
  3. 读端在读,写端关闭,读端读完管道中剩余数据,再读就会读取到"",read返回值为0,表明读管道读到文件结尾
  4. 写端一直在写,读端不读,这时写操作完全无意义,操作系统会直接杀掉写的进程

4、进程池

进程池是把一个个的进程当做资源,再程序启动前预先创建好所有工作子进程,形成资源池,当有新任务时,无需再经历fork创建进程的开销,直接唤醒池中的空闲进程执行,极大提升了效率

采用主从(Master-Slaver)模式架构,父进程负责创建多个匿名管道,并为每个管道fork出一个子进程(Slaver),每个子进程只从其专属管道的读端读取指令,父进程则通过向对应管道的写端发送指令来控制特定子进程

进程池整体功能:父进程创建5个子进程,每个子进程通过管道接收父进程发来的任务编号,执行对应的任务函数,父进程通过管道写数字给子进程派发任务

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

// 第一部分:4个任务函数
// 子进程要完成的任务
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);
}

// 模拟更新用户状态任务
// task_t代表一个函数指针类型:函数无参数,返回 void
typedef void (*task_t)(); // 函数指针

task_t tasks[4] = {SyncDisk, Download, PrintLog, UpdateStatus}; // 任务表 ---面向对象化

//进程池相关
// 匿名枚举
enum
{
    OK = 0,
    PIPE_ERROR,
    FORK_ERROR
};

// 子进程的入口函数
// 子进程从fd读父进程发过来的任务编号
void DoTask(int fd)
{
    while (true)
    {
        int task_code = 0;  // 存储读到的任务编号
        // 从管道fd读取4字节数据,读到的值放入task_code
        ssize_t n = read(fd, &task_code, sizeof(task_code));
        if (n == sizeof(task_code))
        {
            if (task_code >= 0 && task_code < 4)
            {
                taskstask_code; // 执行任务表中的任务
            }
        }
        else if (n == 0)
        {
            // 父进程要结束,我也应该要退出了!
            std::cout << getpid() << ": task quit ..." << std::endl;
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }
}

// 全局常量,进程池中一共创建5个子进程
const int gprocessnum = 5;

// using起别名,cb_t是回调函数类型,接收一个int参数,返回void
// 后面跟子进程时,把子进程管道读端fd传给回调DoTask
using cb_t = std::function<void(int)>;

class ProcessPool
{
private:
    // 父进程管理"通道"
    // 内部Channel:每一个Channel代表一条【管道写端 + 对应子进程】
    class Channel
    {
    public:
        Channel(int wfd, pid_t pid) : _wfd(wfd), _sub_pid(pid)
        {
		        // 构造函数:接收两个参数,wfd管道写端fd,pid子进程id,初始化列表给成员赋值
            _sub_name = "sub-channel-" + std::to_string(_sub_pid);
        }
        
        ~Channel()
        {
        }
        
        // 父进程向管道写端,写入int数字发给子进程
        void Write(int index)
        {
            ssize_t n = write(_wfd, &index, sizeof(index)); 
            (void)n;  // (void)n消除编译器警告
        }
        
        // 返回通道名字,调试打印用
        std::string Name()
        {
            return _sub_name;
        }
        
        // 关闭管道读写端文件描述符,父进程退出时调用
        // 全部写端关闭,子进程read返回0,收到退出信号
        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;
        }
        
        // 打印调试信息:管道写端fd,子进程pid、管道名字
        void PrintInfo()
        {
            printf("wfd: %d, who: %d, channel name: %s\n", _wfd, _sub_pid, _sub_name.c_str());
        }

    private:
        int _wfd;              // 1. wfd
        pid_t _sub_pid;        // 2. 子进程是谁
        std::string _sub_name; // 3. 子channel的名字

    };
    
public:
		// 进程池构造函数
    ProcessPool()
    {
		    // srand()设置全局随机种子
		    rand((unsigned int)time(nullptr) ^ getpid());
    }
    
    ~ProcessPool() {}
    
    // 初始化进程池,接收回调函数,调用函数创建5组管道和子进程
    void Init(cb_t cb)
    {
        CreateProcessChannel(cb);
    }
    
    // 
    void Run()
    {
        int cnt = 10;
        while (true)
        {
            // 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. 发送一个任务给指定的channel(管道+子进程)
            // printf("发送 %d to %s\n", itask, channels[index].Name().c_str());
            // SendTask2Salver(itask, index);
            sleep(1); // 一秒一个任务
        }
    }
    
    // 进程池销毁回收资源
    // 遍历每一个channel:先关闭管道写端,立刻对waitpid回收子进程
    void Quit()
    {
        // version3:
        // 遍历每一个channel,先关闭管道写端,立刻waitpid回收子进程
        for (auto &channel : channels)
        {
            channel.ClosePipe();
            channel.Wait();
        }
        // version2: 逆向回收
        // int end = channels.size()-1;
        // while(end >= 0)
        // {
        //     channels[end].ClosePipe();
        //     channels[end].Wait();
        //     end--;
        // }

        // bug演示
        // for (auto &channel : channels) // 为什么不能这样写?应该怎么写?bug?
        // {
        //     channel.ClosePipe();
        //     channel.Wait();
        // }

        // version1
        // 全部close,再全部wait
        // 1. 让所有子进程退出
        // for (auto &channel : channels)
        // {
        //     channel.ClosePipe();
        // }

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

private:
		// 给指定下标index的通道发送任务编号itask,做边界检查
		// 参数非法直接return,合法调用channel的Write往管道里写数字
    void SendTask2Salver(int itask, int index)
    {
        if (itask >= 4 || itask < 0)
            return;
        if (index < 0 || index >= channels.size())
            return;

        channels[index].Write(itask);
    }

		// 轮询调度选子进程,static int index静态局部变量,函数多次调用值保留
    int SelectChannel()
    {
        static int index = 0;
        int selected = index;
        index++;
        index %= channels.size();

        return selected;
    }
    
    // 随机选任务编号,0~3
    int SelectTask()
    {
        int itask = rand() % 4;
        return itask;
    }
    
    // 循环5次,每次创建一个管道和一个子进程
    void CreateProcessChannel(cb_t cb)
    {
        // 1. 创建多个管道和创建多个进程
        for (int i = 0; i < gprocessnum; i++)
        {
            int pipefd[2] = {0};
            int n = pipe(pipefd);
            if (n < 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)
            {
                // 子进程关闭历史wfd, 影响的是自己的fd表
                if(!channels.empty())
                {
                    for(auto &channel : channels)
                        channel.ClosePipe();
                }

                // child
                close(pipefd[1]); // read
                cb(pipefd[0]);    // 回调: 让子进程调用出去,回调完成,他还会回来的!!!
                exit(OK);         // 根本不会执行后续代码,执行完自己的DoTask()函数之后,自己就退出了
            }
            
            else
            {
                // 父进程 write
                close(pipefd[0]);
                channels.emplace_back(pipefd[1], id);
                // Channel ch(pipefd[1], id);
                // channels.push_back(ch);
                std::cout << "创建子进程: " << id << " 成功..." << std::endl;
                sleep(1);
            }
        }
    }

private:
    // 0. 未来组织所有channel的容器
    std::vector<Channel> channels;
};

int main()
{
    // 1. 初始化进程池
    ProcessPool pp;
    pp.Init(DoTask);
    pp.Debug();

    // 2. 父进程控制子进程,channels
    pp.Run();

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

    return 0;
}

整体流程:

  1. main创建进程池,调用Init
  2. Init循环5次:创建管道 → fork子进程,子进程关掉所有不需要的写端fd,保留读端,调用DoTask阻塞等待读管道任务编号,父进程关掉管道读端,把写端和子进程pid打包成Channel放进vector
  3. 父进程进入Run循环
  4. 如果执行Quit:父进程挨个关闭所有管道写端
  5. 管道全部写端关闭,子进程read返回0,DoTask跳出循环,exit退出
  6. waitpid把每个子进程回收,防止僵尸进程
相关推荐
无毁的湖光-Al1 小时前
解Bug之路-with AI-应用被限流?
java·linux
Brilliantwxx1 小时前
【Linux】 进程(4)七大进程状态深度解析
linux·运维·算法
国际云,接待2 小时前
df 显示磁盘还有空间却无法写文件:Linux inode 耗尽排查与治理
linux·运维·服务器
一路向北finish2 小时前
《CentOS 7 搭建 Hadoop 3.3.6 分布式集群:NFS 共享方案完整实操》
linux·运维·hadoop·分布式·centos
raindayinrain2 小时前
深入理解Linux内核--缺页异常,写时复制
linux·写时复制·缺页异常
好评1243 小时前
【Linux】网络基础概念
linux·网络
杨云龙UP3 小时前
Toad for Oracle 调整表空间 Datafile 大小:Usage、Used Pct of Max 与 RESIZE 实战
linux·运维·服务器·数据库·oracle·dba·数据库运维
raindayinrain3 小时前
深入理解linux内核--文件页高速缓存,页框回收,性能优化
linux·性能优化·高速缓存·页框
badboy1214 小时前
Xilinx Vitis Core Development Kit 2025.2.1_Win+Linux
linux·fpga开发·xilinx