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,而是一组会被组织、选择、切走、恢复、再运行的动态对象。

相关推荐
Urbano2 小时前
夹克与商务单西口袋工序自动化技改研究报告
运维·自动化
骑上单车去旅行4 小时前
MD5校验对比脚本
linux·服务器·windows
平生幻4 小时前
Linux 常用命令
linux
期待着20134 小时前
docker 安装 ,在centos7.9
运维·docker·容器
踏月的造梦星球4 小时前
DM8 DSC 单机双实例部署
运维·开发语言·数据库
MXsoft6185 小时前
平台化是智能运维发展的必然趋势吗?
运维
ShirleyWang0125 小时前
让headlamp控制台能访问
linux·服务器·python·k8s·k3s
liuccn5 小时前
Linux 存储系统:LVM 与直接分区
linux·运维
捷配链5 小时前
六层服务器板行业材料与工艺标准化选型指南
服务器·制造·pcb