1. 进程间通信介绍
1.1 进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的本质:
让不同的进程先看到同一份资源,但是这份资源不能由双方进程提供,而通常是由操作系统所提供!
1.2 进程间通信的发展
- 管道
- System V进程间通信
- POSIX进程间通信
1.3 进程间通信的分类
管道
- 匿名管道pipe
- 命名管道shm
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
2. 管道
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
2.1 匿名管道
父进程通过fork创建子进程,与文件部分无关,所以父进程只会把自己的代码拷贝一份给子进程,而并不会将文件部分也拷贝给子进程。其中父进程会以浅拷贝的方式将自己的文件描述符表拷贝给子进程,这样父子进程的文件描述表都会指向同一份文件了。
通过这张图,我们大概就能明白进程间通讯的本质了:让不同的进程先看到同一份资源。同时也能知道在我们使用fork的时候,当父进程向屏幕打印时,子进程也同时会向屏幕打印的原因了。
如果父进程打开的文件是普通文件,这个文件刷新到磁盘里,然后再与子进程通信,这种通信方式的效率会非常低下。
所以我们需要换一种通信方式。首先保证该通信方式是纯内存级的文件,它不需要在磁盘中存在,甚至不需要名字,只需保证父子进程能看到即可,不要刷新到磁盘上,进而在内存层面上让父进程把数据交给子进程,这种文件我们称之为管道文件。
管道文件是一个纯内存级别的文件,不需要向磁盘刷新。并且它不需要名字、不需要路径,所以也被称作为匿名管道。
管道文件有个特点:只允许单向通信!(如果想双向通信,建立两个管道即可。)
为什么叫作管道呢?
因为这种通信特征像管道,就好像现实生活中的水管自来水管一样,只能从自来水厂流向用户家里,而不能反着来,也就是单向流动,所以我们把它命名为管道!而不是我们因为需要一种通信方式叫管道,才设计出来的管道!
我们如何让不同进程看到同一个文件管道?
#include <unistd.h>
功能:创建一个无名管道;
原型:
int pipe(int fd[2])
;参数:
fd
文件描述符数组,其中fd[0]表示读端,fd[1]表示写端;返回值:成功返回0,失败返回错误代码。
看一眼man手册对管道的描述:
管道的4种情况:
- 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止(写端写入数据了)。
- 正常情况,如果管道被写满了,写端必须等待,直到有空间为止(读端读走数据)。
- 写端关闭,读端一直读取,读端会读到read返回值为0,表示读到文件结尾。
- 读端关闭,写端一直写入,OS会直接杀掉写端进程(因为操作系统不会做任何浪费时间和空间的事情),通过向目标进程发送
SIGPIPE(13)
信号,终止目标进程。
管道的5种特性:
- 匿名管道,可以允许具有血缘关系的进程之间进行进程间通信,常用于父子之间,仅限于此(仅限于有血缘关系之间的通信)。
- 匿名管道,默认要给读写端提供同步机制。
- 匿名管道,是面向字节流的(不会因为怎么写而约束我怎么读,不关心数据底层的内部格式)。
- 管道的生命周期是随进程的。
- 管道是单向通信的,是半双工通信的一种特殊情况。
使用命令:
ulimit -a
可以查询到一些用户级别的配置!
每个用户的服务器的配置都是不相同的~
调用系统系统调用也是有成本的
传参形式:
- 输入参数:
const &
;- 输出参数:
*
;- 输入输出参数:
&
。
在分配任务时,要较为平均的将任务交给进程,要考虑子进程完成任务的负载均衡。
2.2 管道读写规则
当没有数据可读时
O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
- 当管道满的时候
- O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
- O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write程退出
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
2.3 命名管道
匿名管道只能具有血缘关系的进程进行进程间通信,如果我想让两个毫不相干的进程进行通信(通信简写为IPC)呢?此时,命名管道就来了。
- 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
- 命名管道是一种特殊类型的文件。
命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
老规矩,再使用man手册查看一下:
使用该命令创建一个文件来看看:
从权限那一栏可以看出,该文件以p
开头,证明他是管道文件,并且他有路径和名字
这就是他叫命名管道的原因!
因为路径是具有唯一性的,所以我们可以使用路径+文件名的方式,来唯一的让不同进程看到同一份资源!
命名管道也可以从程序里创建,相关函数有:
int mkfifo(const char *filename, mode_t mode);
依旧可以用man手册查看一下,man 3 mkfifo
日常我们使用Makefile
只能默认生成一个可执行程序文件,但是我们可以这样做:
.PHONY:all
all:client server
client:client.cc
g++ -o $@ $^ -std=c++11
server:server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f client server
3. system V共享内存
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
3.1 共享内存接口
shmget
函数
功能:用来创建共享内存
原型:
int shmget(key_t key, size_t size, int shmflg);
参数:
key
:这个共享内存段名字
为了获取的key值更具有唯一性,所以我们用ftok
函数来获取
ftok
函数:功能:将路径名和项目标识符转换为共享内存段的key值
size
:共享内存大小shmflg
:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。IPC_CREAT
,当shm不存在时,就创建,存在就获取并返回;IPC_EXCL
不单独使用,单独使用没有任何意义,通常和IPC_CREAT
一块使用。一般是这样用的:IPC_CREAT | IPC_EXCL
,shm不存在就创建,存在,出错返回。可以保证创建的共享内存是全新的。返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1
使用命令ipcs -m
即可查看当前共享内存
可以看到共享内存也是有权限呢,那么我们自己怎么应该给共享内存设置权限呢?只需在shmflg
后加入即可,例如:
int shmid = shmget(key, 1024, IPC_CREAT | IPC_EXCL | 0644);
共享内存(IPC资源)的生命周期是随内核的!所以我们需要删除共享内存,需要用到命令ipcrm -m shmid
key和shmid的对比
- key:不在应用层使用,只用来在内核中标识shm的唯一性!key的作用就是用来方便我们编程的。
- shmid:我们在使用共享内存的时候,使用shmid来进行操作共享内存!
注:共享内存的大小建议设置为4096的整数倍,因为系统在开辟共享内存的空间是以4096为单位来开辟的,过多过少都会造成空间的浪费!
shmat
函数
功能:将共享内存段连接到进程地址空间(共享内存挂接)
原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数
shmid
:共享内存标识shmaddr
:手动指定连接的地址,默认设置为nullptr
,让操作系统帮我选择即可!shmflg
:它的两个可能取值是SHM_RND
和SHM_RDONLY
,一般设置为0就可。返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1。(和
malloc
的返回值很相似)
说明:
- shmaddr为NULL,核心自动选择一个地址
- shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
- shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr -
(shmaddr % SHMLBA)- shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
直接使用一下该函数char *s = (char*)shmat(shmid, nullptr, 0);
:
可以清楚地看到nattch
那一栏从0变成了1
shmdt
函数
功能:将共享内存段与当前进程脱离,也就是去除共享内存的关联。
原型:
int shmdt(const void *shmaddr);
参数:
shmaddr
:由shmat所返回的指针。
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段
shmctl
函数
功能:用于控制共享内存
原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
shmid
:由shmget返回的共享内存标识码
cmd
:将要采取的动作,有三个平台通用的可取值
IPC_STAT
:把shmid_ds结构中的数据设置为共享内存的当前关联值IPC SET
:在进程有足够权限的前提下,把共享内存的当前关联值设置为shmid_as数据结构中给出的值IPC_RMID
:删除标记部分shmid的共享内存片段。
buf
:指向一个保存着共享内存的模式状态和访问权限的数据结构返回值:成功返回0;失败返回-1
3.2 共享内存的特点
- 共享内存的通信方式,不会提供同步机制,共享内存是直接裸露给所有使用者的,一定要注意共享内存的使用安全问题!
- 共享内存是所有进程进程间通信速度最快的!
- 共享内存可以提供较大的空间
那么为什么共享内存是通信虚度最快的呢?
因为以共享内存的方式通信相对于管道的通信方式来说减少了拷贝次数!
凡是数据迁移,都是拷贝~
4. system V消息队列
- 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
- 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
当然,获取消息队列也有相应的函数:msgget
函数。
既然能获取消息队列,那么也能控制消息队列:msgctl
函数。
最后,通过消息队列发送消息:msgsnd
函数。与之对应的接收消息:msgrcv
函数
可以看到,消息队列的用法与共享内存是非常相似的~
同样,我们依旧可以在使用命令bash中查询到消息队列的信息:
ipcs -q
并且消息队列的生命周期和共享内存一样,也是随内核的!
删除消息消息队列:
ipcrm -q msqid
到这里,再次提出几个问题。
- 在系统中可以同时存在多个消息队列吗?可以!
- 消息队列也在内核中,要把他管理起来,那么该如何管理呢?同样的六字真言,先描述,再组织
- 消息队列 = 队列内容 + 队列属性
5. system V信号量
未来,我们会遇到多执行流访问共享资源而导致数据不一致的问题,信号量的本质就是一个计数器,是对公共资源保护的一种策略。这种策略主要就是同步和互斥,下面先来看看什么是同步和互斥以及一些相关的概念。
- 互斥:任何一个时刻只允许一个执行流(进程)访问公共资源,需要加锁完成。由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥。
- 同步:多个执行流执行的时候,按照一定的顺序执行。
- 临界资源:被保护起来的公共资源叫作临界资源(或者比较标准的说法:系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。)
- 临界区:在进程中涉及到临界资源的程序段叫临界区。
- 原子性:只有两种状态,没有中间状态,要么不做,要么做完。
同样,我们依旧可以类比共享内存,首先是创建信号量的函数
semget
函数:
参数:
nsems
,信号量集,表示你要创建几个信号量。
同样,我们依旧可以在使用命令bash中查询到信号量的信息:
ipc -s
并且信号量的生命周期和共享内存一样,也是随内核的!
删除消息消息队列:
ipcrm -q semid
控制信号量的函数
semctl
函数:
对信号量进行何种操作的函数
semop
函数:
如何理解信号量?
- 信号量就是表示资源数目的计数器,每一个执行流想访问公共资源内的某一份资源,不应该让执行流直接访问,而是先申请信号量资源,其实就是先对信号量计数器进行
--
(减减)操作。从本质上来说,只要--
成功,就完成了对资源的预定,这就是预定机制。- 有申请信号量资源,必然有释放信号量资源,所以与之对应的就是对信号量计数器进行
++
(加加)操作。从本质上来说,只要++
成功,就完成了对资源的释放。
如果信号量的申请失败呢?
执行流需要被挂起阻塞。申请公共资源之前先申请信号量。申请成功则继续向下执行,申请失败则会卡在申请信号量出,也就是被挂起阻塞,不会继续向下执行,直至申请成功。即使申请成功,不着急向下访问,这也是被允许的!
当信号量的值只为1或0时,我们称这种信号量为二元信号量,也就是互斥锁,完成了互斥的功能。
一些细节问题:
- 当我们访问公共资源时,意味着每一个进程都得先看到同一个信号量资源,这份资源只能由OS来提供了,并融进了IPC体系中。
- 信号量的本质也是公共资源,那么如何保证信号量这个公共资源的安全呢?所以信号量的操作必须是原子的。我们把信号量原子性的
--
也就是申请信号量资源,我们称之为P
操作;把信号量原子性的++
也就是释放信号量资源,我们称之为V
操作。