今天有同事问到 top
命令里的 running
, sleep
, stop
, zombie
等进程状态分别是什么意思。于是借这个机会写一下 Linux 系统里的进程状态。
概述
进程(Process)即当前正在运行的计算机程序之实例。每个进程都有一些属性变量,这些变量决定了进程所能使用的计算机资源。进程执行过程中,会处在不同状态;正如人会处在不同的人生状态一样。
和人一样,进程也是进程它妈生的。进程从其父进程当中 fork 出来。而后,程在执行(running)的过程中,会用到一些可用的资源;这一点,和人也是一样的。如果把精力当成是一种资源,那么人睡觉就是等待精力这一资源的过程;进程也是一样,当进程执行所需的资源暂时不可用时,进程就会进入睡眠(sleeping)状态。当然,和人一样,进程也会死。一个进程可能会正常结束,相当于人的寿终正寝;也可能被杀死(kill a process)。
进程的类型
Linux 系统里有几种不同类型的进程:用户进程(User processes)、守护进程(Deamon processes)和内核进程(Kernel processes)。
用户进程
系统里大多数进程都是用户进程。用户进程由通常的用户账户启动,并在用户空间(user space)当中执行。在没有获得额外许可的情况下,通常用户进程无法对处理器进行特殊访问,或是访问启动进程的用户无权访问的文件。
守护进程
守护进程通常是后台程序,它们往往由一些持续运行的服务来管理。守护进程可以用来监听请求,而后访问某些服务。举例来说,httpd
这一守护进程监听访问网络页面的请求。守护进程也可以用来自行启动一些任务。例如,crond
这一守护进程会在预设的时间点启动计划任务。
尽管用于管理守护进程的服务通常是 root
用户启动的,但守护进程本身往往以非 root
用户启动。这种启动方式,符合「只赋予进程运行所必须的权限」之要求,因而能使系统免于一些攻击。举例来说,若是黑客骇入了 httpd
这一由 Apache
用户启动的守护进程,黑客仍然无法访问包括 root
用户在内的其他用户的文件,或是影响其他用户启动的守护进程。
守护进程通常由系统在启动时拉起,而后一直运行到系统关闭。当然,守护进程也可以按需启动和终止,以及让守护进程在特定的系统运行级别上执行,或是在运行过程中触发重新加载配置信息。
内核进程
内核进程仅在内核空间(kernel space)当中执行。内核进程与守护进程有些相似;它们之间主要的不同在于:内核进程对内核数据结构拥有完全的访问权限。此外,内核进程不如守护进程灵活:修改配置文件并触发重载即可修改守护进程的行为;但对于内核进程来说,修改行为则需要重新编译内核本身。
运行状态
系统在进程启动时会赋予其状态。进程的状态由该进程的状态描述符来描述。设置进程状态,通常对应了一个简单的赋值操作。
1 | p->state = TASK_RUNNING; |
这里,p
代表进程,state
是其状态标识,TASK_RUNNING
表示该进程正在运行或可以执行。
进程通常处于以下两种状态之一:
- 在 CPU 上执行(此时,进程正在运行)
- 不在 CPU 上执行(此时,进程未在运行)
同一时间同一 CPU 上只能运行一个进程,其他进程就只能等待,或处于其他状态。也就是说,未在运行的进程可能处于不同状态:
- 可运行状态
- 可中断之睡眠状态
- 不可中断之睡眠状态
- 僵死状态
接下来,详细说说不同状态。
P 之初(人之初)
按 fork(2)
的手册页(执行 man 2 fork
可打开),fork
这一系统调用创建一个与调用 fork
的进程几乎完全相同的进程。这里,前者称为父进程,后者称为子进程。子进程与父进程几乎完全相同,但有以下一些差别:
- 子进程拥有全局唯一的进程 ID(见
setpgid(2)
的手册页) - 子进程的父进程 ID 是父进程的进程 ID
- 子进程不继承父进程的内存锁(见
mlock(2)
和mlockall(2)
的手册页) - 子进程的资源使用计数及 CPU 时间计数(见
getrusage(2)
和times(2)
的手册页)重置为零 - 子进程未处理的信号队列重置为空(见
sigpending(2)
的手册页) - 子进程不继承父进程的信号量修正(见
semop(2)
的手册页) - 子进程不继承父进程的文件区域锁(record lock / file-region lock,见
fcntl(2)
的手册页) - 子进程不继承父进程的计时器(见
setitimer(2)
,alarm(2)
和timer_create(2)
的手册页) - 子进程不继承父进程未完成的异步输入输出操作(outstanding asynchronous I/O operations)(见
aio_read(3)
和aio_write(3)
的手册页) - 子进程不继承父进程的异步输入输出上下文(asynchronous I/O contexts)(见
io_setup(2)
的手册页)
正在运行状态
系统中最珍贵的资源是 CPU。正使用 CPU 的进程处于「正在运行状态」。在 ps
或是 top
中,状态标识为 R
的进程,即处于正在运行状态。
接下来,我们看看进程是怎么进入「正在运行状态」的。比方说,你在 Shell(以 bash
为例)中执行 ls
命令时,Shell 会在环境变量 PATH
记录的搜索路径里寻找 ls
命令对应的可执行文件。找到后,Shell 使用 fork
克隆自身进程,而后在子进程中,使用 ls
的可执行文件替换虚存空间中 Shell 的内容。
此时,系统会设置子进程的运行状态:
1 | p->state = TASK_RUNNING; |
CPU 即可在内核模式运行,又可在用户模式运行。当用户初始化一个进程,进程在用户空间运行,对应 CPU 在用户模式运行。在用户空间运行的进程无权访问内核数据结构和算法。各型号的 CPU 都会提供一些特定的指令,以便在内核模式和用户模式之间切换。如果一个用户级的进程需要访问内核数据结构或算法,则它需要使用系统调用来与文件子系统或是进程控制子系统之间进行交互。部分系统调用罗列如下:
- 文件子系统对应的系统调用:
open()
,close()
,read()
,write()
,chmod()
以及chown()
- 进程控制子系统对应的系统调用:
fork()
,exec()
,exit()
,wait()
,brk()
以及signal()
当内核开始处理来自用户级进程的请求,相应进程就进入了内核空间,对应 CPU 就在内核模式运行。/proc/<pid>/stat
中的第 14 和 15 项,分别记录了进程在用户空间和内核空间执行的时间。摘录部分 proc(5)
的手册页内容如下。
1 | utime %lu |
top
命令的 CPU 统计行,则展示了 CPU 位于用户模式和内核模式的时间占比。
1 | top - 12:27:25 up 2:51, 4 users, load average: 4.37, 3.64, 3.44 |
可运行状态
进程获取了所有所需资源,正等待 CPU 时,就会进入可运行状态。处于可运行状态的进程在 ps
的输出中,也已 R
标识。
举例来说,一个正在 I/O 的进程并不立即需要 CPU。当进程完成 I/O 操作后,就会触发一个信号,通知 CPU 和调度器将该进程置于运行队列(由内核维护的可运行进程的列表)。当 CPU 可用时,该进程就会进入正在运行状态。
和正在运行状态一样,进程的状态被设置为 TASK_RUNNING
:
1 | p->state = TASK_RUNNING; |
睡眠状态
当进程所需的资源暂不可用时,就会进入睡眠状态。此时,进程要么主动进入睡眠状态,要么被内核置于睡眠状态(不管你想不想睡,反正内核会让你睡;因此,后者又称为「进程被内核睡了」)。进入睡眠状态的进程,会立即交出 CPU 的使用权。
当进程所需的资源可用时,CPU 会收到一个信号。于是,当调度器下次调度该进程时,会将它置为正在运行或可以运行状态。
以 login shell 进程为例,它
- 在你键入命令时进入睡眠状态,同时等待一个特定的事件(取决与你键入执行的命令);
- Shell 进程睡眠时,会进入一个特定的等待通道(
WCHAN
, wait channel,同样取决于你键入执行的命令); - 当 Shell 进程等待的事件发生时(例如,收到一个来自键盘的中断
^C
),在该等待通道的所有进程都会苏醒。
执行 ps -l
可看到与当前 shell 关联的进程,执行 ps -el
则可看到系统上所有进程。如果进程处于睡眠状态,ps
输出中的 WCHAN
字段会显示进程在等待什么系统调用。
1 | ps -l | more |
例如,在这里,我们执行了 ps -l | more
这个命令。输出中,more
和 bash
都处于睡眠状态。前者是在等待管道输入,即 pipe_wait
,因为 ps
输出时,more
还没有接到内容。后者是在 等待 ps -l | more
执行完毕,即等待 do_wait
系统调用。
除了等待资源之外,进程也可以主动进入睡眠状态并持续一段时间。例如,sleep()
函数接收一个时间长度(以秒为单位,比如 10 秒)的参数,然后调用该函数的进程就会进入睡眠状态,并持续 10 秒。当睡眠时间结束后,调度器再次调度到该进程时,会将其设置为可运行状态。之后,当 CPU 空闲时,进程会重新进入正在运行状态。
由此可见,
sleep(10)
并不能保证「恰好」睡眠 10 秒,它只保证睡眠时间不少于 10 秒。
部分进程永远不会终止,而是不断地在睡眠、唤醒干活的状态中循环。每次循环开始时,进程进入睡眠状态,然后等待某个特定的事件。当事件发生时,进程被唤醒(进入正在运行或者可以运行状态),然后处理任务。
睡眠状态也分可中断之睡眠状态和不可中断之睡眠状态。
可中断之睡眠状态
可中断之睡眠状态表示进程在等待时间片段或者某个特定的事件。一旦事件发生,进程会从可中断之睡眠状态中退出。ps
命令的输出中,可中断之睡眠状态标识为 S
。
系统会为可中断之睡眠状态的进程设置进程运行状态为:
1 | p->state = TASK_INTERRUPTABLE; |
不可中断之睡眠状态
不可中断之睡眠状态的进程不会处理任何信号,而仅在其等待的资源可用或超时时退出(前提是设置了超时时间)。
不可中断之睡眠状态通常和设备驱动等待磁盘或网络 I/O 有关。在内核源码 fs/proc/array.c
中,其文字定义为 "D (disk sleep)", /* 2 */
。当进程进入不可中断之睡眠状态时,进程不会处理信号,而是将信号都积累起来,等进程唤醒之后再处理。在 Linux 中,ps
命令使用 D
来标识处于不可中断之睡眠状态的进程。
系统会为不可中断之睡眠状态的进程设置进程运行状态为:
1 | p->state = TASK_UNINTERRUPTABLE; |
由于处于不可中断之睡眠状态的进程不会处理任何信号,所以 kill -9
也杀不掉它。解决此类进程的办法只有两个:
- 对于怨妇,你还能怎么办,只能满足它啊:搞定不可中断之睡眠状态进程所等待的资源,使资源可用。
- 如果满足不了它,那就只能 kill the world——重启系统。
进程的终止和僵尸状态
进程可以主动调用 exit
系统调用来终止,或者接受信号来由信号处理函数来调用 exit
系统调用来终止。
当进程执行 exit
系统调用后,进程会释放相应的数据结构;此时,进程本身已经终止。不过,此时操作系统还没有释放进程表中该进程的槽位(可以形象地理解为,「父进程还没有替子进程收尸」);为解决这个问题,终止前,进程会向父进程发送 SIGCHLD
信号,通知父进程来释放子进程在操作系统进程表中的槽位。这个设计是为了让父进程知道子进程退出时所处的状态。
子进程终止后到父进程释放进程表中子进程所占槽位的过程,子进程进入僵尸状态(zombie state)。如果在父进程因为各种原因,在释放子进程槽位之前就挂掉了,也就是,父进程来不及为子进程收尸。那么,子进程就会一直处于僵尸状态。而考虑到,处于僵尸状态的进程本身已经终止,无法再处理任何信号,所以它就只能是孤魂野鬼,飘在操作系统进程表里,直到系统重启。
在 ps
命令的输出中,僵尸状态的进程标识为 Z
。系统会为僵尸状态的进程设置进程运行状态为:
1 | p->state = TASK_ZOMBIE; |