进程间通信方式与线程间通信方式
进程间通信方式与线程间通信方式
零、进程通信的应用场景
- 数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。
- 共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 资源共享:多个进程之间共享同样的资源。需要内核提供锁和同步机制。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
一、进程间通信方式
1.1、管道(pipe)
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用,进程间的亲缘关系通常是指父子进程关系。如通过 fork 操作生成的两个进程。
1.1.1、通信
管道是由内核管理的一个缓冲区,相当于放入内存中的一个纸条。管道的一端连接一个进程的输出,这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。缓冲区不要太大,设计成为环形的数据结构。当管道中没有消息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满消息时,尝试放入消息的进程会等待,直到另一端的进程取出消息。当两个进程都终结的时候,管道也自动消失。(有点类似 Golang 的 Channel,但是 Channel 是可以主动 Close 的)。
1.1.2、创建
管道利用 fork 机制建立,从而让两个进程可以连接到同一个 PIPE 上。
在 Linux 中,管道借助了文件系统的 file 结构和 VFS 的索引节点 inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面实现的。
1.1.3、读写
管道读函数 pipe_read() 和 管道写函数 pipe_write()。写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,读函数则通过复制物理内存中的字节而读出数据。内核必须利用一定的机制同步对管道的访问,如锁、等待队列、信号。写入函数必须检查 VFS 索引节点中的信息,需要同时满足如下条件,才能进行实际的内存复制工作:
- 内存中有足够的空间可容纳所有要写入的数据;
- 内存没有被读程序锁定
写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就休眠在 VFS 索引节点的等待队列中,接下来,内核讲调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。
管道的读取过程和写入过程类似,但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页被释放。
1.1.4、程序实例
函数原型
1 |
|
filedes[0]用于读出数据,读取时必须关闭写入端,即 close(filedes[1]);
filedes[1]用于写入数据,写入时必须关闭读取端,即 close(filedes[0]);
1 | int main(void) |
1.2、命名管道(named pipe/FIFO)
命名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
函数原型
1 |
|
filename 是被创建的文件名称,mode 表示将在文件上设置的权限位和被创建的文件类型,dev 是当创建设备特殊文件时使用的一个值。
程序实例
1 |
|
1.3、信号量(semaphore)
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一个锁机制,防止某进程在访问共享资源时,其他进程也访问此资源。主要作为进程间以及同一进程内不同线程之间的同步手段。
1.3.1、工作原理
由于信号量只能进行两种操作等待和发送信号,即 P(sv) 和 V(sv)
- P(sv):如果 sv 的值大于零,就给它减1;如果它的值为零,就挂起该进程的执行
- V(sv):如果有其他进程因等待 sv 而被挂起,就让它恢复运行,如果没有进程因等待 sv 而挂起,就给它加1
1.3.2、信号量机制
semget函数
1 | int semget(key_t key, int num_sems, int sem_flags); |
第一个参数 key 是整数值(唯一非零),不相关的进程可以通过它访问一个信号量,它代表程序可能要使用的某个资源,程序对所有信号量的访问都是间接的,程序先通过调用 semget 函数并提供一个键,再由系统生成一个相应的信号标识符(semget函数的返回值),只有 semget 函数才直接使用信号量键,所有其他的信号量函数使用由 semget 函数返回的信号量标识符。如果多个程序使用相同的 key 值,key 将负责协调工作。
第二个参数 num_sems 指定需要的信号量数目,它的值几乎总是1。
第三个参数 sem_flags 是一组标志,当想要当信号量不存在时创建一个新的信号量,可以和值 IPC_CREAT 做按位或操作。设置了 IPC_CREAT 标志后,即使给出的键是一个已有信号量的键,也不会产生错误。而 IPC_CREAT | IPC_EXCL 则可以创建一个新的、唯一的信号量,如果信号量已存在,返回一个错误。
semop函数
它的作用是改变信号量的值
1 | int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops); |
sem_id 是由 semget 返回的信号量标识符,sembuf 结构的定义如下:
1 | struct sembuf |
semctl函数
1 | int semctl(int sem_id, int sem_num, int command, ...); |
如果有第四个参数,它通常是一个 union semum 结构
1 | union semun |
command 通常是下面两个值中的其中一个
- SETVAL:用来把信号量初始化为一个已知的值。p 这个值通过 union semun 中的 val 成员设置,其作用是在信号量第一次使用前对它进行设置。
- IPC_RMID:用于删除一个已经无需继续使用的信号量标识符。
1.4、消息队列(message queue)
消息队列是消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号量传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。包括 Posix 消息队列 system V 消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。
结构 msg_queue 用来描述消息队列头,存在于系统空间:
1 | struct msg_queue { |
结构 msqid_ds 用来设置或返回消息队列的信息,存在于用户空间:
1 | struct msqid_ds { |
1.4.1、消息队列与内核的联系
从上图可以看出,全局数据结构 struct ipc_ids msg_ids 可以访问到每个消息队列头的第一个成员 struct kern_ipc_perm;而每个 struct kern_ipc_perm 能够与具体的消息队列对应起来,因为在该结构中,有一个 key_t 类型成员 key,而 key 则唯一确定一个消息队列。
1 | struct kern_ipc_perm |
1.4.2、消息队列的操作
打开或创建消息队列
消息队列的内核持续性要求每个消息队列都在系统范围内对应唯一的键值,所以要获得一个消息队列的描述字,只需提供该消息队列的键值即可;
消息队列描述字是由在系统范围内唯一的键值生成的,而键值可以看作对应系统内的一条路经。读写操作
1
2
3
4
5struct msgbuf
{
long mtype;
char mtext[1];
};mtype 成员代表消息类型,从消息队列中读取消息的一个重要依据就是消息的类型;mtext 是消息内容,当然长度不一定为1。因此,对于发送消息来说,首先预置一个 msgbuf 缓冲区并写入消息类型和内容,调用相应的发送函数即可;对读取消息来说,首先分配这样一个 msgbuf 缓冲区,然后把消息读入该缓冲区即可。
获得或设置消息队列属性
消息队列的信息基本上都保存在消息队列头中,因此,可以分配一个类似于消息队列头的结构,来返回消息队列的属性,同样可以设置该数据结构。
1.5、信号(signal)
信号是一种比较复杂的通信方式,用于通知接受进程某个事件已经发生。
信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程有哪些事情发生了。信号机制经过 POSIX 实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。
1.5.1、信号来源、种类
信号事件的发生有两个来源
- 硬件来源(比如我们按下了键盘或者其它硬件故障);
- 软件来源,最常用发送信号的系统函数是 kill, raise, alarm 和 setitimer 以及 sigqueue 函数,软件来源还包括一些非法运算等操作。
1.5.2、信号种类
可以从两个不同的分类角度对信号进行分类:
- 可靠性方面:可靠信号与不可靠信号;
- 与时间的关系上:实时信号与非实时信号。
不可靠信号
Linux 信号机制基本上是从 Unix 系统中继承过来的。早期 Unix 系统中的信号机制比较简单和原始,后来在实践中暴露出一些问题,因此,把那些建立在早期机制上的信号叫做“不可靠信号”,信号值小于 SIGRTMIN (Red hat 7.2中,SIGRTMIN=32,SIGRTMAX=63)的信号都是不可靠信号。这就是“不可靠信号”的来源。它的主要问题是:
- 进程每次处理信号后,就将对信号的响应设置为默认动作。在某些情况下,将导致对信号的错误处理;因此,用户如果不希望这样的操作,那么就要在信号处理函数结尾再一次调用 signal(),重新安装该信号。
- 信号可能丢失。因此,早期 unix 下的不可靠信号主要指的是进程可能对信号做出错误的反应以及信号可能丢失。
- Linux 支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用该信号的安装函数(信号安装函数是在可靠机制上的实现)。因此,Linux 下的不可靠信号问题主要指的是信号可能丢失。
可靠信号
- 随着时间的发展,实践证明了有必要对信号的原始机制加以改进和扩充,力图实现“可靠信号”。由于原来定义的信号已有许多应用,不好再做改动,最终只好又新增加了一些信号,并在一开始就把它们定义为可靠信号,这些信号支持排队,不会丢失。
- 信号值位于 SIGRTMIN 和 SIGRTMAX 之间的信号都是可靠信号,可靠信号克服了信号可能丢失的问题。Linux 在支持新版本的信号安装函数sigation() 以及信号发送函数 sigqueue() 的同时,仍然支持早期的 signal() 信号安装函数,支持信号发送函数 kill()。
注意:可靠信号是指后来添加的新信号(信号值位于 SIGRTMIN 及 SIGRTMAX 之间);不可靠信号是信号值小于 SIGRTMIN 的信号。信号的可靠与不可靠只与信号值有关,与信号的发送及安装函数无关。
实时信号与非实时信号
非实时信号都不支持排队,都是不可靠信号,编号是1-31,0是空信号;实时信号都支持排队,都是可靠信号。
进程对信号的响应
- 忽略信号,即对信号不做任何处理,其中,有两个信号不能忽略:SIGKILL 及 SIGSTOP;
- 捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数;
- 执行缺省操作,Linux 对每种信号都规定了默认操作
注意:进程对实时信号的缺省反应是进程终止。
信号的发送和安装
- 发送信号的主要函数有:kill()、raise()、sigqueue()、alarm()、setitimer() 以及 abort()。
- 如果进程要处理某一信号,那么就要在进程中安装该信号。安装信号主要用来确定信号值及进程针对该信号值的动作之间的映射关系,即进程将要处理哪个信号;该信号被传递给进程时,将执行何种操作。
注意: linux 主要有两个函数实现信号的安装:signal()、sigaction()。其中 signal() 在可靠信号系统调用的基础上实现,是库函数。它只有两个参数,不支持信号传递信息,主要是用于前32种非实时信号的安装;而 sigaction() 是较新的函数(由两个系统调用实现:sys_signal以及sys_rt_sigaction),有三个参数,支持信号传递信息,主要用来与 sigqueue() 系统调用配合使用,当然,sigaction() 同样支持非实时信号的安装。sigaction() 优于 signal() 主要体现在支持信号带有参数。
1.6、共享内存(shared memory)
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 通信方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往和其他通信方式(如信号量)配合使用来实现进程间的同步和通信。
1.6.1、系统V共享内存原理
进程间需要共享的数据被放在一个叫做 IPC 共享内存区域的地方,所有需要访问该共享区域的进程都要把该共享区域映射到本进程的地址空间中去。系统 V 共享内存通过 shmget 获得或创建一个 IPC 共享内存区域,并返回相应的标识符。内核在保证 shmget 获得或创建一个共享内存区,初始化该共享内存区相应的 shmid_kernel 结构体的同时,还将在特殊文件系统 shm 中,创建并打开一个同名文件,并在内存中建立起该文件的相应 dentry 及inode 结构,新打开的文件不属于任何一个进程(任何进程都可以访问该共享内存区)。所有这一切都是系统调用 shmget 完成的。
1.6.2、系统V共享内存API
shmget() 用来获得共享内存区域的 ID,如果不存在指定的共享区域就创建相应的区域。shmat() 把共享内存区域映射到调用进程的地址空间中去,这样,进程就可以方便地对共享区域进行访问操作。shmdt() 调用用来解除进程对共享内存区域的映射。shmctl() 实现对共享内存区域的控制操作。
1.7、套接字(socket)
套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备间的进程通信。
1.8、全双工管道
共享内存、信号量、消息队列、管道和命名管道只适用于本地进程间通信,套接字和全双工管道可用于远程通信,因此可用于网络编程。
二、线程间通信
2.1、锁机制
包括互斥锁、条件变量、读写锁。
2.2、互斥锁
提供了以排他方式防止数据结构被并发修改的方法。
2.3、读写锁
允许多个线程同时共享数据,而对写操作是互斥的。
2.4、条件变量
可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
2.5、信号量机制(Semaphore)
包括无名线程信号量和命名进程信号量。
无名信号量一般用于线程间同步或互斥,而有名信号量一般用于进程间同步或互斥。它们的区别和管道及命名管道的区别类似,无名信号量则直接保存在内存中,而有名信号量要求创建一个文件。
信号量广泛用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。
编程时可根据操作信号量值的结果判断是否对公共资源有访问的权限,当信号量值大于0时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。
信号量主要用于进程或线程间的同步和互斥这两种典型情况。
信号量用于互斥:
信号量用于同步:
无名信号量基本操作:
需包含的头文件
1 |
注意:编译信号量操作函数时,编译选项需加上参数 -lpthread
。
信号量数据类型为:sem_t
。
1)初始化信号量
1 | int sem_init(sem_t *sem, int pshared, unsigned int value); |
功能:
创建一个信号量并初始化它的值。一个无名信号量在被使用前必须先初始化。
参数:
- sem:信号量的地址。
- pshared:等于0,信号量在线程间共享(常用);不等于0,信号量在进程间共享。
- value:信号量的初始值。
返回值:
- 成功:0;失败:-1
2)信号量 P 操作(减 1)
1 | int sem_wait(sem_t *sem); |
功能:
将信号量的值减1。操作前,先检查信号量 sem 的值是否为0,若信号量为0,此函数会阻塞,直到信号量大于0时才进行减1操作。
参数:
- sem:信号量的地址。
返回值:
- 成功:0;失败:-1
1 | int sem_trywait(sem_t *sem); |
以非阻塞的方式来对信号量进行减1操作。若操作前,信号量的值等于0,则对信号量的操作失败,函数立即返回。
3)信号量 V 操作(加 1)
1 | int sem_post(sem_t *sem); |
功能:
将信号量的值加1并发出信号唤醒等待线程(sem_wait())。
参数:
- sem:信号量的地址。
返回值:
- 成功:0;失败:-1
4)获取信号量的值
1 | int sem_getvalue(sem_t *sem, int *sval); |
功能:
获取 sem 标识的信号量的值,保存在 sval 中。
参数:
- sem:信号量地址。
- sval:保存信号量值的地址。
返回值:
- 成功:0;失败:-1
5)销毁信号量
1 | int sem_destroy(sem_t *sem); |
功能:
删除 sem 标识的信号量。
参数:
- sem:信号量地址。
返回值:
- 成功:0;失败:-1
互斥实例:
1 |
|
同步实例:
1 |
|
2.6、信号机制(signal)
类似进程间的信号处理
参考文章:
1、进程间通信方式+线程间通信方式
2、Linux系统编程——线程同步与互斥:POSIX无名信号量
3、记一次面试:进程之间究竟有哪些通信方式? —- 告别死记硬背
4、Linux进程间的通信方式和原理
- 本文标题:进程间通信方式与线程间通信方式
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/10267b43.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!