一、进程间通信的目的
之前我们编写的代码大多是单进程任务。单进程无法使用进程的并发功能,也无法实现多进程的协同工作。
因此我们要实现多进程任务,而多进程的关键就是进程间通信,进程间通信包括:
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的本质是:让不同的进程共享同一份资源,即读写同一块内存空间。该内存空间不能隶属于任何一个进程,而是要由操作系统提供,否则将违背进程的独立性。
二、进程间通信的常用方法
-
管道:Linux中最古老的进程间通信形式,是Linux文件系统原生支持的。
-
匿名管道pipe
-
命名管道
-
-
System V IPC:更侧重于本地进程间通信
-
System V 消息队列
-
System V 共享内存
-
System V 信号量
-
-
POSIX IPC:基于网络的进程间通信:既能进行本地通信,又能进行网络通信。使代码具有高扩展,高使用性。
-
消息队列
-
共享内存
-
信号量
-
互斥量
-
条件变量
-
读写锁
-
什么是管道?
- 管道是Linux中最古老的进程间通信形式,是Linux文件系统(EXT)原生支持的。
- 我们把从一个进程连接到另一个进程的数据流称为一个“管道”。
- 一个管道只能单向传输数据。
- 管道是基于Linux文件系统而实现的一种进程间通信方式,管道实际上就是一个文件。
三、匿名管道
3.1 实现原理
-
父进程创建管道:分别以读写方式打开同一个管道文件(打开两次)。
-
调用fork(),创建子进程。
在创建子进程时,操作系统会将父进程的文件描述符表拷贝(写时拷贝)到子进程中。这意味着子进程会继承父进程所有打开的文件描述符,包括文件、管道、套接字等。注意:进程管理和文件管理是两个独立的模块。创建子进程时,进程相关的内核数据结构(task_struct,映射页表,mm_struct,files_struct)基本都要进行写时拷贝。而文件相关的内核数据结构(file结构体)不会进行拷贝。
-
构建单向通信的信道:父子进程各自关闭不需要的读写端;发送方关闭读端,接收方关闭写端。
在进程通信的过程中,管道文件中的数据需要写入到磁盘(落盘,持久化)吗?
- 不需要,管道不是磁盘文件,数据落盘对通信双方没有任何意义;而且磁盘的写入速度很慢,会严重拖慢通信速度。
- 管道是一个内存级文件,没有磁盘文件实体。进程间通信是在内存中进行的,准确的说是在管道文件的内核缓冲区中。
总结:
- 管道是一个内存级文件,是由操作系统提供的内核数据,不属于任何一个进程,保证了进程的独立性。
- 通过继承父进程文件描述符的方式,使父子进程共享同一份资源,实现了进程间通信。因此,管道通常用于具有共同祖先(具有亲缘关系)的进程间通信,包括父子,兄弟甚至是祖孙进程。
3.2 创建和打开方式
方式一:通过命令使用匿名管道构建进程间通信,匿名管道的指令符号是 “|”
方式二:匿名管道也可以由进程创建,创建匿名管道的系统调用是pipe
- 原型:int pipe(int fd[2]);
- 功能:创建并以读、写两种方式打开一个匿名管道(无名文件)。
- 头文件:<unistd.h>
- 参数:fd是一个输出型参数,表示读写端的文件描述符数组,其中fd[0]表示读端,fd[1]表示写端
- 返回值:成功返回0,失败返回-1并设置errno。
使用系统调用pipe进行父子进程间通信:
测试代码:
#include .....
int main(){
//1. 使用pipe系统调用创建管道
int pipefd[2]={0};
int ret = pipe(pipefd);
assert(ret != -1); //在release模式下,assert语句被删除,ret成了未使用的变量。
(void)ret; //强转void是为了解除编译警告。
#ifdef DEBUG //条件编译:调试时编译
//使用编译指令定义宏:g++ -D DEBUG
//加#注释定义:g++ #-D DEBUG
cout << "pipefd[0]: " << pipefd[0] << endl;
cout << "pipefd[1]: " << pipefd[1] << endl;
#endif
//2.创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
//子进程是通信接收方
//3.构建单向通信的信道
close(pipefd[1]); //关闭写端
//4.接收信息
char r_buff[1024]; //读缓冲区
while(true)
{
//如果写入的一方没有关闭写端:管道中有数据就读,没有数据就阻塞等待
ssize_t sz = read(pipefd[0], r_buff, sizeof(r_buff)-1);
if(sz>0)
{
r_buff[sz] = '\0';
printf("I'm child process! pid:%d ppid:%d\n", getpid(), getppid());
cout << "father# " << r_buff << endl;
}
else if(sz == 0) //如果写入的一方已经关闭写端:read会返回0,表示读到了文件的结尾!
{
cout << "writer quit(father), reader quit(child)!" << endl;
break;
}
}
close(pipefd[1]); //子进程退出前关闭读端
exit(0);
}
//父进程是通信发送方
//3.构建单向通信的信道
close(pipefd[0]); //关闭读端
//4.发送信息
char w_buff[1024]; //写缓冲区
size_t cnt = 0;
string message = "我是父进程,正在向子进程发送消息!";
while(true)
{
snprintf(w_buff, sizeof(w_buff), "%s pid:%d cnt:%d", message.c_str(), getpid(), ++cnt);
write(pipefd[1], w_buff, strlen(w_buff)); //注意:使用strlen不要+1,不写入末尾的\0
if(cnt == 10)
{
cout << "writer quit(father)!" << endl;
break;
}
sleep(1);
}
close(pipefd[1]); //父进程退出前关闭写端
pid_t cpid = waitpid(id, nullptr, 0);
assert(ret != -1);
cout << "child process quit! child_pid: " << cpid << endl;
return 0;
}
运行结果:
3.3 匿名管道的特点
- 只能用于具有共同祖先(亲缘关系)的进程间通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道进行通信。
- 一般而言,管道的生命周期是随进程的:如果打开该管道的所有进程都退出了,管道文件就会被操作系统销毁释放。
- 为了保证进程通信协同,管道提供了访问控制(管道自带的同步机制):
- 如果写快,读慢:管道被写满时,write调用阻塞,写入进程暂停,直到有进程读走数据。
- 如果写慢,读快:管道中没有数据时,read调用阻塞,读取进程暂停,等到有进程写入数据。
- 如果写端fd被关闭:则read返回0,表示读到了文件结尾。
- 如果读端fd被关闭:则write操作会产生SIGPIPE信号,进而可能导致写入进程退出(异常退出)。
- 管道通信本身并不提供互斥机制,如果需要在管道通信中实现互斥,可以结合其他的同步机制来实现。
- 管道提供流式服务
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,应该建立起两个管道。
3.4 用匿名管道构建进程池
-
进程池(Process Pool)是一种常见的进程管理技术,它可以提高程序的并发性和效率。
-
进程池通常由一组预先创建好的子进程组成,这些子进程可以被主进程动态地分配和管理。主进程将任务分配给子进程,子进程执行任务并将结果返回给主进程。当任务完成后,子进程不会退出,而是继续等待下一个任务的到来。
-
进程池的主要优点是可以避免频繁地创建和销毁进程,从而提高程序的效率。由于子进程已经预先创建好了,因此可以避免每次创建进程时的开销,如内存分配、资源初始化等。此外,进程池还可以避免进程过多导致系统资源耗尽的问题,从而提高程序的稳定性。
-
进程池通常用于处理大量的短时间任务,如网络服务器中的请求处理、数据处理等。通过进程池,可以将任务分配给多个子进程并行处理,从而提高程序的并发性和效率。
示例代码:
//process_pool.cc 进程池的实现代码
#include "task.hpp"
#include ...
const size_t PROCESS_NUM = 5; //创建子进程的数量
uint32_t WaitCommand(int rfd){
uint32_t comid = 0; //严格限制comid的数据类型是32位int,保证在不同平台下运行。
ssize_t sz = read(rfd, &comid, sizeof(comid)); //如果对方不发送命令,就阻塞等待
if(sz == 0) //如果写端fd被关闭:则read返回0,此时子进程退出。
{
return -1;
}
assert(sz == sizeof(uint32_t));
return comid;
}
void SendAndWakeup(pid_t cpid, int wfd, uint32_t cmdid){
write(wfd, &cmdid, sizeof(cmdid));
printf("main process: call %d execute %s through %d\n", cpid, desc[cmdid].c_str(), wfd);
}
int main(){
srand(time(nullptr));
LoadHandler();
//1.创建一个进程信道表:pid-pipefd
vector<pair<pid_t, int>> slots;
//2.循环创建子进程并建立管道通信,填写进程信道表
for(size_t i = 0; i < PROCESS_NUM; ++i)
{
//2.1 创建管道
int pipefd[2] = {0};
int ret = pipe(pipefd);
assert(ret!=-1);
(void)ret;
//2.2 创建子进程
pid_t id = fork();
assert(id!=-1);
if(id == 0)
{
//3. 子进程接收并执行命令
close(pipefd[1]); //子进程关闭写端
while(true)
{
uint32_t comid = WaitCommand(pipefd[0]);
if(comid >= 0 && comid < cmdset.size()) //如果comid合法就执行命令
{
cmdset[comid]();
}
else if(comid == -1) //写端关闭,子进程退出
{
break;
}
else
{
cout << "非法的命令ID!" << endl;
}
}
close(pipefd[1]); //子进程退出前关闭读端
exit(0);
}
//2.3 填写进程信道表
close(pipefd[0]); //父进程关闭读端
slots.emplace_back(id, pipefd[1]);
}
//父进程选择进程发送命令
while(true)
{
//随机任务
//随机选择一个任务
uint32_t cmdid = rand() % cmdset.size();
//选择一个进程 ,采用随机数的方式,选择进程来完成任务,随机数方式的负载均衡
int choice = rand() % slots.size();
//把任务派发给指定的进程
SendAndWakeup(slots[choice].first, slots[choice].second, cmdid);
sleep(1);
//手动指派任务
// int select;
// uint32_t cmdid;
// cout << "############################################" << endl;
// cout << "# 1. show funcitons 2.send command #" << endl;
// cout << "############################################" << endl;
// cout << "Please Select> ";
// cin >> select;
// if (select == 1)
// ShowHandler();
// else if (select == 2)
// {
// cout << "Enter Your Command> ";
// // 选择任务
// cin >> cmdid;
// // 选择进程
// int choice = rand() % slots.size();
// // 把任务给指定的进程
// SendAndWakeup(slots[choice].first, slots[choice].second, cmdid);
// }
// else
// {
// break;
// }
}
//父进程退出前关闭所有管道的写端,对应的子进程都会退出
for(const auto& slot : slots)
{
close(slot.second);
}
// 回收所有的子进程信息
for(const auto& slot : slots)
{
pid_t cpid = waitpid(slot.first, nullptr, 0);
assert(cpid != -1);
cout << "child process[" << cpid << "] exit!" << endl;
}
return 0;
}
//task.hpp 任务处理函数的实现代码
#include ...
typedef function<void()> func;
vector<func> cmdset; cmdset; //命令集:用他调用指定函数
unordered_map<uint32_t, string> desc; //命令描述:用他打印指定函数的字符串描述
void ReadMySQL(){
printf("sub process[%d]: 执行访问数据库任务\n\n", getpid());
}
void AnalyzeURL(){
printf("sub process[%d]: 执行URL解析任务\n\n", getpid());
}
void Encrypt(){
printf("sub process[%d]: 执行加密任务\n\n", getpid());
}
void Save(){
printf("sub process[%d]: 执行数据保存任务\n\n", getpid());
}
//向命令集中加载命令
void LoadHandler(){
uint32_t cmdid = 0;
cmdset.push_back(ReadMySQL);
desc.emplace(cmdid++, "ReadMySQL: 读取数据库");
cmdset.push_back(AnalyzeURL);
desc.emplace(cmdid++, "AnalyzeURL: URL解析");
cmdset.push_back(Encrypt);
desc.emplace(cmdid++, "Encrypt: 加密计算");
cmdset.push_back(Save);
desc.emplace(cmdid++, "Save: 数据保存");
}
//打印所有的命令描述
void ShowHandler(){
for(const auto& iter : desc)
{
printf("cmdid: %d cmddesc: %s\n", iter.first, iter.second.c_str());
}
}
运行结果(执行随机任务):
四、命名管道
匿名管道(pipe)应用的一个限制就是只能用于具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件。
命名管道与匿名管道不同:
- 匿名管道是一个纯内存级文件,没有磁盘文件映像,也就不存在文件名,所在路径等概念。
- 命名管道具有磁盘文件映像,每个命名管道都有一个文件名和唯一的绝对路径。
- 因此不相关的进程可以通过命名管道的路径和文件名打开同一个管道文件进行数据交互。
命名管道与普通磁盘文件不同:
- 命名管道是一种功能性文件,专门用于进程间通信,不能将数据写入到磁盘。因此,命名管道的大小总是0。
- 不管是匿名还是命名管道,进程间通信都是在内存中进行的,准确的说是在管道文件的内核缓冲区中。
提示:命名管道和匿名管道的文件存在形式不同,创建打开方式不同。但是他们构建进程间通信的思路和原理是相同的。
4.1 创建和打开方式
方式一:命名管道可以从命令行上创建,使用指令mkfifo创建命名管道:
方式二:命名管道也可以由进程创建,创建命名管道的系统调用是mkfifo:
注意:以上两种方式只能用于创建FIFO文件,需要调用open函数才能以读或写的形式打开FIFO文件。
命名管道的打开规则:
- 如果当前进程是为读而打开FIFO时,阻塞等待(在open函数内阻塞),直到有相应进程为写而打开该FIFO。
- 如果当前进程是为写而打开FIFO时,阻塞等待(在open函数内阻塞),直到有相应进程为读而打开该FIFO。
- 必须同时打开命名管道的读端和写端,才能进行读取和写入操作。
4.2 使用mkfifo指令创建命名管道
-
使用mkfifo命令创建一个命名管道,文件属性的第一个字符标识文件类型:p – 管道文件
-
只打开命名管道的写端进程,发现写端进程阻塞,无法写入。
-
直到打开命名管道的读端进程时,写端才会进行写入操作,随后读端进程也成功读取到了数据。
提示:如果只打开读端进程进行读取,同样也会阻塞。(命名管道的打开规则)
-
编写一段脚本持续向管道中写入数据并进行读取
4.3 用命名管道实现server&client通信
common.h & log.hpp:
//common.h:server.cxx和client.cxx的公共头文件
#ifndef _COMMON_H_
#define _COMMON_H_
#include ...
using namespace std;
#define MODE 0666 //文件权限码
#define BUFF_SIZE 128 //缓冲区的大小
//server和client打开同一个命名管道
const char* ipcPath = "./fifo.ipc"; //命名管道的所在路径
#endif
//log.hpp:实现打印日志信息的代码
#ifndef _LOG_HPP_
#define _LOG_HPP_
#include ...
using namespace std;
enum MsgTypeID{
DEBUG,
NOTICE,
WORNING,
ERROR
};
const char* MsgTypeName[] = {"DEBUG", "NOTICE", "WORNING", "ERROR"};
void PrintLog(char* msg, MsgTypeID id){
printf("%u | %s: %s\n", (unsigned)time(nullptr), MsgTypeName[id], msg);
}
#endif
server.cxx
//server.cxx
#include "common.h"
#include "log.hpp"
void GetMessage(int fd)
{
//测试一中的进程间通信代码
//...
}
int main()
{
//1.创建命名管道
int ret = mkfifo(ipcPath, MODE);
if(ret == -1) return errno;
PrintLog("创建命名管道成功!", DEBUG);
//2.打开管道文件
int fd = open(ipcPath, O_RDONLY);
if(ret == -1) return errno;
PrintLog("打开管道文件成功!", DEBUG);
//3.进程间通信
//测试一:服务端直接处理客户端信息
char rbuff[BUFF_SIZE];
while(true)
{
ssize_t sz = read(fd, rbuff, sizeof(rbuff)-1);
if(sz>0)
{
//读取到数据
rbuff[sz] = 0;
printf("[%d] clinet say> %s\n", getpid(), rbuff);
}
else if(sz == 0)
{
//end of file
printf("[%d] read end of file, clien quit, server quit too!\n", getpid());
break;
}
else
{
//read error
exit(errno);
}
}
//测试二:服务端创建多个子进程接收处理客户端信息
// int process_num = 3;
// for(int i = 0; i < process_num; ++i)
// {
// pid_t id = fork();
// if(id == -1) exit(errno);
// if(id == 0)
// {
// GetMessage(fd);
// exit(0);
// }
// }
// //等待子进程退出
// for(int i = 0; i < process_num; ++i)
// {
// pid_t cpid = waitpid(-1, nullptr, 0);
// }
//4.关闭文件
close(fd);
PrintLog("关闭管道文件!", DEBUG);
//5.删除命名管道
unlink(ipcPath);
PrintLog("删除管道文件!", DEBUG);
return 0;
}
client.cxx
//client.cxx
#include "common.h"
int main()
{
//1.打开管道文件
int fd = open(ipcPath, O_WRONLY);
if(fd == -1) return errno;
//2.进程间通信
//char wbuff[BUFF_SIZE];
string wbuff(BUFF_SIZE, 0);
while(true)
{
cout << "please enter message line:" << endl;
getline(cin, wbuff);
write(fd, wbuff.c_str(), wbuff.size());
}
//3.关闭文件
close(fd);
return 0;
}
测试一:服务端直接处理客户端信息
-
只打开server端(读端),发现进程在打开管道文件时阻塞。
-
当打开client端(写端)时,server端打开管道文件成功。(命名管道的打开规则)
-
server端能成功接收到client端发送的信息,实现了本地版本的server & client通信!
-
当client端(写端)退出时,server端(读端)读取到管道文件末尾,read返回0,server端也退出。(访问控制)
提示:和匿名管道一样,为了保证进程通信协同,命名管道也提供了访问控制。具体规则请参考匿名管道。
测试二:服务端创建多个子进程接收处理客户端信息。(所有的子进程都继承了主进程打开的管道文件)
从client端发送的消息被server端子进程抢占式接收(可以发现每次输出的子进程pid是随机)
提示:一个管道可以同时具有多个读端,管道的所有读端竞争式的获取资源。