深入理解进程间通信
何为进程间通讯呢?顾名思义,它指的是不同进程之间传播和交换信息。主体是进程,且是大于等于2个进程;工作是通讯,数据交换。
在讲进程通讯之前,先带大家了解一下都有哪些进程间通讯的方式?并对比总结一下它们各自的特点和使用范围。
1.有名管道: 有管道文件 任意进程之间
2.无名管道: 没有管道文件 父子进程之间
3.信号量: 同步控制
4.消息队列: 定向发送数据
5.共享内存: 最快的 IPC 信号量做同步控制
6.文件: 传递数据 文件在磁盘上 系统调用函数对于文件非阻塞
7.信号: 事件 标识
根据它们的特点,在需要进程通讯的时候,选择出最优的进程方式,不过一般推荐选择无名管道,因为它相比其他方式更加简单,更重要的是稳定。
文件和信号在前面的博文中都有详细的提到,这次讲前五种进程间通讯方式。
一、有名管道
1.1 在讲有名管道之前,先说一下管道的一些知识:
管道是Linux中很重要的一种通信方式,是把一个程序的输出直接连接到另一个程序的输入,常说的管道多是指无名管道,无名管道只能用于父子的进程之间,这是它与有名管道的最大区别。
Linux管道的实现机制
从本质上说,管道也是一种文件,但它又和一般的文件有所不同,主要的区别有两点: 1.管道有固定大小:实际上,管道是一个固定大小的缓冲区。在Linux中,该缓冲区的大小为1页,即4K字节,不会像文件一样大小可以不断增大。
当然使用单个固定缓冲区即管道也会带来问题,比如在写管道时可能会写满,当这种情况发生时,随后对管道的write()调用将默认地被阻塞,等待某些数据被读取,以便腾出足够的空间供write()调用写。
2.管道读取进程比文件读取进程快:当所有当前进程数据已被读取时,管道变空。当这种情况发生时,管道一个随后的read()调用将默认地被阻塞,等待某些数据被写入,而文件read()调用返回文件结束。
注意:从管道读数据是一次性操作,数据一旦被读,它就从管道中被抛弃,释放空间以便写更多的数据。
3.管道文件存储在磁盘,普通文件存储在内存里面。
1.2 有名管道
概念:
在文件系统中存在一个文件标识(文件名),但是管道文件不占据磁盘的空间,需要传递的数据缓存在内存区域。
实现进程间通讯的机制:
如图
对管道文件的操作有:
打开:int open(char &path,int flag,);
读:int read(int fd,void *buf,size_t size);
写:int write(int fd,void *buf,size_t size);
关闭:int close(int fd);
那么,如何在linux下创建管道文件呢?分为以下几步:
1. 在终端命令提示行输入:mkfifo 文件名(创造的管道文件名)或代码中实现: int mkfifo(const char *pathname,mode_t mode)函数 ,注意一点,对于普通文件来说,用open可以创造出一个普通文件,但对于管道文件则不可以;
2. 写相应代码;
3. 编译运行。
举一个具体的实例:利用有名管道实现 A 进程将“hello world”发送给 B 进程循,环打印。
写进程代码如下:
读进程代码如下:
写进程运行结果:
读进程运行结果:
通过上面两个进程可以验证有名管道的特性,并得出结论:
1、 open 以只读打开一个管道文件, open 会阻塞运行, 直到有一个进程以只
写或读写打开管道文件。
2、 open 以只写打开一个管道文件, open 会阻塞运行, 直到有一个进程以只读
或读写打开管道文件。
3、 read 函数也会阻塞运行,直到管道中有数据或者写端关闭
1.2 单工通信、半双工通信、全双工通信
单工通信只支持信号在一个方向上传输(正向或反向),任何时候不能改变信号的传输方向。为保证正确传送数据信号,接收端要对接收的数据进行校验,若校验出错,则通过监控信道发送请求重发的信号。
此种方式适用于数据收集系统,如气象数据的收集、电话费的集中计算等。例如计算机和打印机之间的通信是单工模式,因为只有计算机向打印机传输数据,而没有相反方向的数据传输。还有在某些通信信道中,如单工无线发送等。
半双工通信:半双工通信允许信号在两个方向上传输,但某一时刻只允许信号在一个信道上单向传输。因此,半双工通信实际上是一种可切换方向的单工通信。此种方式适用于问讯、检索、科学计算等数据通信系统;
传统的对讲机使用的就是半双工通信方式。由于对讲机传送及接收使用相同的频率,不允许同时进行。因此一方讲完后,需设法告知另一方讲话结束(例如讲完后加上'OVER'),另一方才知道可以开始讲话。
全双工通讯:全双工通信允许数据同时在两个方向上传输,即有两个信道,因此允许同时进行双向传输。全双工通信是两个单工通信方式的结合,要求收发双方都有独立的接收和发送能力。全双工通信效率高,控制简单,但造价高。
计算机之间的通信是全双工方式有计算机之间。电话、手机之间等。
二、无名管道
2.1 基本知识
概念:没有名称的管道(没有管道文件存在)。
依赖的理论基础:fork 之后,父子进程对于文件描述符共享
如图:
限制:
只能应用于父子进程之间完成进程间通讯,并且管道的创建与打开必须在
fork 之前完成。
如图:
2.2 操作
创建和打开:int pipe(int fds[2]);
pipe 函数创建一个无名管道,fds[0]指向读端,fds[1]指向写端。
因为管道是半双工通讯,所以在fork之后,父子进程分别关闭一对读写;
值得注意的是,进程通讯为了保证数据的安全完整读写,要求半双工,即父进程只写或只读,子进程对应相应的读或写,由于fork出的子进程和其父进程都拥有fds[0]和fds[1],因此需要在fork后执行通讯前的第一件事是关闭其中一个权限f[0]或f[1]。
具体操作:
读数据:read(int fds,void *buff,size_t size);
写数据:write(int fds,void* buff,size_t size);
关闭文件:close(fds);
2.3 具体实现
父进程写单词,子进程统计单词个数,输入end结束进程。代码如下:
父进程先关闭读,打开写,然后开始写,写完,最后关闭写;
子进程先关闭写,打开读,然后开始读,读完,最后关闭读。
三、信号量
3.1 基本概念
在讲信号量之前,先讲一下与信号量有关的知识:
1、原子操作
不可中断的操作,一旦操作开始执行,就不能停止,直到其运行完成
2、临界资源
在同一时刻,只能被一个进程访问的资源。
3、临界区
在程序中,访问临界资源的代码区域
4、进程同步
说到同步,好多人会觉得是同时,其实恰恰相反,进程同步指的是一个进程需要等待另一个进程的某些条件的发生,才能接着运行,它们之间是一种合作的关系,就如同接力棒比赛,第一个人跑完到第二个人的位置后,第二个人才能跑起来,在这之前,第二个人只能等待,其他什么都干不了。
5、进程异步
两个或两个以上进程互不干涉,相互无影响,是独立执行的个体
了解完以上内容,就来说说信号量,信号量信号量是做进程间同步控制的一种记录资源数量的一个计数器。它是一个特殊的变量,只能取正整数值,并且程序对其访问只能进行原子操作;一个更正式的定义是信号量是一个特殊的变量,只能对它进行等待(wait)和发送信号(signal)两种操作,在linux中,将P(信号量变量)用于等待,V(信号量变量)用于发送信号。
P(sv) 如果sv的值大于0,就给sv减去1,;如果sv的值等于0,就挂起该进程的执行。
V(sv) 如果有其他进程因等待sv而被挂起,就让它恢复运行;如果没有,就给sv加1。
注:sv:信号量
3.2 具体操作
信号量的操作:
创建(初始化)或者获取
加一 : V 操作 释放资源
减一: P 操作 阻塞运行
删除:
系统提供的操作信号量集的系统 API
1、semget函数的作用是创建一个新信号量或得到一个已有信号量的键
int semget((key_t)key, int nums, int flag);
第一个参数key是一个整数值,程序对所有信号的访问都是间接的,它先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才直接使用信号量键,所有其他的信号量函数都是使用semget函数返回的信号量标识符。
第二个参数nums创建时使用,指定信号量集中信号量的个数(数组的大小),一般都取1。它代表一次创建几个信号量,这个函数的第二个参数好比是停车场的个数,一般都是一个停车场。
第三个参数flag:
1、 操作权限 0nnn(0666)
2、 IPC_CREAT 指定是否是创建信号量集
2、semop函数用于改变信号量的值,一次性操作,p v操作都需要它,第二个参数就是个结构体指针,其中sem_op成员就是控制具体让信号量加1还是减1,加1就设置为1,减1就设为-1
int semop(int semid, struct sembuf * buff, int len);
第一个参数semid为semget函数返回的信号量表示符;
第二个参数buff是指向一个结构体数组的指针,每个数组元素至少包含以下数组成员:
struct sembuf
{
short sem_num;
short sem_op;
short sem_flg;
}
3、semctl函数用来直接控制信号量信息
int semctl(int semid, int num, int cmd, );
第一个参数semid为semget函数返回的信号量表示符;
第二个参数num是信号量编号,当需要用到信号量集时,就需要用到它,他一般取值为0,表示这是第一个也是唯一的一个信号量;
第三个参数cmd是将要采取的动作。
根据操作信号量的几个函数原型,得到以下几个具体的封装函数:
void sem_get();
一般都取一个信号量集,就是一个停车场,信号量集里的信号量,简单而言就是我们之前说的影响pv操作的是每个信号量的信号量多少,好比停车场里是否有车位。车位不为0就可以停车(p操作),为0就不能停,车从停车场出去执行V操作。
Sem_get()初始化联合体一般有三个成员,不过目前我们设置1个就好,设置个val成员。就是每个信号量的初始值,停车场里初始车位是多少。如果为1,1个车位,就代表进程之间要抢这个车位,属于竞争关系;如果为0,就代表进程直接要等待某个进程使用v操作开车走人,才能执行p操作之后的内容,属于一个进程为其他进程服务。
下面三个函数就不做过多解释了,比较简单。
void sem_p();
void sem_v();
void sem_del();
3.3 具体实现
讲了这么多,就请读者自己用一道相关程序题练练手吧。
四、消息队列
4.1 基本概念
消息: 数据 & 类型
队列: 先进先出 优先级队列
消息队列:
发送带有类型的数据,读数据的进程可以根据类型获取特定的数据,并且在这一类型上
遵循先进先出。
其实消息队列和管道很像。但它比起管道不需要固定进程的只读和只写,通讯间的进程都读写可以,并且支持多个进程。
而消息队列发送的消息 实际是一个消息类型和实际的消息。消息类型是什么?它其实是一个长整型数字,具体多少完全由用户来定义,为的是读消息时可以根据这个类型来调整读取的优先级顺序。
消息队列发送的是数据块,一般发送的消息就定义为以下的结构体。
struct msg
{
long type;//自定义的消息类型
char buff[N] ; //实际的数据比如字符数组形式的
}
图解:
Linux用宏MSGMAX和MSGMNB来限制一条消息的最大长度和一个队列的最大长度。
4.2 具体操作
在Linux中使用消息队列
Linux提供了一系列消息队列的函数接口来让我们方便地使用它来实现进程间的通信。它的用法与其他两个System V PIC机制,即信号量和共享内存相似。
1、msgget函数
该函数用来创建和访问一个消息队列。它的原型为:
int msgget((key_t)key, int msgflg);
与其他的IPC机制一样,程序必须提供一个键来命名某个特定的消息队列。第一个参数key为用户随意定义的值;第二个参数msgflg是一个权限标志,表示消息队列的访问权限,它与文件的访问权限一样。msgflg可以与IPC_CREAT做或操作,表示当key所命名的消息队列不存在时创建一个消息队列,如果key所命名的消息队列存在时,IPC_CREAT标志会被忽略,而只返回一个标识符。
它返回一个以key命名的消息队列的标识符(非零整数),失败时返回-1.
2、msgsnd函数
该函数用来把消息添加到消息队列中。它的原型为:
int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);
第一个参数msgid是由msgget函数返回的消息队列标识符。
第二个参数msg_ptr是一个指向准备发送消息的指针,但是消息的数据结构却有一定的要求,指针msg_ptr所指向的消息结构一定要是以一个长整型成员变量开始的结构体,接收函数将用这个成员来确定消息的类型。所以消息结构要定义成这样:
struct my_message{
long int message_type;
};
第三个参数msg_sz是msg_ptr指向的消息的长度,注意是消息的长度,而不是整个结构体的长度,也就是说msg_sz是不包括长整型消息类型成员变量的长度。
第四个参数msgflg用于控制当前消息队列满或队列消息到达系统范围的限制时将要发生的事情,是一个宏
如果调用成功,消息数据的一分副本将被放到消息队列中,并返回0,失败时返回-1.
3、msgrcv函数
该函数用来从一个消息队列获取消息,它的原型为
int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);
参数msgid, msg_ptr, msg_st的作用和函数msgsnd函数的一样。
参数msgtype可以实现一种简单的接收优先级。如果msgtype为0,就获取队列中的第一个消息。如果它的值大于零,将获取具有相同消息类型的第一个信息。如果它小于零,就获取类型等于或小于msgtype的绝对值的第一个消息。
参数msgflg用于控制当队列中没有相应类型的消息可以接收时将发生的事情,是一个宏。
调用成功时,该函数返回放到接收缓存区中的字节数,消息被复制到由msg_ptr指向的用户分配的缓存区中,然后删除消息队列中的对应消息。失败时返回-1.
4、msgctl函数
该函数用来控制消息队列,它与共享内存的shmctl函数相似,它的原型为:
int msgctl(int msgid, int command, struct msgid_ds *buf);
第一个参数command是将要采取的动作,它可以取3个值,
IPC_STAT:把msgid_ds结构中的数据设置为消息队列的当前关联值,即用消息队列的当前关联值覆盖msgid_ds的值。
IPC_SET:如果进程有足够的权限,就把消息列队的当前关联值设置为msgid_ds结构中给出的值
IPC_RMID:删除消息队列
第二个参数buf是指向msgid_ds结构的指针,它指向消息队列模式和访问权限的结构。msgid_ds结构至少包括以下成员:
struct msgid_ds
{
uid_t shm_perm.uid;
uid_t shm_perm.gid;
mode_t shm_perm.mode;
};
成功时返回0,失败时返回-1.
与命名管道相比
4.3 具体实现
A进程负责输入类型和数据,B进程负责读取类型为1000和2000的数据。
A进程代码:
B进程代码:
与命名管道相比
消息队列的优势在于
1、消息队列也可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难。
2、同时通过发送消息还可以避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法。
3、接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收。
五、共享内存
5.1 基本概念
进程间共享一个内存,如图:
共享内存是最快的一种 IPC
原因:
1、对于使用共享内存的单个进程而言,共享内存属于进程的一部分,直接通
过指针操作这块空间,少了陷入内核过程。
2、进程直接通过指针操作共享空间,少了用户态与内核态数据的拷贝
buff(栈区空间) ---> 共享空间
共享空间 ---> buff(栈区空间)
5.2 具体操作
共享内存一般都要与信号量一起使用,以实现同步机制。
四个函数具体声明如下
1.shmget函数创建共享内存
int shmget(key_t key,size_t size,int shmflg);
第一个key值自己随意定义
第二个参数size是自己要开辟的共享内存大小
第三个参数就是权限标志 类似 0664|IPC_CREAT 这样的
失败返回-1 成功返回标识符shmid
2.shmat函数,第一次创建共享内存时,它不被任何访问,要想启动对该共享内存的访问,必须将其连接到一个进程的地址空间中,这项工作由shmat函数完成。
void *shmat(int shmid,const void * shm_addr,int shmflg);
第一个参数就是shmget返回的标识符
第二个参数为共享内存连接到当前进程中的地址,可以自己给定,一般都是NULL让系统自己选择最适合的。
第三个参数就是宏,可以SHM_RDONLY表示只读。也可以写0表示默认的可读可写。
失败返回-1 成功返回指向共享内存第一个字节的指针
3.将共享内存从当前进程中分离
int shmdt(void *shmatrt)
参数为 shmat中返回的指针。
失败返回-1,成功为0。
4.shmctl函数和消息队列与信号量的控制函数几乎一样。
int shmctl(int shm_id,int command,struct shmid_ds*buf);
第二个参数取IPC_RMID;
第三个参数一般为0
5.3 具体实现
例题:
A 进程接受用户输入,B 进程统计字符个数!
A进程代码:
使用共享内存的优缺点
1、优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。
2、缺点:共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。