自律是普通人最大的武器
| 系列专栏 | 直达链接 |
|---|---|
| 🐧 Linux | 进入专栏 → |
| 🔤 C语言 | 进入专栏 → |
| 🔧 C++ | 进入专栏 → |
| 🐬 MySQL | 进入专栏 → |
| 🧩 算法刷题 | 进入专栏 → |
🏠 博客主页:虚无的纽扣
📦 作者仓库:github | gitee
目录
- 引言
- 1、为什么需要进程间通信
- 2、怎么通信
- 3、管道
- 匿名管道的原理
- 管道的读写与特性
- 4、命名管道
- 5、System V 共享内存
- 共享内存的原理与唯一标识
- 创建、挂接与删除
- 为什么最快,以及和动态库的关系
- 6、System V 信号量
- 7、拓展:IPC 的内核结构
- 结语
引言
在前面几篇博客里,我们从进程的概念和状态讲起,聊完了进程优先级与环境变量,又用一整篇的篇幅拆开了进程地址空间,最后还自己动手实现了一个简易的 Shell。走到这里,我们对一个进程自身的运作已经不算陌生了。不过仔细想想,前面所有内容都有一个隐含的前提——我们观察的始终是单独的一个进程;可真实的操作系统里同时跑着成百上千个进程,它们各自拥有独立的地址空间,页表把虚拟地址映射到属于自己的那一份物理内存上,也就是说,进程 A 看不见进程 B 的数据,进程 B 也看不见进程 A 的数据,这份“看不见”正是操作系统给每个进程的安全隔离,它的来龙去脉我们在《每个进程都拥有“假内存” | 进程地址空间详解》里已经拆解过了。
隔离本身是件好事,但它也带来了一个很现实的问题:如果两个进程想交换数据、共享一份资源,该怎么办?这就是我们今天要聊的进程间通信。这篇文章里,我们会从最古老的管道讲起,一路走到 System V 标准的共享内存与信号量,最后再钻进内核,看看这几种 IPC 资源在操作系统内部是怎么被统一管理起来的。
1、为什么需要进程间通信
为什么要进程间通信?把进程放进一个真实的场景里想一想,答案就很清楚了。首先是数据传输,多个进程之间可能要进行数据传输,比如一个进程负责采集、另一个进程负责处理,数据就得从前者流到后者;其次是资源共享,多个进程之间可能共享同样的资源,比如同一台硬件设备、同一块内存区域;再往后是通知事件,一个进程需要向另一个或一组进程发送消息,通知它发生了某种事件。
最后是进程控制,有些进程希望完全控制另一个进程的执行,如 debug 进程,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。这四种需求看起来各不相同,但它们最终的落脚点是一样的。
2、怎么通信
那么,两个进程要怎么才能通信呢?前面说过,进程之间之所以看不见彼此,是因为它们的页表指向的是不同的物理内存;那反过来想,只要能让两个进程的页表指向同一块物理内存,它们不就等于看到了同一份数据了吗?所以进程间通信的本质是:先让不同的进程看到同一份资源,然后才有通信的条件,而这一份资源,是由操作系统提供的。顺着这个思路往下走,你会发现接下来的每一种通信方式,其实都是在回答“怎么让两个进程看到同一份资源”这一个问题。
3、管道
匿名管道的原理
管道是最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流,称之为管道;我们最熟悉的匿名管道,通常就用来做父子之间的通信。要理解它的原理,得先回到进程创建的那一刻:当父进程创建子进程时,父进程内的所有数据都会给子进程拷贝一份,但是文件并不会打开第二次,而是子进程拷贝下来的页表中会有同样的映射关系,指向打开的那个文件;struct file也拷贝了一份,但 inode 和数据页只进行了一次浅拷贝,只发生了指针拷贝。
所以父子进程就可以同时访问同一个文件,可以同时看到文件中的缓冲区里的数据——这不就满足了我们上面说的进程间通信的本质,让不同的进程看到同一份资源吗?这就是所谓的管道。具体来说,父进程先分别以读写方式打开一个内存级的文件,作为管道,然后创建子进程,子进程经过拷贝也拥有同样的文件描述符,然后一般都是实现父子单向通信,将父子进程不需要的文件描述符关掉,就可以实现父子间的单向通信。
创建管道文件用的是int pipe(int pipefd[2]);,返回值表示是否创建成功,并将文件描述符保存在pipefd这个数组中。这种文件不需要文件路径,因为它是内存级的,只存在内存里,不会刷新到磁盘上,它只有 inode 和内容(也就是缓冲区),其他的文件结构都是不存在的,所以也不需要文件名,这种管道就叫匿名管道。那没有文件名字,又是怎么保证两个进程打开的是同一个管道文件呢?因为子进程继承父进程的文件描述符表。
管道的读写与特性
管道创建好之后,读写两端的行为会出现四种典型情况:
1、写慢,读快,读端阻塞等待。
2、写快,读慢,管道文件写满后,写端就要阻塞等待。
3、写端关闭,读端继续,read 就会读到返回值为 0,表示文件结尾。
4、写端继续,读端关闭,写端再写入没有任何意义,操作系统会杀掉写端进程,发送 13 号信号。
另外,管道的容量在 Ubuntu 系统里为 64KB。把管道的特点归纳一下:
1、匿名管道只能用来进行具有血缘关系的进程进行进程间通信(常用于父子)。
2、管道文件自带同步机制。
3、管道是面向字节流的。
4、管道是单向通信的,任何一个时刻,只能一个发、一个收,就叫半双工;如果任何一个时刻都可以同时发收,就叫全双工,管道属于半双工的一种特殊情况。
5、文件的生命周期是随进程的。
4、命名管道
匿名管道只能用来进行具有血缘关系的进程进行进程间通信,那如果两个进程不相关,该如何进行通信呢?当两个独立的进程访问同一份文件时,第一个进程在访问时,操作系统会将该文件的属性和内容加载到内存中,然后进程的struct file中就会有两个指针,分别指向存储属性的 inode 和存储数据的缓冲区;当第二个进程也加载这个文件时,虽然操作系统也会创建一个独立的struct file,但因为指向的文件相同,所以它也会指向同一份 inode 和缓冲区。
所以,上面父子进程看到同一份资源的方式是通过继承,而这里则是通过打开同一个路径下的同一个文件,来让两个进程看到同一份资源;而这个文件是有名字的,所以就叫命名管道。这样一来,这个文件就不是我们之前说过的单纯是内存级的文件,而是一个磁盘级的文件;当一个文件是磁盘级文件时,它里面的数据是要被刷新到磁盘上的,可是今天我们不想它刷新到磁盘上,所以 Linux 系统中会支持一种特殊的文件,叫管道文件,它的特点就是只会被打开,不需要刷新。
当写入方没有执行 open 的时候,read 方就要在 open 内部进行阻塞,直到有人把管道文件打开了,open 才会返回,因为只打开 read 方没有意义,没有 write 方往里面写信息,所以 read 方会阻塞直到有 write 方启动。总的来说:命名管道的特性和匿名管道是一致的,只有一点不同——命名管道可以用来进行不相关的进程间通信。
5、System V 共享内存
共享内存的原理与唯一标识
System V 是一种标准,Linux 内核支持了这种标准,专门设计了一个 IPC 通信模块(IPC 就是 Inter-Process Communication,进程间通信的缩写)。先说共享内存的原理:进程 A 在物理内存上申请一块空间,并在自己的虚拟地址空间中也申请一块空间,用页表建立映射关系;进程 B 也在自己的虚拟空间内部申请一块空间,用自己的页表与物理内存的空间也建立映射关系;这样进程 A 和 B 就都可以访问同一块物理内存空间了。
上述操作都是操作系统完成的,操作系统给我们提供系统调用,我们可以用系统调用完成上述操作;释放这块物理空间,只要先取消关联关系,然后由操作系统释放内存就行。在操作系统内部,可能同时存在多组进程都在使用不同的共享内存来进行通信,操作系统内就同时存在多个共享内存,所以操作系统要管理共享内存;既然操作系统要管理共享内存,说明共享内存一定要有对应的描述共享内存的内核结构体对象,进程和共享内存的关系,就是内核数据结构之间的关系。
那么,我们怎么知道共享内存存在还是不存在?又怎么保证两个不同的进程,拿到的是同一个共享内存呢?这两个问题的答案,都需要用到系统调用的第一个参数 key。不同的进程要使用共享内存来进行通信,我们需要一种方法来标识共享内存的唯一性,这个唯一性就由 key 来区分;key 不是内核直接形成的,而是在用户层构建并传入操作系统的。
为什么 key 要这么设计呢?如果 key 值是由操作系统在创建共享内存后返回给其中一个进程的值的话,那么其他想要使用这个共享内存的进程无法获取这个 key 值,也就无法找到对应的共享内存;那如果我们现在让这个 key 值在进程内设定,然后在创建共享内存时将其也传入进去,其他的进程是不是就可以用这个 key 值去寻找对应的共享内存了?那其他的进程又是怎么拿到这个 key 值的呢?我们可以在每一个进程里都设置一个同样的方法,通过这个方法就可以计算出一个同样的 key 值,不就可以做到获得同一个 key 值了吗?这就和我们上面讲过的命名管道类似,命名管道就相当于是把管道文件的路径作为这个 key 值,用同样的路径就可以找到同一个管道文件,key 值不过是换了一种方法而已。
这个 key 值可以用一个函数来获得,就是 ftok,它的用法如图 3 所示。pathname 就是文件路径,proj_id 随便输入任意值都行,这样 ftok 算法就会结合这两个数据构建出一个独特的 key 值,如果重复了就更改传入的数据就行。
创建、挂接与删除
和曾经在基础 IO 时讲过的类似,shmget的标记位shmflg常见的只有两个:IPC_CREAT:创建共享内存,如果目标共享内存不存在就创建,否则打开已经存在的共享内存并返回。IPC_EXCL:单独使用无意义,必须与上一个标记位组合使用才有意义,如果要创建的共享内存不存在就创建,如果已经存在shmget就会出错返回。这听起来好像没什么用,实际上它的用途是:只要shmget成功返回,一定是一个全新的共享内存。
在创建共享内存时,这个共享内存也会被系统看作一个文件,所以在创建时我们也要给共享内存设置权限,在标记位的后面用按位或符号|或上0666就行;返回值为一个整型,从 0 开始,可以记作 shmid。
共享内存有一点和管道很不一样:进程结束,如果没有进行删除共享内存,共享内存资源就会一直存在。共享内存的资源,生命周期随内核,如果没有显式的删除,即便进程退出了,IPC 资源依旧被占用。删除或控制共享内存,在用户层我们不能使用 key,key 可以只给内核来区分唯一性,在用户层我们需要用 shmid 进行管理共享内存;shmctl的 cmd 需要传入对应的命令,删除共享内存的命令为IPC_RMID,第三个参数是传入共享内存的属性,这里我们还没学到,直接传入nullptr就行。
我们现在已经知道了怎么创建和删除共享内存,那我们怎么建立进程与共享内存间的映射呢?shmat的返回值是映射完成的起始虚拟地址空间地址,第一个参数是共享内存的标识符,表明你要链接哪一个共享内存;第二个参数传一个虚拟地址,可以固定地址进行挂接,正常我们用不到,设置为nullptr就行;第三个参数也是标志位,我们在使用时一般都是既读又写的,所以设置为 0 就行,使用共享内存的默认设置。shmat就是将共享内存挂接到当前进程的地址空间中。
关于删除共享内存还漏了一个知识点,在删除共享内存时我们不能直接的删除,我们需要先进行去关联。假设我们现在有进程挂载在共享内存上,我们需要先解除挂载才能进行删除。
为什么最快,以及和动态库的关系
接下来是读写。读写共享内存并没有使用系统调用,因为我们一旦把共享内存映射成功了,共享内存的映射位置在堆和栈之间,属于共享区,共享区属于用户空间,可以让用户直接使用。共享内存是进程间通信中速度最快的方式,原因有两点:1、映射之后读写可以直接被对方看到;2、不需要进行系统调用获取或者写入内容。但它的缺点也很明显:通信双方没有同步机制,可能导致写入端才输入了一部分,读取端就已经读走了,共享内存没有保护机制。
另外,如果共享内存的另一端不是进程,而是动态库,动态库可以将自己的代码和数据加载到共享内存中,进程建立对应的映射关系,然后对进程的数据做地址空间级的重定向,这样就能以用户地址的方式访问库中的方法;所以共享内存不仅是实现进程间通信的一种方式,也是动态库加载到内存、让进程看到的底层原理。
最后补充一个细节:在内核中,共享内存在创建的时候,它的大小必须是 4KB(4096)的整数倍,但是在用户级你申请用多少就给你分配多少,意思就是你如果申请 4097 个字节,虽然共享内存实际申请了 8KB,但是你能使用的还是只有 4097,因为你只申请了 4097 个字节。
6、System V 信号量
信号量的本质是一个计数器,一块内存空间里划分出很多个小内存空间,每有一个空间正在被访问,计数器就减一,每有一个空间结束访问,计数器就加一。在这里要先介绍一个概念——临界资源,临界资源指的是一次只允许一个进程访问的共享资源,像我们上面讲的共享内存就是非临界资源。
要想访问临界资源,都需要申请,而我们所说的信号量,就相当于管理着一块块的临界资源,描述的是临界资源中资源数量的多少。所有进程想访问临界资源中的一小块,就必须先申请信号量,申请信号量本身是一种对资源的预定机制,只要某个进程申请了一块临界资源,哪怕没有使用,这块资源也无法再分配给其他进程,直到结束使用为止,所以信号量本质上是一种对资源的预定机制。
为了防止在申请资源时出现两个进程同时申请资源的情况,申请资源时的操作必须为原子性操作。原子性是什么呢?简单来说就是不是 0 就是 1,没有中间态,要么申请了要么没申请,不会有申请过程这种东西;换成更严格的说法,原子操作是不可被分割的,执行到一半不会被打断,也正因为如此,它才不会出现“申请到一半被别人插一脚”的情况。只要某进程申请资源,如果申请成功就立马获得资源,不会出现一个进程申请过程中另一个进程也申请导致的两个进程申请到同一块资源的问题。
信号量这种 IPC 资源最重要的两个问题是:1、不要让两个进程访问同一个位置;2、不要放入过多的进程进来。这两个问题都可以通过保证资源申请的原子性来解决,当资源已经被申请完还有进程要继续申请,信号量就会将这些进程进行阻塞挂起并管理起来,直到有资源结束使用后再按照顺序进行分配。如果我们将一整块资源整体使用,就叫做二元信号量,如果分块使用,就是多元信号量。
7、拓展:IPC 的内核结构
聊完了共享内存和信号量,最后我们钻到内核里看看,操作系统是怎么把这些 IPC 资源统一管理起来的。这一节还会带出一个前面没提过的成员——消息队列,它和共享内存、信号量一样都属于 System V 标准,我们这里不展开它怎么用,只看看内核是把它们放在一起管的。
IPC 资源在操作系统中会存在一个全局的数据结构ipc_ids,这个数据结构里有一个指针叫 entries,指向一个叫做ipc_id_ary的结构体,结构体里有一个数组,是一个柔性数组。内核里还有个管理消息队列的结构,叫msg_queue,管理信号量的sem_array,管理共享内存的shmid_kernel,它们三个数据结构里的第一个成员是q_perm、sem_perm、shm_perm,这种结构在内核中叫做kern_ipc_perm,这三种 IPC 的 key 值都存在这个数据结构里,包括用户信息和权限等级等都存在这里面;而上面讲到的那个柔性数组里存放的数据类型,就是kern_ipc_perm*。
所以虽然共享内存、消息队列、信号量这三种 IPC 资源都不一样,但是它们的开头第一个元素都一样,操作系统就可以将这三种 IPC 资源维护在一个柔性数组里。所以我们之前说的 shmid,其余还有 msgid、semid 其实拿到的就是数组下标,在使用时用这个数组下标就可以索引到对应的 IPC;索引到了怎么访问呢?索引到了对应的下标后,将整个数组下标位置使用强制类型转换就可以将该位置的资源转化成对应的 IPC 资源,就可以整体访问了,这就是用 C 语言实现多态的方式。
结语
这节课我们学习了进程间通信的几种方式,从最古老的管道,到 System V 标准的共享内存与信号量,它们看似各不相同,但本质上都在回答同一个问题——怎么让两个互相看不见的进程,看到同一份资源。匿名管道靠继承,命名管道靠同一个路径,共享内存靠把两块虚拟地址映射到同一块物理内存上,而信号量则在这之上补了一层对资源数量的管理。
如果有哪里讲得不清楚,也欢迎各位大佬在评论区指正,我们一起把这些底层的东西啃透。以后我也会继续创作 Linux 方向的博客,如果觉得有帮助的话,希望各位大佬能三联关注支持一下,看到后我会进行回关和回访。