理解通信
为什么要通信?
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享相同的资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知他们发生了某种事件(如进程终止时要通知父进程)
- 进程控制:有些进程希望完全控制另一个进程的执行,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够即使知道它的状态改变
什么是通信?
- 进程通信,通常也翻译为进程间通信,指的是操作系统中,两个或多个不同的进程之间交换数据或信息的机制。由于每个进程都有自己独立的用户地址空间,一个进程无法直接访问另一个进程的变量或数据结构。因此,操作系统必须提供专门的机制来实现数据在不同进程间的传递。这些机制就统称为进程通信。
怎么通信?
- 进程间通信的本质:是先让不同的进程先看到同一份资源【某一种形式的内存】(不能由任何一个进程提供,由OS提供 --> 系统调用 --> OS的接口 --> 设计统一的通信接口)。(然后才有通信条件)
环境选择
这里我们选用Ubuntu20.04 + C++ + VScode
具体的通信方式
常见的通信方式有两种
- 基于文件的管道通信
- System V - 本机通信
这里我们着重看管道通信,在后面的内容中笔者会将System V - 本机通信这部分内容补齐。
匿名管道
背景
匿名管道是最基础也最经典的IPC实现方案。依托Linux 一切皆文件 的核心设计理念,管道并没有从零设计专属的通信系统调用,而是复用现有VFS虚拟文件系统框架,直接沿用read、write这类通用文件读写接口实现数据交互,极大精简了内核的设计成本。
应用层调用pipe创建管道后,程序会拿到一组读写文件描述符,紧接着通过fork创建子进程时,操作系统会完整拷贝父进程的文件描述符管理结构。父子进程拥有各自独立的用户进程地址空间 ,文件描述符表在用户侧看似相互隔离,但在内核空间中,两组文件描述符最终绑定同一个内核struct file对象 ,这份由进程复制带来的内核资源共享,正是匿名管道实现通信的核心前提。内核会通过引用计数记录当前引用该管道资源的进程数量,进程执行close关闭文件描述符时计数器递减,直至引用计数归零,操作系统才会释放管道占用的全部内核资源。
抛开管道的具体实现细节,所有进程间通信的底层逻辑拥有统一本质:实现通信的先决条件是让多个独立进程可以访问同一份共享资源 。匿名管道恰好利用fork带来的资源共享能力,让父子进程共用同一片内核缓冲区,一端进程通过写文件描述符向共享内存写入数据,另一端依靠读文件描述符从同一块内存中读取数据,以此完成两个进程间的数据传输。
原理
在Linux的进程间通信体系中,匿名管道是面向亲缘进程最简单高效的IPC方案,它依靠int pipe(int pipefd[2]);专属系统调用完成创建,作为典型的匿名内存级资源,管道没有磁盘上对应的实体文件、不需要文件路径与文件名 ,由操作系统在内核中单独开辟内存缓冲区实现,同时依托Linux 一切皆文件 的设计复用已有的read、write文件读写代码,不用单独开发通信接口 ,这也是管道能快速落地使用的设计巧思。
整个父子进程借助管道通信被拆分为标准三步执行逻辑:
- 首先由父进程调用
pipe函数创建管道,操作系统在内核分配一块环形内存作为管道缓冲区,并在父进程的文件描述符表里分配两个空闲文件描述符,其中pipefd[0]绑定管道读端、pipefd[1]绑定管道写端,进程默认的0、1、2号fd依旧指向终端tty设备; - 紧接着父进程调用fork创建子进程,fork的内核逻辑会让新生成的子进程完整继承父进程的整张文件描述符表,子进程内部的3、4号文件描述符和父进程指向同一个内核管道缓冲区,这也是匿名管道仅能用于父子等亲缘进程、两个进程可以操作同一份管道资源的底层答案;
- 最后为了契合管道半双工单向通信的属性,父子进程各自关闭闲置端口:父进程关闭负责读取的fd0只保留写端fd1,子进程关闭负责写入的fd1仅保留读端fd0,端口裁剪完成后,数据流被固定为从父进程流向子进程,父进程通过write向内核管道缓冲区写入数据,子进程调用read从同一块内存中取出数据,最终实现稳定的父子单向进程通信。
这套执行链路也从实操层面印证了IPC的核心思想:进程通信的本质就是让多个独立进程能够访问同一份共享资源,匿名管道借助fork带来的文件描述符继承机制共享内核内存缓冲区,规避磁盘IO损耗的同时,也天然限定了自身只能在有亲缘关系的进程间使用的特性。
管道也是文件,也属于文件!!
demo代码
c
void child_write(int wfd)
{
char buffer[1024];
int cnt = 0;
while(true)
{
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[1024];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "child say: " << buffer << std::endl;
}
}
}
上面的代码分别是子进程写和父进程读,这没什么好说的,下面我们来看一下主函数:
c
int main()
{
// 1.创建管道
int fds[2] = {0};
int n = pipe(fds);
if(n < 0)
{
std::cerr << "pipe failed" << std::endl;
return 1;
}
std::cout << "fds[0]: " << fds[0] << std::endl;
std::cout << "fds[1]: " << fds[1] << std::endl;
// 2.创建子进程
pid_t id = fork();
if(id == 0)
{
// 3.关闭不需要的读写端,形成通信信道
// f -> r, c -> w
close(fds[0]);
child_write(fds[1]);// 子进程写入
close(fds[1]);
exit(0);
}
// 3.关闭不需要的读写端,形成通信信道
// f -> r, c -> w
close(fds[1]);
father_read(fds[0]);
waitpid(id, nullptr, 0);
close(fds[0]);
return 0;
}
首先,创建管道的时候为什么选择fds[2],而不是0或者1?因为0是读端,1是写端。
创建完子进程之后,我们把不需要的读写端进行关闭。因为设定的是子进程用来写,父进程用来读,所以我们需要关闭子进程的读端以及父进程的写端。这样一个简单的通信管道就写好了。
重点:
5种特性
- 匿名管道,只能用来进行具有血缘关系的进程进行进程间通信(常用于父子)
- 管道文件,自带同步机制
- 管道是面向字节流的
- 管道是单向通信的 --> 属于半双工的一种特殊情况(任何一个时刻,一个发,一个手 ------ 半双工。任何一个时刻,可以同时收发 ------ 全双工)
- (管道)文件的生命周期是跟随进程的

4种通信情况
- 写慢,读快 ------ 读端就要阻塞(等写)
- 写快,读慢 ------ 写满了的时候,写就要阻塞等待(等读)
- 写关闭,继续读 ------ read就会读到返回值为0,表示文件结尾
- 读关闭,继续写 ------ 写端再写入,没有任何意义。OS不会做没有意义的事情 ==> OS会发送异常信号杀掉写端进程
基于匿名管道的进程池
在Linux后端开发中,频繁fork创建、销毁进程会带来极高的内核调度开销,进程池便是为解决这个痛点诞生的经典并发模型。进程池的核心思路是程序启动阶段预先批量创建若干常驻工作子进程,实现进程资源复用,依靠任务分发+进程休眠唤醒完成任务调度 。
整个运行逻辑可以拆解为主控调度 、进程池工作 、任务执行 三个环节。程序启动后,主控父进程预先开辟固定数量的子进程,组成常驻的进程资源池,池内所有工作子进程初始化后默认进入阻塞休眠状态;当外部业务产生任务时,主控进程会将业务逻辑封装成带有专属标识的任务码,基于约定的通信或同步机制挑选进程池里的空闲子进程,触发休眠进程的唤醒操作。被唤醒后的工作子进程会领取对应的任务数据,落地执行业务逻辑;单个任务处理完成后,子进程不会退出销毁,而是重新回到阻塞待命状态,等待主控进程下一次的任务唤醒。
下面我们来看一下代码实现:
为了方便后面演示,这里我们先写一个task的相关代码,方便后续指派一些打印、下载等的任务:
hpp
typedef void (*task_t)();
void PrintLog()
{
std::cout << "我是一个打印任务" << std::endl;
}
void Download()
{
std::cout << "我是一个下载任务" << std::endl;
}
void Upload()
{
std::cout << "我是一个上传任务" << std::endl;
}
class TaskManager
{
public:
TaskManager()
{
srand(time(nullptr));
}
void Register(task_t t)
{
_tasks.push_back(t);
}
int Code()
{
return rand() % _tasks.size();
}
void Execute(int code)
{
if(code >= 0 && code < _tasks.size())
{
_tasks[code]();
}
}
~TaskManager()
{}
private:
std::vector<task_t> _tasks;
};
接着再把创建进程池和自动派发任务的main函数写出来:
c
#include "ProcessPool.hpp"
int main()
{
// 创建进程池
ProcessPool pp(gdefaultnum);
// 启动进程池
pp.Create();
// 自动派发任务
int cnt = 10;
while(cnt--)
{
pp.Run();
sleep(1);
}
// 回收、结束进程池
pp.Stop();
return 0;
}
下面来到最终要的部分:进程池的相关代码:
整体架构分为三层:Channel(单管道封装)→ ChannelManager(管道集群管理)→ ProcessPool(进程池主控),采用父进程预创建子进程+一对一匿名管道,父通过管道下发任务码,子阻塞读取任务执行。
1.头文件与宏定义部分
cpp
#ifndef __PROCESS_POOP_HPP__
#define __PROCESS_POOP_HPP__
#include <iostream>
#include <vector>
#include <unistd.h>
#include <cstdlib>
#include <sys/wait.h>
#include "Task.hpp"
头文件分工:unistd.h 提供pipe/fork/read/write/close系统调用,是管道和多进程必备头文件;sys/wait.h用于waitpid回收子进程避免僵尸进程;Task.hpp是自定义任务模块,内部封装任务注册、任务执行逻辑,实现任务和进程池解耦。
头文件保护宏防止头文件被重复包含引发编译报错。
cpp
const int gdefaultnum = 5;
定义进程池默认子进程数量,后续创建进程时如果不自定义数目,默认初始化5个工作子进程。
2.Channel 单管道封装类
cpp
class Channel// 管道
{
public:
Channel(int fd, pid_t id):_wfd(fd), _subid(id)
{
_name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(_subid);
}
Channel代表一根管道+一个绑定的子进程,构造函数接收父进程持有的管道写端fd、对应子进程PID;拼接字符串生成信道名称,仅用于日志打印调试,方便运行时查看管道与进程对应关系。
cpp
~Channel()
{}
析构暂时无需手动释放资源,管道fd统一由管理器主动关闭。
cpp
int FD(){return _wfd;}
pid_t Subid(){return _subid;}
std::string Name(){return _name;}
三个成员方法作为对外接口,分别向外暴露管道写文件描述符、子进程PID、信道名字,满足上层管理器操作需求。
cpp
void Send(int code)
{
int n = write(_wfd, &code, sizeof(code));
(void)n;
}
父进程调用Send向管道写入int类型任务编码,子进程读取编码匹配对应业务;(void)n消除编译器「变量未使用」告警,正式工程可增加返回值判断写入是否成功。
cpp
void Close()
{
close(_wfd);
}
关闭父进程所持有的管道写端fd,当管道所有写端全部关闭后,子进程端read会返回0,触发子进程正常退出。
cpp
void Wait()
{
pid_t rid = waitpid(_subid, nullptr, 0);
(void)rid;
}
阻塞等待当前绑定的子进程结束,回收进程资源,避免产生僵尸进程;入参0代表阻塞等待,子进程未退出时当前调用线程卡住。
cpp
private:
int _wfd;
pid_t _subid;
std::string _name;
};
私有成员:_wfd父进程管道写fd,_subid绑定子进程PID,_name调试用信道名,封装数据避免外部随意篡改。
3.ChannelManager 管道管理器
cpp
class ChannelManager// 管道管理
{
public:
ChannelManager():_next(0)
{}
管理器构造初始化轮询下标_next为0,用于后续轮询选取子进程做负载均衡。
cpp
void Insert(int wfd, pid_t subid)
{
_channels.emplace_back(wfd, subid);
}
新建Channel对象存入vector容器,每当成功创建一组管道+子进程,父进程就调用Insert保存资源。
cpp
Channel &Select()
{
auto &c = _channels[_next];
_next++;
_next %= _channels.size();
return c;
}
简易轮询调度算法:每次取下标对应的信道,下标自增后对容器长度取模,实现任务轮流分配给各个子进程,均匀分摊任务。
cpp
void PrintChannel()
{
for(auto &channel : _channels)
{
std::cout << channel.Name() << std::endl;
}
}
调试函数,遍历打印所有信道名称,方便排查管道和子进程绑定关系。
cpp
void StopSubProcess()
{
for(auto &Channel : _channels)
{
Channel.Close();
std::cout << "关闭子进程" << Channel.Name() << std::endl;
}
}
批量关闭所有父侧管道写端,只做关闭fd操作,不回收子进程。
cpp
void WaitSubProcess()
{
for(auto &Channel : _channels)
{
Channel.Wait();
std::cout << "回收子进程" << Channel.Name() << std::endl;
}
}
只批量阻塞回收所有子进程,前置需要提前关闭管道写端让子进程退出。
cpp
void CloseAndWait()
{
for(int i = _channels.size(); i >= 0; i--)
{
_channels[i].Close();
std::cout << "关闭子进程" << _channels[i].Name() << std::endl;
_channels[i].Wait();
std::cout << "回收子进程" << _channels[i].Name() << std::endl;
}
}
进程池停止核心方法,倒序关闭+回收。Linux下顺序关闭容易出现waitpid阻塞卡死,倒序是工程避坑方案;先关fd让子进程退出,再立刻wait回收。
cpp
void CloseAll()
{
for(auto &Channel : _channels)
{
Channel.Close();
}
}
进程池最关键避坑接口:fork后子进程会继承父进程全部管道fd,子进程启动时调用CloseAll,关闭除自身读管道外所有管道写端。如果不关闭多余写fd:父进程关闭自己写端后,子进程手里仍持有写fd,管道引用计数不为0,子进程read永远不会返回0,进程无法正常退出。
cpp
~ChannelManager()
{}
private:
std::vector<Channel> _channels;
int _next;
};
_channels容器存储全部管道信道;_next轮询游标,配合Select实现轮询调度。
4.ProcessPool 进程池主类
cpp
class ProcessPool// 进程池
{
public:
ProcessPool(int num): _process_num(num)
{
_tm.Register(PrintLog);
_tm.Register(Download);
_tm.Register(Upload);
}
构造函数接收自定义进程数量,初始化任务管理器,向TaskManager注册三类业务函数:日志打印、文件下载、文件上传,后续通过不同任务码映射不同函数。
cpp
void Work(int rfd)
{
while(true)
{
int code = 0;
ssize_t n = read(rfd, &code, sizeof(code));
子进程常驻工作函数,入参是子进程所持管道读端fd;死循环阻塞read等待父进程下发任务码,无任务时子进程阻塞休眠,不占用CPU。
cpp
if(n > 0)
{
if(n != sizeof(code))
{
continue;
}
std::cout << "子进程" << getpid() << "收到一个任务码" << code << std::endl;
_tm.Execute(code);
}
成功读到完整int任务码,调用任务管理器Execute执行对应注册的业务函数。
cpp
else if(n == 0)
{
std::cout << "子进程退出" << std::endl;
break;
}
else
{
std::cout << "读取错误" << std::endl;
break;
}
}
}
read返回0代表管道所有写端全部关闭,跳出循环子进程正常退出;read<0代表读异常,进程直接退出。
cpp
bool Create()
{
for(int i = 0; i < _process_num; i++)
{
int pipefd[2] = {0};
int n = pipe(pipefd);
if(n < 0)return false;
循环创建N组管道+子进程;pipe创建匿名管道,pipefd0=读端、pipefd1=写端,创建失败直接返回false。
cpp
pid_t subid = fork();
if(subid < 0)return false;
else if(subid == 0)
{
_cm.CloseAll();
close(pipefd[1]);
Work(pipefd[0]);
close(pipefd[0]);
exit(0);
}
子进程分支:先调用管理器CloseAll关闭所有继承来的多余管道写fd,再关闭自身管道写端,只保留读端进入Work循环;进程退出前关闭读fd。
cpp
else
{
close(pipefd[0]);
_cm.Insert(pipefd[1], subid);
}
}
return true;
}
父进程分支:不需要读端,关闭pipefd0;保存管道写fd和子进程PID到信道管理器,完成一组资源绑定。
cpp
void Debug()
{
_cm.PrintChannel();
}
对外调试接口,调用管理器打印全部管道信息。
cpp
void PushTask(int taskcode)
{
auto &c = _cm.Select();
std::cout << "选择了一个子进程:" << c.Name() << std::endl;
c.Send(taskcode);
std::cout << "发送了一个任务码:" << taskcode << std::endl;
}
手动下发任务接口,外部传入任务码,轮询选中子进程,通过管道发送任务编码。
cpp
void Run()
{
int taskcode = _tm.Code();
auto &c = _cm.Select();
std::cout << "选择了一个子进程:" << c.Name() << std::endl;
c.Send(taskcode);
std::cout << "发送了一个任务码:" << taskcode << std::endl;
}
自动运行模式:由任务管理器随机生成任务编码,自动下发任务,适用于程序内部自主生成任务场景。
cpp
void Stop()
{
_cm.CloseAndWait();
}
进程池销毁入口,调用管理器倒序关闭管道+逐个回收子进程,安全释放全部资源。
cpp
~ProcessPool()
{}
private:
ChannelManager _cm;
int _process_num;
TaskManager _tm;
};
#endif
私有成员:管道管理器、进程总数、任务管理器,三者构成进程池全部核心资源;最后#endif结束头文件。
总代码
c
#ifndef __PROCESS_POOP_HPP__
#define __PROCESS_POOP_HPP__
#include <iostream>
#include <vector>
#include <unistd.h>
#include <cstdlib>
#include <sys/wait.h>
#include "Task.hpp"
// 先描述
class Channel// 管道
{
public:
Channel(int fd, pid_t id):_wfd(fd), _subid(id)
{
_name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(_subid);
}
~Channel()
{}
int FD(){return _wfd;}
pid_t Subid(){return _subid;}
std::string Name(){return _name;}
void Send(int code)
{
int n = write(_wfd, &code, sizeof(code));
(void)n; // 强转,没有使用,使用一下绕过编译器的检查
}
void Close()
{
close(_wfd);
}
void Wait()
{
pid_t rid = waitpid(_subid, nullptr, 0);
(void)rid;
}
private:
int _wfd;
pid_t _subid;
std::string _name;
};
// 再组织
class ChannelManager// 管道管理
{
public:
ChannelManager():_next(0)
{}
void Insert(int wfd, pid_t subid)
{
_channels.emplace_back(wfd, subid);
}
Channel &Select()
{
auto &c = _channels[_next];
_next++;
_next %= _channels.size();
return c;
}
void PrintChannel()
{
for(auto &channel : _channels)
{
std::cout << channel.Name() << std::endl;
}
}
void StopSubProcess()
{
for(auto &Channel : _channels)
{
Channel.Close();
std::cout << "关闭子进程" << Channel.Name() << std::endl;
}
}
void WaitSubProcess()
{
for(auto &Channel : _channels)
{
Channel.Wait();
std::cout << "回收子进程" << Channel.Name() << std::endl;
}
}
// 解决之前提到的坑
void CloseAndWait()
{
// for(auto &Channel : _channels)
// {
// Channel.Close();
// std::cout << "关闭子进程" << Channel.Name() << std::endl;
// Channel.Wait();
// std::cout << "回收子进程" << Channel.Name() << std::endl;
// }
// 解决方案1:倒着关闭
for(int i = _channels.size(); i >= 0; i--)
{
_channels[i].Close();
std::cout << "关闭子进程" << _channels[i].Name() << std::endl;
_channels[i].Wait();
std::cout << "回收子进程" << _channels[i].Name() << std::endl;
}
// 2.真的让父进程一人指向所有管道w端
// 让子进程关闭自己继承下来的,它的哥哥进程的w端关闭就行CloseAll
}
void CloseAll()
{
for(auto &Channel : _channels)
{
Channel.Close();
}
}
~ChannelManager()
{}
private:
std::vector<Channel> _channels;
int _next;
};
const int gdefaultnum = 5;
class ProcessPool// 进程池
{
public:
ProcessPool(int num): _process_num(num)
{
_tm.Register(PrintLog);
_tm.Register(Download);
_tm.Register(Upload);
}
void Work(int rfd)
{
while(true)
{
int code = 0;
ssize_t n = read(rfd, &code, sizeof(code));
if(n > 0)
{
if(n != sizeof(code))
{
continue;
}
std::cout << "子进程" << getpid() << "收到一个任务码" << code << std::endl;
_tm.Execute(code);
}
else if(n == 0)
{
std::cout << "子进程退出" << std::endl;
break;
}
else
{
std::cout << "读取错误" << std::endl;
break;
}
}
}
bool Create()
{
for(int i = 0; i < _process_num; i++)
{
// 1.创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if(n < 0)return false;
// 2.创建子进程(父写,子读)
pid_t subid = fork();
if(subid < 0)return false;
else if(subid == 0)
{
// 子进程
// 让子进程关闭自己继承下来的,它的哥哥进程的w端关闭就行
_cm.CloseAll();
// 3.关闭不需要的文件描述符
close(pipefd[1]);
Work(pipefd[0]);
close(pipefd[0]);
exit(0);
}
else
{
// 父进程
// 3.关闭不需要的文件描述符
close(pipefd[0]);// 写端:pipefd[1]
_cm.Insert(pipefd[1], subid);
// 写端wfd,子进程是谁subid
}
}
return true;
}
void Debug()
{
_cm.PrintChannel();
}
void PushTask(int taskcode)
{
// 1.选择一个信道 负载均衡的选择一个子进程,完成任务
// 1.轮询(轮流来) 2.随机 3.channel添加负载指标
auto &c = _cm.Select();
std::cout << "选择了一个子进程:" << c.Name() << std::endl;
// 2.发送任务
c.Send(taskcode);
std::cout << "发送了一个任务码:" << taskcode << std::endl;
}
void Run()
{
// 1.选择一个任务
int taskcode = _tm.Code();
// 2.选择一个信道 负载均衡的选择一个子进程,完成任务
auto &c = _cm.Select();
std::cout << "选择了一个子进程:" << c.Name() << std::endl;
// 3.发送任务
c.Send(taskcode);
std::cout << "发送了一个任务码:" << taskcode << std::endl;
}
void Stop()
{
// 关闭父进程所有wfd
//_cm.StopSubProcess();
//回收
//_cm.WaitSubProcess();
_cm.CloseAndWait();
}
~ProcessPool()
{}
private:
ChannelManager _cm;
int _process_num;
TaskManager _tm;
};
#endif