进程间关系和守护进程¶
约 3782 个字 99 行代码 15 张图片 预计阅读时间 14 分钟
进程组¶
在前面多次提到了进程的概念,其实每一个进程除了有一个进程ID(PID)之外还有一个进程组ID(PGID)
进程组是一个或者多个进程的集合,一个进程组可以包含多个进程。每一个进程组也有一个唯一的进程组ID(PGID),并且这个PIGD类似于PID,同样是一个正整数,可以存放在pid_t
数据类型中
例如,启动一个sleep
进程:
Bash | |
---|---|
1 |
|
使用ps
指令查看该进程如下:
在上图中,因为sleep
进程是直接在终端上运行的,所以其父进程就是PPID为466951的-bash
进程,而PGID就是进程组ID
假设现在一次性启动多个sleep
进程:
Bash | |
---|---|
1 |
|
再次使用ps
指令查看这些进程如下:
可以看到,因为都是在终端启动,所以这些进程的PPID都是一样的,表示-bash
进程。仔细观察PGID,可以发现,这些进程的PGID都是一样的,并且PGID值就是第一个当前组第一个进程的PID
根据这个现象可以推出一个结论:进程组的ID值等于当前组第一个进程的PID。对于进程组的第一个进程来说,该进程也被称为组长进程。那么既然是组长进程,这个进程创建的子进程自然也就与组长进程属于同一个组,并且组长进程退出也不会影响到当前组的其他进程
对于上面的最后一点「组长进程退出也不会影响到当前组的其他进程」来说,还是以上面多个sleep
进程为例,如果现在删除组长进程(sleep 1000
),那么后续的两个进程的进程组ID依旧不会改变,如下图所示:
会话¶
了解了进程组,那么是否存在一个范围包括所有的进程组呢?当然有,这个就是会话,会话可以看成是一个或者多个进程组的集合,例如下图:
那么,如何形成一个会话呢?有一种情况非常常见,就是用户通过终端工具登录到Linux系统,此时Linux就会为当前客户端分配一个终端文件,并匹配一个对应的bash
进程,此时的终端文件+bash
进程就构成了一个会话,因为bash
属于当前会话,所以在当前bash
中启动的所有进程都属于当前会话,而因为每个进程属于对应的进程组,所以实际上bash
管理的就是一个一个的进程组
一个操作系统可能会有多个客户端进行连接,此时操作系统需要创建多个会话,所以操作系统为了管理这些会话也需要对浙西会话进行先描述再组织,此时每个会话就会有自己对应的属性,其中就有一个会话ID的属性(SID)
同样,启动多个sleep
进程:
Bash | |
---|---|
1 |
|
使用ps
指令查看这些进程如下:
在上面的字段中就有对应的会话ID,如果此时将sleep
进程放置到后台运行,再启动一个其他进程,例如下面的代码形成的进程:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
运行后使用ps
同时查看sleep
进程和上面的进程:
在上图中,-E
选项表示匹配多个,而sleep
和a.out
中间的|
表示匹配这两个中的每一个,所以这样就会同时看到sleep
进程和a.out
进程,观察他们的SID会发现他们都有一样的SID,尽管他们的PGID不同,所以这也印证了一个会话中可以有多个进程组
但是,如果细心发现,这次实验和上面的实验会有所不同:为什么需要将sleep
进程放置到后台再启动a.out
进程呢,可不可以不在后台启动sleep
进程情况下直接启动a.out
呢?答案是不可以,因为在一个Linux规定,在一个会话中,尽管可以有多个进程组,但是同一个时刻只能有一个前台进程,而可以有0个或者多个后台进程,而且只有前台进程可以从标准输入中读取信息
之所以这样规定,主要是因为标准输入文件只有一份,而且键盘文件也只有一个,所以在同一个时刻只能有一个进程接收到键盘发送的信息。根据这个原因,再思考前面提出的问题:因为只有前台进程可以从标准输入读取信息,在sleep
进程执行之前,前台进程就是-bash
,一旦sleep
在前台执行,那么根据上面说的规定,此时-bash
进程就只能变成后台进程,而sleep
就是前台进程,但是sleep
并没有对从标准输入中读取到的数据进行处理,所以如果让sleep
在前台执行,那么想要在当前会话中直接运行a.out
就是无法做到的事情
最后,如果一个会话被关闭(例如关闭终端连接工具),此时会话中的进程在一般情况下也会受到一定的影响,但是不一定会直接崩溃
终端文件¶
终端文件就是连接Linux操作系统时默认打开的文件,每当有一个终端工具连接到当前Linux系统,就可以看到多了一个文件,这个文件默认命名是按照数字进行命名,从0开始,使用下面的指令可以查看当前打开的文件:
Bash | |
---|---|
1 |
|
例如,当前启动了三个终端,所以对应的就有3个文件:
默认情况下,因为每一个进程都是-bash
进程的子进程,所以这些子进程会拷贝-bash
进程的文件描述符表,对应的也就是有标准输入、标准输出和标准错误。基于这个原因,每一个在终端下运行的程序的输入和输出都会直接在终端上获取和显示,而当用户在终端中向进程发送信号,默认也会将这些信号发给前台的进程
当需要查看一个运行程序的文件描述符表时,可以使用下面的指令进行查看:
Bash | |
---|---|
1 |
|
例如,查看a.out
程序打开的文件描述符:
在上面的结果中,可以看到因为a.out
在0
终端下运行,所以其文件描述符全部指向的是0
终端。但是在虚拟机中可能指向有一些不同
作业控制¶
作业是针对用户来讲,用户完成某项任务而启动的进程,一个作业既可以只包含一个进程,也可以包含多个进程,进程之间互相协作完成任务,通常是一个进程管道。
Shell分前后台来控制的不是进程而是作业或者进程组。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以同时运行一个前台作业和任意多个后台作业,这称为作业控制
还是以多个sleep
进程为例:
Bash | |
---|---|
1 |
|
当直接回车运行上面的指令,就是让该进程在前台运行,而需要让其在后台运行,就需要在指令最后添加&
,即:
Bash | |
---|---|
1 |
|
此时终端会弹出一个内容:
Bash | |
---|---|
1 |
|
这个内容表示当前有一个任务号为1的任务正在后台执行
如果想要将该任务调到前台,就可以使用下面的指令:
Bash | |
---|---|
1 |
|
例如对于上面的任务应当执行:
Bash | |
---|---|
1 |
|
如果想要查看当前有多少个作业在后台运行就可以使用下面的命令:
Bash | |
---|---|
1 |
|
如果想要更加详细的内容,比如显示每一个进程组中的所有进程,就可以带上-l
:
Bash | |
---|---|
1 |
|
例如对于后台运行的sleep
进程,有下面的结果:
如果再启动一个进程让其在后台运行,再使用jobs
命令查看就会得到下面的结果:
此时就会看到2号作业后面变成了+
,而1号作业变成了-
,这里的+
和-
分别表示默认作业和即将成为默认作业
因为a.out
进程后于sleep
进程,所以后来者居上,即2号作业成为了默认作业,而因为1号作业紧接着2号作业,所以1号作业是即将成为默认作业
如果此时再执行一个后台任务,就会看到下面的结果:
可以看到,原来1号作业是即将成为默认作业,现在变成了2号作业,原来是2号作业为默认作业,现在变成了3号作业,并且1号作业当前后面没有任何符号,此时表示其他作业
前面以及介绍了如何使一个任务以后台任务启动,并且如何将后台任务调到前台,但是如果一个任务本身是前台任务,又该如何将其设置为后台作业呢?
这里需要经历两步:
- 使用Ctrl+Z将前台任务暂停
- 再使用
bg 任务号
命令让指定任务在后台启动
例如,将正在前台运行的a.out
进程进入后台运行:
守护进程¶
所谓守护进程,就是一种特殊的孤儿进程。前面提到当一个会话结束时会影响到其中的进程组,如果希望某一个进程组不受当前会话的影响,就需要将该进程组单独提出来放到另外一个会话中,如果这个会话中只有某一个进程组,那么这个进程组中的进程就是守护进程
所以,实际上一般网络服务都是守护进程,而为了让网络服务变成守护进程,就需要借助相应的接口。在Linux中,有直接的接口可以让调用进程变成守护进程:
C | |
---|---|
1 |
|
该接口的第一个参数表示是否需要改变当前工作目录,第二个参数表示是否需要关闭文件描述符
如果该接口执行成功就返回0,否则返回-1并设置错误码
但是,如果直接使用这个接口也不太容易理解这个接口到底做了什么,所以为了更加充分理解这个接口,本次考虑手动实现这个接口的基本功能:
首先,为了让当前进程组可以脱离当前的会话,就需要有创建一个会话,在Linux中,创建会话可以使用setsid
接口,其原型如下:
C | |
---|---|
1 |
|
该接口表示创建一个新会话,但是需要注意,这个接口的调用方一定不能是当前进程组的组长
根据这个接口的介绍,下面设计一个思路:既然setsid
接口不能是当前进程组的组长,那么就可以让子进程去调用这个接口,所以先调用fork
创建一个子进程,子进程会继承当前父进程的进程组ID,此时再让父进程直接退出,让子进程执行后续的代码,其中就包括setsid
即可
所以基本调用setsid
的基本逻辑如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
但是仅仅这样写还不够,因为有的时候需要忽略可能引起程序异常退出的信号,所以还需要在执行开始就对一些信号进行捕捉,例如SIGCHLD
和SIGPIPE
:
C++ | |
---|---|
1 2 3 4 5 6 7 8 |
|
创建完会话之后,因为每一个进程都有自己的CWD
,可以考虑让用户自己决定是否将当前进程的CWD
更改成为/
根目录,所以此时可以提供一个参数用于判断是否要改变CWD
为什么要更改CWD
到根目录
将守护进程的当前工作目录(CWD)更改为根目录(/
)有以下几个重要原因:
- 防止文件系统挂载点被占用:如果守护进程的
CWD
在某个挂载点目录下,当需要卸载该文件系统时会失败,因为进程的CWD
会导致文件系统处于"忙"状态 - 避免资源访问问题:守护进程可能长期运行,如果
CWD
在某个用户目录下,该目录可能被删除或权限发生变化,从而会导致守护进程无法正常访问资源 - 统一标准位置:根目录是系统最基本的目录,将
CWD
设为根目录便于管理和维护,使得相对路径都基于根目录,更可控 - 安全性考虑:避免守护进程依赖特定用户目录,减少因目录权限变化导致的安全隐患,而根目录通常具有较严格的访问控制
- 可移植性:根目录在所有UNIX/Linux系统中都存在,不依赖于特定的目录结构,从而提高了程序的可移植性
所以代码修改如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
接着,因为有可能某一个进程中存在一些输入输出的行为,这种行为可能在守护进程下导致进程崩溃,所以可以考虑关闭三种标准文件描述符,但是这种做法并不是很推荐,更推荐的是将三种标准文件描述符重定向到/dev/null
文件中。这里可以给用户提供自定义空间,也就是给定一个参数让用户决定哪一种方式是合适的
关于/dev/null
文件
在Linux中,这个文件是一个数据黑洞,即写入到该文件的任何数据都会被丢弃,系统会返回写入成功,但实际上数据被直接丢弃,不会占用任何磁盘空间。因为其中没有内容,所以这个文件也属于一种空文件,所以读取该文件时会立即返回EOF
而不会阻塞进程,最后结果就是永远返回读取成功,但是读不到任何数据
所以,上面的代码还需要最后一步:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
接下来启动运行代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 |
|
通过ps
命令即可查看到守护进程:
如果想要终止这个守护进程就需要用到sudo + kill
,例如:
Bash | |
---|---|
1 |
|
网络服务进程变成守护进程¶
以上一节的网络计算器为例,修改主函数逻辑如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
在上面的代码中需要注意,因为服务器有一些日志输出,所以需要更改日志输出位置为文件而不是默认的控制台
测试如下:
服务端:
客户端: