一文读懂Linux进程、进程组、会话、僵尸
作者简介
herongwei,北交硕士毕业,现就职于搜狗公司,后端开发工程师。从事 C++,Golang ,Linux 后端开发。
追求技术,热爱编程与分享,希望能和大家多多交流学习~
座右铭: 认真的人,自带光芒!
GitHub: https://github.com/rongweihe
个人博客: https://rongweihe.github.io/
1、前言
在研究 Linux 实现之前,首先要对进程、进程组、会话,线程有个整体的了解:一个会话包含多个进程组,一个进程组包含多个进程,一个进程包含多个线程。
2、进程控制
进程是 Linux 操作系统环境的基础,它控制着系统上几乎所有的活动。每个进程都有自己唯一的标识:进程 ID,也有自己的生命周期。进程都有父进程,父进程也有父进程,从而形成了一个以 init 进程 (PID = 1)为根的家族树。除此以外,进程还有其他层次关系:进程组和会话。
一个典型的进程的生命周期如下图所示。
3、进程ID
Linux 下每个进程都会有一个非负整数表示的唯一进程 ID,简称 pid。
Linux 提供了getpid 函数来获取进程的 pid,同时还提供了 getppid 函数来获取父进程的 pid,相关接口定义如下:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
每个进程都有自己的父进程,父进程又会有自己的父进程,最终都会追溯到 1 号进程即 init 进程。这就决定了操作系统上所有的进程必然会组成树状结构,就像一个家族的家谱一样。可以通过 pstree 的命 令来查看进程的家族树,如下图所示。
procfs 文件系统会在 /proc 下为每个进程创建一个目录,名字是该进程的 pid。目录下有很多文件, 用于记录进程的运行情况和统计信息等。因为进程有创建,也有终止,所以 /proc/ 下记录进程信息的目录(以及目录下的文件)也会发生变化。如下图所示:
4、进程创建
Linux 下创建新进程的系统调用是 fork。其定义如下:
#include <sys/types.h>
#include <unistd.h>
pid_t fork( void );
该函数的每次调用都会返回两次:在父进程中返回的是子进程的 PID,在子进程中则返回 0 。该返回值是后续代码用来判断当前进程是父进程还是子进程的依据。
fork 函数会复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的 PPID 被设置成了原进程的 PID,信号位图被清除(也就是原进程设置的信号处理的函数不再对新进程起作用)。
子进程的代码与父进程完全相同,同时它还会复制父进程的教据(堆数据、栈数据和靜态数据)。
数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。
如果我们在程序中分配了大量内存,那么使用 fork 时也应当谨慎,避免没必要的内存分配和数据复制。
此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加 1,不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加 1。
5、进程层次
进程组和会话在进程之间形成了两级的层次关系:
进程组是一组相关进程的集合,会话是一组相关进程组的集合。
一个进程会有如下 ID:
1)PID:进程的唯一标识。如果一个进程含有多个线程,所有线程调用 getpid 函数会返回相同的值。
2)PGID:进程组 ID。每个进程都会有进程组 ID,表示该进程所属的进程组。默认情况下新创建的进程会继承父进程的进程组 ID。
3)SID:会话 ID。每个进程也都有会话 ID。默认情况下,新创建的进程会继承父进程的会话 ID。
4)PPID:是程序的父进程号。
可以调用如下指令来查看所有进程的层次关系(To print a process tree):
1、ps -ejH
2、ps axjf
也可以调用以下函数获取进程组 ID 跟会话 ID :
1、pid_t getpgrp(void);
2、pid_t getsid(pid_t pid);
前面提到,新进程默认会继承父进程的进程组 ID 和会话 ID,那么看到这里,有同学可能会问了:如果都是默认情况的话,那么一层层往上计算,所有的进程应该有共同的进程组 ID 和会话 ID ,但是当调用 ps axjf 命令查看,实际情况并非如此,系统中存在很多不同的会话,每个会话下也有不同的进程组。
如下图所示:
这是什么原因呢?
《Linux环境编程:从应用到内核》这本书里,有一个解释,说的比较生动形象:
1)可以打个比方,以家族企业的创业为例,每个进程可以比喻成家族企业的每个成员。
2)如果从创业之初,所有家族成员都安分守己,循规蹈矩,默认情况下,就只会有一个公司、一个部门。但是也有些“叛逆”的子弟,愿意为家族公司开疆拓土,愿意成立新的部门。
3)这些新的部门就是新创建的进程组。如果有子弟“离经叛道”,甚至不愿意呆在家族公司里,他别开天地,另创了一个公司,那这个新公司就是新创建的会话组。由此可见,系统必须要有改变和设置进程组 ID 和会话 ID 的函数接口,否则,系统中只会存在一个会话、一个进程组。
这样一来,是不是就比较好理解了。
6、进程的状态
在 Linux 中,进程具有以下可能的状态:
new-表示正在创建进程。
ready-表示该进程就绪,等待分配处理器。
running -表示该进程正在执行中。
waiting-表示该进程正在等待某些事件发生(例如I / O完成或信号接收)。此外,内核还区分了两种类型的等待进程。可中断的等待过程–可以被信号中断,而不可中断的等待过程–直接在硬件条件下等待,并且不能被任何事件/信号中断。
terminated-表示该进程已完成执行。
如下图所示:
在 Linux 中,常用的 ps-ef
或 ps aux top
命令,也可以用 top
命令查看,如下图所示:
另外几个查看进程的命令, htop
, jobs
, fg
和 bg
命令也可以学习一下。
此外,我们还必须了解其它三种进程:
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作。
僵尸进程:一个进程使用 fork 创建子进程,如果子进程先退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
守护进程:(英语:daemon)是一种在后台执行的程序。此类程序会被以进程的形式初始化。守护进程程序的名称通常以字母“d”结尾:例如,syslogd 就是指管理系统日志的守护进程。
Linux 系统中,每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init,而 init 进程会循环地 调用 wait() ,处理已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会代表收容所一样处理它的一切善后工作。因此孤儿进程并不会有什么危害。
而对于僵尸进程,如果进程不调用 wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。
解决:僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们要消灭系统中大量的僵死进程时,要做的就是把产生大量僵死进程的那个父进程杀死(通过 kill 发送 SIGTERM 或者 SIGKILL 信号)。杀死之后,产生的僵死进程就变成了孤儿进程,这些孤儿进程会被 init 进程接管。
7、进程组
进程组和会话是为了支持 shell 作业控制而引入的概念。
修改进程组 ID 的接口如下
int setpgid(pid_t pid, pid_t pgid);
这个函数的含义是,找到进程 ID 为 pid 的进程,将其进程组 ID 修改为 pgid,如果 pid 的值为 0,则表示要修改调用进程的进程组 ID。该接口一般用来创建一个新的进程组。
下面三个函数接口含义一致,都是创立新的进程组,并且指定的进程会成为进程组的首进程。
如果参数 pid 和 pgid 的值不匹配,那么 setpgid 函数会将一个进程从原来所属的进程组迁移到 pgid 对应的进程组。
setpgid(0,0)
setpgid(getpid(),0)
setpgid(getpid(),getpid())
setpgid 函数有一些限制:
1)pid 参数必须指定为调用 setpgid 函数的进程或其子进程,不能随意修改不相关进程的进程组 ID,如果违反这条规则,则返回 -1,并置 errno 为 ESRCH。
2)pid 参数可以指定调用进程的子进程,但是子进程如果已经执行了exec函数,则不能修改子进程的进程组 ID。如果违反这条规则,则返回-1,并置 errno 为 EACCESS。
3)在进程组间移动,调用进程,pid 指定的进程及目标进程组必须在同一个会话之内。这个比较好理解,不加入公司(会话),就无法加入公司下属的部门(进程组),否则就是部门要造反的节奏。如果违反这条规则,则返回-1,并置 errno 为 EPERM。
4)pid 指定的进程,不能是会话首进程。如果违反这条规则,则返回 -1,并置 errno 为 EPERM。
有了创建进程组的接口,新创建的进程组就不必继承父进程的进程组 ID 了。
最常见的创建进程组的场景就是在 shell 中执行管道命令。
代码如下:
cmd1 | cmd2 | cmd3
下面用一个简单的命令 ps ax|grep nfsd
来说明,其进程之间的关系如下图所示。
ps
进程和 grep
进程都是 bash
创建的子进程,两者通过管道协同完成一项工作,它们隶属于同一个进程组,其中 ps
进程是进程组的组长。
进程组的概念并不难理解,可以将人与人之间的关系做类比。一起工作的同事,自然比毫不相干的路人更加亲近。shell 中协同工作的进程属于同一个进程组,就如同协同工作的人属于同一个部门一样。
引入了进程组的概念,可以更方便地管理这一组进程了。比如这项工作放弃了,不必向每个进程一一发送信号,可以直接将信号发送给进程组,进程组内的所有进程都会收到该信号。
8、会话
当有新的用户登录 Linux 时,登录进程会为这个用户创建一个会话。
用户的登录 shell 就是会话的首进程。会话的首进程 ID 会作为整个会话的 ID。会话是一个或多个进程组的集合,包括了登录用户的所有活动。
在登录 shell 时,用户可能会使用管道,让多个进程互相配合完成一项工作,这一组进程属于同一个进程组。
前台进程和后台进程:
用户在 shell 中可以同时执行多个命令。对于耗时很久的命令(如编译大型工程),用户不必在傻傻的等待命令运行完毕才执行下一个命令。
用户在执行命令时,可以在命令的结尾添加“&”符号,表示将命令放入后台执行。这样该命令对应的进程组即为后台进程组。
在任意时刻,可能同时存在多个后台进程组,但是不管什么时候都只能有一个前台进程组。只有在前台进程组中进程才能在控制终端读取输入。当用户在终端输入信号生成终端字符(如 ctrl+c、ctrl+z、ctr+\等)时,对应的信号只会发送给前台进程组。
shell 中可以存在多个进程组,无论是前台进程组还是后台进程组,它们或多或少存在一定的联系,为了更好地控制这些进程组(或者称为作业),系统引入了会话的概念。
会话的意义在于将很多的工作集中在一个终端,选取其中一个作为前台来直接接收终端的输入及信号,其他的工作则放在后台执行。
可以使用下面几个函数来通知内核哪一个进程组是前台进程组,以便设备驱动程序知道该把终端输入和终端产生的信号发往何处。
#include <unistd.h>
#include <termios.h>
pid_t tcgetpgrp(int fd); /* 返回值:若成功,返回前台进程组 ID;否则,返回 -1 */
int tcsetpgrp(int fd, pid_t pgrpid); /* 返回值:若成功,返回 0;否则,返回 -1 */
pid_t tcgetsid(int fd);/* 返回值:若成功,返回会话首进程的进程组 ID;否则,返回 -1 */
一个或多个进程组的集合组成了会话,以用户登录系统为例,可能存在如下图所示的情况。
Linux 系统提供 setsid 函数来创建会话,其接口定义如下:
#include <unistd.h>
pid_t setsid(void);
如果这个函数的调用进程不是进程组组长,那么调用该函数会发生以下事情:
1)创建一个新会话,会话 ID 等于进程 ID,调用进程成为会话的首进程。
2)创建一个进程组,进程组 ID 等于进程 ID,调用进程成为进程组的组长。
3)该进程没有控制终端,如果调用 setsid 前,该进程有控制终端,这种联系就会断掉。
调用 setsid 函数的进程不能是进程组的组长,否则调用会失败,返回-1,并置 errno 为 EPERM。
这个限制的合理在于如果允许进程组组长迁移到新的会话,而进程组的其他成员仍然在老的会话中,那么,就会出现同一个进程组的进程分属不同的会话之中的情况,这就破坏了进程组和会话的严格的层次关系。
9、总结
最后来张图,帮助大家更好的了解 PID、PGID、PPID、Session 。
参考资料:《Linux 环境编程:从应用到内核》。
(END)
相关阅读:
宋宝华:让Linux的段错误(segmentation fault)不再是一个错误
宋宝华:世上最好的共享内存(Linux共享内存最透彻的一篇)
宋宝华: 一图理解终端、会话、 进程组、进程关系
相关课程:
《Linux系统编程两驾马车》之《多进程编程》课程
点一点右下角”在看”,为大神打Call~
更多推荐
所有评论(0)