👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
- 一、进程间通信介绍
-
-
- [1.1 是什么](#1.1 是什么)
- [1.2 为什么(目的)](#1.2 为什么(目的))
- [1.3 怎么做到的(浅聊)](#1.3 怎么做到的(浅聊))
- [1.4 进程间通信的实现方式](#1.4 进程间通信的实现方式)
-
- 二、什么是管道
- 三、匿名管道
-
-
- [3.1 匿名管道的工作原理](#3.1 匿名管道的工作原理)
- [3.2 站在文件描述符角度-深度理解管道](#3.2 站在文件描述符角度-深度理解管道)
- [3.3 pipe系统调用接口](#3.3 pipe系统调用接口)
- [3.4 匿名管道的特征](#3.4 匿名管道的特征)
- [3.5 匿名管道的四种情况](#3.5 匿名管道的四种情况)
-
- 四、简单模拟进程池(匿名管道的应用场景)
- 五、相关代码
一、进程间通信介绍
1.1 是什么
进程间通信
IPC
(Inter-Process Communication
)就是两个或多个进程实现数据层面的交互。
1.2 为什么(目的)
-
数据传输:一个进程需要将它的数据发送给另一个进程。
-
资源共享:多个进程之间共享同样的资源。
-
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
-
进程控制:有些进程希望完全控制另一个进程的执行(如
Debug
进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。 -
...
1.3 怎么做到的(浅聊)
-
进程间通信的本质:让不同的进程能共享同一份"资源"。这个"资源"可以理解为特定形式的内存空间。当一个进程将数据写入共享内存后,另一个进程就可以从同一份内存空间中读取这些数据,从而实现了进程之间的数据传递和共享。
-
这个资源(内存)一般由操作系统提供。那为什么不是两个进程中的其中一个提供这个"资源"呢?可以假想一下,假设这个资源真的由其中一个进程提供,而进程是具有独立性的,各自拥有自己的虚拟地址空间和资源,那么它就会暴露自己的内部状态和数据给其他进程,如果提供资源的进程出现故障或意外终止,其他进程可能会受到影响,因为它们依赖于这个进程提供的资源。
-
相比之下,如果资源由操作系统提供,而进程相当于用户,如果想使用资源,那么操作系统必定会提供统一接口。
-
而我们知道,因为系统中不止一对进程在进行通信,可能会存在多个,那么操作系统就要提供多份"资源",因此操作系统就必须对"资源"进行管理,这又得搬出管理的六字真言:先描述,再组织。
1.4 进程间通信的实现方式
二、什么是管道
在Linux
中,管道可以被视为一种特殊类型的文件,它是基于文件级别的通信方式 。它使得一个进程的输出可以直接成为另一个进程的输入,从而实现了进程之间的数据传输和协作。在Linux
中,你可以使用管道符号 |
将一个进程的输出发送到另一个进程的输入。
- 比方说你想要统计一个文件中包含的单词数量。
其中,当cat
命令和wc
命令运行起来后就是两个进程,cat
进程通过标准输出将数据传输到管道当中,wc
进程再通过标准输入从管道当中读取数据,至此便完成了两个进程间通信。
在Linux
中,有两种类型的管道,一种是匿名管道(Anonymous Pipe
),另一种是命名管道(Named Pipe
)。因此,这篇博客重点是要解释匿名的工作原理等方面。(命名管道在下一篇博客讲解)
三、匿名管道
3.1 匿名管道的工作原理
-
在
Linux
中,管道可以被视为一种特殊类型的文件,它是基于文件级别的通信方式。为什么这样说呢?管道的使用方式类似于文件的读写,它是通过文件描述符进行访问,管道的读取端和写入端分别对应着文件的读取和写入。在管道中写入的数据会被暂存在内核的缓冲区中,然后读取进程从缓冲区中读取,因为它在文件系统中没有具体的文件路径或名字,因此被称为匿名管道。它其实是操作系统在内存中创建的一段缓冲区。(后面会介绍) -
在
Linux
中,使用管道符号|
本质上就是创建了一个匿名管道,它常用于父子进程(或者有血缘关系)之间的通信。
不知道大家有没有好奇:进程通信时每次都要向文件中读取数据,而文件是存储在磁盘上的,这不会导致效率非常低吗?如果效率非常低的话,Linux
为什么还要开发出管道符号|
呢?因此,我们必须要知道匿名管道的工作原理。
进程间通信的本质:让不同的进程看到同一份资源 。因此,使用匿名管道实现父子进程间通信的原理就是:让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
学习到现在,我们都清楚(复习):
-
当一个程序运行起来(加载到内存),就是一个进程,操作系统会管理该进程并且其创建
task_struct
结构体对象。 -
当进程打开文件时,操作系统会在内核中创建数据结构来描述这个已打开的文件对象
struct file
。它包含了inode
(文件的所有属性)、指向文件操作函数表的指针(文件操作方法)、内核缓冲区等等。 -
而进程可以打开多个文件。所以,操作系统还需要对打开的文件进行管理,所以进程
task_struct
对象里有一个指针struct files_struct* files
,这个指针指向一个结构体files_struct
,而这个结构体包含一个指针数组struct file* fd_array[]
,这个数组我们可以称之为文件描述符表。数组中的每个元素都是指向当前进程所打开文件的指针(地址)!默认情况下,当一个进程启动时,操作系统会打开三个标准流(文件):stdin
(键盘文件)、stdout
(显示器文件)、stderr
(显示器文件)。
回归正题:
此时fork
创建子进程,操作系统会以父进程为模板,为子进程创建新的进程控制块task_struct
、files_struct
等。但并不会复制父进程打开的文件对象 struct file
,因为文件操作通常是在用户空间的,与进程无关;而子进程会拷贝父进程的文件描述符表 files_struct
,这是因为文件描述符表是进程的一部分,它记录了进程打开的文件描述符和相应的文件对象。因此,子进程的 struct file* fd_array[]
中的元素会指向父进程打开的文件对象,即父子进程(不同的进程)可以看到同一份资源(打开的文件),这不就是通信的本质吗?!
又因为文件对象struct file
是由操作系统管理的,如果操作系统识别到是普通文件,就通过内核缓冲区刷新策略往磁盘上读写;而如果识别到时管道文件,操作系统不会将数据刷新到磁盘,而是直接在内核缓冲区中进行数据传输。因此,这种操作方式使得管道的数据传输更加高效。
注意:匿名管道在相关进程的生命周期内有效,但是并不是一旦相关进程结束就会自动关闭并释放资源。而是会依靠文件对象的引用计数机制来管理资源的释放。只有当所有相关进程都关闭了对管道的引用时,文件对象的引用计数变为零,才会触发操作系统对管道资源的释放。
3.2 站在文件描述符角度-深度理解管道
如果父进程以只读方式打开管道,并且在创建子进程时,子进程也会以只读方式打开管道。那么进程间该如何通信呢?
显然以上这种方式是无法进行通信的。如果要实现通信,一种常见的方法(步骤)是:
- 父进程在打开管道的时候,以读和写的方式打开。
- 接下来父进程
fork
创建子进程,子进程会拷贝父进程的文件描述符表,所以子进程也会以读写的方式打开管道文件
- 接下来根据实际需求,可以通过关闭相应的管道端口来实现单向通信。比方说,你想让父进程向管道写入数据,而子进程从管道读取数据。那么你就需要关闭父进程的读取端,关闭子进程的写入端。
接下来可能有人会想:为什么管道两端不设计成既可读,也可写呢?
-
竞态条件:如果管道的两端都可以读写,那么可能会出现竞态条件。两个进程同时尝试在管道中读取和写入数据,可能导致数据的不一致性和混乱。
-
死锁:读写管道的两端同时进行读写操作时,可能会导致死锁。例如,一个进程在等待从管道中读取数据,而另一个进程在等待向管道中写入数据,这种情况下可能会导致两个进程相互等待,最终造成死锁。
-
数据丢失:如果管道的两端同时读写,可能会导致数据丢失。例如,一个进程正在向管道中写入数据,同时另一个进程正在从管道中读取数据,这种情况下可能会导致部分数据被丢失。
-
混乱的通信:同时读写管道可能会导致通信的混乱和不可预测性。由于数据的读写是同时进行的,可能会导致数据的顺序混乱或部分数据丢失,使通信变得不可靠。
因此,为了避免这些问题,一般建议使用管道时一端只负责写入数据,另一端只负责读取数据。这样可以确保通信的可靠性和一致性。
3.3 pipe系统调用接口
在Linux
中,除了使用管道符号|
创建和使用匿名管道之外,也可以在程序中使用 pipe
系统调用进行创建和使用。
【函数原型】
cpp
#include <unistd.h>
int pipe(pipefd);
其中:
-
pipefd
:做输出型参数,传一个整型数组,用于存放管道文件的两个文件描述符。pipefd[0]
是管道的读取端,pipefd[1]
是管道的写入端。 -
返回值
-
成功时,返回值为
0
,并且在pipefd
数组中存放管道的两个文件描述符。 -
失败时,返回值为
-1
,错误码保存在errno
中。
-
在创建匿名管道实现父子进程间通信的过程中,需要pipe
函数和fork
函数搭配使用,我们设置父进程只能向管道读取数据,而子进程从管道写入数据,代码如下(配代码注释)
【程序结果】
3.4 匿名管道的特征
-
匿名管道常用于具有血缘关系的进程。
-
管道只能单向通信。
-
父子进程是会协同的,同步与互斥的,是为了保护管道文件的数据安全(信号量及多线程再谈)。意思就是说:在管道通信过程中,如果父进程负责从管道读取数据,而子进程负责向管道写入数据,要确保进程在通信时以正确的顺序发送和接收消息,避免出现数据竞争和不确定行为。
-
管道的生命周期随进程。因为管道在操作系统中被看作是一种文件,但是它其实是是通过内存缓冲区来传输数据。当所有打开管道的进程(读取端和写入端)都关闭了管道,管道所占用的资源就会被操作系统释放。
-
管道是面向字节流的。(网络再谈)
3.5 匿名管道的四种情况
- 读写端正常,管道如果为空,读端就会被阻塞,等待写端的数据。
比方说,写端只向管道写三次数据,那么对应的读端就会接收三次数据,后续读端就会被阻塞,等待写端数据
【程序结果】
- 读写端正常,管道如果被写满,写端就要被阻塞。这说明管道是有限大小的缓冲区,此大小由操作系统决定。
比方说:子进程不断写入,父进程不读取(死循环啥也不干)
【程序结果】
- 读端正常读,写端关闭,那么读取端会在读取完管道中的所有数据后得到一个特殊的信号,表明已经到达了管道的末尾。这样读取操作就会返回值为
0
,也就是read
函数会返回0
,而不会再被阻塞(不会再等待管道中会有新的数据)。因此,当写端关闭后,读端就知道不会再有新的数据写入管道,可以安全地关闭管道,结束通信。
【程序结果】
因此,当写端关闭后,读端就知道不会再有新的数据写入管道,可以安全地关闭管道,结束通信。
- 写端正常,读端关闭。那么这两个进程之间通信就没有意义了,因此,操作系统就要杀掉正在写入的进程。那如何杀掉呢?--- 当操作系统希望终止一个正在运行的进程时,通常会发送一个特定的信号 给该进程来杀掉。
(该图片来自于往期博客)
那么写端进程退出时究竟是收到了什么信号,我们可以来验证一下
【程序结果】
运行结果显示,子进程(写端)退出时收到的是13
号信号。
通过kill -l
命令可以查看13对应的具体信号。
由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE
信号将写端进程终止的。
四、简单模拟进程池(匿名管道的应用场景)
在另一篇博客中,博主正在加工中~
五、相关代码
本篇博客的相关代码:点击跳转