通信原理
进程间通信的本质就是让不同的进程看到同一份资源或同一块内存空间,这是进程间通信的必要条件。这份资源被称为管道,它只能由操作系统提供,因为我们用户进行的操作最终都会转化为进程,而进程之间是相互独立的。

如上图,子进程和父进程的文件描述符表(图中的file_struct,用于管理进程打开的文件)是相同的,表中的指针指向父进程打开的文件的file结构体,通过它们就能访问这些文件。因此,父子进程可以都看到父进程打开的文件,它们天然具有通信的条件。同理,同一个父进程下的不同子进程之间也具有通信条件。
匿名管道
管道分为匿名管道 和**命名管道。**父子进程和兄弟进程的独特性质使得他们可以通过匿名管道进行通信。匿名管道是一块内存空间,只能由一方进行写入,另一方进行读取,也就是只能进行单向通信。大致使用流程如下

可以看到匿名管道的读取端和写入端是分开的两个文件描述符,如果要父进程写入,就要用系统调用close关闭父进程的读端,此时只能由子进程读取。同样子进程也要关闭写端,管道会检查读写端的引用计数,如果没有及时关闭可能会出错。如果要父进程读取也是类似,关闭不使用的端口。

在父进程中调用系统调用接口pipe就能创建匿名管道。由于是内存级的空间,所以没有文件名,这也是为什么被称为匿名管道。pipe需要传入一个int[2]类型的数组,它会在数组中填写两个文件描述符,第一个用于读取,第二个用于写入。使用示例如下。
cpp
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>
#include<cstring>
int pipefd[2]={0};//用于传入pipe
void child_speak(){//
char buffer[1024];
snprintf(buffer,sizeof(buffer),"Batman defeated Joker in this movie");
sleep(3);//想一会再说
write(pipefd[1],buffer,strlen(buffer));
}
void father_listen(){
char buffer[1024];
int count=0;
while(!count){
count=read(pipefd[0],buffer,35);
std::cout<<count<<std::endl;
}
buffer[35]='\0';
std::cout<<"My son said:"<<"\""<<buffer<<"\""<<std::endl;
}
void testpipe(){//儿子说,爸爸听
if(pipe(pipefd)){//成功返回0
std::cerr<<"Error in pipe"<<std::endl;
exit(1);
}
int pid=fork();
if(pid==0){//子进程
close(pipefd[0]);//不用的管道端要关掉,有引用计数的
child_speak();
close(pipefd[1]);
exit(0);
}
close(pipefd[1]);
father_listen();
close(pipefd[0]);
int *ws;
wait(ws);
}
int main(){
testpipe();
return 0;
}
注:C语言中,字符串相关的库函数在向一段空间输入字符串时,默认会预留最后一个位置给'\0',而系统调用不会。
匿名管道的性质
匿名管道具有五种特性:
1.匿名管道只能用来进行具有血缘关系的进程之间的通信,通常是父子进程间的通信
2.匿名管道文件自带同步机制。运行上面的代码可以发现,父进程在子进程对管道写入之前,会进入阻塞状态,直到子进程写入完成,父进程才能读取。有点类似我们使用scanf函数时的样子。
3.匿名管道是面向字节流的(这个可以暂时不管)。
4.匿名管道是单向通信的。它是一种半双工的特殊情况,进行读取的进程和进行写入的进程是固定的。任何时刻只能由一方发送,另一方接收的通信方式被称为半双工 ,可以双方可以同时发送接收的被称为全双工。
5.匿名管道文件的生命周期是随进程的,其它文件也是如此。进程退出时,它打开的所有文件都会被关闭或减少引用计数。
使用匿名管道通信时,有四种通信情况:
当写入的速度较慢,读取速度较快时,读取的进程会阻塞,即特性2中所说的情况。
当写入速度较快,读取速度较慢时,由于匿名管道的容量有限,为4kb,管道被写满时,写入的进程会阻塞。
当写入端关闭时,读取端的read函数会返回0,表示读到文件结尾。
当读取端关闭时,如果写端没有关闭,由于此时写入没有意义,操作系统会杀掉写入的进程。
进程池
根据匿名管道的同步机制,我们可以在父进程中创建多个管道,与多个子进程进行通信,父进程对管道进行写入,子进程则读取,当父进程没有写入时,子进程就会进入阻塞状态等待父进程写入。这样就能实现父进程向管道写入一次,子进程就运行一次。在此基础上,规定父进程每次只向管道中写入一个4字节的整形,子进程每次只将管道中的4个字节的内容当整形读取,根据整形的值执行不同的任务,就能实现父进程指挥子进程工作,这个整形被称为任务码,这个结构则被称为进程池。如下图。

注:Linux下的.cxx后缀的是c++的源文件,.hpp后缀的是头文件和源文件的混合体,可以像头文件一样使用,还可以直接在里面给出定义。
下面是进程池的参考实现,大家在实现时注意不要把关闭管道文件等影响外部数据的操作写在结构体的析构函数里面,因为赋值操作也会调用析构,可能一个不注意管道就被关闭了。而且由于不能随便赋值,会把代码搞得很复杂。
cpp
//processpool.hpp
#pragma once
#include"channel.hpp"
#include"task.hpp"
#include<unistd.h>
#include<iostream>
#include<string>
#include<vector>
#include<sys/wait.h>
class processpool{
public:
processpool(int n=0)
{
if(n > 0) add_process(n);
}
void add_process(int num=1){
for(int i=0;i<num;i++){
_processnum++;
_channels.emplace_back();
int pid=fork();
if(pid==0){
_channels.back().only_read();
int code=-1;
while(code!=0){
code=_channels.back().read_code();//子进程在此处阻塞,其_channels不会新增
if(code==0)
std::cout<<"管道写入端已关闭,子进程即将退出"<<" pid: "<<getpid()<<std::endl;
else {
std::cout<<"子进程收到的任务码为:"<<code<<" pid: "<<getpid()<<std::endl;
_taskm.execute(code);
}
}
close(_channels.back()._pipefd[0]);//关闭管道
exit(0);
}
else if(pid>0){
_channels.back().only_write();//父进程
_childpids.push_back(pid);
}
else{
std::cerr<<"进程创建时出错"<<std::endl;
exit(1);
}
}
}
void assign_task(int code){
if(code<=0){
std::cerr<<"请传入正确格式的任务码"<<std::endl;
return;
}
static int cur=0;
_channels[cur].write_code(code);
cur=(cur+1)%_processnum;
}
void terminate(){
//倒着关闭管道,让后面的子进程退出并关闭所有端口
for(int i=_channels.size()-1;i>=0;i--){
_channels[i].close_channel();
}
for(auto pid:_childpids){
int status;
waitpid(pid,&status,0);
}
}
// private:
std::vector<channel> _channels;//所有管道
std::vector<int> _childpids;//子进程pid
int _processnum=0;//进程数
task_manager _taskm;
};
cpp
//channel.hpp
#pragma once
#include<unistd.h>
#include<iostream>
#include<string>
struct channel{
channel(){
if(pipe(_pipefd)==-1){
std::cerr<<"管道开辟失败"<<std::endl;
exit(1);
}
}
void only_read(){
close(_pipefd[1]);
_dirc=0;
}
void only_write(){
close(_pipefd[0]);
_dirc=1;
}
int get_fd(){//返回端口的文件描述符
if(_dirc==-1){
std::cout<<"请先确定读还是写"<<std::endl;
exit(1);
}
else{
return _pipefd[_dirc];
}
}
int read_code(){//从管道中读取内容
if(_dirc==-1){
std::cout<<"请先确定是读取还是写入"<<std::endl;
exit(1);
}
else if(_dirc==1){
std::cout<<"写入方不能读取"<<std::endl;
exit(1);
}
std::string buffer;
char temp='\n';
int count=0;
do{
while(temp!='\0'){
count=read(_pipefd[0],&temp,1);
if(count<0){
std::cerr<<"读取时发生错误"<<std::endl;
exit(1);
}
else if(count==0) return 0;
buffer+=temp;
}
if(buffer[0]=='\0'){
std::cerr<<"任务码异常"<<std::endl;
exit(1);
}
else return std::stoi(buffer);
buffer.clear();
}while(count>=0);
std::cerr<<"读取时发生错误"<<std::endl;
exit(1);
}
void write_code(int code){//写入内容
if(_dirc==-1){
std::cout<<"请先确定是读取还是写入"<<std::endl;
exit(1);
}
else if(_dirc==0){
std::cout<<"读取方不能写入"<<std::endl;
exit(1);
}
std::string buffer=std::to_string(code);
buffer+='\0';
int count=write(_pipefd[1],buffer.c_str(),buffer.size());//加'\0'表示结束
if(count<0){
std::cerr<<"写入时发生错误"<<std::endl;
exit(1);
}
}
//vector扩容时,会拷贝原数组,然后调原数组元素的析构,导致管道关闭,所以文件不能在channel的析构函数关闭
int _pipefd[2];
int _dirc=-1;//用于判断是读还是写
};
cpp
//main.cpp
#include"processpool.hpp"
void test(){
processpool pp(3);
for(int i=1;i<20;i++){
pp.assign_task(i);
}
pp.terminate();
}
// void test(){//测试channel
// channel c;
// int p=fork();
// if(p==0){
// c.only_write();
// c.write_code(92);
// }
// else{
// c.only_read();
// std::cout<<c.read_code()<<std::endl;
// }
// }
int main(){
test();
return 0;
}
cpp
//task.hpp
#include<iostream>
#include<functional>
#include<unistd.h>
#include<vector>
using task_t = std::function<void()>;
void basic_task1(){
std::cout<<"正在执行基本任务"<<std::endl;
sleep(1);
}
void basic_task2(){
std::cout<<"正在下载文件"<<std::endl;
sleep(1);
}
void basic_task3(){
std::cout<<"正在清理垃圾"<<std::endl;
sleep(1);
}
struct task_manager{
task_manager(){
_tasks.push_back(basic_task1);
_tasks.push_back(basic_task2);
_tasks.push_back(basic_task3);
}
void register_task(task_t task){
_tasks.push_back(task);
}
void execute(int code){
if(code<=0){
std::cerr<<"任务码异常"<<std::endl;
exit(1);
}
else if(code>_tasks.size()){
std::cout<<"对应任务不存在"<<std::endl;
return;
}
else{
_tasks[code-1]();
}
}
std::vector<task_t> _tasks;
};
命名管道
如果两个进程没有"血缘关系",可以通过命名管道进行通信。原理很简单,就是打开同一份文件。既然是文件,自然就有自己的文件名,因而被称为命名管道。这个文件也不是普通的文件,而是**管道文件。**只能打开,不能刷新到磁盘,也就是不能保存修改。
使用命令mkfifo [文件名] 即可在当前路径下创建管道文件。创建完成后,就可以像下图这样通过命令在两个窗口间进行简单的通信。这里用cat读取文件内容时,如果另一边没有输入,则cat对应的进程会阻塞,所以命名管道也有同步机制。事实上,命名管道除了可以在无血缘关系的进程间通信,其它的四个特性与四个通信情况都和匿名管道的相同。

在代码中则可以调用系统调用接口mkfifo来创建命名管道,参数pathname是文件名或路径名,mode用于设置读写执行权限。创建成功会返回0,失败则返回-1。**创建成功后对管道文件进行文件操作即可通信。**比较特殊是,如果读端和写端只打开了一个,那么open函数会阻塞住,直到另一端也被打开。如果管道文件不存在或open函数设置了非阻塞选项(O_NONBLOCK),则会直接打开失败。

使用实例如下
cpp
//server.cpp
#include"PublicFile.hpp"
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<iostream>
#include<cstdio>
void serve(){
umask(0);
if(mkfifo(NAMEDPIPE,0666)==-1){
std::cerr<<"管道创建时出错"<<std::endl;
exit(1);
}
FILE* fp=fopen(NAMEDPIPE,"r");
char buffer[64];
fscanf(fp,"%s",buffer);
std::cout<<buffer<<std::endl;
if(-1==unlink(NAMEDPIPE)){
std::cerr<<"管道关闭时出错"<<std::endl;
exit(1);
}
}
int main(){
serve();
return 0;
}
cpp
//PublicFile.hpp
#pragma once
#define NAMEDPIPE "fifo"
cpp
//commander.cpp
#include"PublicFile.hpp"
#include<iostream>
#include<cstdio>
#include<unistd.h>
void command(){
FILE* fp=fopen(NAMEDPIPE,"w");
if(fp==NULL){
std::cerr<<"管道打开失败"<<std::endl;
exit(1);
}
fprintf(fp,"这是一条命令\n");
sleep(1);
}
int main(){
command();
return 0;
}

System V IPC
System V是一种标准,IPC是进程间通信的意思,System V IPC就是一套经典的进程间通信方案,包括三个IPC机制:共享内存、消息队列和信号量。
共享内存
共享内存的原理就是在内存中申请一段空间,并通过虚拟地址将这块空间映射到两个进程的进程地址空间中的共享区,通过访问这段空间就能实现进程间通信。如下

由于可能会有多组进程间都在通过共享内存进行通信,这样在内存中就会存在多个共享内存,因此操作系统自然需要将它们管理起来。管理的形式依旧是先描述再组织,即通过内核数据结构体记录共享内存的数据进行管理。
shmget与ftok
在代码中使用系统调用shmget即可创建共享内存,参数size是要创建的共享内存的大小

shmflg是选项,如果传入IPC_CREAT,表示创建共享内存,如果目标共享内存已存在则会打开这个共享内存。如果传入IPC_CREAT | IPC_EXCL,也就是再添加选项IPC_EXCL(该选项单独使用无意义),此时若要创建的共享内存已存在,则会出错返回。Linux也是将共享内存视为文件管理,访问时也有对应的读写权限,所以使用shmget创建共享内存时时,需要在这个选项中再添加权限值以设置共享内存的访问权限,IPC_CREAT|IPC_EXCL|0666。
创建成功后会返回一个整数值,用于访问共享内存。参数key用于确保两进程打开的是同一个共享内存,使用时,先约定好key的值,由一个进程创建管道并设置key,另一个进程打开共享内存时传入key,就能保证两进程打开的是同一个共享内存。key的取值一般使用ftok函数来生成
其内部会根据传入的路径和id生成一个key值,只要参数相同,生成的key值就是相同的。
shmctl
共享内存不会随着进程的结束而回收,如果没有进行删除操作,它会一直存在,生命周期随操作系统内核。使用命令ipcs -m可以查看现有的共享内存,使用命令ipcrm -m [共享内存ID]可以删除对应的共享内存,代码中则使用shmctl删除。

shmctl接口是用来控制共享内存的。参数shmid是目标共享内存的标识码,参数buf用于传入或接收共享内存段的结构体,而这个结构体正是操作系统用于管理共享内存的结构体,使用man shmctl可以在手册中查看结构体中的成员,具体使用方法和参数op有关。op是操作选项,有三个选项可选。传入IPC_STAT时,参数buf用于接收共享内存的信息,传入IPC_SET时则是用buf传入要修改的信息,使用时一般是先创建一个shmdt结构体对象,然后调用shmctl使用前面的选项IPC_STAT接收共享内存的数据,然后在其基础上对成员进行修改,最后再调用shmctl使用选项IPC_SET对共享内存的数据进行修改。参数op传入IPC_RMID则是删除共享内存,此时buf传入NULL即可。
shmat与shmdt
前面提到使用共享内存时需要将共享内存映射到程序的地址空间内,这个操作也需要我们调用函数来执行。使用shmat函数即可将共享内存挂接到程序地址空间中,第一个参数shmid就是共享内存的id,第二个参数是要挂接的目标虚拟地址,这个设置为nullptr让操作系统自己选即可,第三个参数用于处理读写权限,暂时不用管,设置为0使用默认设置即可。挂接失败会返回-1,成功会返回共享内存在程序地址空间中的起始虚拟地址,这个返回值其实与malloc的没有什么区别,使用方式是相同的。强制类型转换成任意一种指针然后当数组使用即可。

共享内存是进程间通信中速度最快的方式,,这是因为共享内存被映射到了程序地址空间的共享区,对其进行读写时只需使用地址直接访问,而命名管道的文件缓冲区和匿名管道都在操作系统内核,需要调用系统调用才能访问。但这个特性也使得共享内存没有同步机制,这可能会导致写入端还没有将数据完整地写入时,读取端就读取内容了,导致数据传输不完整,因此还需要自行补充同步机制,可以在写入端写入完成后,通过命名管道发送信息,读取端接收到信息后从共享内存中读取内容。
共享内存创建时,它在操作系统内核中的大小必须是4KB(4096B)的整数倍,如果用户设定的大小不是4KB的整数倍,则会向上取整。但在实际使用时,用户设定是多少字节就只能用多少字节,剩下的空间相当于浪费掉了。
在关闭共享内存前,需要让各个进程与共享内存去关联,使共享内存的引用计数为0,使用shmdt即可。将shmat返回的共享内存地址指针传入即可。
cpp
#include <sys/shm.h>
int shmdt(const void *shmaddr);
消息队列(了解)
消息队列的原理就是让要发送数据的进程将带有数据的结构体挂到一个队列中,让读取的进程在队列中获取结构体,从而拿到数据。为了区分来自不同进程的数据结构体,在结构体中有用于识别的成员,一般是int类型。只要在通信前先约定好具体的值,就能在队列中区分出对应的结构体。消息队列的生命周期也是随内核。
信号量(暂作了解)
访问资源的程序被称为临界区 ,通过保护临界区的代码就能保护对应的资源。保护方式不唯一,如在任何时刻只允许一个执行流(进程)访问资源,这种访问方式称为互斥 ;同步则是让多个执行流在访问资源时具有一定的顺序性。通过信号量可以使用互斥机制保护,也可以使用同步机制保护。具体的使用方式如下图所示,大致了解结构即可。

信号量也叫信号灯,本质是一个独立的计数器 ,用来表明临界资源中的资源数量。以共享内存为例子。如果要让多个进程并发式地访问共享内存,就要在共享内存中划分多个不同的区域,分别进行使用。那么信号量就记录了该共享内存中未使用的区域个数。实际上,我们是先确认信号量的上限,即未使用区域个数的上限,然后才在共享内存等资源中划分区域。不同进程通过访问同一个信号量来确定资源的使用情况。
为了防止不同的进程访问到同一块区域,进程访问前就需要申请信号量(被称为P操作),看是否还有闲置的资源,如果没有就进入阻塞状态进行等待。申请信号量的本质就是对资源的预订。进程访问结束后,则需要释放信号量(V操作)。如果信号量只有0与1两个状态,即该资源同一时间只能由一个进程使用,则称其为二元信号量,其实就是互斥。由于信号量需要被多个进程访问,因此信号量本身也是一个共享资源,但操作系统内核会对其进行保护,不需要我们操心。
那么为什么说信号量这个计数器是独立的呢?这是因为信号量并不能阻止进程访问资源,它就是一个单纯的计数器,和进程要访问的资源没有任何关系,几乎只是记录了一个数字。创建信号量后,即使不申请信号量,进程也能访问资源。就像马路上的红绿灯一样,不会阻拦汽车通过,但是遵守红绿灯的信号能让交通更有秩序。
操作系统同样需要对信号量进行管理,管理方式仍是先描述再组织,而在描述信号量的结构体中同样可以找到成员结构体ipc_perm,里面同样有key值,而共享内存和消息队列也是如此。事实上,这三者被操作系统视为同一种资源,如此一来就不难理解为什么这三者属于system V。