📖 前言:本期介绍进程间通信。
目录
- 🕒 1. 理解进程间通信
- 🕘 1.1 什么是通信
- 🕘 1.2 为什么要有通信
- 🕘 1.3 如何进行进程间通信
- 🕒 2. 管道
- 🕘 2.1 匿名管道
- 🕤 2.1.1 回顾文件系统
- 🕤 2.1.2 理解通信的本质问题
- 🕤 2.1.3 管道文件的刷新
- 🕤 2.1.4 匿名管道的概念
- 🕘 2.2 匿名管道的编码部分
- 🕘 2.3 管道的特点
- 🕘 2.4 如何理解命令行中的管道
- 🕘 2.5 进程控制多个子进程
- 🕒 3. 命名管道
- 🕘 3.1 预备工作
- 🕘 3.2 命令行中的命名管道
- 🕘 3.3 命名管道
🕒 1. 理解进程间通信
🕘 1.1 什么是通信
- 数据传输: 一个进程需要将它的数据发送给另一个进程
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
🕘 1.2 为什么要有通信
在之前所写的C/C++代码中,都是单进程的。但实际上,我们在完成某种业务内容时是需要多进程协同的。比如cat file | grep 'hello'
就是将file中的内容打印在显示器之前通过grep进行指定内容的过滤,这就是多进程协同。
🕘 1.3 如何进行进程间通信
经过发展,最终有这么两套方案:
- POSIX:让通信过程可以跨主机
- System V:聚焦在本地通信,即一台机器的两个进程进行通信。
- 共享内存
- 消息队列
- 信号量
对于System V ,在这里只关注共享内存,除了上述两套标准,还有一种方法:管道也是通信的一种方式,管道依托于文件系统来完成进程间通信的方案。
🕒 2. 管道
管道是基于文件系统的进程通信的方式。
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
🕘 2.1 匿名管道
🕤 2.1.1 回顾文件系统
我们之前所学习的文件系统中,有这样的结构:通过PCB—task_struct(进程控制块),每一个进程都有一个task_struct,同样知道struct files_struct其中包含一个进程描述符表的array数组,通过特定的文件描述符找到磁盘加载到内存中对应的文件。
当该PCB创建子进程时,不会拷贝磁盘中的文件,而是拷贝一份struct files_struct同样指向父进程对应的struct file:
🕤 2.1.2 理解通信的本质问题
- OS需要直接或间接给通信双方的进程提供“内存空间”;
- 要通信的进程,必须看到一份公共的资源。
通信的成本一定不低,这是因为不能直接考虑通信的问题,必须先让不同的进程看到同一份资源,然后才能利用这份资源进行通信。因此我们未来学习通信的接口,与其说是通信的接口,倒不如说是同一份资源的接口。而我们目前所学习的就是让不同进程如何能够看到同一份资源。
不同的通信种类,实际上就是OS系统的不同模块对应的功能,比如文件系统之间通信的模块就是管道,System V的模块就是System V通信……
而对于上面的struct file,实际上就是父进程与子进程的同一份资源,这份资源是由文件系统提供的,struct file包括file的操作方法和自己的内核缓冲区;父进程通过文件缓冲区将数据写入,子进程通过文件缓冲区将数据读取,这不就是一个进程写入,另一个进程读取,不就是进程间通信吗?
因此这个struct file文件就是管道文件。
🕤 2.1.3 管道文件的刷新
我们知道,struct file是从磁盘加载到内存的,而父子进程的每一次写入,struct file不会从内存中刷新到磁盘,虽然通过一定的操作是可行的,但进程与进程之间的通信是从内存到内存的,没有必要牵扯到磁盘。一旦刷新到磁盘,就会大大降低通信的速度。所以管道文件是一个内存级别的文件,不会进行磁盘刷新。
🕤 2.1.4 匿名管道的概念
经过上面的学习,那如何让两个进程看到同一个管道文件呢?——>通过fork创建子进程完成。但当前这个管道文件并没有名字,所以被称为匿名管道。
Q:为什么父进程分别以读和写的方式打开同一个文件?A:只有父进程打开读和写,产生的文件描述符才会被子进程继承,子进程才能有读和写的功能。
总结:
我们对应的父进程通过调用管道特定的系统调用,以读和写的方式打开一个内存级的文件,并通过fork创建子进程的方式,被子进程继承下去之后,各自关闭对应的读写端,形成的一条通信信道,这条信道是基于文件的,因此称为管道。
匿名管道:目前能用来进行父子进程之间进行进程间通信!
上述所讲的都是如何建立公共的资源,并没有涉及到通信,通信需要在具体场景才能实现。
🕘 2.2 匿名管道的编码部分
int pipe(int pipefd[2]);//管道:输出型参数,成功则返回0,头文件为unistd.h
功能:获取读和写的文件描述符(0, 1)传到参数中。
创建管道文件,打开读写端:
#include <iostream>
#include <unistd.h>
#include <cassert>
using namespace std;
int main()
{
int fds[2];
int n = pipe(fds);
assert(n == 0);
// 0,1,2->3,4
// [0]:读取 [1]:写入
cout<<"fds[0]:"<<fds[0]<<endl; // 3
cout<<"fds[1]:"<<fds[1]<<endl; // 4
return 0;
}
# Makefile
mypipe:mypipe.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf mypipe
[hins@VM-12-13-centos pipe]$ ./mypipe
fds[0]:3 # 读取
fds[1]:4 # 写入
fork子进程:
#include <iostream>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
int fds[2];
int n = pipe(fds);
assert(n == 0);
// fork
pid_t id = fork();
assert(id>=0);
if(id==0)
{
//子进程通信
exit(0);
}
//父进程通信
n = waitpid(id,nullptr,0);
assert(n == id);
return 0;
}
关闭父子进程不需要的文件描述符,完成通信:
#include<iostream>
#include<cstdio>
#include<string>
#include<cstring>
#include<unistd.h>
#include<cassert> // C/C++混搭
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
// 让父进程读取,子进程写入
int main()
{
// 第一步:创建管道文件,打开读写端
int fds[2];
int n = pipe(fds);
assert(n == 0);
// 第二步:fork子进程
pid_t id = fork();
assert(id >= 0);
if(id == 0)
{
// 子进程进行写入,所以关掉读权限
close(fds[0]);
// 子进程的通信代码
const char *s = "I'm a Child process. I'm sending you a message";
int cnt = 0;
while(true)
{
cnt++;
char buffer[1024];// 只有子进程能看到
snprintf(buffer, sizeof buffer, "child->parent say: %s[%d][%d]", s, cnt, getpid());
write(fds[1], buffer, strlen(buffer)); // 反斜杠0只有C语言认
sleep(1);// 每隔一秒写一次
}
// 子进程
close(fds[1]);
exit(0);
}
// 父进程进行读取
close(fds[1]);
// 父进程的通信代码
while(true)
{
char buffer[1024];
ssize_t s = read(fds[0], buffer, sizeof(buffer)-1);
if(s > 0) buffer[s] = 0;// 去除反斜杠0
cout << "Get Message#" << buffer <<"| my pid: " << getpid() << endl;
// 细节:父进程可没有进行sleep
}
n = waitpid(id, nullptr, 0);
assert(n == id);
// 0, 1, 2->……
// 谁是读取,谁是写入
// [0]:读取
// [1]:写入
cout << "fds[0]: " << fds[0] << endl;//3 读
cout << "fds[1]: " << fds[1] << endl;//4 写
return 0;
}
[hins@VM-12-13-centos pipe]$ ./mypipe
Get Message#child->parent say: I'm a Child process. I'm sending you a message[1][18505]| my pid: 18504
Get Message#child->parent say: I'm a Child process. I'm sending you a message[2][18505]| my pid: 18504
Get Message#child->parent say: I'm a Child process. I'm sending you a message[3][18505]| my pid: 18504
Get Message#child->parent say: I'm a Child process. I'm sending you a message[4][18505]| my pid: 18504
....
因此,上述代码的子进程没有打印任何的消息,而是我们的父进程获取读取消息并打印出来,这种通信就被成为管道通信。
🕘 2.3 管道的特点
读写特征:
上述代码中我们在子进程中sleep(1),实际上这使得父进程在read时暂停1秒,即在read(读)时阻塞;那如果把子进程的sleep去掉,在父进程中sleep(n),那么子进程的缓冲区就会被写满(因为子进程没有延迟非常快),如果还在写,就会将原来的覆盖,导致写端被阻塞;如果将写端关闭,那么就会读到0;如果读关闭,依旧让他去写,实际上没有任何意义,浪费系统资源,OS会给写进程发送信号,终止写端。通过实现最后一组情况,结果发送的信号为13号信号:SIGPIPE。
管道的特征:
- 管道的生命周期随进程一样。
- 匿名管道可以用来进行具有血缘关系的进程直接进行通信,常用于父子通信。
- 管道是面向字节流的(网络)。
- 半双工 – 单向通信(特殊概念)。
- 互斥与同步机制 – 对共享资源进行保护的方案。
后三点慢慢接触。
🕘 2.4 如何理解命令行中的管道
对于cat file | grep 'hello
在命令中实际上会作为字符串先被扫描一遍,将出现的 | 记录下来,并创建进程。其中产生的缓冲区会将管道左侧将要打印的数据加载到缓冲区,在通过右侧的进行筛选并打印到指定位置。
🕘 2.5 进程控制多个子进程
父进程可以实现向任意一个子进程中写入,我们可以让父进程向任何进程中写入一个四字节的命令操作码,称之为commandCode,即现在想让哪一个进程运行,就向哪一个进程发送数据,举个例子:如果发送是1,就让子进程下载,发送是2,就让子进程做特定的计算……;那为什么可以这样随意控制子进程是否运行呢?这是因为如果我们不将数据写入或者写的慢,那么子进程就需要等,产生阻塞,所以跟根据这样的思想设计如下代码:
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <cstdlib>
#include <cassert>
#include <ctime>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;
#define PROCESS_NUM 5
#define Make_Seed() srand((unsigned long)time(nullptr)^getpid()^0X55^rand()%1234)
typedef void(*func_t)();//函数指针类型
//模拟子进程需要完成的任务
void downLodeTask()
{
cout<<getpid()<<"下载任务"<<endl;
sleep(1);
}
void ioTask()
{
cout<<getpid()<<"IO任务"<<endl;
sleep(1);
}
void flushTask()
{
cout<<getpid()<<"刷新任务"<<endl;
sleep(1);
}
//多进程代码
class sunEndPoint
{
public:
sunEndPoint(pid_t subId,int writeFd)
:_subId(subId)
,_writeFd(writeFd)
{
char namebuffer[1000];
snprintf(namebuffer,sizeof(namebuffer),"process-%d[pid(%d)-fd(%d)]",num++,_subId,_writeFd);
_name=namebuffer;
}
public:
string _name;
pid_t _subId;//pid
int _writeFd;//写fd
static int num;
};
int sunEndPoint::num=0;
void loadTaskFunc(vector<func_t>* out)
{
assert(out);
out->push_back(downLodeTask);
out->push_back(ioTask);
out->push_back(flushTask);
}
int recvTask(int readFd)
{
int code=0;
ssize_t s=read(readFd,&code,sizeof(code));
if(s==sizeof(code))//合法信息
{
return code;
}
else if(s<=0)
{
return -1;
}
else
return 0;
}
void createSubProcess(vector<sunEndPoint>* subs,vector<func_t>& funcMap)
{
vector<int> deleteFd;//解决下一个子进程拷贝父进程读端的问题
for(int i=0;i<PROCESS_NUM;++i)
{
int fds[2];
int n=pipe(fds);
assert(n==0);
(void)n;
pid_t id=fork();
if(id==0)//子进程
{
//关闭上一个文件的写端文件描述符
for(int i=0;i<deleteFd.size();++i)
{
close(deleteFd[i]);
}
//子进程,处理任务
close(fds[1]);
while(true)
{
//1、获取命令码,如果父进程没有发送,子进程被阻塞
int commandCode=recvTask(fds[0]);
//2、完成任务
if(commandCode>=0&&commandCode<funcMap.size())
{
funcMap[commandCode]();
}
else if(commandCode==-1)
{
break;
}
}
exit(0);
}
close(fds[0]);
sunEndPoint sub(id,fds[1]);
subs->push_back(sub);
deleteFd.push_back(fds[1]);
}
}
void sendTask(const sunEndPoint& process,int taskNum)
{
cout<<"send task num"<<taskNum<<"send to->"<<process._name<<endl;
int n=write(process._writeFd,&taskNum,sizeof(taskNum));
assert(n==sizeof(int));//判断是否成功写入4个字节
(void)n;
}
void loadBlanceContrl(const vector<sunEndPoint>& subs,const vector<func_t>& funcMap,int count)
{
int processnum =subs.size();//子进程的个数
int tasknum=funcMap.size();
bool forever=(count==0?true:false);
while(true)
{
//选择一个子进程,从vector<sunEndPoint>选择一个index
int subIdx=rand()%processnum;
//选择一个任务,从vector<func_t>选择一个index
int taskIdx=rand()%tasknum;
//将任务发送给指定的子进程,将一个任务的下标发送给子进程
sendTask(subs[subIdx],taskIdx);//taskIdx作为管道的大小4个字节
sleep(1);
if(!forever)//forever不为0
{
--count;
if(count==0)
break;
}
}
//写端退出,读端将管道内数据读完后read返回0
for(int i=0;i<processnum;++i)
{
close(subs[i]._writeFd);//最晚被创建的子进程拥有早期创建的子进程的父进程的读端,所以这里其实是后创建的进程先关闭
}
}
//回收子进程
void waitProcess(vector<sunEndPoint> processes)
{
int processnum=processes.size();
for(int i=0;i<processnum;++i)
{
waitpid(processes[i]._subId,nullptr,0);
cout<<"wait sub process success"<<processes[i]._subId<<endl;
}
}
//父进程给子进程发布命令,父进程写,子进程读
int main()
{
Make_Seed();//创建随机数
//父进程创建子进程及和子进程通信的管道
vector<func_t> funcMap;//vector<函数指针> funcMap
loadTaskFunc(&funcMap);//加载任务
vector<sunEndPoint> subs;//子进程集合
createSubProcess(&subs,funcMap);//维护父子通信信道
//这里的程序是父进程,用于控制子进程
int taskCnt=9;//让子进程做9个任务
loadBlanceContrl(subs,funcMap,taskCnt);
//回收子进程信息
waitProcess(subs);
return 0;
}
[hins@VM-12-13-centos procpool]$ ./processpool
send task num: 1 send to -> process-9[pid(1222)-fd(13)]
1222: IO任务
send task num: 1 send to -> process-8[pid(1221)-fd(12)]
1221: IO任务
send task num: 0 send to -> process-8[pid(1221)-fd(12)]
1221: 下载任务
wait sub process success ...: 1213
wait sub process success ...: 1214
wait sub process success ...: 1215
wait sub process success ...: 1216
wait sub process success ...: 1217
wait sub process success ...: 1218
wait sub process success ...: 1219
wait sub process success ...: 1220
wait sub process success ...: 1221
wait sub process success ...: 1222
🕒 3. 命名管道
🕘 3.1 预备工作
新建servers.cc与client.cc及makefile,让servers.cc负责整体工作。
// server.cc
#include<iostream>
int main()
{
std::cout << "hello server" << std::endl;
return 0;
}
// client.cc
#include<iostream>
int main()
{
std::cout << "hello client" << std::endl;
return 0;
}
# Makefile
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11 -g
client:client.cc
g++ -o $@ $^ -std=c++11 -g
.PHONY:clean
clean:
rm -f server client
🕘 3.2 命令行中的命名管道
通过指令:mkfifo 文件名
就可以创建一个管道文件。
左侧将打印的信息重定向到named_pipe管道文件中,右侧cat作为进程再把named_pipe管道数据读了进来,通过这种方式,就完成了命令行式的进程间通信。但发现管道文件的大小仍为0。
如果两个进程打开同一个文件,那么在系统角度,还用不用为第二个进程在打开文件的时候在内核当中再重新创建一个struct file
呢?
答案是没有必要的。操作系统会自己识别文件已经被打开了,就不再需要这个操作了。实际上这也是操作系统为了减轻没必要的性能损失。
我们之前提到过,要想让两个进程之间进行通信,就需要有一份共享的资源,匿名管道以继承的方式拥有共同的文件(文件地址具有唯一性),那么命名管道是如何让不同的进程看到同一份资源的呢?
让不同的进程打开指定名称(文件路径+文件名)的同一个文件就可以了。
即我们之前演示的命令行中的文件路径默认是当前路径,因此能够进行进程间通信。
🕘 3.3 命名管道
为了能让client.cc和server.c看到同一份资源。因此再新建一个头文件:comm.hpp
对于mkfifo,不仅仅在指令中存在,在系统调用中也有此接口:
头文件:#include<sys/types.h> #include<sys/stat.h>
接口:int mkfifo(const char *pathname, mode_t mode);
mode_t类型为权限,返回值为0是创建成功。
既然都要用,那就放在公共的comm.hpp
中。
接下来,我们就需要将管道建立在指定路径下,既可以建立在当前路径下,也可以建立在系统的tmp
路径下,此次就建立在tmp路径下:(tmp路径可以被任何人读、写、执行)
// server.cc
#include "comm.hpp"
int main()
{
bool ret = createFifo(NAMED_PIPE);
assert(ret);
(void)ret;
return 0;
}
// comm.hpp
#pragma once
#include<iostream>
#include<string>
#include<cstring>
#include<sys/types.h>
#include<sys/stat.h>
#include<cerrno>
#include<cassert>
#define NAMED_PIPE "/tmp/mypipe.2023"
bool createFifo(const std::string& path)
{
umask(0);
int n = mkfifo(path.c_str(), 0666);//读、写、执行
if(n == 0) return true;
else
{
std::cout << "errno: " << errno << "err string: " << strerror(errno) << std::endl;
return false;
}
}
但是如果想在代码中删除,如何做?因此接下来介绍删除文件的接口:
头文件:#include<unistd.h>
函数接口:int unlink(const char* path);
功能:删除文件path,删除成功则返回0。
在comm.hpp中封装好删除的函数:
void removeFifo(const std::string &path)
{
int n = unlink(path.c_str());
assert(n==0);
(void)n;//防止n没使用而警告
}
在server.cc中进行调用:
#include "comm.hpp"
int main()
{
bool ret = createFifo(NAMED_PIPE);
assert(ret);
(void)ret;
removeFifo(NAMED_PIPE);
return 0;
}
这样,就可以创建文件之后自动删除,如果想要观察,就需要在创建与删除之间加上个sleep,否则运行太快无法具体观察创建和删除的过程。
至此,我们就完成了通过server.cc对管道文件的创建和删除。然后呢?只要能创建和删除了,然后就是通信了,那server.cc和client直接如何通信呢?接下来的代码就没有新的东西了,即让server.cc和client.cc打开同一个文件,让server.cc读,让client.cc写,这样就可以了。代码:
// comm.hpp
#pragma once
#include<iostream>
#include<string>
#include<cerrno>
#include<cassert>
#include<cstring>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#define NAMED_PIPE "/tmp/mypipe.2023"
bool createFifo(const std::string& path)
{
umask(0);
int n = mkfifo(path.c_str(), 0666);//读、写、执行
if(n == 0) return true;
else
{
std::cout << "errno: " << errno << "err string: " << strerror(errno) << std::endl;
return false;
}
}
//去掉管道文件
void removeFifo(const std::string& path)
{
int n = unlink(path.c_str());
assert(n == 0);//debug有效,release里面就被去掉了
(void)n;//n不使用就会出现warning,代码变成release之后没有assert,n就不会被使用,因此在这里使用一下。
}
// server.cc
#include"comm.hpp"
int main()
{
bool r = createFifo(NAMED_PIPE);
assert(r);
(void)r;
std::cout << "server begin: " << std::endl;
int rfd = open(NAMED_PIPE, O_RDONLY);
std::cout << "server end: " << std::endl;
if(rfd < 0) exit(1);
//read
char buffer[1024];
while(true)
{
ssize_t s = read(rfd, buffer, sizeof(buffer)-1);
if(s > 0)
{
buffer[s] = 0;
std::cout << "client->server# "<< buffer << std::endl;
}
else if(s == 0)
{
std::cout << "client quit, me too!" <<std::endl;
break;
}
else
{
std::cout << "err string: " << strerror(errno) << std::endl;
break;
}
}
close(rfd);
removeFifo(NAMED_PIPE);//删除
return 0;
}
// client.cc
#include"comm.hpp"
int main()
{
std::cout << "client begin: " << std::endl;
int wfd = open(NAMED_PIPE, O_WRONLY);
std::cout << "client end: " << std::endl;
if(wfd < 0) exit(1);
//write
char buffer[1024];
while(true)
{
std::cout << "Please Say# ";
fgets(buffer, sizeof(buffer), stdin);
ssize_t n = write(wfd, buffer, strlen(buffer));
assert(n == strlen(buffer));
(void)n;
}
close(wfd);
return 0;
}
执行观察:先运行server,再运行client,观察server端的变化:
通过这个现象就可以看出,我们将读的一段打开了,他不会直接运行,而是阻塞到读端,当把写端打开了,他才会继续向下运行。也就是说,读端和写端都打开,才会继续向后运行。其次我们发现:左侧的写端没有空行,但是右端的有空行,这是因为左侧的回车同样被存到/tmp/mypipe.2023中,因此在读端读时就会将其看成换行并打印在屏幕上,因此下面这样就可以解决:
// client.cc
#include"comm.hpp"
int main()
{
std::cout << "client begin: " << std::endl;
int wfd = open(NAMED_PIPE, O_WRONLY);
std::cout << "client end: " << std::endl;
if(wfd < 0) exit(1);
//write
char buffer[1024];
while(true)
{
std::cout << "Please Say# ";
if(strlen(buffer) > 0) buffer[strlen(buffer)-1] = 0; // 去掉换行
fgets(buffer, sizeof(buffer), stdin);
ssize_t n = write(wfd, buffer, strlen(buffer));
assert(n == strlen(buffer));
(void)n;
}
close(wfd);
return 0;
}
最后在client里进行ctrl c结束。至此,我们就完成了通信。
OK,以上就是本期知识点“进程间通信”的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟📌~
💫如果有错误❌,欢迎批评指正呀👀~让我们一起相互进步🚀
🎉如果觉得收获满满,可以点点赞👍支持一下哟~
❗ 转载请注明出处
作者:HinsCoder
博客链接:🔎 作者博客主页