环境:centos7.6,腾讯云服务器
Linux文章都放在了专栏:【Linux】欢迎支持订阅
进程间通信介绍
什么是进程间通信?
进程间通信(Interprocess communication,简称IPC)就是让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。
通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程资源共享:多个进程之间共享同样的资源。通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
通信的本质
- 由于进程具有独立性,所以这也就增加了通信的成本。
- 让两个不同的进程实现通信,前提条件就是让两个进程看到同一份资源。因此,任何通信的手段都是先让不同的进程看到同一份资源,然后一方写入,一方读取,实现通信。
通信的发展与分类
本次章节讲着重讲解管道通信以及System V共享内存,其余有兴趣自行了解即可。
管道通信
管道概念
管道是unix中最古老的进程间通信的方式,把一个程序的输出直接连接到另一个程序的输入。既然是通信,那么一定遵循通信的原理,即:使不同进程看到同一份资源,一方写入一方读取。如下所示:
在这里,whoami与cat运行时为两个不同的进程。whoami用来查看当前用户,whoami进程通过stdout,讲数据打印到“管道”。然后cat进程通过stdin,进行从管道中读取,并对stdout重定向,输出到文件log.txt中。
匿名管道通信
通信原理
匿名管道通信的本质也是使不同进程看到同一份资源,匿名管道用于具有血缘关系的进程进行通信,一般用于父子进程。在学习之前,我们要知道,父进程fork后,子进程继承父进程的相关数据结构,不会继承父进程曾经打开的文件对象。如下所示:
而匿名管道的原理,便是根据这种父子继承的特点,使父子进程看到同一份“被打开的文件”,从而完成通信的前提条件,只不过这个“同一份被打开的文件”,是由OS来维护,只存在于内存。属于内存级别的文件。如下所示:
pipe函数创建匿名管道
#include<unistd.h>
int pipe(int pipefd[2]);
参数pipefd[2]为一个输出型参数,是一个数组,pipefd[0] :表示管道的读端、pipefd[1]:表示管道的写端。调用成功返回0,失败返回-1.
通信步骤
- 1、父进程调用pipe函数创建管道
- 2、fork创建子进程
- 3、父关闭写,子关闭读。实现子进程写入,父进程读取
代码示例
子进程向父进程写入“hello i am your child !”;
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<wait.h>
#include<cstring>
using namespace std;
int main()
{
int pipefd[2]={0};
//1、创建管道文件
int n=pipe(pipefd);
if(n == -1)
{
cout<<"mkpipe false"<<endl;
return 1;
}
//2、创建子进程
pid_t id=fork();
if(id == 0)
{
//child
//3、子进程关闭读,父进程关闭写
close(pipefd[0]);//pipefd[0] 为读,pipefd[1] 为写
//4、子进程写入数据、父进程读取数据
const char* str="hello i am your child!";
int wn=write(pipefd[1],str,strlen(str));
if(wn<0)
{
cout<<"child write false"<<endl;
return 2;
}
}
//father
//3、子进程关闭读,父进程关闭写
close(pipefd[1]);
//4.从读端读取数据
char buffer[30];
int rn=read(pipefd[0],buffer,sizeof(buffer)-1);
if(rn >0)
{
buffer[rn]='\0';
cout<<"我是父进程,收到子进程的信息:"<<buffer<<endl;
}
waitpid(id,NULL,0);
return 0;
}
管道通信四大现象
- 1、如果读端将数据读取完毕后,写端不进行写入,那么读端将会一直等待,直到写端写入数据
现象如下:
- 2、如果写端将管道写满了,那么就不能继续写入数据了,除非读端将管道数据读取后,才能继续写。
- 3、将写端关闭,那么读端读完管道中的数据后就会读到文件结尾,也就是说,此时read函数会返回0
- 4、将读端关闭,写端进行写入,但是此时的写入就毫无意义,而OS不会为了维护无意义的写入,此时OS会发送13号信号,终止写端进程。
管道通信特点
- 管道的本质就是文件,所以管道的生命周期随进程,因为fd文件描述符的生命周期 随进程
- 管道通信是一种单向通信,如果要实现双向,则需要借助两个管道
- 匿名管道通信通常用来进行有血缘关系的进程,常用于父子进程。(因为我们用pipe函数创建管道,我们不知道该管道文件的名称,所以叫匿名管道通信)
- 管道通信中,数据写入的次数与读取的次数不一定严格匹配。(管道自带同步与互斥机制,同步与互斥,将放在后面章节讲解)
命名管道通信
命名管道通信原理
实际上与匿名管道原理相同,创建一个管道文件,然后让不同的进程分别以读和写的方式打开,然后实现通信。不同的是,上方所讲的“匿名管道”只能用来具有“血缘”关系的进程通信,而本次所讲的命名管道,既可以实现“血缘进程”,也可以实现互不相关的进程进行通信。
命名管道的创建
第一种方式:命令行指令:mkfifo filename:创建名称为filename的管道文件
如下图所示,创建了一个名为pipe的管道文件
既然创建了该管道文件,我们也可以在命令行输入指令,利用管道进行通信,如下所示: 我让两个互不相关的进程,一个进程每秒循环往管道进行打印数据,另一个cat进程则进行读取数据。
当然,这种命名管道也遵循管道通信的四大现象,比如假如我将读端关闭,那么此时写端进程就成了无意义的写入,OS不会进行维护,就会就发送13号信号,终止写端进程。如下所示:
第二种方式:系统调用函数:mkfifo
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
其中,参数pathname表示命名管道文件的名称(默认在当前路径下创建),mode表示文件权限。(注意:文件实际的权限受权限掩码umask的影响,在前面权限相关章节已做讲解。umask()函数可以设置权限掩码)。
管道文件创建成功,返回0,失败返回-1.
如下所示
如果比较细心的话,我们会发现,该管道文件的大小为0,事实上,进程间通信中往管道中写入数据,都是在内存中完成的,OS并不会把数据刷新到磁盘文件中,因为这样做并没有意义,所以即使我们不断地往管道文件中写入数据,我们查看磁盘下该文件的大小时,始终为0。
命名管道的删除
第一种方式:直接在命令行rm指令删除即可
第二种方式:系统调用函数:unlink()函数
#include <unistd.h>
int unlink(const char *pathname);
命名管道打开规则
如果当前打开操作是为读而打开 FIFO 时O_NONBLOCK disable :阻塞直到有相应进程为写而打开该 FIFOO_NONBLOCK enable :立刻返回成功如果当前打开操作是为写而打开 FIFO 时O_NONBLOCK disable :阻塞直到有相应进程为读而打开该 FIFOO_NONBLOCK enable :立刻返回失败,错误码为 ENXIO
同样,假如A以写的方式打开命名管道,此时A也会进入阻塞,直到B进程以读的方式打开管道文件,A才继续运行。
通信演示(文件拷贝)
实际上,进程间通信,不仅仅可以实现数据的传输,还可以让一个进程给另一个进程发送指令,使之根据不同指令执行不同方法。同样,也可以比如说让另一个进程实现文件拷贝。如下:
我要创建两个进程server进程与client进程,client进程负责将log.txt文件的数据写入到管道,而server进程则创建一个newlog.txt的文件,然后从管道中将数据读到newlog.txt中,从而实现文件拷贝。
通信原理:
通信实现
为了确保两个进程打开的管道文件名称不会出错,这里我们自定义一个头文件,并使两个进程共用。如下:
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<cerrno>
#include<cstring>
#include<cassert>
using namespace std;
#define PIPENAME "./mypipe"
接下来是server.cc的实现,如下:
#include"comm.h"
int main()
{
umask(0000);
//1、创建管道文件
int n=mkfifo(PIPENAME,0664);
if(n== -1)
{
cerr<<"mkfalse:"<<errno<<strerror(errno)<<endl;
return 1;
}
cout<<"mkpipe success"<<endl;
//2、读方式打开管道文件
int rfd=open(PIPENAME,O_RDONLY);
if(rfd<0)
{
cerr<<"open false:"<<errno<<strerror(errno)<<endl;
return 2;
}
cout<<"open pipe success"<<endl;
//3、以写的方式打开newlog.txt文件,如果不存在则创建
int wfd=open("newlog.txt",O_WRONLY | O_CREAT,0666);
assert(wfd>=0);
//4、读取管道数据,并写入newlog.txt
char buf[128];
while(1)
{
//4.1每次读取前将buf清空
buf[0]='\0';
size_t s=read(rfd,buf,sizeof(buf)-1);//(从管道读取)
if(s>0)
{
buf[s]='\0';
//将数据写入newlog.txt
write(wfd,buf,strlen(buf));
}
else if(s == 0)
{
cout<<"数据读取完毕,server读端关闭"<<endl;
close(rfd);
break;
}
else
{
cout<<"数据读取出错"<<endl;
return -1;
}
}
cout<<"结束通信,完成拷贝"<<endl;
close(wfd);
unlink(PIPENAME);//删除管道文件
return 0;
}
client.cc的实现:
#include"comm.h"
int main()
{
//1、以写的方式打开管道文件
int wfd=open(PIPENAME,O_WRONLY);
if(wfd<0)
{
cerr<<"open false:"<<errno<<strerror(errno)<<endl;
return 2;
}
cout<<"open pipe success"<<endl;
//2、以读的方式打开log.txt文件
int fd=open("log.txt",O_RDONLY);
assert(fd>=0);
//3、读取文件数据,并写入管道
char buf[128];
while(1)
{
buf[0]='\0';//置空
size_t s=read(fd,buf,sizeof(buf)-1);//读取文件数据
if(s>0)
{
buf[s]='\0';
//写入管道
write(wfd,buf,strlen(buf));
}
else if(s == 0)
{
cout<<"读取文件结尾"<<endl;
break;
}
else{
cout<<"读取文件数据出错"<<endl;
return -1;
}
}
cout<<"client写端关闭"<<endl;
close(wfd);
close(fd);
return 0;
}
我们来观看运行结果:
在这里,举这个例子的原因在于,假如我们将这里的管道想象成网络,将client想象成我们的Windows Xshell,将server想象成centos服务器,那么此时我们实现的就是文件的上传功能,反过来,我们实现的就是文件的下载功能。
命名管道与匿名管道的区别
匿名管道由pipe函数创建并打开,适用于具有血缘关系的进程,且该管道文件没有名称
命名管道由mkfifo函数创建,由open函数打开,可以自定义命名,并且可以实现任意进程之间的通信。但两者通信的底层实际并无差别
system V 共享内存
共享内存通信原理
不管是什么方式,实现进程间通信的前提都是让不同的进程看到同一份资源。共享内存也不例外。而共享内存的原理则是在内存中申请一块空间,然后通过各进程对应的页表映射给不同的进程,此时两个进程,就会看到同一份资源。而在内存中申请的这块空间,就叫共享内存。如下图所示:
共享内存数据结构
由于系统中可能存在大量的进程通信,这也就意味着系统中会存在大量的共享内存。因此,OS一定会对其进行管理,那么OS如何管理呢?六个字:先描述,再组织。即OS会给每一个共享内存,开辟一个结构体对象,该结构体内包含了共享内存的所有属性等相关信息,然后通过某种数据结构(链表等),将所有的结构体链接起来。该结构体名为:struct shmid_ds,如下:
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
那么此时就出现了一个问题,既然系统中存在那么多共享内存,那么又是如何知道各个进程对应的是哪个呢?实际上每个共享内存在申请时,都会有一个key值,这个值就是为了标识共享内存的唯一性。两个进程通过该key值,就会找到各自对应的“共享资源”。我们发现,shmid_ds的第一个成员,也是一个结构体变量,而key就存在该结构体shm_perm中。如下:
struct ipc_perm
10 {
11 __kernel_key_t key;//这就是用来标识共享内存的key
12 __kernel_uid_t uid;
13 __kernel_gid_t gid;
14 __kernel_uid_t cuid;
15 __kernel_gid_t cgid;
16 __kernel_mode_t mode;
17 unsigned short seq;
18 };
(shmid_ds存在于/usr/include/linux/shm.h下、shm_perm存在于/usr/include/linux/ipc.h下)
通信步骤
整体来说,分为:1、创建共享内存。2、将共享内存与进程进行关联。3、进行通信。4、取消关联。5、释放共享内存。
共享内存的创建
系统调用函数:shmget
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
对于第一个参数,就是我们上面提到的key,用来确保该共享内存是唯一的。第二个参数就是该共享内存的大小。第三个参数表示创建共享内存的方式选项。当共享内存创建成功时,会返回共享内存的id值(identifier),失败返回-1。
对于key值,我们不是随便传的,因为要确保该key值是唯一存在的,不会与系统内别的key值发生冲突,因此,我们需要用到函数:ftok
ftok函数
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
该函数表示:根据传入的已存在的路径名pathname,以及一个整数标识符proj_id,来生成一个发生冲突概率极低的key值。
这里需要注意的是:在进行通信的进程,必须保证传入的pathname以及proj_id是同一个,这样才能生成同一个key,从而找到同一份共享资源,实现通信。
关于shmget的第三个参数常见组合
一般常用组合:
组合 | 代表含义 |
IPC_CREAT | 创建一个共享内存,如果该共享内存已存在,则获取已存在的共享内存的id,若不存在,则创建一个新的共享内存,并返回该共享内存的id(该选项无法保证一定是最新创建的共享内存) |
IPC_CREAT | IPC_EXCL | 创建一个共享内存,若该共享内存不存在,则创建一个新共享内存并返回其id,若已存在,则立马保存返回。(该组合保证了创建出来的共享内存,一定是最新的) |
当然,我们也可以在第三个参数后面加上权限,比如IPC_CREAT | 0666,用来控制创建出来的共享内存的权限。
代码演示
接下来,我们来创建一个共享内存,并打印其对应的key值以及id值。
运行结果如下:
我们可以通过命令行指令来查看关于共享内存的相关信息
IPC命令行指令查看信息
指令 | 作用 |
ipcs | 列出消息队列、共享内存、信号量相关信息 |
ipcs -m | 只列出共享内存相关信息 |
ipcrm -m shmid | 删除id值为shmid的共享内存 |
我们发现与我们打印出来的结果一致(key转为16进制,就是这里显示的key)
另外:perms就是我们设置的权限、bytes为大小,nattch表示关联的进程数(此时还未关联,所以为0),status表示该共享内存的状态。
共享内存的释放
我们发现,当进程结束后,共享内存依然存在,也就意味着:共享内存的生命周期随OS,不随进程。因此我们还需要进行删除,如果不进行删除,再次运行上面的代码就会报错(IPC_CREAT|IPC_EXCL)
释放共享内存有两种方式,一种是上面讲的用命令行指令:ipcrm -m shmid,来进行删除。这里就不进行演示了,很简单。零另外一种就是用系统调用函数:shmctl
shmctl
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
第一个参数为共享内存对应的id值,cmd表示具体的函数控制操作,第三个参数为输出型参数,即把共享内存的相关属性,输出到结构体buf中。
该函数调用成功返回0,失败返回-1
对于cmd,常见选项如下:
cmd值 | 代表含义 |
IPC_RMID | 删除这块共享内存 |
IPC_STAT | 得到共享内存的状态,把共享内存的shmid_ds结构复制到buf中 |
IPC_SET | 改变共享内存的状态,把buf所指的shmid_ds结构中的uid、gid、mode复制到共享内存的shmid_ds结构内 |
因此,我们可以在末尾加上如下代码:
关联与去关联
关联操作也就是将内存中的共享内存,映射到进程对应的地址空间。该操作需使用函数shmat
shmat函数
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
首先,第一个参数就是共享内存的id值,第二个参数表示:指定一个区域,将共享内存映射到此处,一般我们设置为nullptr,设置为nulptr表示让OS自己选择区域映射。第三个参数表示关联共享内存时设置的一些属性。一般如下:
shmflg参数选项 | 代表含义 |
SHM_RDONLY | 只读模式,只可以进行读取操作 |
0 | 读写都可 |
当关联成功时,会返回映射到进程地址空间的起始地址,失败返回-1。
shmdt函数
该函数用来去关联。
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
这里的参数就是将要去关联的共享内存映射到进程地址空间的起始地址,也就是shmat函数的返回值。去关联成功时,返回0,失败返回-1.
代码演示
这里,我们通过管擦ipcs -m,可以看到nettch对应的值先是由0变成1,再由1变成0。
用共享内存实现客户端与服务端进程通信
既然了解了共享内存实现的原理,接下来我们将用此实现进程间通信:客户端不断地往共享内存写数据,服务端不断地从共享内存读数据,并将数据打印到显示器。在这里,我们将用一个类,来对实现的方法进行封装。基本框架如下所示:
接下来将对这些函数进行实现,如下所示:
将这些主要函数实现后,我们只需要在构造时,根据不同的type,从而执行不同的方法即可,如下所示:
至此,一个共享内存的类就实现完毕,接下来只需要进行通信即可,客户端不断地写,服务端不断地读。如下所示:
由于此时我们还没有运行客户端,进行写入,所以此时服务端读取永远是空信息。如下:
因此,我们要再完善客户端,实现写入功能。这里简单的写一个,如下:
此时当我们两个进程都运行时,一方写入,一方读取,实现通信:
当然,这里只是简单的实现通信,我们还可以在共享内存中加入管道,用来控制进程,当客户端写入完成后,服务端再进行读取。这里由于篇幅原因就不进行演示,可自行实验,有问题也可进行探讨。
管道VS共享内存
我们知道,管道通信的实现,需要借助于read、write函数,而该函数在前文我们讲过了已经,本质上其实是一个拷贝函数,如下图所示,如果要实现文件数据的传输,至少需要进行4次拷贝,而共享内存则只需两次即可,如下:
因此:管道通信是进程间通信中最快的一种通信方式,但是,共享内存并不提供任何保护机制,即共享内存不会自带同步与互斥,而管道则自带同步与互斥。各有利弊。(同步与互斥会在后面章节讲解)
end.
生活原本沉闷,但跑起来就会有风!🌹