目录
进程是操作系统一个核心概念。每个进程都有自己的唯一标识:进程 ID,也有自己的生命周期,一个典型的进程的生命周期如图:
进程 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/ 下记录的目录(以及目录中的内容)也会发生变化。
操作系统必须保证在任意时刻都不能出现两个进程有相同 pid 的情况。虽然进程的 ID 是唯一的,但是进程 ID 可以重用。进程退出后,其进程 ID 还可以再次分配给其它的进程使用。那么问题来了,内核是如何分配进程 ID 的?
Linux 分配进程 ID 的算法不同于给进程分配文件描述符的最小可用算法,它采用了延迟重用的算法,即分配给新创建进程的 ID 经量不与最近终止进程的 ID 重复,这样就可以防止将新创建的进程误判为使用相同进程 ID 的已经退出的进程。
内核采用的方法如下:
- 位图记录进程 ID 的分配情况(0 为可用,1 为已占用)
- 将上次分配的进程 ID 记录到 last_pid 中,分配进程 ID 时,从 last_pid+1 开始找起,从位图中寻找可用的 ID
- 如果找到位图集合的最后一位仍不可用,则回滚到位图集合的起始位置,从这里开始找(回绕时并不是从 0 开始找起,而是从 300 开始。内核在 kernal/pid.c 文件中定义了 RESERVED_PIDS,其值是 300,300 以下的 pid 被系统占用,不能分配给用户进程)
既然是位图记录进程 ID 的分配情况,那么位图的大小就必须要考虑周全。位图的大小直接决定了系统允许同时存在的进程的最大个数,这个最大个数在系统中称为 pid_max.
Linux 系统下可以通过 procfs 或 sysctl 命令来查看 pid_max 的值:
此外,这个上限值是可以修改的,系统管理员可以通过如下的方法来修改此上限值:
但是内核自己也设置了硬上限,如果将 pid_max 的值设置成一个大于硬上限的值会失败:
从上面的操作我们可以看出,Linux 系统将系统进程数的硬上限设置为 4194304。那么内核又是如何决定系统个数的硬上限的呢?对此内核定义了如下宏:
#define PID_MAX_LIMIT (CONFIG_BASE_SMALL ? PAGE_SIZE * 8 : \
(sizeof(long) > 4 ? 4 * 1024 * 1024 : PID_MAX_DEFAULT))
从上面的代码逻辑中可以看出系统进程个数硬上限的逻辑为:
- 如果选择了 CONFIG_BASE_SMALL 编译选项,则为页面 PAGE_SIZE 的位数
- 如果选择了 CONFIG_BASH_FULL 编译选项,那么:对于 32 位操作系统,系统进程个数硬上限为 32768;对于 64 位操作系统,系统进程个数硬上限为 4194303(这个数字相当庞大,足够应用层使用)
进程的层次
每个进程都有父进程,父进程也有父进程,这就形成了一个以 init 进程为根的家族树。除此之外,进程还有其他层次关系:进程、进程组和会话。
进程组是一组相关进程的集合,会话是一组相关进程组的集合。
因此,一个进程会有如下 ID:
- PID :进程的唯一标识
- PGID :进程组 ID。每个进程都会有进程组 ID,表示该进程所属的进程组。默认情况下新创建的进程会继承父进程的进程组 ID
- SID :会话 ID。每个进程也会有会话 ID。默认情况下,新创建进程会继承父进程会话 ID
可以通过如下指令来查看所有进程的层次关系:
对于进程而言,可以通过如下函数调用来获取其进程组 ID 和会话 ID:
#include<unistd.h>
pid_t getpgrp(void); //获取进程组 ID
pid_t getsid(pid_t pid); //获取进程所属会话 ID
前面起到过,新创建的进程默认继承父进程的进程组 ID 和 会话 ID,如果都是默认情况的话,那么所有的进程应该有共同的进程组 ID 和会话 ID。事实并不是如此,系统中存在很多不同的会话,每个会话下有不同的进程组。
系统有改变和设置进程组 ID 和会话 ID 的函数接口。。。
进程组和会话是为了支持 shell 作业控制而引入的概念。
当有新的用户登录 Linux 时,登录进程会为这个用户创建一个会话。用户登录的 shell 就是会话的首进程。会话的首进程 ID 会作为整个会话的 ID。会话是一个或多个进程组的集合,囊括了用户的所有活动。
在登录 shell 时,用户可能会使用管道,让多个进程互相配合完成一项工作,这一组进程属于同一个进程组。
当用户通过 SSH 客户端工具(xshell 等)进入 Linux 时,与上述登录情况是类似的。
进程组
修改进程组 ID:
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
该函数的含义是,找到进程 ID 为 pid 的进程,将其进程组 ID 修改为 pgid,如果 pid 的值为 0,则表示要修改调用进程的进程组 ID。该接口一般用来创建一个新的进程组。
下面三个接口含义一致,都是创立新的进程组,并且指定的进程会成为进程组的首进程。如果参数 pid 和 pgid 的值不匹配,那么该函数会将一个进程从原来所属的进程组迁移到 pgid 所属的进程组。
setpgid(0,0);
setpgid(getpid(),0);
setpgid(getpid(),getpid());
使用 setpgid 函数需要注意:
- pid 参数必须指定为调用 setpgid 函数的进程或其子进程,不能随意修改不相关进程的进程组 ID,如果违反此条规则,则返回 -1,并置 error 为 ESRCH
- pid 参数可以指定为调用 setpgid 进程的子进程,但是如果子进程执行了 exec 系列函数,则不能修改子进程的进程组 ID。如果违反此条规则,则返回 -1,并置 error 为 EACCESS
- 在进程组间移动,调用进程,pid 指定的进程及目标进程组必须属于同一个会话。如果违反此条规则,则返回 -1,并置 error 为 EPERM
- pid 制定的进程,不能是会话的首进程。如果违反此条规则,则返回 -1,并置 error 为 EPERM
有了创建进程组的接口,子进程就不必继承父进程的进程组 ID 了。最常见的创建进程组的场景就是 shell 中执行管道命令:
ps ax | grep nfsd
ps 进程和 grep 进程都是 bash 创建的子进程,两者通过管道协同完成一项工作,他们属于同一个进程组,其中 ps 进程是进程组的组长。
shell 中协同工作的进程属于同一个进程组,就如同协同工作的人属于同一个部门一样。
引入进程组的概念,可以更方便的管理这一组进程了。比如某项工作放弃了,就不必向每个进程一一发送信号,可以直接将信号发送给进程组,进程组内的所有进程都会收到该信号。
子进程一旦执行 exec 系列函数,父进程就无法调用 setpgid 函数来设置子进程的进程组 ID,这条规则会影响 shell 的作业控制。处于保险的考虑,一般父进程在调用 fork 创建子进程后,会调用 setpgid 函数设置子进程的进程组 ID,同时子进程也会调用 setpgid 函数来设置自身的进程组 ID。这两次调用有一次是多余的,但是这样可以保证 fork 之后,无论是父进程先执行,还是子进程先执行,子进程已经进入了指定的进程组当中。由于 fork 之后,父子进程的执行顺序是不确定的,因此如果不这样做,就会造成一定时间窗口内,无法确定子进程是否进入了相应的进程组。
会话
会话是一个或多个进程组的集合,以用户登录系统为例,可能存在如图所示的情况:
系统提供 setsid 函数来创建会话:
#include <unistd.h>
pid_t setsid(void);
如果调用这个函数的进程不是进程组的组长,那么调用该函数会发生以下事情:
- 创建一个会话,会话 ID 等于进程 ID,调用进程成为会话的首进程
- 创建一个进程组,进程组 ID 等于进程 ID,调用进程成为进程组的组长
- 该进程没有控制终端,如果调用 setsid 函数前,该进程有控制终端,这种联系就会断掉
调用 setsid 函数的进程不能是进程组的组长,否则调用会失败,返回 -1,并置 error 为 EPERM。
这个限制是比较合理的。如果允许进程组组长迁移到新的会话,而进程组的其他成员还在老的会话中,那么,就会出现同一个进程组的成员分属不同的会话之中的情况,这就破坏了进程组和会话的严格的层次关系了。
Linux 提供 setsid 命令,可以在新的会话中执行命令,通过该命令可以很容易的验证上面提到的三点:
系统创建了新的会话 10446,新的会话下又创建了新的进程组,会话 ID 和进程组 ID 都等于进程 ID,而该进程已经没有任何控制终端了(TTY 对应的值为 '?',表示进程没有控制终端)
常用的调用 setsid 函数的场景是 login 和 shell。除此以外创建 deamon 也要调用 setsid 函数。
进程创建之 fork
Linux 系统下,进程可以调用 fork 函数来创建新的进程。调用进成为父进程,被创建进成为子进程。
#include <unistd.h>
pid_t fork(void);
与普通函数不同。fork 函数会返回两次。一般来说,创建两个完全相同的进程并没