Linux 进程优先级、切换与调度:从孤儿进程到 O(1) 调度模型

一句话导读:这篇把进程状态之后最容易混在一起的几个问题串起来:父进程先退出会怎样、进程为什么需要优先级、nice 到底修正了什么、CPU 如何切走一个进程,以及经典 O(1) 调度模型如何用队列、位图和双队列解决"选谁运行"的问题。

写在前面

前面理解进程状态时,有一个很关键的结论:进程到底是运行、阻塞还是等待,本质上不是看它"心情如何",而是看它的 task_struct 被组织到了哪个数据结构里。

可运行进程在运行队列里;等待键盘、磁盘、网络等资源的进程,会被挂到对应资源的等待队列里。Linux 管理对象的思路可以概括为:先描述,再组织。进程由 task_struct 描述,再通过链表、队列、哈希表等结构组织起来。

这篇就在这个基础上继续往前走:既然 CPU 面前有很多可运行进程,那它先运行谁?运行到一半又怎么停下来?停下来以后,下一次如何接着原来的位置继续?

目录

  1. 孤儿进程:父进程先退出,子进程怎么办
  2. 为什么孤儿进程必须被领养
  3. 优先级 priority:资源稀缺时的先后顺序
  4. Linux 普通进程的 PRINI 与 nice 值
  5. 调整优先级:topnicerenice 与系统调用
  6. 多进程的四个基本属性:竞争、独立、并行、并发
  7. 时间片 time slice:死循环为什么不会天然卡死系统
  8. 进程切换 context switch:寄存器内容的保存与恢复
  9. 调度 scheduling 和切换 switching 的区别
  10. 经典 O(1) 调度模型:runqueue、140 个队列与 bitmap
  11. active / expired 双队列:为什么能缓解饥饿
  12. 新进程、抢占 preemption 与多 CPU 负载均衡
  13. 常见实习/面试问题小结
  14. 总结

1. 孤儿进程:父进程先退出,子进程怎么办

僵尸进程 (zombie process) 讨论的是:子进程先退出,父进程还活着,但父进程不回收子进程退出信息,于是子进程的 PCB 还要保留一部分退出状态。

孤儿进程 (orphan process) 正好从另一个方向理解父子关系:父进程先退出,子进程还在运行。这个时候子进程不会变成"没有父进程的进程",它会被 1 号进程领养。被领养后的子进程,就叫孤儿进程。

先写一个最小实验:父进程运行 5 秒后退出,子进程一直运行,并持续打印自己的 PIDPPID

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 1;
    }

    if (id == 0) {
        while (1) {
            printf("child: pid=%d, ppid=%d\n", getpid(), getppid());
            sleep(1);
        }
    } else {
        int cnt = 5;
        while (cnt--) {
            printf("parent: pid=%d, child=%d\n", getpid(), id);
            sleep(1);
        }
    }

    return 0;
}

这段代码里,fork() 返回两次:

  • 在子进程中返回 0,所以进入 if (id == 0) 分支。
  • 在父进程中返回子进程的 pid,所以进入 else 分支。
  • 如果返回负数,表示创建失败,需要用 perror() 输出错误原因。

子进程里用 getpid() 查看自己的进程 ID,用 getppid() 查看父进程 ID。父进程只循环 5 次,之后 main() 返回,进程退出;子进程是死循环,所以会继续活着。

对应的 Makefile 可以先写得很简单:

makefile 复制代码
myprocess: myprocess.c
	gcc -o myprocess myprocess.c

.PHONY: clean
clean:
	rm -f myprocess

编译运行:

bash 复制代码
make
./myprocess

再开一个终端持续观察:

bash 复制代码
while :; do
    ps -al | head -1
    ps -al | grep myprocess | grep -v grep
    sleep 1
done

刚开始会看到父子进程同时存在,子进程的 PPID 是父进程的 PID。大约 5 秒后,父进程退出,子进程仍然存在,此时子进程的 PPID 会变成 1

⚠️ 易错点:Ctrl+C 通常只会作用在当前前台控制的进程组上。子进程被 1 号进程领养、逐渐转为后台状态后,可能不能再靠原来的终端中断干掉它,需要用 kill

bash 复制代码
kill -9 子进程PID

-9 对应 SIGKILL,是强制终止。平时优先用普通 kill PID,只有进程不响应时再用 -9

2. 为什么孤儿进程必须被领养

1 号进程在较新的发行版里通常是 systemd,老系统里可能是 init。可以用下面命令查看:

bash 复制代码
ps -p 1 -f

更早的启动阶段还会涉及 0 号进程、idle/swapper 等内核启动细节。复习进程父子关系时可以先抓住主线:普通用户态进程如果失去原父进程,会被 1 号进程或对应的子进程管理机制接管。

为什么要领养?反过来想最清楚:如果不领养,子进程将来退出时会进入僵尸状态,但原来的父进程已经没了,没有人调用 wait()waitpid() 回收它的退出信息。这样 PCB 中保留的退出状态就可能长期留在系统里。

所以孤儿进程被领养,不是为了好看,而是为了让它未来退出时仍然有人负责回收。

💡 复习提示:现实里能处理一个孩子后续事务的通常是家人或公共管理系统;进程里也类似,能回收子进程退出状态的要么是父进程,要么是系统级的接管者。

3. 优先级 priority:资源稀缺时的先后顺序

优先级 (priority) 的本质是:衡量一个对象得到某种资源的先后顺序。

放到进程里,进程优先级就是:进程获得 CPU 资源的先后顺序。CPU 少,进程多,大家必然要排队;只要要排队,就要有规则决定谁先谁后。

这里要把优先级和权限 (permission) 区分开:

  • 权限回答的是:能不能得到资源。
  • 优先级回答的是:已经能得到资源时,谁先得到。

比如访问文件时,系统会根据进程的 UIDGID 等信息判断它对文件是拥有者、所属组还是 other。注意:系统不是直接和"人"打交道,而是通过进程承载用户的请求。也就是说,访问权限最终表现为"进程与文件之间的权限关系"。

💡 复习提示:权限像"有没有入场券",优先级像"入场后排第几"。不要把"先后问题"和"资格问题"混成一个概念。

4. Linux 普通进程的 PRINI 与 nice 值

ps -al 可以看到和优先级相关的列:

bash 复制代码
ps -al

常见输出里会有 PRINI

  • PRI:priority,进程最终展示出来的优先级。
  • NI:nice value,优先级修正数据。

在这篇讨论的普通分时进程模型里,可以先用下面公式理解:

text 复制代码
最终优先级 = 默认基准优先级 80 + nice 值

默认 nice 值是 0,所以普通进程默认看到的 PRI 常常是 80nice 值越小,最终 PRI 越小,进程越优先;nice 值越大,最终 PRI 越大,进程越靠后。

nice 的范围不是无限的:

text 复制代码
nice: -20 ~ 19
普通进程 PRI: 80 + [-20, 19] = 60 ~ 99

如果尝试把 nice 调成 -100,系统会把它限制到 -20;如果尝试调成 100,会被限制到 19。这样设计是为了避免用户随意把自己的进程调到极端高优先级,导致低优先级进程长期拿不到 CPU。

⚠️ 易错点:数值越低,优先级越高。PRI=60PRI=99 更优先。

⚠️ 易错点:NI 严格说不是最终优先级,它是"修正数据"。最终调度看到的优先级需要结合基准优先级理解。

5. 调整优先级:topnicerenice 与系统调用

先从命令行方法看。

启动一个进程时指定 nice 值:

bash 复制代码
nice -n 10 ./myprocess

这表示以 nice=10 启动进程。普通进程默认基准按 80 理解时,最终优先级就是 90,优先级被调低。

进程已经启动后,用 renice 修改:

bash 复制代码
renice -n 12 -p 25110

其中:

  • -n 12 表示把 nice 值设置为 12
  • -p 25110 表示作用到 PID 为 25110 的进程。

也可以进入 top 后按 r,输入 PID,再输入新的 nice 值。

如果想在程序里查看或修改优先级,可以使用 getpriority()setpriority()

c 复制代码
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    errno = 0;
    int nice_value = getpriority(PRIO_PROCESS, 0);
    if (nice_value == -1 && errno != 0) {
        perror("getpriority");
        return 1;
    }

    printf("before: pid=%d, nice=%d\n", getpid(), nice_value);

    if (setpriority(PRIO_PROCESS, 0, 10) == -1) {
        perror("setpriority");
        return 1;
    }

    errno = 0;
    nice_value = getpriority(PRIO_PROCESS, 0);
    if (nice_value == -1 && errno != 0) {
        perror("getpriority");
        return 1;
    }

    printf("after: pid=%d, nice=%d\n", getpid(), nice_value);
    while (1) {
        sleep(1);
    }

    return 0;
}

关键参数:

  • PRIO_PROCESS:表示按进程维度操作。
  • 第二个参数为 0:表示当前进程。
  • setpriority(..., 10):尝试把当前进程 nice 值设置为 10

返回值要注意:getpriority() 合法返回值可能是 -1,所以判断失败不能只看返回值,需要先把 errno 清 0,再结合 errno 判断是否真的出错。

编译运行:

bash 复制代码
gcc -o prio_demo prio_demo.c
./prio_demo

⚠️ 易错点:普通用户通常可以把自己的进程优先级调低,也就是把 nice 调大;把 nice 调小、提高优先级往往需要更高权限。权限不足时,setpriority() 可能失败并设置 errno

6. 多进程的四个基本属性:竞争、独立、并行、并发

理解优先级之后,要补上四个概念,它们是后面理解调度的地基。

竞争性:系统资源相对于进程数量永远是少的。CPU、内存、磁盘、网络带宽、显示器、键盘、鼠标等资源都要被多个进程共享。只要资源少,多个进程之间就必然存在竞争;有竞争,才需要权限、优先级和调度。

独立性:每个进程都有自己的 PCB、代码和数据。一个进程崩溃,通常不会直接把其他进程也带崩。更深入的独立性要结合进程地址空间、页表和虚拟内存继续理解。

并行 (parallelism):多个 CPU 或多个真正可同时执行的硬件执行单元上,同一时刻有多个进程同时运行。

并发 (concurrency):单 CPU 上任意一个瞬间只能运行一个进程,但通过高速切换,在一段时间内让多个进程的代码都向前推进。

💡 复习提示:如果没有特殊说明,分析多进程时优先用并发模型来想,因为它更能暴露调度、切换、上下文保存这些关键问题。

7. 时间片 time slice:死循环为什么不会天然卡死系统

分时操作系统 (time-sharing operating system) 的核心特点之一,是基于时间片进行调度。一个进程拿到 CPU 后,不是想跑多久就跑多久,而是临时拥有 CPU 一小段时间。

这段时间就叫时间片 (time slice)。

假设一个进程分到 1ms

  • 如果它在 1ms 内执行完,就正常退出。
  • 如果它没有执行完,时间片到了以后,会被操作系统从 CPU 上剥离下来。
  • 它需要重新进入运行队列,等待下一次被调度。

这就解释了一个常见现象:死循环进程会让系统变卡,但不会天然把系统彻底卡死。因为它不能永久占有 CPU,时间片到了仍然会被切换下去,其他进程就还有机会响应。

当然,如果系统资源被大量消耗,或者内核态出现不可抢占的长时间执行,现象会更复杂。这里先抓住普通分时调度下的主线。

8. 进程切换 context switch:寄存器内容的保存与恢复

CPU 执行进程时,真正直接打交道的是进程的代码和数据。CPU 内部有大量寄存器 (register),用于保存当前执行过程中的临时信息。

常见寄存器包括:

  • PC/EIP:程序计数器或指令指针,保存下一条要执行指令的地址。
  • ESP/EBP:和栈顶、栈帧相关。
  • EAX/EBX/ECX/EDX:通用寄存器,可保存临时计算结果。
  • CS/DS/SS/ES/FS/GS:段相关寄存器。
  • EFLAGS:状态标志寄存器。
  • CR0 ~ CR4:控制寄存器,和 CPU 控制状态有关。

这里最容易错的点是:寄存器是 CPU 内部的硬件空间,寄存器里的值才是某个进程当前运行到一半留下来的临时数据。

用 C 语言变量类比:

c 复制代码
int a = 10;
a = 20;
int b = a;

a 这个变量空间没有变,但里面的内容可以从 10 变成 20。寄存器也是这样。CPU 寄存器空间通常只有一份,但不同进程运行时,会把自己的临时数据写进去。

进程切换要解决的问题是:进程 A 运行到一半被切走,下次不能从头开始,也不能丢掉临时计算结果。它必须从历史位置继续。

一次典型切换可以这样理解:

  1. 进程 A 正在 CPU 上运行,寄存器中保存着 A 的临时数据。
  2. A 的时间片到了,操作系统要把 A 切下去。
  3. 切走前,先把 A 对应的寄存器内容保存起来。
  4. 调度器选择进程 B。
  5. 如果 B 是老进程,就把 B 之前保存的上下文恢复到 CPU 寄存器中;如果 B 是第一次运行,就按它的入口地址等初始上下文开始。
  6. CPU 继续执行 B。

所谓进程上下文 (process context),在这里重点指硬件上下文 (hardware context):CPU 寄存器内容的快照。

那这些上下文保存到哪里?可以先理解为和 task_struct 绑定,通过 PCB 能找到。

早期 Linux 内核的 task_struct 里能看到 tss 这样的任务状态段 (Task State Segment) 相关结构,用来保存硬件上下文。现代内核实现已经演进,具体字段位置不必死背;复习时真正重要的是:上下文必须和具体进程绑定,切走时保存,切回时恢复。

💡 复习提示:保存不是把寄存器这个硬件搬走,而是把寄存器里的内容拷贝到进程对应的上下文保存区。

9. 调度 scheduling 和切换 switching 的区别

调度器 (scheduler) 做的事情可以拆成两类:

  • 切换:把当前进程的上下文保存起来,把下一个进程的上下文恢复上来。
  • 调度:从可运行进程集合中选择下一个应该运行的进程。

切换回答的是"如何换上去、还能接着跑";调度回答的是"下一个该选谁"。

前面讲时间片、寄存器和上下文,是为了解决切换;接下来讲运行队列、位图和双队列,是为了解决调度。

10. 经典 O(1) 调度模型:runqueue、140 个队列与 bitmap

为了方便理解优先级如何真正影响"选谁运行",这里使用经典 O(1) 调度模型来拆解。现代 Linux 默认调度器已经演进到 CFS 等模型,但这个经典模型非常适合把运行队列、优先级、位图和饥饿问题讲清楚。

首先,一个 CPU 对应一个运行队列 (runqueue)。如果有两个 CPU,就可以有两个运行队列;如果有四个 CPU,就可以有四个运行队列。

一个运行队列里,不是只有一个简单 FIFO 队列。为了体现优先级,可以把它理解成一组按优先级分桶的队列:

c 复制代码
struct task_struct *queue[140];

这里的 queue[140] 可以理解为 140 个优先级队列的入口。Linux 优先级范围可分成两段理解:

  • 0 ~ 99:实时优先级 (real-time priority),这篇不展开。
  • 普通分时进程可映射到剩余的普通优先级范围。

在这里讨论的普通进程里,PRI 范围是 60 ~ 99。可以把它映射到某个下标区间,再将同优先级的进程挂到同一个队列中。

text 复制代码
宏观:先按优先级选队列
局部:同一个优先级队列内先进先出 FIFO

如果优先级为 60 的进程有 3 个,它们都进入同一个优先级队列;如果优先级为 61,进入另一个队列。这样调度器可以先看高优先级队列,再从对应队列头部取出一个 task_struct

如果每次都从头遍历 140 个队列,虽然 140 是常数,但设计上仍然不够优雅。于是引入位图 (bitmap):

位图的含义:

  • 位图中的一个 bit 对应一个优先级队列。
  • bit 为 0:对应队列为空。
  • bit 为 1:对应队列里有可运行进程。

如果用 unsigned int bitmap[5],一个 unsigned int 按 32 bit 理解,5 * 32 = 160 bit,足够覆盖 140 个队列,多出来的 bit 不使用。

调度时可以按下面思路走:

c 复制代码
struct task_struct *pick_next_task(struct rq_elem *active)
{
    if (active->nr_active == 0) {
        return NULL;
    }

    int index = find_first_set_bit(active->bitmap);
    if (index < 0) {
        return NULL;
    }

    return pop_front(active->queue[index]);
}

这段伪代码表达的是:

  1. 先看 nr_active,如果活跃进程数为 0,就没有可选进程。
  2. 再查 bitmap,快速找到第一个有进程的优先级队列。
  3. 根据下标定位 queue[index]
  4. 从队头取出一个 PCB。

这就是 O(1) 调度模型里"快速挑选"的直觉来源:不是在所有进程里线性找,而是通过优先级队列和位图快速定位。

11. active / expired 双队列:为什么能缓解饥饿

如果只有一组优先级队列,会出现一个问题:高优先级死循环进程时间片用完后,又回到高优先级队列尾部。只要前面的高优先级进程不断回来,低优先级进程可能长期等不到 CPU,这就是进程饥饿 (starvation)。

解决思路是引入两组队列:

  • active:当前轮次正在被调度的队列。
  • expired:时间片用完但还没执行完的进程,被放入过期队列。

可以用一个结构体抽象这组数据:

c 复制代码
struct rq_elem {
    int nr_active;
    unsigned int bitmap[5];
    struct task_struct *queue[140];
};

struct runqueue {
    struct rq_elem prio_array[2];
    struct rq_elem *active;
    struct rq_elem *expired;
};

初始化时:

c 复制代码
struct runqueue rq;

rq.active = &rq.prio_array[0];
rq.expired = &rq.prio_array[1];

调度规则可以这样理解:

c 复制代码
void scheduler_tick(struct runqueue *rq)
{
    struct task_struct *next = pick_next_task(rq->active);

    if (next == NULL) {
        struct rq_elem *tmp = rq->active;
        rq->active = rq->expired;
        rq->expired = tmp;
        next = pick_next_task(rq->active);
    }

    if (next != NULL) {
        context_switch(next);
    }
}

这里有一个很重要的点:交换的是两个指针的内容,不是把整组队列搬来搬去。指针也是变量,交换两个指针指向的对象,就可以让 CPU "看待队列的视角"发生变化。

当一个进程从 active 被调度走,时间片用完但没有执行结束,就不再放回 active,而是进入 expired。这样 active 会越来越少,expired 会越来越多。当 active->nr_active == 0 时,交换 activeexpired,进入下一轮。

这个设计的好处是:已经在本轮运行过的进程不会立刻回到本轮继续插队;低优先级进程虽然仍然靠后,但至少有机会在当前 active 轮次中被轮到。

⚠️ 易错点:prio_array[2] 是两个"大结构",每个结构里都有计数器、位图和 140 个队列。activeexpired 是指向这两个结构的指针。

⚠️ 易错点:这不是说低优先级一定和高优先级一样快,而是说调度器尽量避免"已经跑过的高优先级进程反复插到没跑过的低优先级进程前面"。

12. 新进程、抢占 preemption 与多 CPU 负载均衡

新进程来了应该放入 active 还是 expired?可以分两个角度看。

如果把新进程放入 expired,它处于"就绪但暂时不被本轮调度"的状态。这个理解和抽象操作系统原理里的就绪状态很接近。

但现代分时系统常常支持抢占 (preemption)。如果当前正在运行的是 PRI=80 的进程,此时来了一个 PRI=60 的高优先级进程,系统可能希望它尽快运行。要让它"插队",放到 expired 没有意义,因为 expired 要等 active 空了才会被切换过来;放入 active 才有可能排到更前,并触发后续抢占判断。

多 CPU 场景还要多考虑一步:每个 CPU 都有自己的运行队列。如果 CPU0 队列里挂了大约 100 个任务,CPU1 只有 20 个,把新任务继续塞给 CPU0 通常就不合理。系统需要根据调度策略选择负载较低的 CPU,让多个 CPU 尽量都忙起来,这就是负载均衡 (load balance)。

runqueue 里常能看到一些辅助信息,例如:

  • cpu_load:用于评估 CPU 负载。
  • nr_switches:切换次数,切换越多通常说明越忙。
  • nr_running:可运行进程数量。

真实内核会比这里复杂得多,但复习时先抓住这条主线:每个 CPU 一套运行队列,调度器既要考虑局部优先级,也要考虑整体负载。

13. 常见实习/面试问题小结

1. 什么是孤儿进程?为什么要被 1 号进程领养?

父进程先退出,子进程仍在运行,这个子进程会被 1 号进程或相应系统管理机制接管,称为孤儿进程。领养的核心意义是:子进程未来退出时仍然有人回收退出状态,避免僵尸进程长期存在。

2. 僵尸进程和孤儿进程有什么区别?

僵尸进程是子进程已经退出,但父进程还没有回收它的退出信息;孤儿进程是父进程先退出,子进程还在运行并被系统接管。

3. 优先级和权限有什么区别?

权限决定能不能访问某资源;优先级决定已经能访问资源时,谁先得到资源。进程调度里的优先级主要针对 CPU 资源的获取顺序。

4. PRINI 分别是什么?

PRI 是最终展示的优先级,NI 是 nice 值,是优先级修正数据。普通进程可先按 PRI = 80 + nice 理解,nice 范围为 -20 ~ 19,所以普通进程优先级常见范围为 60 ~ 99

5. 为什么数值越小优先级越高?

这是系统设计约定。复习时记住结论:PRI=60PRI=99 更容易先被调度。

6. 为什么不直接修改 PRI,而要引入 nice 值?

如果运行队列中的老进程直接改 PRI,它已经挂在某个优先级队列里,位置是否要立刻调整会很麻烦;不调整又会出现"显示优先级是 60,却仍挂在 80 对应队列"的不一致。引入 nice 后,可以把修正推迟到下一次重新入队时处理,更容易配合调度队列结构。

7. 进程切换保存的是什么?

保存的是 CPU 寄存器里的内容,也就是当前进程的硬件上下文,而不是把寄存器这个硬件本身保存起来。

8. 切换和调度有什么区别?

调度负责选择下一个进程;切换负责保存旧进程上下文、恢复新进程上下文,并让 CPU 从新进程的历史位置继续执行。

9. 什么是进程饥饿?

低优先级进程长时间得不到 CPU 资源,导致代码长期无法推进,就叫进程饥饿。优先级范围过大、调度策略不合理、已运行进程反复插队,都可能加重这个问题。

10. 为什么 O(1) 调度模型要用 bitmap?

如果逐个遍历优先级队列,查找成本不够理想。bitmap 用一个 bit 表示一个队列是否为空,调度器可以快速定位第一个非空队列,再从队头取出进程。

14. 总结

进程不是静态地"加载在那里",而是在队列、时间片、上下文和调度器之间动态流转。孤儿进程让父子关系的回收链条闭合;优先级解决 CPU 资源稀缺时的先后顺序;nice 让优先级调整能和调度结构配合;进程切换通过保存和恢复寄存器上下文,让进程能从历史位置继续;经典 O(1) 调度模型则用 runqueue、bitmap、active/expired 双队列,把"如何快速选中下一个进程"讲成了一套可以落地的数据结构。理解到这里,进程在脑子里就不再是一块静态 PCB,而是一组会被组织、选择、切走、恢复、再运行的动态对象。

相关推荐
桑榆4164 小时前
双系统(Windows + Ubuntu)安装流程
linux·windows·ubuntu
jerryinwuhan8 小时前
《系统部署与运维》开篇 课程介绍
运维
ltl10 小时前
实时 OS 巡礼:VxWorks、QNX、Zephyr 与 PREEMPT_RT
linux
我是谁??10 小时前
Ubuntu22.04更换清华源
linux·运维·服务器
圣殿骑士-Khtangc11 小时前
Go字符串高效拼接性能对比与底层原理分析
服务器·前端·golang
Shell运维手记11 小时前
Linux 常用基础命令学习笔记
linux·运维·笔记·学习·算法·github
朴马丁11 小时前
国际与国产PLM在精细化工赛道的布局:2026年主要厂商技术特色
大数据·运维·人工智能·流程行业plm·化工新材料
这个DBA有点耶12 小时前
数据库一体机架构演进:从硬件堆叠到软硬深度耦合
服务器·网络·数据库·硬件架构·运维开发·database·数据库架构
Elastic 中国社区官方博客12 小时前
Elasticsearch:使用 AI Agent 来创建 workflow
大数据·运维·人工智能·elasticsearch·搜索引擎·自动化·全文检索
测试运维日常笔记13 小时前
Linux系统安装配置TigerVNC服务器完整指南
linux·运维·服务器