本篇文章主要讲解 Linux 中的进程状态
目录
[1 操作系统学科中的进程状态](#1 操作系统学科中的进程状态)
[2 Linux 操作系统中的进程状态](#2 Linux 操作系统中的进程状态)
[2.1 R --- 运行状态](#2.1 R — 运行状态)
[2.1.1 如何在 Linux 中理解 R 状态](#2.1.1 如何在 Linux 中理解 R 状态)
[2.1.2 查看进程状态](#2.1.2 查看进程状态)
[2.2 S/D --- 睡眠状态](#2.2 S/D — 睡眠状态)
[2.2.1 如何理解阻塞状态](#2.2.1 如何理解阻塞状态)
[2.2.2 如何理解 Linux 中的 S/D 状态](#2.2.2 如何理解 Linux 中的 S/D 状态)
[2.2.3 为什么会有 D 状态](#2.2.3 为什么会有 D 状态)
[2.3 T --- 停止状态](#2.3 T — 停止状态)
[2.3.1 如何理解 T(stopped) 状态](#2.3.1 如何理解 T(stopped) 状态)
[2.3.2 为什么存在 T (stopped) 状态](#2.3.2 为什么存在 T (stopped) 状态)
[2.3.3 如何理解T (tracing stop) 状态](#2.3.3 如何理解T (tracing stop) 状态)
[2.4 如何理解挂起状态](#2.4 如何理解挂起状态)
[2.5 Z --- 僵尸状态](#2.5 Z — 僵尸状态)
[2.5.1 为什么会存在 Z 状态](#2.5.1 为什么会存在 Z 状态)
[2.5.2 僵尸进程的危害](#2.5.2 僵尸进程的危害)
[2.6 X --- 死亡状态](#2.6 X — 死亡状态)
[3 孤儿进程](#3 孤儿进程)
[4 总结](#4 总结)
1 操作系统学科中的进程状态
进程状态是一个十分重要的概念,进程所处的状态决定了进程接下来所进行的任务与工作是什么。在计算机操作系统这门学科里面,讲解了进程在从创建到消亡时会包含哪些状态:

总共会包含七大状态,包括创建状态、就绪状态、运行状态、阻塞状态、就绪挂起状态、阻塞挂起状态以及最后的终止状态。
创建状态:进程刚刚被创建时的状态,处于创建状态的进程并没有各种资源,此时的进程是不能够被调度的,需要操作系统为该进程分配各种资源以支持进程的调度与运行,例如描述进程的结构体 PCB。当操作系统为进程分配了各种所需资源之后,进程就会由创建态进入就绪状态。
就绪状态:处于该状态的进程,操作系统已经将进程执行所需的各种资源分配好了,正在等待操作系统的调度。一旦操作系统调度了处于就绪状态的进程,该进程就会由就绪状态变为运行状态。
运行状态:显然,处于该状态的进程正在运行。一旦该进程的时间片用完,就会由运行状态再变回就绪状态。而处于运行状态的进程也会由于等待某个硬件或者某种独立资源而由进程状态变为阻塞状态。
阻塞状态:处于该状态的进程正在等待某个独立资源或者是某种事件的发生,一旦该进程等待成功,进程就会由阻塞状态重新回到就绪状态。
终止状态:显然,处于当前状态的进程已经结束运行,终止了。进入当前状态可能是进程正常执行完,也有可能是进程发生了某种错误,比如野指针访问,除 0 等错误而导致进程异常退出。不管是上面那种情况,进程都是由运行态进入了终止态。
就绪挂起状态:处于当前状态的进程具备运行的条件,但是目前是处于外存中,需要由操作系统激活,才会由就绪挂起状态转变为就绪态,也就是由外存进入内存。
阻塞挂起状态:与就绪挂起状态不同,在进入阻塞挂起状态之前,进程是处于阻塞态的,但是由于某种条件(后面会讲解是什么条件),进程由内存变到了外存,之后由操作系统激活后,进程由阻塞挂起才会重新变为阻塞态。
上面讲解了操作系统学科中对于各种进程状态的定义,但是上述进程状态比较抽象,也很难理解。接下来我们看一下具体的操作系统 --- Linux 操作系统是如何定义进程状态的。
2 Linux 操作系统中的进程状态
我们先来看一下 Linux 内核中具体有哪些进程状态:

该进程状态数组位于 Linux 源码fs/proc/array.c文件中。
可以看到操作系统对于进程状态的描述也分为 7 个,分别是 R、S、D、T(stopped)、T(tracing stop)、Z、X 状态,而在每个状态的后面标明了进程状态所对应的数字是几,比如 R 运行状态是 0。所以本质上进程状态就是一个数字罢了。
在进程的 task_struct 中也会存在一个成员 state 来表示当前进程的状态:

可以看到state 是一个 long int 类型,当 state 为 0 时,说明当前进程处于 R 状态,当 state 为 1 时,当前进程为 S 状态。所以切换进程在本质上就是更改 task_struct 中的 state 数字,更改了 state 就是更改了进程状态。
接下来我们就来仔细讲解每个进程状态。
2.1 R --- 运行状态
2.1.1 如何在 Linux 中理解 R 状态
对于 Linux 中的进程来说,如果状态是 R,那就代表其是处于运行状态。在操作系统学科里面,运行状态是指一个进程正在处于运行过程之中,就绪状态是指正在等待被调度运行的进程。但是对于 Linux 操作系统中的进程来说,并没有就绪和运行状态之分,无论是正在运行还是等待运行的进程都是处于 R 状态。
我们都知道进程是需要被调度的,也就是进程需要排队来挨个等待被调度运行,那么怎么刻画进程的调度呢?之前我们讲解过,管理一个进程主要是管理描述其信息的结构体,也就是 task_struct,调度进程,其实就是调度进程的 task_struct。所以进程在排队,其实就是 task_struct 在排队,那么我们就可以用一个队列数据结构 task_struct* runqueue 来让进程进行排队,这个 runqueue 就是进程的运行队列,处于队头的 task_struct 就是下一个即将被调度运行的进程(在 CPU 中会有一个 task_struct* current 指针指向正在被调度运行的进程)。
**进程处于 R 状态,其实就是进程的 task_struct 处于 runqueue 调度队列中。**而在 Linux 系统中,并没有所谓的就绪与运行状态之分,只要处在 runqueue 调度队列中,就是运行状态。如果非要区分就绪与运行状态,那就是处于 runqueue 队列中就是就绪状态,正在执行的进程是运行状态。总结一句话就是,在 Linux 系统中,没有就绪状态,只有运行状态。当进程处在 runqueue 等待调度队列或者正在被执行的进程,其状态就是 R 状态。

2.1.2 查看进程状态
我们可以通过 ps 命令来查看进程状态:
bash
ps axj | head -1 && ps axj | grep 进程名字
通过以上那个命令我们就可以查看运行进程的状态了。
makefile:

status.c:

然后我们要查看 ./status 进程运行状态,我们可以新起一个 ssh 渠道来查看:

然后选择复制 ssh 渠道就可以了。

通过 ps axj 命令我们就查看到了 ./status 进程的状态为 R+。
我们确实看到了进程是 R 状态,但是为什么进程的状态会带个 + 呢?其实是一个进程状态带一个 +,那就说明该进程是前台进程,不带 + 那就是后台进程。就是比如在 windows 中,你打开音乐软件在放音乐,然后你打开了浏览器软件,那么此时的浏览器软件就是前台进程,音乐软件就是后台进程。但是虽然音乐软件是后台进程,但是它依旧是一个进程,仍然在放音乐,仍然在运行。只不过不会响应你键盘的输入,只有浏览器这个前台进程才会响应你键盘的输入。
通过上述例子我们就验证了进程的 R 状态。
2.2 S/D --- 睡眠状态
2.2.1 如何理解阻塞状态
一般进程在等待某个设备时,其就会进入阻塞状态。比如 scanf 函数,一旦遇到该函数,进程就会在 scanf 函数这里阻塞住,等待键盘这个硬件就绪。所以说,进程的阻塞状态实际上就是进程在等待某个硬件。这与运行状态其实类似,进程处于运行状态时,在 runqueue 中等待时,其实就是在等待 CPU 这个硬件。所以阻塞状态的本质就是进程的描述结构体 task_struct 位于某个硬件,比如键盘、显示器的等待队列中。而进程由运行状态变为阻塞状态本质也就是 task_struct 从进程的调度队列 runqueue 中链入到设备的等待队列中,将进程的状态由 R 修改为 S/D ;等设备等待完成之后,再将 task_struct 从设备等待队列链入到 runqueue 中,再将进程状态由 S/D 修改为 R,这样就完成了进程运行和阻塞状态之间的切换。
下面的这张图就展示了进程由 R 状态变为 S/D 状态:

但是在实际 Linux 系统中,并不是会将整个 task_struct 结构体链入到设备的等待队列。在 Linux 2.6.18 的内核中,会为当前 task_struct 创建一个 __wait_queue 的结构体,将这个结构体插入到设备的等待队列中:

可以看到,在这个结构体中包含了一个 private 指针,这个指针就回指了当前阻塞进程的 task_struct,将该结构体链入到等待队列中之后,再将进程状态改为 S/D 就可以了。
2.2.2 如何理解 Linux 中的 S/D 状态
在 Linux 中,S/D 两个状态对应着操作系统学科中的阻塞状态。
S 状态的全称是 interruptible sleep,叫做可中断睡眠 ,也叫做浅度睡眠 ;D 状态的全称是 uninterruptible sleep 或者 disk sleep,叫做不可中断睡眠 ,也叫做深度睡眠。他俩的区别从名字就可以看出来,一个在阻塞时是可以被打断的,另一个是在阻塞时是不可以被打断的。
S 状态是浅度睡眠状态,通常是在等待普通硬件设备时,如键盘、显示器等设备时,或者进程通过 sleep 进入睡眠时,进程就会进入 S 状态。
status.c:


此时进程是 S 状态,等待的设备为键盘,所以此时 ./status 进程的 task_struct 是处于键盘的等待队列中。但是你依然可以通过 ctrl + c 来终止进程:

D 状态是深度睡眠状态,一般是与磁盘进行 I/O 操作时进程才会变为 D 状态,此时的进程是不可被中断的。也就是一旦进程进入磁盘 I/O,要么 I/O 结束,要么磁盘损坏,否则进程是不会被打断的,即使是以后学习的 9 号信号也是无法杀死进程的(9 号信号不同于其他信号,是不可捕捉、不可忽略、不可屏蔽的,有时被简称为必杀信号,因为其几乎可以杀死除 D 状态外所有进程),可以通过 kill -l 命令来查看所有信号:

我们可以使用 dd 命令来查看进程进入 D 状态:
bash
sudo dd if=/dev/vda of=/dev/null bs=1M
然后使用下面命令来查看 dd 进程状态:
bash
ps axj | grep "dd if=/dev"

/dev/vda 为 centos 版本的 Linux 系统下的磁盘文件,如果是 ubuntu 版本的,磁盘文件一般是/dev/sda,所以上面的 dd 命令就是从 /dev/vda。上面那个命令的作用就是从 /dev/vda 文件下复制 1MB 的数据写入 /dev/null 文件下,也就是丢弃了这些数据。所以 dd 命令会访问磁盘,所以会进入 D 状态。
2.2.3 为什么会有 D 状态
那么我们可能会问,为什么阻塞状态除了 S 状态,还要有 D 状态呢?只有一个 S 状态不就可以了吗?
这两种状态的区别一种就是 S 状态可以响应外部信号,但是 D 状态是不响应外部信号的,也就是变为 D 状态的进程是无法被杀死的。设计这一状态的原因就是为了保证进程在进行 I/O 操作时是不可以被打断的,用来保护磁盘数据的完整性与一致性。因为磁盘写入数据的时间比较长,如果在磁盘写入数据期间进程被杀死了,那么磁盘写入数据的结果就无法交给进程,那么磁盘的行为就是不确定的,可能会将这部分数据丢弃掉,当这部分数据是十分重要的数据时,就会造成无可避免的损失。所以必须保证 I/O 操作必须是不可被中断的,所以才有了 D 状态。
2.3 T --- 停止状态
T 停止状态分为两种,一个是 stopped,另一种是 tracing stop 状态,虽然都是停止,但是区别还是很大的。
2.3.1 如何理解 T(stopped) 状态
上面我们提到过,可以通过给进程发送 9 号信号来杀死进程。如果我们想让进程进入 stooped 状态,我们就可以通过给进程发送 19 号来暂停进程:

发送信号命令:
bash
kill -19 [进程 pid]
暂停进程之后我们可以通过 18 号信号来使进程继续运行。
status.c:


可以看到在向进程发送了 19 号信号之后,进程就变成了 T 状态,再向进程发送 18 号信号,进程就由 T 状态变为了 R 状态,但是此时进程已经是后台进程了,已经不响应键盘了,所以我们必须通过发送信号来杀死这个进程,比如发送 9 号信号:

总结一下,T (stopped) 状态就是进程暂停运行时会进入的状态。当进程再次运行起来时,会自动变为后台进程,不会是前台进程。
2.3.2 为什么存在 T (stopped) 状态
T 状态与 X 状态的不同就在于 T 状态的进程只会暂停,不会终止。假设当前存在一个任务非常耗时、占用资源特别多的主进程,还存在一些任务耗时比较短的小进程,这些小进程虽然耗时很短,但是一旦发生,就会比那个主进程优先级更高。当主进程在运行时,突然一个小进程要运行,但是主进程的运行非常影响小进程的运行,但是又不能杀掉这个主进程,此时就可以先让这个主进程变为 T 状态,然后让小进程运行结束,再启动主进程。
所以,T 状态的存在主要是进行一些进程管理与作业控制的。
2.3.3 如何理解T (tracing stop) 状态
另一个 T 状态叫做 tracing stop 状态,顾名思义,这个状态是进程被追踪时才会进入的一种暂停状态,也就是进程在被调试时会进入的一种状态。
我们在 makefile 中 gcc 后面添加一个 -g 选项,使得当前可执行程序可以被调试。


我们首先使用 gdb 调试,此时 gdb 会变为一个进程:

然后我们为进程添加一个断点,再使用 r 来运行,可以看到多了一个 /home/ltl/Test/status 进程:

当前这个进程就是 t,也就是 tracing stop 状态。
所以从上面那个例子就可以看出来,我们在进行 gdb 调试时,如果按下了 r,是 gdb 创建了一个子进程来帮助我们执行我们的代码,并且如果我们打了断点,那么创建的这个子进程就会进入 t 状态。
所以 T (tracing stop) 状态的存在就是为了进行调试的。被调试的进程由于打了断点而暂停运行时就会进入 T (tracing stop) 状态。
2.4 如何理解挂起状态
在操作系统学科中,还有两个比较特殊的状态,那就是阻塞挂起与就绪挂起状态。在 Linux 中,并没有与之对应的状态,是因为进程是否处于挂起状态,完全是由操作系统决定的,并不是由程序员决定的。
操作系统充当着管理者的角色,不仅要对进程进行管理,还要管理内存、文件等。所以在管理过程中会消耗大量的资源,其中就包括内存资源。之前我们说过,进程 = 内核数据结构 + 自己的代码和数据 ,所以一个进程运行起来,内存中不仅会存在描述一个进程的 task_struct,还有进程的代码和数据。但是对于处于阻塞状态和就绪状态的进程,由于没有在运行,所以其代码和数据在内存中就相当于无效数据,也就是白占着内存资源。所以当操作系统检测到内存资源严重不足时,就会选择将处于阻塞状态进程的代码和数据先放到磁盘的 swap 分区(就是一块磁盘上的空间),如果内存资源还是严重不足,那就会将处于就绪状态进程的代码和数据放到磁盘的 swap 分区,以此来调整内存资源。像这样的,代码和数据被从内存交换到 swap 分区的进程此时所处的状态就是挂起状态。
之前我们说过,阻塞状态就是进程的 task_struct 由运行队列链入到设备的等待队列。所以阻塞挂起状态就是进程的 task_struct 处于设备的等待队列中,但是自己的代码和数据并不在内存,而是处于磁盘的 swap 分区中;就绪挂起状态就是进程的 task_struct 处于运行队列中,但是代码和数据处于磁盘的 swap 分区中。

总结一下,Linux 中并没有真正的描述挂起状态的标志,进程是否会处于挂起状态完全是由操作系统决定的。当内存资源不足时,操作系统会将进程的代码和数据放到磁盘的 swap 分区,进程就会变为挂起状态。当进程的 task_struct 位于设备的等待队列时,就叫做阻塞挂起状态;task_struct 位于运行队列 runqueue 时,就叫做就绪挂起状态。
2.5 Z --- 僵尸状态
2.5.1 为什么会存在 Z 状态
进程创建出来就是为了完成某种任务的,可能是进行计算任务,也可能是进行 I/O 任务,那么当前进程将任务完成的怎么样是通过其退出信息,也就是退出码来判断的,我们可以通过一个 C 程序来看一下退出码有哪些,顺便看一下每个退出码的退出信息是什么:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
int i = 0;
for (;i < 135; i++)
printf("%d:%s\n", i, strerror(i));
return 0;
}

这里只截取了一部分,有 0-133,一共 134 个退出码。在退出码中我们看到 0 号是表示成功的意思,所以我们一般都喜欢在 main 函数中 return 0 来表示程序运行成功。
所以知道一个进程的退出码十分重要,那么回收进程的退出码工作是由谁做的呢?当然是当前进程的父进程来做的,因为在 Linux 中运行一切程序都是通过创建子进程的方式来完成的。但是当前进程可能会比父进程先退出,如果子进程退出之后立即释放掉其 task_struct,父进程就不能回收其退出码了,所以就会存在一种状态使得进程退出后不销毁 task_struct(但是代码和数据就会释放了),等到父进程回收完退出码之后再退出。这个状态就是僵尸状态。
Z 状态全称为 zombie,也就是僵尸状态。当一个进程运行结束了,但是其父进程还没有读取其退出码,那么当前这个进程就会处于僵尸状态,等待其父进程来回收它:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
//fork 创建子进程
pid_t id = fork();
if (id > 0)
{
perror("fork");
return 0;
}
else if (id == 0)
{
//子进程
printf("我是子进程,pid: %d\n", getpid());
//子进程休眠 5 秒钟,直接退出
sleep(5);
printf("子进程退出了\n");
exit(0);
}
//父进程
printf("parent[%d] is sleeping...\n", getpid());
sleep(30);
return 0;
}

可以看到刚开始两个进程都在休眠,处于 S 状态;但是过了一段时间,子进程退出以后,因为父进程还在休眠,还没有回收子进程的退出码,所以子进程变为了 Z 状态等待父进程回收退出码。
2.5.2 僵尸进程的危害
上面说过回收一个进程的 task_struct 十分重要,因为这是判断任务有没有完成以及完成的怎么样的评判标准。而退出码也属于当前进程的信息,所以就需要使用 task_struct 来描述起来。在 task_struct 中存在一个 exit_code 字段来保存其退出信息:

所以如果父进程不回收子进程的退出码,那么子进程就会一直处于 Z 状态,那么子进程的 task_struct 就会一直存在内存中,不会释放,这样就造成了内存泄露情况。
后面在进程控制中我们会通过 wait 或者 waitpid 系统调用使父进程等待子进程,以防止僵尸进程导致的内存泄露情况。
2.6 X --- 死亡状态
死亡状态比较简单,父进程将子进程的退出信息回收之后,进程就会由 Z 状态变为 X 状态,操作系统此时就会回收子进程,释放其资源,包括 task_struct。所以 X 状态一般不会再任务列表中看到,只是一瞬间的事情。
3 孤儿进程
子进程如果先退出,父进程没有退出,子进程是会进入 Z 状态的。那如果子进程没有退出,父进程反而先退出了呢?
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 0;
}
else if (id == 0)
{
printf("我是子进程,pid: %d\n", getpid());
sleep(10);
}
else
{
printf("parent[%d] is sleeping...\n", getpid());
sleep(3);
printf("父进程退出了\n");
}
return 0;
}

可以看到当父进程退出之后,原来子进程的 ppid 也就是其父进程的 pid 变成了 1,说明当父进程退出之后,子进程会被 1 号进程领养。像这样的,父进程先退出的子进程,称为孤儿进程,所有的孤儿进程都会被 1 号进程领养。那么 1 号进程是谁呢?

在当前的 Linux 版本内核中,1 号进程称为 systemd 进程,再早一点的内核中,1 号进程是 init 进程。在当前,我们可以直接将 1 号进程理解为操作系统本身。
总结一下,父进程先退出的子进程称为孤儿进程,孤儿进程会被 1 号进程 init/systemd 进程领养。之后 1 号进程会承担子进程的回收工作。
4 总结
在本篇文章中,我们了解了操作系统学科中的七大状态,包括创建、就绪、运行、阻塞、终止、阻塞挂起与就绪挂起状态,但是比较冲向。之后,我们通过 Linux 中进程的具体状态 R、S、D、T(stopped)、T(tracing stop)、X、Z 状态来将进程状态具体化,比如处于阻塞状态的进程本质上就是进程的 PCB 位于设备的等待队列中。还讲解了孤儿进程是什么。总之,进程状态是进程中比较重要的一个模块,进程的状态决定了进程接下来的动作是什么。