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 四种情况
- 子进程写的慢,父进程就要阻塞等待,等管道有数据,父进程才能读,管道为空,读就要阻塞,给写机会,管道为满,写就要阻塞,给读机会
- 子进程写的快,父进程不读,管道一旦被写满,子进程就必须阻塞了
- 读端在读,写端关闭,读端读完管道中剩余数据,再读就会读取到"",read返回值为0,表明读管道读到文件结尾
- 写端一直在写,读端不读,这时写操作完全无意义,操作系统会直接杀掉写的进程
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;
}
整体流程:
- main创建进程池,调用Init
- Init循环5次:创建管道 → fork子进程,子进程关掉所有不需要的写端fd,保留读端,调用DoTask阻塞等待读管道任务编号,父进程关掉管道读端,把写端和子进程pid打包成Channel放进vector
- 父进程进入Run循环
- 如果执行Quit:父进程挨个关闭所有管道写端
- 管道全部写端关闭,子进程read返回0,DoTask跳出循环,exit退出
- waitpid把每个子进程回收,防止僵尸进程