进程
进程
一、进程控制
1. 父子进程
(1)循环创建子进程
在一个父进程中循环创建3个子进程,也就是最后需要得到4个进程,1个父进程,3个子进程,为了方便验证程序的正确性,要求在程序中打印出每个进程的进程ID
main.c
1 | // process_loop.c |
编译结果:
1 | (base) zxz@zxz-B660M-GAMING-X-AX-DDR4:~/Demo/6$ gedit 1.c |
进程id 从9821变化至9828,则8个进程id,其中有7个子进程
接下来分析上边的编写的代码,通过画图的方式分析为什么得到了7个子进程:
上图中的树状结构,蓝色节点代表父进程:
- 循环第一次 i = 0,创建出一个子进程,即红色节点,子进程变量值来自父进程拷贝,因此 i=0
- 循环第二次 i = 1,蓝色父进程和红色子进程都去创建子进程,得到两个紫色进程,子进程变量值来自父进程拷贝,因此 i=1
- 循环第三次 i = 2,蓝色父进程和红色、紫色子进程都去创建子进程,因此得到4个绿色子进程,子进程变量值来自父进程拷贝,因此 i=2
- 循环第三次 i = 3,所有进程都不满足条件 for(int i=0; i<3; ++i)因此不进入循环,退出了。
若创建子进程没有对其进行退出,若循环轮数为n,则最后产生的子进程数量为2^n -1
对上述代码进行修改,每次创建子进程后,及时对其进行退出操作
1 | // process_loop.c |
编译运行
1 | (base) zxz@zxz-B660M-GAMING-X-AX-DDR4:~/Demo/6$ make 1 |
二、管道
管道的是进程间通信(IPC - InterProcess Communication)的一种方式,管道的本质其实就是内核中的一块内存(或者叫内核缓冲区),这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里边,因此我们不能直接对其进行任何操作。
因为管道数据是通过队列来维护的,我们先来分析一个管道中数据的特点:
- 管道对应的内核缓冲区大小是固定的,默认为4k(也就是队列最大能存储4k数据)
- 管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。
- 管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)。
- 管道是单工的:数据只能单向流动, 数据从写端流向读端
- 对管道的操作(读、写)默认是阻塞的
- 读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
- 写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除
管道操作就是文件IO操作,内核中管道的两端分别对应两个文件描述符,通过写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来。读写管道的函数就是Linux中的文件IO函数
1 | // 读管道 |
分析为什么管道可以用于父子进程间的通信:
在上图中假设父进通过一系列操作可以通过文件描述符表中的文件描述符fd3写管道,通过fd4读管道,然后再通过 fork() 创建出子进程,那么在父进程中被分配的文件描述符 fd3, fd4也就被拷贝到子进程中,子进程通过 fd3可以将数据写入到内核的管道中,通过fd4将数据从管道中读出来。(子进程将父进程中的资源进行拷贝)
也就是说管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口和出口(读端和写端的文件描述符),那么他们之间就可以通过管道进行数据的交互。
1.匿名管道
匿名管道是管道的一种,既然是匿名也就是说这个管道没有名字,但其本质是不变的,就是位于内核中的一块内存,匿名管道拥有上面介绍的管道的所有特性,但是额外的:匿名管道只能实现有血缘关系的进程间通信。比如:父子进程,兄弟进程,爷孙进程,叔侄进程
函数原型:
1 |
|
参数:传出参数,需要传递一个整形数组的地址,数组大小为 2,也就是说最终会传出两个元素
- pipefd[0]: 对应管道读端的文件描述符,通过它可以将数据从管道中读出
- pipefd[1]: 对应管道写端的文件描述符,通过它可以将数据写入到管道中
返回值:成功返回 0,失败返回 -1
2.有名管道
有名管道拥有管道的所有特性,之所以称之为有名是因为管道在磁盘上有实体文件, 文件类型为p ,有名管道文件大小永远为0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件就可以得到操作有名管道的文件描述符,通过文件描述符读写管道存储在内核中的数据。
有名管道也可以称为 fifo (first in first out),使用有名管道既可以进行有血缘关系的进程间通信,也可以进行没有血缘关系的进程间通信。创建有名管道的方式有两种,一种是通过命令,一种是通过函数。
- 通过命令
1 | $ mkfifo 有名管道的名字 |
- 通过函数
1 |
|
- 参数:
- pathname: 要创建的有名管道的名字
- mode: 文件的操作权限, 和open()的第三个参数一个作用,最终权限: (mode & ~umask)
- 返回值:创建成功返回 0,失败返回 -1
3.进程间通信
使用有名管道进行进程间通信
注意:有名管道操作需要通过 open() 操作得到读写管道的文件描述符,如果只是读端打开了或者只是写端打开了,进程会阻塞在这里不会向下执行,直到在另一个进程中将管道的对端打开,当前进程的阻塞也就解除了。所以当发现进程阻塞在了open()函数上不要感到惊讶
写管道的进程:write_pid.c
1 | // |
读管道的进程:read_pid.c
1 | // |
编译运行结果:
三、内存映射
如果想要实现进程间通信,可以通过函数创建一块内存映射区,和管道不同的是管道对应的内存空间在内核中,而内存映射区对应的内存空间在进程的用户区(用于加载动态库的那个区域),也就是说进程间通信使用的内存映射区不是一块,而是在每个进程内部都有一块。
由于每个进程的地址空间是独立的,各个进程之间也不能直接访问对方的内存映射区,需要通信的进程需要将各自的内存映射区和同一个磁盘文件进行映射,这样进程之间就可以通过磁盘文件这个唯一的桥梁完成数据的交互了
如上图所示:磁盘文件数据可以完全加载到进程的内存映射区也可以部分加载到进程的内存映射区,当进程A中的内存映射区数据被修改了,数据会被自动同步到磁盘文件,同时和磁盘文件建立映射关系的其他进程内存映射区中的数据也会和磁盘文件进行数据的实时同步,这个同步机制保障了各个进程之间的数据共享。
使用内存映射可以实现进程间的通信,创建内存映射区的函数原型如下:
1 |
|
参数:
- addr: 从动态库加载区的什么位置开始创建内存映射区,一般指定为NULL, 委托内核分配
- length: 创建的内存映射区的大小(单位:字节),实际上这个大小是按照4k的整数倍去分配的
- prot: 对内存映射区的操作权限
- PROT_READ: 读内存映射区
- PROT_WRITE: 写内存映射区
- 如果要对映射区有读写权限: PROT_READ | PROT_WRITE
- flags:
- MAP_SHARED: 多个进程可以共享数据,进行映射区数据同步
- MAP_PRIVATE: 映射区数据是私有的,不能同步给其他进程
- fd: 文件描述符, 对应一个打开的磁盘文件,内存映射区通过这个文件描述符和磁盘文件建立关联
- offset: 磁盘文件的偏移量,文件从偏移到的位置开始进行数据映射,使用这个参数需要注意两个问题:
- 偏移量必须是4k的整数倍, 写0代表不偏移
- 这个参数必须是大于 0 的
返回值:
- 成功: 返回一个内存映射区的起始地址
- 失败: MAP_FAILED (that is, (void *) -1)
注意事项:
1 | 1. 第一个参数 addr 指定为 NULL 即可 |
内存映射区使用完之后也需要释放,释放函数原型如下:
1 | int munmap(void *addr, size_t length); |
参数:
- addr: mmap()的返回值, 创建的内存映射区的起始地址
- length: 和mmap()第二个参数相同即可
返回值:
- 函数调用成功返回 0,失败返回 -1
1.进程通信
操作内存映射区和操作管道是不一样的,得到内存映射区之后是直接对内存地址进行操作,管道是通过文件描述符读写队列中的数据,管道的读写是阻塞的,内存映射区的读写是非阻塞的。内存映射区创建成功之后,得到了映射区内存的起始地址,使用相关的内存操作函数读写数据就可以了。
(1)无血缘关系的进程
对于没有血缘关系的进程间通信,需要在每个进程中分别创建内存映射区,但是这些进程的内存映射区必须要关联相同的磁盘文件,这样才能实现进程间的数据同步
进程A的代码
1 | // |
进程B 的代码
1 | // |
四、共享内存
五、信号
1.信号集
(1)阻塞\为决信号集
在PCB中有两个非常重要的信号集。一个称之为“阻塞信号集”,另一个称之为“未决信号集”。这两个信号集体现在内核中就是两张表。但是操作系统不允许我们直接对这两个信号集进行任何操作,而是需要自定义另外一个集合,借助信号集操作函数来对PCB中的这两个信号集进行修改
- 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间
- 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生
信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了 防止信号打断某些敏感的操作
阻塞信号集和未决信号集在内核中的结构是相同的,它们都是一个整形数组(被封装过的), 一共 128 字节 (int [32] == 1024 bit,4字节一个整形,一个字节8bit,则32*32 = 1024bit),1024个标志位,其中前31个标志位,每一个都对应一个Linux中的标准信号,通过标志位的值来标记当前信号在信号集中的状态
在阻塞信号集中,描述这个信号有没有被阻塞:
- 默认情况下没有信号是被阻塞的, 因此信号对应的标志位的值为 0
- 如果某个信号被设置为了阻塞状态, 这个信号对应的标志位 被设置为 1
在未决信号集中, 描述信号是否处于未决状态:
- 如果这个信号被阻塞了, 不能处理, 这个信号对应的标志位被设置为1
- 如果这个信号的阻塞被解除了, 未决信号集中的这个信号马上就被处理了, 这个信号对应的标志位值变为0
- 如果这个信号没有阻塞, 信号产生之后直接被处理, 因此不会在未决信号集中做任何记录
(2)信号集函数
因为用户是不能直接操作内核中的阻塞信号集和未决信号集的,必须要调用系统函数,关于阻塞信号集可以通过系统函数进行读写操作,未决信号集只能对其进行读操作
读/写阻塞信号集的函数:
1 |
|
参数:
how:
SIG_BLOCK: 将参数 set 集合中的数据追加到阻塞信号集中
SIG_UNBLOCK: 将参数 set 集合中的信号在阻塞信号集中解除阻塞
SIG_SETMASK: 使用参 set 结合中的数据覆盖内核的阻塞信号集数据
oldset: 通过这个参数将设置之前的阻塞信号集数据传出,如果不需要可以指定为NULL
sigprocmask()
函数有一个 sigset_t
类型的参数,对这种类型的数据进行初始化需要调用一些相关的操作函数:
1 |
|
未决信号集不需要程序猿修改, 如果设置了某个信号阻塞, 当这个信号产生之后, 内核会将这个信号的未决状态记录到未决信号集中,当阻塞的信号被解除阻塞, 未决信号集中的信号随之被处理, 内核再次修改未决信号集将该信号的状态修改为递达状态(标志位置0)。因此,写未决信号集的动作都是内核做的,这是一个读未决信号集的操作函数:
1 |
|
使用一张图总结信号集操作函数之间的关系:
2.信号捕捉
Linux中的每个信号产生之后都会有对应的默认处理行为,如果想要忽略这个信号或者修改某些信号的默认行为就需要在程序中捕捉该信号。程序中进行信号捕捉可以看做是一个注册的动作,提前告诉应用程序信号产生之后做什么样的处理,当进程中对应的信号产生了,这个处理动作也就被调用了
(1)signal
使用 signal() 函数可以捕捉进程中产生的信号,并且修改捕捉到的函数的行为,这个信号的自定义处理动作是一个回调函数,内核通过 signal() 得到这个回调函数的地址,在信号产生之后该函数会被内核调用
1 |
|
参数:
signum: 需要捕捉的信号
handler: 信号捕捉到之后的处理动作, 这是一个函数指针, 函数原型
1
typedef void (*sighandler_t)(int);
这个回调函数是需要程序猿写的, 但是程序猿不调用, 由内核调用,内核调用回调函数的时候, 会给它传递一个实参,这个实参的值就是捕捉的那个信号值
实例程序:
使用 signal() 函数来捕捉定时器产生的信号 SIGALRM
:
1 |
|
(2)sigaction
sigaction() 函数和 signal() 函数的功能是一样的,用于捕捉进程中产生的信号,并将用户自定义的信号行为函数(回调函数)注册给内核,内核在信号产生之后调用这个处理动作。sigaction() 可以看做是 signal() 函数是加强版,函数参数更多更复杂,函数功能也更强一些。函数原型如下:
1 |
|
参数:
- signum: 要捕捉的信号
- act: 捕捉到信号之后的处理动作
- oldact: 上一次调用该函数进行信号捕捉设置的信号处理动作, 该参数一般指定为NULL
返回值:函数调用成功返回0,失败返回-1
该函数的参数是一个结构体类型,结构体原型如下:
1 | struct sigaction { |
结构体成员介绍:
- sa_handler: 函数指针,指向的函数就是捕捉到的信号的处理动作
- sa_sigaction: 函数指针,指向的函数就是捕捉到的信号的处理动作
- sa_mask: 在信号处理函数执行期间, 临时屏蔽某些信号, 将要屏蔽的信号设置到集合中即可
- 当前处理函数执行完毕, 临时屏蔽自动解除
- 假设在这个集合中不屏蔽任何信号, 默认也会屏蔽一个(捕捉的信号是谁, 就临时屏蔽谁)
- sa_flags:使用哪个函数指针指向的函数处理捕捉到的信号
- 0:使用 sa_handler (一般情况下使用这个)
- SA_SIGINFO:使用 sa_sigaction (使用信号传递数据==进程间通信)
- sa_restorer: 被废弃的成员
3.SIGCHLD信号
当子进程退出、暂停、从暂停回复运行的时候,在子进程中会产生一个SIGCHLD信号,并将其发送给父进程,但是父进程收到这个信号之后默认就忽略了。我们可以在父进程中对这个信号加以利用,基于这个信号来回收子进程的资源,因此需要在父进程中捕捉子进程发送过来的这个信号。