前言:上一篇介绍进程,这篇接着看看进程的状态、优先级与切换。
1.进程的状态
1.1运行、就绪与等待
我们平时说"这个程序正在运行",一般是在说它已经启动,并且还没有结束。但从操作系统的角度看,一个还没有结束的进程,并不一定正在使用CPU。
比如程序需要从键盘读入一个数字,但我们还没有输入。这个时候即使一直把CPU交给它,它也没办法完成后面的计算,因为它缺的不是CPU,而是输入的数据。
所以先把下面两种"等待"区分开:
- 就绪:执行所需的条件已经具备,只是在等CPU。
- 阻塞:还在等某个事件或资源,例如输入数据、I/O完成,此时暂时不具备继续执行的条件。
真正得到CPU、正在执行指令,才是通常状态模型中的运行。就绪和运行之间可以相互转换;运行中的进程如果需要等待某个事件,就可能进入阻塞状态,事件满足后再回到就绪状态。
这里需要注意的是,被唤醒不等于马上执行。被唤醒只是重新具备了参与调度的条件,什么时候得到CPU,还需要操作系统安排,这点会在下面介绍调度队列时会有解释。

上面是帮助理解的简化关系图。如果落实到Linux中,我们经常会在ps的输出里看到这些状态字母,下面是我收集到的一些进程状态:
| 状态 | 含义 | 理解时需要注意的地方 |
|---|---|---|
| R | running / runnable | 正在运行,或者已经就绪、在运行队列中等待CPU |
| S | interruptible sleep | 可中断睡眠,正在等待事件,可以被合适的信号唤醒 |
| D | uninterruptible sleep | 不可中断等待,常见于I/O相关等待 |
| T | stopped | 被停止信号暂停执行 |
| t | tracing stop | 在被调试器跟踪时停止 |
| Z | zombie | 已经退出,但退出信息还没有被回收 |
| X | dead | 死亡状态,通常不会在进程列表中观察到 |
Linux中的R把"就绪"和"正在运行"都包含进去了。 因此看到多个进程都是R
S也不只来自sleep。等待输入、等待某些事件时同样可能进入S;
D虽然经常被叫作"磁盘睡眠",对于严格的不可中断等待,即使发送SIGKILL,也可能要等等待条件结束以后才能真正退出哦。
T的话则是暂停,不是执行完毕。例如可以在另一个终端,对自己已经确认可以操作的测试进程执行:
bash
kill -STOP PID
kill -CONT PID
这里的PID为实际进程编号,两条命令分别执行、分别观察。前一条使进程暂停,后一条使它能够继续运行,不过不要对不认识的系统进程操作,不然容易翻车。
查看时仍然使用我们熟悉的命令:
bash
ps axj
ps aux
如果看到S+、Z+,前面的S、Z才是基本状态。不同发行版之间可能会有所变化,所以还是要参考官方文档为主具体字母可以对照ps手册的状态说明。
1.2状态变化
上一篇我们提到过,操作系统管理进程的基本思路是"先描述,再组织"。
进程状态同样不是一个贴在程序外面的标签,而是由内核中的状态字段等信息来描述,再配合相关队列组织任务。
可以把运行队列理解成"已经具备执行条件的可运行任务集合",其中既包括正在运行的任务,也包括就绪等待CPU的任务;
这样,状态变化就可以和数据结构联系起来:
- 进程需要等待事件时,内核更新相关状态,使它暂时不再作为可运行任务参与调度,并建立相应的等待关系。
- 事件发生以后,内核找到相关等待者,将满足条件的任务唤醒。
- 任务重新具备运行条件,重新进入后续的调度过程。
内核并不只维护一条链表,也不是把整个PCB在内存中搬来搬去。很多时候,改变的是结构中的状态值以及不同链表、队列之间的连接关系。
Linux中常见的链表组织方式也很有意思:把链表连接成员嵌在被管理对象里面,用其中的前后指针把对象串起来。一个对象还可以有多个连接成员,分别参与不同用途的组织关系,并不是每条链表都重新复制一份完整对象。
1.3僵尸进程
假设父进程创建子进程,是为了让子进程完成一项任务。那么子进程结束以后,父进程是不是还需要知道:任务完成得怎么样,是正常退出,还是出现了问题?
因此,子进程在退出时要留下退出信息方便父进程知道任务是否完成,完成得怎么样
在默认退出处理方式下,子进程已经退出,而父进程还没有通过wait或waitpid等接口读取并回收相关信息,子进程就会处于Z状态,也就是僵尸进程。
先来看一个例子。将父进程的休眠时间设为30秒,子进程休眠5秒后退出,给我们留出观察的时间:
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id > 0)
{
printf("parent[%ld] is sleeping...\n", (long)getpid());
sleep(30);
}
else
{
printf("child[%ld] will exit after 5 seconds...\n", (long)getpid());
sleep(5);
exit(EXIT_SUCCESS);
}
return 0;
}
保存为Myprocess.c,在Linux下编译运行:
bash
gcc Myprocess.c -o Myprocess
./Myprocess
然后在另一个终端查看:
bash
ps axj | grep Myprocess
按这段程序的执行流程,最初父子进程都还活着。大约5秒后,子进程退出,而父进程还在休眠,也没有调用回收接口,于是出现Z状态。
下面这次观察中,就能看到对应的现象:

那僵尸进程有什么影响呢?
一个僵尸进程不会继续消耗CPU执行代码,它原来的主要用户空间资源也已经释放。但用于保存退出信息的内核记录和进程表位置仍然需要保留。如果父进程不断创建子进程,又一直不回收,就可能积累大量这样的记录,最终影响新进程的创建。
同样,kill -9也不是清理僵尸进程的方法,因为它已经退出了,再发送结束信号并不能替父进程读取退出信息。应当由父进程或后续接管者完成回收。
1.4孤儿进程
刚才是子进程先退出。如果反过来,父进程已经结束,子进程还没有结束,这个子进程通常就称为孤儿进程。
先把它和僵尸区分开:
| 对比 | 僵尸进程 | 孤儿进程 |
|---|---|---|
| 重点 | 子进程退出信息尚未回收 | 原父进程先退出 |
| 子进程是否已经退出 | 已经退出 | 可以仍在正常执行或等待 |
| 是否是一个独立的状态字母 | 对应Z | 不是一种额外的状态,仍可处于R、S等状态 |
所以孤儿进程不等于坏掉的进程,也不等于僵尸进程。它只是原来的父进程不在了,需要由其他进程接管后续的回收责任。
下面这个例子把顺序反过来,子进程休眠10秒,父进程休眠3秒后退出:
c
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
printf("I am child, pid: %d\n", getpid());
sleep(10);
}
else
{
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}
正常执行时,大约3秒以后父进程结束,而子进程还有一段时间才结束。这个时间段内继续用ps axj查看子进程,可以观察它的PPID是否已经变成了接管者的编号。睡眠时间用于留出观察窗口,不是精确的同步保证。
在很多系统上,接管者是1号进程。例如我这台系统,PID为1的进程是systemd:

2.进程的优先级
2.1PRI和NI
系统里可以同时存在很多进程,但能够同时执行任务的CPU资源肯定是有限的。当多个任务都已经就绪时,操作系统就需要决定怎样分配CPU。
优先级就是影响这种分配的因素之一。优先级更高,CUPh会优先为这个进程分配资源
我们可以通过长格式查看相关信息:
bash
ps -l
如果需要扩大显示范围,也可以使用ps -al。在输出中先认识这几列:
| 字段 | 含义 |
|---|---|
| UID | 进程对应的用户身份编号 |
| PID | 进程编号 |
| PPID | 父进程编号 |
| PRI | 当前显示格式下的优先级数值 |
| NI | nice值,用来影响普通任务的调度待遇 |

用户名是便于人阅读的表示,内核中的身份判断需要使用对应的编号。
再来看PRI和NI。对于这里讨论的普通任务,nice值的范围是**-20到19,一共40个取值**:
- nice值越小,通常越有利于竞争CPU。
- nice值越大,通常越愿意把CPU让给其他竞争者。
- 常见的一般初始nice值是0
是否更快拿到CPU,还和其他任务、调度策略以及资源限制等有关。
2.2查看和调整nice
最直观的方式是使用top:
- 输入
top,找到需要操作的测试进程。 - 按
r,输入它的PID。 - 输入准备设置的nice值,随后观察
NI。 - 看完按
q退出。
普通用户通常可以把自己进程的nice值调大。
另外两个相关命令是:
nice:以调整后的nice启动程序。renice:调整已经存在的进程。
但这个东西一般还是不要动,至少对于我这样的小白是这样
3.并发、并行与进程切换
3.1多个进程推进
前面提到进程要竞争CPU,这就是进程的竞争性 。不过它们又有各自的运行数据和管理信息,普通私有数据的修改不会随意影响另一个进程,这体现了进程的独立性。
接下来再区分两个经常一起出现的词:
- 并发:多个任务在一段时间内都得到推进。
- 并行:多个任务在同一时刻真正同时执行。
下面这个咖啡机的例子就比较直观:

上面两队人共用一台咖啡机,如果两队交替得到服务,一段时间里两队都在向前推进,但同一时刻只有一台机器在处理一份工作,可以类比单个CPU上的并发。
下面两队分别使用两台咖啡机,两份工作就可以在同一时刻进行,可以类比多个CPU执行资源上的并行。
3.2进程切换
如果只有一个CPU执行资源,却要让多个进程轮流运行,就会遇到一个问题:进程A的计算做到一半,换成进程B以后,再回到A时怎么知道应该接着算哪里?
比如A刚完成一部分计算,寄存器里还保存着中间结果。如果直接让B把这些寄存器覆盖掉,再回来时A就无法沿着原来的执行过程继续了。
因此,切换之前必须保存A恢复执行所需的信息,切换到B时再恢复B对应的信息。这些用于恢复执行的现场,就叫作上下文。
其中会涉及指令执行位置、栈指针以及需要保存的寄存器等信息。
可以按三个动作来理解:
- 保存A的现场:记住A执行到哪里,以及恢复它所需的状态。
- 选择下一个任务:调度器决定接下来由谁使用CPU。
- 恢复B的现场:让CPU按照B之前的执行位置和状态继续工作。
CPU中的寄存器在某一时刻保存的是当前任务的执行状态,而内存中可以保存多个任务各自的上下文。所以我们看到的是"程序轮流执行",并不是"所有进程共用同一份执行进度"。
时间片可以理解成某些调度策略允许任务在一轮中使用的CPU时间额度。用完以后,调度器可能换另一个任务运行;但是CUP的执行速度太快了,时间片一般也会很短,我们平常用电脑时几乎感受不会到进程切换
4.Linux的O(1)调度队列
4.1运行队列与优先级数组
前面知道了进程状态和优先级,现在还有一个问题:已经就绪的进程很多,操作系统怎样快速找到下一个合适的任务?
这里通过早期Linux 2.6的O(1)调度器来理解一种具体的实现方式,后期肯定会变化的但我这里只是为了学习目的。
这个设计中,每个CPU有自己的运行队列runqueue。如果系统有多个CPU,就不能只看一条队列,还需要考虑不同CPU之间的负载分配。
运行队列里有两套优先级数组,分别承担两种角色:
- active:活动数组,组织当前可以参与这一轮调度的任务。
- expired :过期数组,通常组织已经用完这一轮时间额度、暂时等待后续轮次的任务。每套数组内部不是一条大链表,是按优先级分组的多条链表。早期实现里有140个内部优先级位置,下标从0到139,数值越小表示内部优先级越高。
其中0到99用于实时任务的内部优先级,100到139用于普通任务。
同一个优先级下的任务放在同一条链表里。调度时先确定哪个优先级非空,再从对应链表取任务,而不是每次都遍历系统里所有进程做一次全面比较。
4.2bitmap快速找到任务
先来看优先级数组的结构:
c
struct prio_array {
unsigned int nr_active;
DECLARE_BITMAP(bitmap, MAX_PRIO+1); /* include 1 bit for delimiter */
struct list_head queue[MAX_PRIO];
};
这是内核中的结构片段。
queue是链表头数组。MAX_PRIO为140时,就对应140个优先级位置;每个位置连接这个优先级下的任务。
那么bitmap有什么用呢?
假设140条队列里只有第120条和第139条有任务。我们当然可以从下标0开始一条一条看,但实际上只需要知道哪些位置非空:
- 对应位为1,表示这个优先级的队列有任务。
- 对应位为0,表示这个优先级的队列为空。
插入任务使一个空队列变成非空时,要设置相应的位;最后一个任务离开队列时,也要清除对应的位。链表和位图要配合维护。
这样就可以极大的降低查找的成本
4.3 活动数组与过期数组
如果总从active中取任务,那么这些任务用完自己的时间额度以后怎么办?
先沿着普通任务的简化主线来看:
- 从active里选出合适的任务运行。
- 该任务时间片耗尽时,重新计算相关优先级并补充下一轮时间片,然后通常放到expired中,等待后续轮次。
- 当active已经没有任务时,交换active和expired两个指针,让原来的过期数组成为新的活动数组。
这里尤其要注意:是交换指针,不是把两套数组中所有任务逐个复制一遍。

这也解释了为什么保存两个指针很方便。active这个名字描述的是当前角色,并不是永远只能指向数组A;expired同理。
当然真实环境下实现会更加复制还会考虑交互性和饥饿问题。例如某些交互任务在时间片耗尽后可能重新进入active,同时又要防止expired中的任务长期得不到机会;实时任务也有自己的规则。因此上图表达的是理解结构的主线,不是全部分支。相应结构与处理可以在早期Linux 2.6的调度源码中对照查看。
这样再回过头来看,"先描述,再组织"就更具体了:状态说明任务现在能不能参与执行,优先级影响资源分配,队列把任务组织起来,而上下文切换让选中的任务能够沿着自己的执行进度继续运行。
完