Learning Linux Kernel (Part 3) - Task & Scheduler
How the task is managed and scheduled in Linux kernel?
前言
上一章节:Linux Kernel (Part 2) - Bootstrapping 如果你没有阅读过,建议先阅读。本章节我们将引入 Linux kernel 的核心对象之一:task,以及调度器。
内核眼中的”进程”
在用户态编程时,我们常常把”进程”想象成一个封装好的黑盒——它有自己的内存空间、打开的文件、运行的代码。这种直觉在日常开发中没什么问题,但一旦你走进 Linux 内核的世界,就会发现事情远比这复杂,也远比这优雅。
Linux 内核并不像教科书那样维护一个叫”Process”的超级对象。相反,它把我们通常称为”进程”或”线程”的东西,拆分成了一组可以独立管理、灵活组合的内核对象。理解这套拆分方式,是理解后面所有内容的基础。
task_struct
在这组对象中,task_struct 是当之无愧的核心。你可以把它理解为”一个可被调度的执行流在内核中的描述符”。调度器最关心的就是它——每当内核需要决定”接下来哪段代码应该在 CPU 上跑”时,它打交道的对象就是 task_struct。
一个 task_struct 里包含了大量信息:调度状态、CPU 寄存器上下文、指向地址空间的指针、打开文件表的引用、信号处理状态、进程号与线程号、父子关系、安全凭据……如果你只需要记住一个内核数据结构的名字,那就是它。
但 task_struct 并不能独自描述一个完整的”进程”。原因很简单:一个执行流所关联的资源种类繁多,而其中有些资源是每个 task 独有的(比如寄存器上下文和调度状态),有些则可以在同一进程内的多个线程之间共享(比如地址空间和打开文件表)。为了优雅地支持这种”有些共享、有些独有”的语义,Linux 把这些资源拆成了独立的内核对象,再通过指针挂接到 task_struct 上。
围绕 task_struct 的资源对象
让我们逐一看看这些被拆出来的关键对象:
mm_struct——地址空间。 如果说 task_struct 回答的是”谁在执行”,那么 mm_struct 回答的就是”它看到的虚拟内存世界长什么样”。它描述了进程拥有的虚拟内存区域(VMA)、页表根地址、代码段和数据段的布局、堆和栈的位置等信息。当两个 task 共享同一个 mm_struct 时,它们看到的就是完全相同的用户地址空间——这正是”线程”最核心的特征之一。
files_struct——打开文件描述符表。 这就是 fd = 0, 1, 2, 3, … 背后那整套映射。它维护着从文件描述符到内核 struct file 对象的映射关系。如果多个 task 共享同一个 files_struct,它们操作的就是同一组文件描述符。
fs_struct——文件系统视角。 这个名字很容易让人联想到文件系统的实现,但它描述的其实是”这个进程怎么看待路径世界”——具体来说就是当前工作目录(cwd)和根目录(root)。相对路径的解析就依赖于它。一个简单的区分方式是:files_struct 关心的是”我打开了哪些文件”,fs_struct 关心的是”我站在文件系统的哪个位置”。
signal_struct 与 sighand_struct——信号状态。 Linux 的信号模型本身就比较复杂,所以信号相关的状态也被拆成了多个对象。粗略来说,signal_struct 偏向整个线程组(进程)层面的信号状态,而 sighand_struct 则偏向信号处理动作(比如 handler 表)。此外,每个 task 还有自己独立的 pending signal 集合。核心认知是:信号相关的状态远不是一个简单的整数能描述的,而是由一组对象协作完成。
cred——安全凭据。 它回答的是”这个 task 以什么身份在系统里行动”,包含 uid、gid、capability 集合等安全相关属性。内核在做权限检查时,查看的就是这个对象。
把拼图组合起来
如果我们把上面这些对象画成一张图,一个”进程/线程”在内核里的全貌大致如下:
task_struct
-> mm_struct // 地址空间
-> files_struct // 打开的文件描述符表
-> fs_struct // cwd/root 等路径视角
-> signal/sighand // 信号相关状态
-> cred // 身份和权限
-> sched info // 调度信息
-> parent/children // 进程关系plaintext这张图传达的关键信息是:进程并不是一个封闭的黑盒,而是一组可以灵活组合的内核对象。 理解了这一点,很多后续的概念就会自然变得清晰。
线程与进程
从内核的角度看,线程和进程并不是两种截然不同的东西。它们的底层表示都是 task_struct,区别仅仅在于这些 task 之间共享了多少资源对象。
如果两个 task 共享同一个 mm_struct、同一个 files_struct、同一套信号处理状态——那我们通常称它们为同一进程内的”线程”。如果它们各自拥有独立的地址空间和文件表,各有各的资源边界——那它们更像是两个独立的”进程”。
这种设计背后的哲学非常 Unix:用灵活的底层原语加上不同的组合方式,来表达高层概念。 而不是在内核里硬编码两套完全不同的实体。
fork() 和 clone() 就是实现这种组合的接口。fork() 更像传统的”创建新进程”,默认会为子进程复制或逻辑独立很多资源;而 clone() 则要底层得多,它允许调用者精确指定新 task 与父 task 之间共享哪些资源。线程的创建,本质上就是通过 clone() 来指定”共享地址空间、共享文件表、共享信号处理”的。
为什么这套拆分如此重要?
你可能会觉得”了解内核对象拆分”只是一个冷知识,但实际上,未来你在分析内核问题时,几乎所有关键问题都可以归结为对这张对象图的追问:
- 这个属性是 per-task 的,还是属于某个共享对象?
- 一个线程修改了某个状态,同进程的其他线程会不会看到变化?
- 上下文切换的开销到底花在哪里——是切 task 贵?还是换
mm贵?还是文件表共享导致的锁竞争? - 某个 bug 到底是生命周期管理出了问题,还是共享关系搞错了?
把”进程”当成一个黑盒,这些问题都会显得模糊。但把它拆成对象图,很多答案就会变得清晰。
fork() 和 exec()
fork() 和 exec() 是 Unix/Linux 世界中最经典的一对系统调用。几乎所有新进程的诞生,都要经过它们的接力。但许多人对它们的理解停留在”fork 创建进程,exec 执行程序”这种粗略的描述上——而实际的设计要巧妙得多。
用最短的话概括:fork() 解决的是”谁来跑”,exec() 解决的是”跑什么”。
fork() 不是”启动一个程序”
这大概是关于 fork() 最常见的误解了。fork() 做的事情并不是去启动一个新程序,而是复制出一份当前执行环境的副本。调用 fork() 之后,系统中会多出一个新的 task,它几乎是父进程的翻版——拥有看起来一样的地址空间、相似的打开文件表、从同一段代码的同一位置继续执行。父子之间唯一的区别体现在 fork() 的返回值上:父进程拿到子进程的 pid,子进程拿到 0。
pid_t pid = fork();
if (pid == 0) {
// 子进程的代码路径
} else {
// 父进程的代码路径
}c同一个系统调用,靠返回值分流父子逻辑——这是一种非常简洁且优雅的 API 设计。子进程并不会”自动去运行另一个程序”,它只是从当前代码的当前位置继续执行。至于”它最终变成什么程序”,那是 exec() 的工作。
exec() 不是”创建新进程”
与 fork() 恰恰相反,exec() 不会创建任何新的 task。它做的事情是把当前 task 的用户态程序映像整个替换掉。调用前后,task_struct 还是同一个,pid 通常也不变,但用户地址空间被彻底换了——代码段、数据段、栈、程序入口,全都变成了新程序的。
如果用一个比喻来说:exec() 更像是同一个容器,把里面原来的程序倒掉,装进去一个全新的程序。不是”再开一个容器”,而是”换了容器里的内容”。所以 exec() 成功后,原来的那段代码就不复存在了——它不会像普通函数那样”返回”到旧程序,只有失败时才会返回错误。
execve() 和 exec() 到底是什么关系?
这里需要澄清一个很容易让人混淆的概念——当我们说 exec() 的时候,在 C 语言的标准库里其实根本不存在一个叫 exec() 的函数。它只是一个”家族”的统称。
最核心的区别在于:execve() 是真正的底层老板(内核系统调用),而 exec() 是一群为了方便你点单的服务员(用户态的库函数家族)。
execve() 是真正的内核系统调用。 它是 Linux 内核提供的唯一一个用于执行新程序的系统调用接口。当你去看内核源码时,最终真正执行清空当前进程内存、加载新程序代码、设置栈和环境变量这些硬核操作的,是内核里的 sys_execve。它的传参方式非常严格,必须提供三个精确的参数——程序路径、参数数组(argv)、环境变量数组(envp):
int execve(const char *pathname, char *const argv[], char *const envp[]);cexec() 家族是用户态的包装函数。 它们是 C 标准库(如 glibc)提供的包装函数,运行在用户态。这个家族包括 6 个常用的成员,名字都以 exec 开头:execl、execlp、execle、execv、execvp、execvpe。这些函数的存在仅仅是为了让程序员写代码更方便——不管你调用哪一个,它们在底层都会把你的参数按照 execve() 需要的格式打包好,然后统一去调用 execve()。
它们各自提供了不同的”贴心服务”:
- 带
p的(如execlp):你不需要写程序的绝对路径,它会自动去PATH环境变量里帮你找(比如直接写"ls"而不是"/bin/ls")。 - 带
l的(如execl):允许你把参数像列表一样一个个写出来,不用自己去构建一个数组。 - 带
e的(如execle):允许你显式地传一份自定义的环境变量数组。 - 带
v的(如execv):参数以数组形式传入,适合参数个数在运行时才确定的场景。
所以当我们在这篇文章里说 exec() 时,指的是这整个家族的行为——而它们最终干的活,全部都是委托给 execve() 这个唯一的内核系统调用来完成的。
Unix 的经典美学
那么,为什么不干脆设计一个 spawn(program, args) 一步到位呢?好问题,答案正是 Unix 设计哲学的精髓所在。
将进程创建拆成 fork() 和 exec() 两步,带来了一个非常强大的组合性优势:在 fork 和 exec 之间,存在一个可以自由定制子进程环境的窗口期。
以 shell 执行 ls -l 为例,整个过程大致如下:
- Shell 先调用
fork(),得到一个几乎一样的子进程 - 子进程在
fork()返回后走自己的代码分支 - 在调用
exec()之前,子进程可以自由地做各种准备工作:重定向 stdin/stdout/stderr、关闭或保留特定的文件描述符、设置环境变量、改变工作目录、设置用户身份、建立管道、配置信号处理…… - 准备完毕后,子进程调用
exec("ls"),把自己替换成ls程序 - 与此同时,父进程(shell)继续管理前台和后台任务
如果把所有这些定制选项都塞进一个巨大的 spawn 参数包里,接口会变得极其笨重。而 fork + exec 的两步流程让一切都非常自然——先复制出一份环境,按需调整,最后再换成目标程序。
这就是 Unix API 设计的经典美学:小原语组合成强表达力。
从内核对象角度看这两个调用
从前面介绍的对象模型来看,fork() 和 exec() 的内部动作截然不同:
fork() 的核心工作是创建一个新的 task_struct,复制父 task 的大量元数据,为子 task 建立调度上下文,并处理地址空间、打开文件表、信号状态、凭据、父子关系等一系列对象的复制或共享。其中有一个至关重要的优化:地址空间不会被立刻全量复制。这就是大名鼎鼎的 Copy-on-Write(COW)——逻辑上子进程获得了父进程地址空间的完整副本,但物理上它们先共享同一批页面,只有在某一方真正写入时才会触发复制。这是 fork() 在现代系统里依然高效可用的根本原因——否则每次 fork 都全量拷贝内存,开销会大到难以接受。
exec() 的核心工作则完全不同:通过 VFS 找到目标程序文件、判断可执行格式(如 ELF)、销毁或替换旧的用户态内存映像、建立新的 mm_struct 布局、映射新的代码段/数据段/栈、设置 argv/envp、配置初始寄存器状态、把程序计数器指向新程序的入口。注意,在整个过程中,task_struct 本身和 pid 都没有变——变的只是这个 task 所承载的程序内容。
用一个更生动的类比来说:fork() 更像是”让一个新人出生”,exec() 更像是”给这个人换一整套大脑和身体”。
值得留意的源码入口
如果你以后想深入源码,以下几个文件是很好的起点:
| 文件 | 内容 |
|---|---|
kernel/fork.c | task 创建与复制路径 |
fs/exec.c | exec 路径 |
fs/binfmt_elf.c | ELF 格式加载器 |
include/linux/sched*.h | task 相关核心数据结构 |
Task 的一生
理解了 task 是什么、怎么被创建之后,下一个自然的问题就是:一个 task 会经历怎样的一生?
Linux 里的 task 不是一个静态的”进程盒子”,而更应该被理解为一个有完整生命周期的动态对象——它出生、排队、运行、阻塞、被唤醒、可能分裂出后代、可能更换程序身份、最终退出、变成残骸、等待回收。
出生
一个新 task 的诞生通常源于 fork()、clone() 或内核自行创建的内核线程。无论走哪条路径,底层都会汇聚到同一个核心流程:分配一个新的 task_struct,建立调度上下文,挂接上 mm、files、fs、signal、cred 等资源对象,然后设定初始的执行位置。对于用户态进程或线程,这个初始位置通常是 fork() 的返回点;对于内核线程,则是某个内核函数的入口。
出生 ≠ 立刻运行
一个很重要的认知是:task 被创建出来,不代表它此刻就在 CPU 上跑。 新 task 通常先进入 runnable 状态——它满足了运行的所有前提条件,但还没有真正拿到 CPU 时间片。只有当调度器选中它、把它放上某个 CPU 之后,它才进入 running 状态。
这个区分看似细微,但在分析并发行为和性能问题时至关重要。
三态循环:running、runnable、blocked
在 task 的运行期间,它会在三种核心状态之间反复切换,形成一个循环:
- running → runnable:时间片用完或被更高优先级的 task 抢占,暂时让出 CPU,但随时准备再次运行。
- running → blocked:需要等待某个外部事件(磁盘 I/O 完成、网络数据到达、锁可用、子进程退出、定时器到期……),继续占着 CPU 毫无意义,于是主动睡下去,让调度器把 CPU 交给其他有事可做的 task。
- blocked → runnable:等待的条件终于满足了,内核将其标记回 runnable 并放回调度队列——但注意,唤醒不等于立刻运行,唤醒只意味着”你重新有资格参与竞争 CPU 了”,至于什么时候真正跑起来,还得看调度器的安排。
分析任何内核控制流时,有一个非常实用的思维框架:先问两个问题——“它现在在哪个状态?“和”是什么事件让它跳到了另一个状态?“这就是状态机视角,它能帮你把复杂的并发场景分解成清晰的状态转移。
生命线上的两个特殊事件
在 task 的运行过程中,可能发生两个改变生命线形态的重要事件:
fork()——生命线分叉。 当一个正在运行的 task 调用 fork() 时,生命周期出现了”分叉”——原来的 task 继续存在,同时一个全新的 task 也诞生了。此后父子各自独立运行,谁先跑、跑多久,完全由调度器决定。
exec()——换一副躯壳。 当 task 调用 exec() 时,没有新的 task 出生,也没有 pid 的变化,但程序映像被整个替换了。如果把 task 看成一条生命线,那么 fork() 是这条线分出了岔路,而 exec() 则是同一条线上的生命换了一个全新的身份。
退出与善后
当进程完成了它的工作(从 main 函数返回、调用 exit())或收到致命信号时,就会走上退出路径。内核需要做大量的清理工作:释放资源、关闭文件描述符、通知父进程、处理线程组关系、确保这个 task 不再被调度。
但 task 并不会在退出的瞬间就从系统中彻底消失。它通常会短暂进入一个特殊状态——zombie。
Zombie:已死未葬
Zombie 这个词听起来很恐怖,但它的本质其实很简单:task 已经执行结束了,但还保留着最基本的退出信息,等待父进程来回收。
为什么不能在 exit() 的那一刻就把一切都删干净?因为父进程可能还需要获取子进程的退出码、pid 和资源使用统计。在父进程调用 wait() / waitpid() 之前,这些信息必须被保留。所以 zombie 状态的 task 不再运行、不再真正占用用户态资源,但 task_struct 等最小的元数据还留在那里。
记住:exit() 不是生命周期的最后一步,wait() 才是。 子进程的退出和父进程的回收,是两个独立的阶段。
完整生命周期一览
new task created
-> runnable(排队等 CPU)
-> running(获得 CPU,开始执行)
-> blocked <-> runnable <-> running(在三态间反复切换)
-> maybe fork()(生命线分叉,新 task 诞生)
-> maybe exec()(程序映像被替换)
-> exit(退出,开始清理)
-> zombie(等待父进程回收)
-> parent wait()(父进程领取退出状态)
-> fully reaped(彻底从系统中消失)plaintext线程的本质
别在内核里找”Thread”对象
如果你在 Linux 内核源码里搜索一个叫 thread 或 process 的专有数据结构,你不会找到。这不是因为内核不支持线程,而是因为 Linux 从根本上就没有把”线程”和”进程”设计成两种不同的实体。它们在内核里的底层表示是完全一样的——都是 task_struct。区别仅仅在于:多个 task 之间共享了哪些资源。
用户态的直觉是对的:进程有独立的地址空间和资源边界,线程是同一进程内的多个执行流。但到了内核层面,这段直觉会被翻译成一种更具体、更精确的表述——多个 task 共享同一个 mm_struct,往往还共享 files_struct、信号处理状态等。
| 线程间共享的资源 | 每个线程独有的资源 |
|---|---|
mm_struct(地址空间) | task_struct(调度实体) |
files_struct(文件描述符表) | CPU 寄存器上下文 |
fs_struct(cwd/root) | 内核栈 |
sighand_struct(信号处理表) | thread-local 状态 |
signal_struct(组信号状态) | 每线程 pending signal |
共享 mm 是线程最核心的标志
在所有共享关系中,共享 mm_struct 是最关键的。它意味着:同样的虚拟地址在多个线程里指向同一份内存内容,一个线程写入全局变量另一个线程立刻可见,一个线程 malloc 出来的对象另一个线程可以直接访问。
判断”这更像线程还是进程”,最有力的一个问题就是:它们是不是共享同一个 mm_struct?
线程也需要独立的 task_struct
即使线程共享了大量资源,它仍然是一个独立的执行流。每个线程都需要自己的 CPU 寄存器现场、调度状态、内核栈、blocked/runnable/running 状态和线程级 pending signal。没有这些,调度器就无法单独地暂停和恢复每个线程。
调度器调度的是 task,不是抽象的”进程盒子”。 这是理解 Linux 线程性能行为很重要的一点。
clone():灵活的任务构造器
clone() 系统调用的本质是:创建一个新的 task,同时精确指定它和父 task 共享哪些资源。 它不是一个简单的二选一开关——不是只有”完全独立进程”和”标准线程”两种选项。clone() 提供了一组细粒度的共享标志(mm、files、fs、信号、线程组等),你可以按需组合。这使得 clone() 更像是一个”任务构造器”,而 fork() 只是它的一种特定组合(“什么都不共享”),pthread_create() 是另一种特定组合(“尽量都共享”)。
线程组与 tgid
在多线程场景中,用户态所说的”进程”通常对应内核里的一个 thread group——一组共享资源的 task。其中有一个 group leader,它的 pid 同时也作为这个组的 tgid(Thread Group ID)。
task A: pid = 100, tgid = 100 (leader)
task B: pid = 101, tgid = 100
task C: pid = 102, tgid = 100plaintext三个 task 都是独立的调度单位,但在用户态它们统称”进程 100”。单线程进程里 pid == tgid,差别不明显;一旦进入多线程世界,这个区分就变得非常重要。
线程带来的复杂性
线程共享地址空间带来了效率上的好处——创建更快、切换更轻(不需要切换 mm),但也带来了一系列棘手的问题。最典型的就是信号处理:某个信号是发给整个进程还是某个特定线程?handler 是共享的吗?一个线程收到致命信号后整个进程怎么办?
更深层的问题来自并发:当多个执行流在同一个地址空间里同时读写同一批对象时,就需要 mutex、spinlock、condition variable、memory ordering、futex 等一整套同步机制来保证正确性。理解了”线程就是共享 mm 的多个 task”,你就会自然理解为什么这些工具是必不可少的。
线程不只带来”更快的并发”,也带来了更复杂的共享语义、同步问题和生命周期管理。它本质上是拿共享带来的效率换共享带来的复杂性。
wait()、zombie、orphan 与 PID 1
这几个概念经常被混为一谈,但它们其实对应着 task 生命周期中截然不同的阶段,以及父子关系中截然不同的问题。让我们逐一厘清。
wait():父进程的回收义务
当子进程退出时,父进程通常还需要知道它的退出码、它是否被信号杀死、它的资源使用情况。wait() / waitpid() 就是父进程向内核”领取”这些信息的接口。调用 wait() 时,内核会在父进程的子进程集合中寻找已退出的子进程,把退出状态交给父进程,然后彻底回收残留的内核对象。
如果没有子进程已退出但还有活着的子进程,父进程可以选择阻塞等待——这又和前面讲的 task 状态机无缝衔接了。
Zombie:已死但未收尸
Zombie 不是卡住的程序,不是挂起的程序,不是占满 CPU 的程序。它的准确定义是:已经结束执行、用户态资源大多已释放,但退出状态还保留着等待父进程回收的 task。 它已经死了,只是还没有被”正式登记注销”。
Orphan:还活着但没了父亲
Orphan 和 zombie 完全不是一回事,千万不要混淆:
| 概念 | 含义 |
|---|---|
| zombie | 子进程已经死了,但还没被 wait() 回收 |
| orphan | 子进程还活着,但父进程先死了 |
一个是”已死未收尸”,一个是”还活着但没爹了”。而且,一个 orphan 在将来退出后也可能短暂变成 zombie——直到它的新父进程把它回收。这两个概念可以先后发生在同一个进程身上,但它们描述的是完全不同的问题。
PID 1:系统进程树的最终兜底家长
当一个进程的父进程先退出时,这个变成 orphan 的进程需要有人接管。传统上,这个角色落在 PID 1(init / systemd)身上。所以 PID 1 的职责远不止”启动服务”那么简单——它还肩负着领养孤儿进程、负责最终的 wait() 回收的重任。
现代 Linux 还支持 subreaper 机制——允许某个进程充当”小号 PID 1”,在局部进程树中接管 orphan 子孙。这在服务管理器、容器运行时等场景中非常实用。
举一个具体的例子:
sleep 100 &
# 然后 shell 退出bash此时 sleep 还活着,变成 orphan,被 PID 1 接管。100 秒后 sleep 退出,短暂变成 zombie,然后被 PID 1 回收。orphan 是活着时的父子关系问题,zombie 是死后等待回收的问题。
SIGCHLD 与 wait() 的关系
子进程退出时,父进程通常会收到 SIGCHLD 信号。但 SIGCHLD 只是一个通知——“嘿,你的某个孩子退出了”——真正的回收动作还是得靠 wait()。不调用 wait(),zombie 就不会被清理。
这也解释了为什么编写不当的 daemon 或服务管理器会产生大量僵尸进程:它们的父进程收到了 SIGCHLD 但没有正确调用 wait() 来回收。Zombie 虽然不占 CPU、不占地址空间,但仍然会占用 pid 号位和内核表项,大量积累会造成 pid 空间紧张和系统管理混乱。
编写 daemon、服务管理器、进程 supervisor 时,正确处理
SIGCHLD和wait()是基本功。
调度器何时介入
很多人在初学操作系统时,会下意识地把调度器想象成”一个独立的后台线程,不断扫描系统中的所有 task,挑一个最该跑的放上 CPU”。但 Linux 的调度器并不是这样工作的。
调度器更像是嵌在各种事件路径里的一段逻辑——它在 syscall 路径、wakeup 路径、tick 路径、中断返回路径、主动阻塞路径中被触发,在合适的时机重新做”谁该跑”的决策。它介入的核心原因只有一个:当前 CPU 上这个 task 不该继续独占 CPU 了,或者有别的 task 更应该跑。
六类触发时机
时机一:当前 task 主动阻塞。 这是最经典的一种场景。比如用户进程调用 read() 但数据还没从磁盘到达、等待 mutex 释放、等待子进程退出——当前 task 继续占着 CPU 没有任何意义。于是 task 把自己挂到等待队列、状态变成 blocked,然后显式调用 schedule() 让调度器选别人来跑。这里的 schedule() 是一个非常关键的函数——它是”现在真的需要选下一个 task 了”的核心调度入口。
时机二:当前 task 跑了太久。 一个 CPU-bound 的 task 一直占着 CPU 不放,但 runqueue 里还有其他 runnable task 在排队。调度器在记账时发现这一点,就会给当前 task 设置一个标记:need_resched。这个标记的含义是——“当前 task 先别闷头跑了,尽快找机会重新做一次调度决策”。但要注意,need_resched 不等于立刻切走,它只是一个”该找时机换人了”的请求。
时机三:更值得跑的 task 被唤醒了。 某个 I/O 完成、某个高优先级 task 变成 runnable——如果新唤醒的 task 比当前正在跑的更应该先执行,内核就给当前 task 打上 need_resched,等到合适时机发生抢占。这是 wakeup path 和 preemption 的交汇点。
时机四:时钟 tick 到来。 系统时钟中断会周期性触发。在时钟中断处理中,调度器可以做记账工作(当前 task 又跑了多久)、检查是否应该触发重新调度、推进各种时间相关的逻辑。但时钟 tick 不等于”每来一次就一定切换 task”——它只是提供了一个自然的检查时机。
时机五:从内核返回用户态前。 很多时候,内核在处理 syscall、中断或 fault 的过程中不一定会立刻做调度切换。但在”准备返回用户态”这个边界点上,内核会检查当前 task 有没有被标记了 need_resched。如果有,就先做一次调度,再返回。这个时机之所以特别好,是因为此时 trap 已经处理完毕,当前 task 的内核态工作通常处于一致状态,不会有半个 syscall 逻辑悬在半空中。
时机六:内核抢占点。 如果内核配置为可抢占内核(preemptible kernel),那么即使当前 task 正在内核态执行,也不一定能一直跑到 syscall 结束。只要当前上下文允许抢占、没有持有禁止抢占的锁、且 need_resched 已经被标记,内核就可以在某些点上被抢占。这里涉及 preempt_count 的概念——它跟踪”当前这段代码是不是暂时不允许被抢占”。
need_resched 与 schedule():标记 vs 动作
这两个概念必须区分清楚:
| 概念 | 含义 |
|---|---|
need_resched | ”该重新调度了”的请求标记。还没真的切换。 |
schedule() | ”真的进入调度器,选下一个 task”的执行动作。 |
一个是决策信号,一个是决策执行。need_resched 只是说”该换人了”,而 schedule() 才是真正动手换人。
为什么不在进入内核时立刻切换?
你可能会问:既然 syscall 要进入内核,为什么不在入口就做调度?原因有三:
- 手上的活还没干完。 刚进入内核时,参数还没检查、trap frame 还没整理好、可能还持有锁——在这个时候做调度切换容易导致状态不一致。
- 进入内核不代表当前 task 不该跑。 比如
getpid()这种极短的 syscall,每次都考虑切换反而会带来不必要的开销。 - 返回用户态前才是天然边界。 在这个点上,trap 处理已经完成,当前 task 的内核态工作处于一致状态,切换不会把半完成的逻辑悬在半空中。
syscall 返回值不会丢
你可能还会担心:如果 syscall 处理完毕后,调度器决定先让别的 task 跑,那当前 task 的返回值会不会丢?答案是不会。内核在进入 syscall 时就已经保存了完整的用户态现场(寄存器、返回地址等),syscall 的返回值也会被放到约定的寄存器中。即使 task 被调度器暂时换走,等它将来再次被选中时,内核会从保存的现场恢复,一切如同无事发生。
用一种更优雅的方式来理解:Syscall 进入内核时,本质上创建了一个”将来返回用户态”的 continuation。调度器要做的只是决定——这个 continuation 现在执行,还是以后再执行。
一个完整的例子
让我们用一个具体场景把上面所有概念串起来:
用户态程序正在运行
-> 时钟中断到来,CPU 进入内核态
-> 中断处理程序进行调度记账
-> 发现当前 task 已经跑了相当长的时间
-> 设置 need_resched 标记
-> 中断处理结束
-> 准备返回用户态前,检查 need_resched
-> 发现标记已设置,调用 schedule()
-> 调度器选择另一个 task 运行plaintext注意:中断发生的那一瞬间并没有立刻切换 task,而是先完成中断本身的处理逻辑,然后在返回用户态这个安全的边界点上做出调度决策。
阶段性总结
走到这里,我们已经完整梳理了两条互相交织的主线:
启动与控制流主线: 从 firmware 到 bootloader,到 kernel early boot,到 start_kernel(),再到 PID 1 和用户空间的启动。在稳态运行阶段,用户程序通过 syscall/exception/interrupt 进入内核,内核处理完毕后可能做一次调度决策,然后返回用户态。
执行体与生命周期主线: task 从出生(runnable)到运行(running),在 running/runnable/blocked 的三态间反复切换,可能通过 fork 分裂出后代、通过 exec 更换程序身份,最终退出变成 zombie,等待父进程 wait 回收。
把这两条线编织在一起,就构成了 Linux 内核运行时行为的核心图景。让我们用一份清单来概览所有核心认知:
- Linux 里执行的基本单位是 task,不是教科书上那种抽象的”进程”
- 线程和进程不是两种不同的实体,而是同一种底层对象(task)加上不同的资源共享策略
task_struct是执行体的中心对象,mm_struct是地址空间对象,files_struct是打开文件表对象fork()创建新 task,exec()替换当前 task 的程序映像——两步分离带来了强大的组合性- zombie 是已退出但还没被
wait()回收的进程;orphan 是父进程先死了但子进程还活着的进程 - PID 1(或 subreaper)负责接管 orphan 并承担最终的回收义务
- 调度器不是一个独立的后台线程,而是嵌在各种内核路径里的决策逻辑,在阻塞、唤醒、tick、返回用户态前等时机介入
理解了这些,你就拥有了一个分析并发行为、调试生命周期 bug、理解性能开销来源的坚实基础。内核的世界还有很多更深层的话题等待探索——锁与同步原语、内存管理、cache coherence、调度器策略——但无论走向哪个方向,今天建立起来的这套”task 对象图 + 生命周期状态机 + 调度触发时机”的思维框架,都会是你最可靠的地图。
调度类
之所以刚刚说是“阶段性总结”那是因为本章还有第二个重要介绍的内容——调度类。它非常复杂,所以后续的介绍甚至也还只是 cover 了调度类中的一部分。我们在 Part 1 中介绍的 CFS 调度器,其实只是调度类中的一个。接下来,我们就来详细介绍一下调度类。
为什么需要调度类?
如果你对 Linux 调度器的全部印象只有”CFS 红黑树”,那第一件需要更新的认知就是:Linux 从不会把所有 task 一视同仁地扔进同一棵树里。 在决定”谁该运行”之前,内核会先回答一个更基本的问题——这个 task 属于哪种调度类(sched_class)?
为什么要有这一层分类?原因在于,系统中运行着目标截然不同的 task。有的 task 只是普通的用户程序,追求公平地共享 CPU 时间;有的是音频处理线程,对延迟极度敏感,必须尽快响应;还有的甚至携带着明确的截止时间——“必须在 5ms 内完成这一轮计算”。
如果用一套”所有人都按公平性排队”的规则来统一管理,问题就来了:音频实时线程可能被普通的 CPU 密集型程序拖慢,deadline 任务无法表达它的时限要求,而 idle 任务根本不应该和正常任务在同一个层面上竞争。
所以 Linux 选择了分层。你可以这样理解:先确定你属于哪个”世界”,再在那个世界内部决定你和其他人怎么比较。
五大调度类速览
Linux 内核定义了五种调度类,按优先级从高到低排列:
- stop:内核内部使用的最高优先级类,用于 CPU 热插拔、migration 等极特殊的控制任务。普通开发者几乎不会直接接触它。
- deadline:面向有严格时间约束的任务。每个 task 携带运行预算(runtime)、周期(period)和截止时间(deadline),更接近调度理论中的实时调度模型。
- rt(real-time):实时任务类。比普通任务优先级高,强调及时响应和严格的优先级保证。
- fair:普通任务的默认归宿,也就是 CFS 的主场。目标是让所有普通任务公平地分享 CPU 时间,用 vruntime 来记账。
- idle:当一个 CPU 上真的没有任何其他 task 可以运行时,idle task 作为”保底”出场。
一个关键认知:Linux scheduler 不是 CFS 的一言堂,而是多个调度类并存的联合体。
调度类之间的层次关系
理解调度类之间的关系,最核心的直觉是:调度器在选择下一个要运行的 task 时,不是把所有 runnable task 混在一起比较,而是先按调度类的优先级逐层检查。
这个检查顺序是:
stop > deadline > rt > fair > idleplaintext具体来说,调度器会先看当前 CPU 上有没有 stop 类的 runnable task,有就选它;没有,再看 deadline 类;还没有,看 rt 类;再没有,才轮到 fair 类。如果连 fair 类都是空的,那就只剩 idle task 了。
这意味着什么呢?如果一个 rt task 处于 runnable 状态,那不管有多少普通 fair task 在排队,它们都得先让路。这就是为什么在实际系统中,一旦有实时线程在运行,普通程序往往会显得”被压制”——它们确实被压制了,因为它们根本不在同一个竞争层级上。
sched_class 的设计哲学
从代码设计的角度看,sched_class 本质上是一种用 C 语言实现的多态接口(polymorphic interface)。每种调度类需要提供一组标准操作:如何把 task 加入队列(enqueue)、如何移出队列(dequeue)、如何选出下一个要运行的 task(pick next)、时钟 tick 到达时该做什么(task tick)、有新 task 被唤醒时是否需要抢占当前 task(wakeup preempt check)。
如果你熟悉 Linux 的 VFS 子系统,会觉得这种设计味道非常熟悉——VFS 用 file_operations 加上 inode/file/dentry 等抽象来实现文件系统的多态;调度器用 sched_class 加上各类自己的数据结构来实现调度策略的多态。公共框架 + ops table + 各实现自治,这是 Linux 内核中反复出现的经典设计模式。
分析调度行为的正确顺序
建立了调度类的概念后,以后看到一个 task,不应该立刻跳到”它在 CFS 红黑树里什么位置”,而应该先问一个更基本的问题:“它属于哪个调度类?” 因为如果它是 rt 或 deadline task,那它压根不在 CFS 那套公平规则下竞争。
正确的分析顺序应该是:
- 先看调度类:这个 task 是 fair、rt 还是 deadline?
- 再看类内部规则:在它所属的类里,它和同类的其他 task 怎么竞争?
- 再看外部因素:它和其他 CPU、wakeup 路径、CPU affinity 之间的关系是什么?
per-CPU Runqueue 的真实面貌
struct rq:本地调度状态的总控对象
在理解了调度类的分层之后,下一个需要建立的核心概念是 per-CPU runqueue。
在 Linux 内核中,每个 CPU 都拥有自己独立的 runqueue,具体表现为一个 struct rq 实例。但要注意,rq 的名字虽然叫”runqueue”(运行队列),它实际上远不止一条简单的链表或队列。更准确地说,rq 是这个 CPU 上所有本地调度状态的总控对象——它包含了当前正在运行的 task、各个调度类的候选人集合、时间记账信息,以及大量的统计数据。
让我们看看 rq 中几个最重要的成员:
curr:指向当前这个 CPU 正在执行的 task。这是最重要的一个指针——调度器每次介入时,最先需要评估的就是curr和其他候选人之间的关系。idle:这个 CPU 的 idle task。它不是普通任务,而是当 CPU 上没有任何 runnable task 时运行的”保底”执行体。stop:指向 stop class 的特殊高优先级 task,用于 CPU 控制等内核内部场景。nr_running:当前 CPU 上处于 runnable 状态的 task 总数。这个计数器能帮助调度器和负载均衡逻辑快速判断这个 CPU 是否繁忙、是否存在任务积压。clock:这个 runqueue 自己维护的时间基准。调度器在做公平性计算、运行时间统计、延迟判断时,都依赖这个本地时钟。
各调度类的候选人不会混在一起
这是理解 rq 结构最关键的一点。rq 并不会把所有 runnable task 倒进一个大容器里,而是按调度类分开维护各自的候选人集合:
rq
-> current task: curr
-> idle task: idle
-> dl runnable set // deadline 类的候选集
-> rt runnable set // rt 类的候选集
-> fair runnable set // fair 类的候选集(CFS 红黑树)plaintext所以 per-CPU runqueue 的真实模样,更像是一个总控对象加上多套分层的 runnable 集合,而不是一条”所有 task 平铺排队”的线性队列。当调度器需要选出下一个要运行的 task 时,逻辑更像是:先检查 deadline 集合里有没有人,再检查 rt 集合,再检查 fair 集合,如果全都是空的,就运行 idle task。
为什么要 per-CPU 各一个 rq?
你可能会问:为什么不用一个全局的 runqueue 来统一管理所有 CPU 的调度呢?
答案和性能息息相关。采用 per-CPU 的设计,大多数 enqueue 和 dequeue 操作都可以在本地 CPU 上完成,不需要获取全局锁、不需要和其他 CPU 竞争。这样做的好处包括:减少锁竞争、利用 cache locality(本地 CPU 频繁访问自己的 rq,数据更可能留在缓存中)、让调度决策尽量保持本地化。
所以当一个 task 被唤醒时,内核不会说”把它放到全局大队列里吧”,而更像是在回答两个问题:“它该去哪个 CPU 的 rq?” 以及 “放进那个 CPU 的哪个 class 集合里?” 这一步就已经把调度、wakeup 路径和 CPU affinity 联系在一起了。
一次真正的调度决策是什么样的
假设当前 CPU 上触发了调度(不论是因为 task 阻塞、时钟 tick 到达、还是有新 task 被唤醒),一次完整的调度决策大致如下:
- 内核进入调度路径(
schedule()被调用) - 查看当前 CPU 的
rq - 更新
rq的时钟和记账信息 - 处理
prev(当前正在运行的 task):它是继续保持 runnable?还是已经变成 blocked?还是有其他特殊状态? - 按调度类的优先级逐层挑选
next - 找到
next后,执行 context switch - 更新
rq->curr = next
这里需要特别注意的是,调度决策的核心角色只有两个:prev(刚才正在运行的 task)和 next(即将要运行的 task)。 正确的心智模型不应该是”从树里找最小 vruntime”,而是更完整的:处理 prev → 挑选 next → 切换 curr。
prev 的三种命运
当调度器介入时,prev(当前 task)并不总是面对同一种命运:
- 情况 A:它阻塞了。 比如它在等待 I/O 完成或等待一把锁。这种情况下,它会被从 runnable 集合中移除,暂时退出竞争 CPU 的世界。
- 情况 B:它仍然是 runnable 的,只是时间片用完了或者有更高优先级的人来了。 这时它会被放回自己所属 class 的 runnable 集合里,以后继续参与竞争。
- 情况 C:它是 idle 或其他特殊 task。 对此有各自的特殊处理逻辑。
换句话说,一次调度不仅仅是”选谁上来”的问题,还包括**“把当前运行的人妥善安置到正确的位置”**。
next 是怎么被选出来的
选出下一个 task 的过程,概念上可以用伪代码表示:
pick next:
if deadline class 有 runnable task:
从 deadline 里选
else if rt class 有 runnable task:
从 rt 里选
else if fair class 有 runnable task:
在 fair 内部按 vruntime/EEVDF 规则选
else:
选 idle taskplaintext整个 pick 过程本质上是两层:第一层按调度类的优先级逐层淘汰,第二层在被选中的类内部用该类自己的规则做最终决策。 这就是 Linux 调度器最基本的骨架。
curr 和 runnable 集合的关系
还有一个值得单独强调的直觉:curr 是”此刻已经占据 CPU 的人”,而各调度类的 runnable 集合是”候选人池”。curr 不是一个虚拟的抽象概念,它就是当前这个 CPU 上真正在执行指令的那个 task。而 runqueue 里其他 runnable task 则是随时准备接班的候补选手。
调度问题的本质,就是在回答这样一个问题:当前占着 CPU 的人还能不能继续占下去?如果不能,候选池里谁来接班?
Wakeup Path
wakeup 的本质:获得参赛资格,而非直接冲线
在理解了 runqueue 的结构后,我们来看一个调度器中特别容易产生误解的环节——唤醒路径。
先给出一个最核心的判断:wakeup 的本质不是”让 task 马上执行”,而是”让 task 重新具备竞争 CPU 的资格”。”
很多对调度行为的直觉性误判,都源于把 wakeup 和 running 混淆成了同一件事。一个 task 被唤醒,仅仅意味着它从”沉睡”状态回到了”可以参与竞争”的状态——至于它能不能真的跑起来,还得看当时的竞争环境。
task 是怎么”睡下去”的
要理解 wakeup,首先要理解一个 task 是如何进入睡眠的。一个 task 进入睡眠,通常会经历以下步骤:
- 发现条件不满足:比如要读的数据还没到、要获取的锁被别人持有、要等待的子进程还没退出。
- 把自己挂到 wait queue 上:wait queue(等待队列)是内核中一种常见的数据结构,你可以把它理解为”一群正在等待同一个条件成立的 task 的列表”。等某个文件描述符变得可读、等某把锁被释放、等某次 I/O 完成——这些等待都不是抽象的”空想”,内核会将等待者挂到一个具体的数据结构上。
- 将自己的状态改为 sleeping / blocked
- 调用
schedule(),主动让出 CPU
经过这个过程后,task 就退出了当前 CPU 的竞争。在被唤醒之前,它不在任何 CPU 的 runnable 集合里,而是静静地挂在某个 wait queue 上。
这里有一个重要的认知需要建立:调度器本身并不负责”记住每个 task 在等什么”。 那是由各个具体子系统的 wait queue 来表达的——I/O 子系统有它的等待队列,锁机制有它的等待队列,各种同步原语也各自维护着等待队列。调度器只关心 task 的状态:runnable 还是 blocked。
条件满足时,wakeup 路径到底做了什么
当等待的条件终于满足(I/O 完成、锁释放、数据到达、超时触发),内核会沿着 wait queue 找到等待的 task,然后执行一整条唤醒路径:
- 找到等待的 task
- 将它从 wait queue 上摘下来
- 将它的状态改回 runnable
- 选择一个目标 CPU
- 将它 enqueue 到那个 CPU 的 runqueue 中相应调度类的集合里
- 判断是否需要触发抢占
注意,wakeup 不是一个点状的动作,而是一条完整的路径:
wait queue
-> 状态改为 runnable
-> 选择目标 CPU
-> enqueue 到目标 rq
-> 可能触发对当前 task 的抢占plaintext为什么被唤醒不等于立刻运行
因为 runqueue 是一个竞争池,不是一个执行保证。被唤醒之后,task 只是从 blocked 变成了 runnable,回到了”可以抢 CPU”的世界。但它能不能立刻变成 running,还取决于很多因素:
- 目标 CPU 上是否已经有人在运行
- 它属于哪个调度类(如果它是 fair task,而当前 CPU 上有一个 rt task 在跑,它就得等)
- 它的优先级、vruntime、截止时间如何
- 它被放到了哪个 CPU
- 当前上下文是否允许立刻进行抢占(比如是否还在中断处理中)
用一个比喻来说:wakeup 相当于拿到了比赛的入场资格,而 running 才是真正站上赛场。 这是两个不同的阶段。
一个具体的例子
假设 task A 正在运行,它发起了一次磁盘读取:
A 正在运行
-> 调用 read()
-> 数据尚未到达
-> A 变为 blocked,被挂到 I/O 子系统的 wait queue 上
-> 调用 schedule()
-> CPU 切换去运行 task B
... 时间过去 ...
磁盘 I/O 完成:
-> 中断到达
-> I/O 完成处理逻辑执行
-> 将 A 从 wait queue 上摘下
-> A 的状态变为 runnable
-> A 被 enqueue 到某个 CPU 的 rq 上plaintext但此时 A 还不一定马上运行。因为当前 CPU 可能还在处理中断、B 可能仍在运行并且时间片还没用完、调度器也可能判断让 B 跑完当前这一小段更合适、或者 A 被放到了另一个 CPU 上而那个 CPU 也有事在做。
wakeup 只是”把 A 重新送回赛道”,不是”让 A 立刻冲过终点”。
目标 CPU 是怎么选择的
wakeup 路径中有一个非常关键的步骤值得展开——CPU 选择。一个 task 醒来后,内核需要决定把它放到哪个 CPU 的 rq 上。这个决策会考虑很多因素:
- 它上次运行在哪个 CPU:如果还放在那个 CPU 上,之前的 L1/L2 cache 内容可能还是热的,性能更好。
- cache locality 是否值得保留:如果 task 最近才在某个 CPU 上运行过,缓存数据可能仍然有效。
- CPU affinity 约束:task 可能通过
sched_setaffinity()限制了只能在某些 CPU 上运行。 - 目标 CPU 的负载情况:如果某个 CPU 已经很忙了,把 task 放过去只会增加排队时间。
- 唤醒者在哪个 CPU:有时候把被唤醒的 task 放在唤醒者所在的 CPU 附近,可以获得更好的局部性。
- 调度类的特殊要求:不同调度类可能有不同的 CPU 选择偏好。
所以 wakeup path 不仅仅是一个状态转换(从 blocked 到 runnable),而是:从睡眠状态,进入某个具体 CPU 上的具体调度竞争环境。 调度策略、缓存局部性和负载均衡在这一步就已经开始交织了。
什么情况下 task 醒了能很快抢占当前 task
虽然说 wakeup 不等于立刻运行,但确实存在一些情况下,被唤醒的 task 能够很快就抢占当前正在运行的 task:
- 高优先级任务醒来:如果一个 rt task 被唤醒了,而当前 CPU 上跑的是普通 fair task,那 fair task 通常很快就会被切走。
- 交互型 task 有很大的”公平欠账”:比如一个交互型 task 睡了很久,它的 vruntime 相对于其他人来说非常小(意味着它”欠”了很多 CPU 时间),那它醒来后会非常有竞争力,很可能很快就能上 CPU。
- 当前 task 已经被标记为”该让出了”:如果当前 task 已经跑了很长时间,而刚醒来的 task 又很有竞争力,抢占就很容易被触发。
但即使在这些情况下,也不一定是”中断发生的那一纳秒就立刻切换”。很多时候,内核仍然会选择在一个合适的、安全的边界上完成切换。
wakeup 和 preemption 的关系
这两个概念联系紧密,但它们不是一回事:
- wakeup:让一个 blocked task 重新变成 runnable——“有一个新的候选人回来了”
- preemption:把当前正在 running 的 task 切走,让别的 task 上 CPU——“当前占位者被换下了”
wakeup 可能导致 preemption(新来的候选人太强了,当前选手被换下),但不是一定会导致。正确的理解是:wakeup 是”候选人回到赛场”,preemption 是”当前选手被替换下场”。前者是后者的一种触发条件,但不是充分条件。
wakeup latency 为什么重要
wakeup latency 指的是:从”条件满足、task 被唤醒”到”task 真正开始在 CPU 上执行”之间经过的时间。
这个延迟之所以重要,是因为很多用户可感知的交互体验问题,以及很多实时性场景的性能瓶颈,都出在这里。想象一下:键盘输入已经到达了、处理键盘事件的线程也已经被唤醒了,但它在 8ms 之后才真正获得 CPU 开始处理——用户就会感觉到明显的输入延迟或卡顿。
调度器不仅需要关心吞吐量和公平性,还必须关心 wakeup 之后能不能尽快让等待的 task 得到响应。 这正是 wakeup path 在 scheduler 设计中占据重要地位的原因。
Preemption
preemption 的最短定义
抢占(preemption)= 当前 task 还没有主动放弃 CPU,但内核决定暂停它的执行,把 CPU 交给另一个更值得运行的 task。
这和 task 自己主动阻塞是完全不同的两件事:
- 阻塞(blocking):当前 task 自己说”我现在无法继续推进了(在等 I/O / 等锁),先去睡一会儿”——这是 task 的主动选择。
- 抢占(preemption):内核说”你虽然还能继续运行,但现在有人比你更需要 CPU,先停一下”——这是调度器的强制干预。
用户态抢占:最直观的场景
考虑最简单的情况:task A 正在用户态执行一个无限循环 while (1) { compute(); }。它没有发起任何系统调用、没有主动阻塞、也没有调用任何”让出 CPU”的函数。如果没有抢占机制,A 就会永远占着 CPU,系统中的其他 task 全部饿死。
显然,操作系统不能允许这种事情发生。
实际会发生的是:当时钟中断到达、或者有更高优先级的 task 被唤醒时,内核会评估当前的调度状况。如果判断 A 已经运行了足够长的时间、或者有人比 A 更应该获得 CPU,内核就会在 A 的 thread_info 中设置一个 need_resched 标志。然后,当控制流到达一个合适的边界——最典型的就是从中断返回用户态之前——内核会检查这个标志,发现它已经被设置,于是调用 schedule() 完成 task 切换。
内核态抢占:为什么更复杂
用户态抢占相对简单——用户态代码就是普通的计算逻辑,在任何指令边界上打断它通常都不会造成问题。但内核态就不一样了。
内核态代码不只是在做”普通计算”。它可能正在修改关键的内核数据结构、持有某把保护共享资源的锁、处理中断相关的状态、访问必须保持一致性的对象。如果在这些操作进行到一半的时候随便抢占,系统就可能陷入不一致的状态,导致各种难以调试的问题。
所以内核态不是”任意时刻都能被抢占”的,而是需要检查当前上下文是否安全。这里有一个核心概念——preempt_count:
- 当
preempt_count == 0时:说明当前处于普通上下文中,原则上允许内核抢占。 - 当
preempt_count != 0时:说明当前处于某种不适合被抢占的上下文中(比如持有自旋锁、处于中断处理中、或者显式关闭了抢占)。
哪些情况下通常不能抢占
最重要的几类场景:
持有自旋锁(spinlock)时。 自旋锁保护的通常是非常短但非常敏感的临界区。如果一个 task 拿着自旋锁被抢占切走了,那另一个 CPU 上试图获取同一把锁的 task 就会一直自旋等待(spin)。被切走的 task 因为没有在运行,无法释放锁,而等待的 task 又在空耗 CPU 不停自旋——这会造成严重的性能问题,甚至在某些情况下导致死锁。
处于中断或原子上下文时。 这类上下文本来就不允许随意睡眠或切换,因为它们通常有严格的执行约束。
显式关闭抢占时。 某些内核代码会主动调用 preempt_disable() 说”这段代码必须不被打断地执行完”,比如操作 per-CPU 数据时。
核心结论:“内核可抢占”从来不是绝对的,它始终依赖于当前的执行上下文。
need_resched:意图与实施的分离
need_resched 是抢占机制中一个精妙的中介信号。它的含义不是”立刻硬切走当前 task”,而是**“当前 task 应该在最近的合适时机让出 CPU”**。
一次典型的抢占触发路径是这样的:
- 时钟 tick 到达、或者有新 task 被唤醒
- scheduler 评估后判断当前 task 应该被切换
- 设置
need_resched标志(标记意图) - 等到达安全边界:返回用户态前、内核重新允许抢占的位置、或显式调度点
- 在安全边界上调用
schedule()(执行实施)
这里的设计哲学是:need_resched 是”意图”,schedule() 是”实施”。 意图可以在任何时候产生(比如在中断处理程序中),但实施必须等到安全的时刻。
为什么”安全边界”如此重要
因为内核需要维持其数据结构的一致性。想象一下,如果在以下操作进行到一半时被切走:
- 一个链表正在被修改——已经修改了
next指针,但还没来得及更新prev指针 - 一个引用计数刚刚递增了,但相关的状态还没有更新
- 某个对象正处于中间态,既不是旧状态也不是新状态
- 一把锁已经获取了但还没释放
- 中断控制器的状态还没有完全恢复
在任何这些情况下被切走,都可能导致其他 task 看到不一致的状态,进而引发各种问题。
所以内核调度和抢占的一个核心原则是:尽量在数据结构处于一致状态的边界点进行切换。 典型的安全边界包括:task 主动阻塞时、从中断/异常/syscall 返回用户态之前、重新开启抢占之后、以及代码中显式的调度点。
抢占与延迟的权衡
调度器在抢占机制上需要平衡两个彼此矛盾的目标:
- 尽快响应更重要的 task:如果一个高优先级的实时 task 被唤醒了,我们希望它尽快获得 CPU。
- 不要在不安全的位置打断当前内核执行路径:在关键操作中途切走会导致一致性问题。
太保守(很少抢占):当前 task 会占 CPU 太久,调度延迟变差,用户感觉系统响应迟钝。太激进(频繁抢占):内核一致性维护更加困难,锁的复杂度增加,并发问题激增。
preemption 本质上是一种平衡机制:在”尽快响应”和”保证内核执行安全”之间寻找最佳折中点。
一个典型的抢占例子
假设 task A 是一个普通的 fair task,正在用户态做计算。task B 是一个刚被唤醒的交互型任务(比如用户刚刚按下了键盘):
A 在用户态运行
-> 时钟中断到达 / B 被唤醒
-> scheduler 判断 B 比 A 更值得先运行
-> 在 A 上设置 need_resched
-> 到达安全边界(比如从中断返回用户态前)
-> 调用 schedule()
-> 从 A 切换到 Bplaintext注意:A 并没有自己主动要求阻塞或让出 CPU,是调度器基于公平性或优先级的判断决定让它暂停。这就是抢占。
Load Balancing 和 CPU Affinity
多核环境下的核心问题
到目前为止,我们已经建立了这些概念:调度的基本单位是 task;每个 CPU 有自己的 rq;rq->curr 是当前正在运行的 task;runnable task 会进入各自调度类的本地集合;wakeup 会把 task enqueue 到某个 CPU 的队列上;preemption 决定是否需要在当前 CPU 上立刻换人。
但在多核环境下,有一个我们还没有展开讨论的核心问题:task 应该放在哪个 CPU 上运行?
这个问题之所以关键,是因为”放在哪”会同时影响吞吐量、延迟、cache locality、NUMA locality、锁竞争和负载均衡等多个维度。这就是 CPU affinity、load balancing 和 migration 登场的地方。
CPU affinity:task 可以跑在哪些 CPU 上
CPU affinity = 一个 task 被允许或倾向在哪些 CPU 上运行。
这个概念可以从两个层面来理解:
- 硬亲和性(hard affinity):通过
sched_setaffinity()等接口设置的 cpumask,明确限制 task 只能在某些 CPU 上运行。如果一个 task 的 affinity mask 只包含 CPU 0 和 CPU 1,那它永远不会被调度到 CPU 2 上。 - 软亲和性(soft affinity):没有硬性限制,但调度器会倾向于让 task 留在它最近运行过的 CPU 上,以保留 cache 和其他硬件状态的局部性。
所以 affinity 不仅关乎”能不能在某个 CPU 上跑”,还关乎”更适合在哪个 CPU 上跑”。在内核实现中,这通常体现为 cpumask、per-task 的 allowed CPUs 集合,以及 scheduler domain 和 CPU topology 的约束。
为什么不能随便迁移 task
既然多个 CPU 都有各自的 runqueue,一个自然的想法就是:如果某个 CPU 太忙了,把一些 task 搬到空闲的 CPU 上不就好了?
思路是对的,但搬迁(migration)并不是免费的午餐。一个 task 留在原来的 CPU 上运行,通常有以下好处:
- L1/L2 cache 中可能还保留着它之前访问过的数据,避免了冷启动
- TLB 中可能还有它的地址翻译条目,避免了 TLB miss
- 分支预测器可能已经学习了它的分支模式
- per-CPU 数据的访问更高效
如果频繁地把 task 从 CPU 0 搬到 CPU 7,所有这些”热”状态都会丢失,task 到了新 CPU 后相当于冷启动,实际性能可能反而下降。如果涉及跨 NUMA 节点的迁移,代价还会更大。
调度器的目标从来不是”让所有 CPU 的负载绝对均匀”,而是在”平衡负载”和”保留局部性”之间寻找折中。 这一点非常关键。
负载均衡(Load Balancing)
负载均衡的目标用一句话概括就是:当某些 CPU 太忙、另一些 CPU 太闲时,把 runnable task 重新分布得更合理。
举个例子:如果 CPU 0 上堆积了 10 个 runnable task,而 CPU 1 上几乎空着——不做任何干预的话,CPU 0 上的 task 排队严重,响应变慢,而 CPU 1 上的计算能力被白白浪费。这时调度器会尝试通过 pull work(把其他 CPU 的 task 拉过来)或 push work(把自己的 task 推出去)来重新分配负载。
但请注意,balancing 的目标不是追求数学上的完美均衡,而是避免明显的失衡、减少不必要的排队和 CPU 空闲。
两种不同的均衡手段
多核调度中的负载均衡,实际上有两种主要的触发时机和方式:
Wakeup placement(唤醒时放置)。 当一个 task 从 blocked 变回 runnable 时,调度器在 wakeup path 中当场决定把它放到哪个 CPU。如果这一步做得好——比如选择了一个既不太忙、又对 cache 友好的 CPU——后续就不需要频繁迁移。这是一种”在入口处做决定”的策略。
Periodic / idle balancing(周期性和空闲时均衡)。 即使 wakeup 时的初始放置不是最优的,调度器后续仍然有机会修正。它会在周期性检查、某个 CPU 变得空闲、或某个 scheduler domain 触发均衡时,重新评估是否有必要把某些 CPU 上的 runnable task 迁移到其他 CPU 上。
所以多核调度不是一次性的决策,而是初始放置 + 持续修正的动态过程。
migration 的代价
migration 的表面看起来很简单——不过是把一个 runnable task 从一个 CPU 的 rq 搬到另一个 CPU 的 rq。但在实现层面,这涉及的操作远不止”移动一个指针”:
- 需要从源 CPU 的对应调度类集合中 dequeue
- 需要更新目标 CPU 的各种 bookkeeping 信息
- 需要 enqueue 到目标 CPU 的对应调度类集合中
- 如果必要,还要在目标 CPU 上触发抢占
- 整个过程涉及双方
rq的锁、负载统计更新、affinity 合法性检查和调度类特定的 dequeue/enqueue 逻辑
migration 绝不是一个轻量的”数组元素搬位置”操作。
Scheduler Domains 和硬件拓扑
Linux 不会天真地把所有 CPU 视为完全等价的平面结构。它能够感知硬件的层次结构,并据此组织调度逻辑。典型的层次包括:
- SMT siblings:超线程共享同一个物理核心
- 同 core / 同 cluster:共享某一级缓存(比如 L2)
- 同 socket:共享最后一级缓存(LLC)和内存控制器
- 同 NUMA node:共享本地内存
- 跨 NUMA node:跨互连访问远程内存
这些层次会直接影响负载均衡的策略——在同一个物理核心或同一个 LLC 范围内迁移 task,代价相对较小(缓存共享程度高);而跨 socket 或跨 NUMA 节点迁移,代价可能大得多(缓存完全冷启动 + 内存访问延迟增加)。
Linux scheduler 基于这种硬件拓扑构建了 scheduler domain 和 sched group 的层次结构,让 balancing 决策能够感知迁移的”距离”和”代价”。 这就是 Linux 调度器多核部分复杂性的根源之一。
Idle balancing:空闲 CPU 主动找活干
当一个 CPU 变成空闲状态时(当前 runqueue 上没有 runnable task 了),它不会坐等任务到来。相反,它通常会主动尝试:“我自己没活干了,不如从别的 CPU 那里拉一些 runnable task 过来?”
这就是典型的 pull model。这种做法的好处是:不需要等全局统一的调度决策,空闲的 CPU 主动拉取工作负载,在多核系统上比较自然高效。
调度器的启发式决策
在实际的 wakeup path 和 balancing 决策中,调度器不会仅仅看”哪个 CPU 最空”就把 task 搬过去。它还会综合考虑:
- 这个 task 最近是不是就在某个 CPU 上运行过(cache 热度)
- 唤醒它的任务在哪个 CPU(放在同一个 CPU 可以提高局部性)
- 如果放在另一个空闲 CPU 上,迁移带来的收益是否大于局部性损失
调度器做的很多时候不是”求最优解”,而是基于启发式规则做出”足够好”的决策——locality-aware、load-aware、latency-aware 的综合 placement。 这也是为什么阅读 scheduler 源码时,你会发现大量的启发式判断(heuristic),而不是纯粹的教科书式公式。
affinity 的约束来源不只是用户设置
一个 task 最终能运行在哪些 CPU 上,不仅取决于用户通过 sched_setaffinity() 手动设置的绑核策略,还可能受到以下因素的约束:
- cpuset cgroup:容器或 cgroup 对 CPU 集合的限制
- CPU 配额和隔离配置:如
isolcpus、nohz_full等内核启动参数 - 实时任务策略的要求
- 内核线程的绑定需求
调度器最终看到的 allowed CPUs 集合,往往是多种约束综合作用后的结果。
NUMA
NUMA 是什么?
NUMA,全称 Non-Uniform Memory Access(非一致内存访问)。 它描述的是这样一种硬件现实:一台机器上的所有物理内存,对所有 CPU 来说并不是以同样的代价可达的——有的内存”离这个 CPU 近”,访问速度更快、延迟更低;有的内存”离这个 CPU 远”,需要经过额外的硬件互连才能到达,访问代价更高。
为什么会出现 NUMA?
在只有少量 CPU 核心的小型系统上,所有核心共享同一个内存控制器,访问任意内存地址的代价基本相同——这种架构被称为 UMA(Uniform Memory Access)。你的笔记本电脑大概率就是这种架构。
但到了多路 CPU 的服务器上,情况就不一样了。每个 CPU socket 通常自带内存控制器和一组 DIMM 插槽:
Socket 0 Socket 1
-> CPU cores -> CPU cores
-> 本地内存控制器 -> 本地内存控制器
-> 本地 DIMMs -> 本地 DIMMs
| |
+---------- 互连总线 ----------+plaintext在这种架构下,Socket 0 上的 CPU 核心访问 Socket 0 本地的内存很快(直接通过本地内存控制器),但访问 Socket 1 的内存就需要经过芯片间的互连总线(比如 Intel 的 UPI 或 AMD 的 Infinity Fabric),延迟会显著增加。
关键在于:这仍然是一台”共享内存”的机器——所有 CPU 理论上都能访问所有 RAM,只不过代价不一致。一句话总结:NUMA 不是”内存不能共享”,而是”共享,但远近有别”。
理解 NUMA 的核心抽象
理解 NUMA 最好的方式是把它想象成:一台机器内部有多个”小岛”,每个小岛都有自己的 CPU 核心和一块本地内存。这些小岛之间可以互相访问对方的内存,但跨岛访问的代价比岛内访问要高。 每个小岛就是一个 NUMA node。
NUMA 对程序的影响
假设一个线程运行在 Node 0 的 CPU 上,但它频繁访问的内存页大部分分配在 Node 1——这意味着每次内存访问都要”跨岛取东西”。直接后果可能包括:吞吐量下降、延迟升高、内存带宽利用变差、程序的可扩展性变差。
所以在 NUMA 系统上,一个核心的优化原则是:尽量让计算和数据待在同一个 node 上。
Linux 如何感知和管理 NUMA
Linux 内核完整地感知了 NUMA 的拓扑结构。它知道每个物理页属于哪个 node、每个 CPU 属于哪个 node。基于这些信息,调度器和内存管理子系统可以做出更明智的决策:
- task 应该运行在哪个 CPU 上?
- 它的内存页在哪个 node 上?
- 如果 task 和它的内存不在同一个 node,应该搬 task 还是搬内存?
- 是否需要自动进行 NUMA 平衡?
NUMA 与调度器的交织
如果调度器只看”哪个 CPU 最空”就决定把 task 搬过去,在 NUMA 系统上可能会做出糟糕的决策。考虑这个场景:CPU 7 很空闲,但它在 Node 1 上,而 task 的所有热数据都在 Node 0 的内存中。把 task 搬到 CPU 7 上虽然减轻了 Node 0 的 CPU 负载,但每次内存访问都变成了远程访问,实际性能可能反而下降。
所以调度器需要在 CPU 负载均衡和内存局部性之间做权衡。在 NUMA 机器上,“把 task 放在哪个 CPU 上跑”和”task 的数据在哪个 node 上”同样重要。
内存分配的 NUMA 感知
内核在分配内存页时,不仅仅是”找一页空闲内存”那么简单。它还会考虑”从哪个 node 分”。理想的情况是:task 在 Node 0 上运行,分配给它的内存页也来自 Node 0。
这就引出了一个重要的策略——first-touch policy(首次触碰策略)。很多时候,页不是在 malloc() 调用时就真正分配的(那只是虚拟地址空间的预留),而是在第一次被实际访问时通过 page fault 触发真正的物理页分配。这时 Linux 通常倾向于:从发生 page fault 的那个 CPU 所在的 node 分配物理页。
这个策略对并行程序有一个重要的实践启示:让将来要使用某块数据的线程自己去做初始化。比如在一个并行数组计算中,不要用一个主线程预先初始化所有数据(这会导致所有页都分配在主线程所在的 node),而应该让每个工作线程初始化属于自己的那一部分。这样做不仅是”写一遍数组”那么简单——它还决定了物理页最终落在哪个 NUMA node 上。这个技巧在 HPC 和服务器调优中非常常见。
Automatic NUMA Balancing
Linux 不仅仅被动接受内存的初始分布,还会尝试动态修正。内核可以观察 task 实际在访问哪些内存页,如果发现 task 和它频繁访问的内存长期分离在不同 node 上,它可能会:
- 把 task 迁移到更靠近其内存的 CPU 上(让人靠近数据)
- 或者把内存页迁移到 task 所在的 node 上(让数据靠近人)
核心权衡是:是让人去找数据,还是让数据来找人? Linux 的 automatic NUMA balancing 试图自动化这个决策。
NUMA 在哪些场景下影响最明显
- 多路服务器(2 路、4 路甚至更多 socket)
- 大内存数据库(如 PostgreSQL、MySQL 在大内存机器上)
- 内存带宽敏感的工作负载
- 多线程高并发服务(如 web 服务器、消息队列)
- 大规模 in-memory cache(如 Redis 集群)
- 高性能计算(HPC)和科学计算
如果是单路的笔记本或小型桌面机、且负载不重,你实际上很难感受到 NUMA 的存在。
观察 NUMA 的工具
几个最常用的命令:
lscpu:查看 CPU 拓扑和 NUMA node 信息numactl --hardware:查看有哪些 node、每个 node 包含哪些 CPU 和多少内存numastat:查看 NUMA 相关的统计数据/sys/devices/system/node/:内核通过 sysfs 暴露的 node 信息numactl:控制进程的 CPU 和内存策略(--cpunodebind、--membind、--interleave)
NUMA 就是在一台共享内存的机器内部,CPU 访问不同物理内存的代价不一样,因此”算力放哪”和”数据放哪”必须被联合考虑。
Fair Class 的对象体系
fair class 内部的对象关系
在前面的章节中,我们反复提到调度类是 scheduler 的第一层抽象。现在让我们深入到最常见的调度类——fair class——内部,看看它的对象体系是如何组织的。
先从 task_struct 出发。Linux 中每个 task 的描述结构里,不仅有一个调度类指针,还同时携带了多种调度类的调度实体:
task_struct
-> sched_entity se // fair class 的调度实体
-> sched_rt_entity rt // rt class 的调度实体
-> sched_dl_entity dl // deadline class 的调度实体plaintext对于普通任务来说,最重要的是 task_struct::se——一个 struct sched_entity 类型的成员。
sched_entity 是 fair class 视角下的”可调度实体”。这个名字中的”entity”而非”task”是有深意的——因为在支持 group scheduling 的配置下,一个 sched_entity 不一定只代表单个 task,它还可能代表一个 task group 或一个 cgroup 下的公平调度实体。
cfs_rq 则是 fair class 自己维护的 runqueue。在 per-CPU 的总 rq 对象下面,fair class 有自己独立的子队列。如果系统开启了 group scheduling,这些 cfs_rq 甚至会形成层次结构。
核心认知:fair class 内部的调度不是简单的”一群 task 在一棵树里排队”,而是”一群 sched_entity 在 cfs_rq 上竞争”。entity 既可能代表 task,也可能代表 group。
经典 CFS 的核心概念:vruntime、min_vruntime、rb-tree
CFS(Completely Fair Scheduler)的核心思想可以用几个关键概念来理解:
- vruntime(virtual runtime):每个 entity 的”公平运行账本”。它记录的不是真实的墙钟时间,而是经过权重修正后的虚拟运行时间。
- min_vruntime:当前这棵 fair 树中最小的 vruntime 基准值。你可以把它理解为”这个 runqueue 的公平时钟推进到哪了”。
- rb-tree(红黑树):所有 runnable entity 按 vruntime 的大小排列在红黑树中。
- weight:由 nice 值映射而来的权重,决定了 entity 获得 CPU 时间的比例。
经典 CFS 的选择逻辑非常简洁:vruntime 最小的那个 entity,最值得被调度。 enqueue 把 entity 插入红黑树,dequeue 从树中移除,pick_next 选择最左边(vruntime 最小)的节点。
官方文档明确指出:p->se.vruntime 是 per-task 的虚拟运行时间,CFS 总是试图运行 vruntime 最小的实体,而 rq->cfs.min_vruntime 单调递增,记录 runqueue 的最小基准。(来源:CFS Scheduler ↗)
权重(weight)和 nice 值如何影响调度
fair class 不是直接用”实际运行了多少毫秒”来记账,而是按权重修正后记账:
- 权重更大的 entity(nice 值更低/优先级更高):实际运行同样的 1ms,vruntime 增长得更慢——意味着它不容易”跑到红黑树的右边”。
- 权重更小的 entity(nice 值更高/优先级更低):实际运行同样的 1ms,vruntime 增长得更快——意味着它很快就会”偏右”。
长期来看,权重大的 entity 更容易保持较小的 vruntime,因此能获得更多的 CPU 份额。权重小的 entity 则相反。
nice 值不是直接决定”谁立刻跑”的开关,而是通过影响权重来改变公平账本的增长速度。 最终效果是:nice 值低的 task 获得更大比例的 CPU 时间,但不会完全饿死 nice 值高的 task——因为 CFS 本质上是公平的,只是”公平的比例”不同。
sched_entity 为什么如此重要
sched_entity 的存在把 fair class 的调度逻辑和 task_struct 做了解耦。如果你去阅读 sched/fair.c 的源码,会发现很多函数操作的不是 task_struct *p,而是 struct sched_entity *se——因为 fair class 不仅仅调度 task,还可能调度 group entity。
sched_entity 是 fair 调度世界里的统一抽象层。 这和 VFS 中不直接只看 ext4 的 inode 而是先操作统一的 struct inode 抽象非常相似——同样的设计思想在 Linux 内核中反复出现。
Group scheduling:为什么 cfs_rq 会形成层次
如果系统开启了 cgroup 的 CPU fair group scheduling(CONFIG_FAIR_GROUP_SCHED),调度器可以实现先在组之间公平,再在组内部公平的效果。
举个例子:假设系统中有一个 browser cgroup 和一个 multimedia cgroup,每个 cgroup 各自被视为 root cfs_rq 上的一个 sched_entity。调度器先让这两个组按各自的 shares 分配 CPU 时间,然后在每个组内部,各自的 task 再继续按 fair 规则竞争:
CPU rq
-> root cfs_rq
-> se(browser group) // 先在组间公平
-> se(multimedia group) // 先在组间公平
browser group se
-> browser's own cfs_rq
-> task A // 组内再公平
-> task B // 组内再公平plaintext这种层次化的公平性在容器化环境中特别有用——它让 Kubernetes 等平台能够在不同 Pod 之间按比例分配 CPU 资源。
EEVDF:fair class 的现代演进
从 Linux 6.6 开始,fair class 的 pick_next 逻辑正在从经典 CFS 向 EEVDF(Earliest Eligible Virtual Deadline First) 过渡。这是 fair class 最重要的现代演进之一。(来源:EEVDF Scheduler ↗)
EEVDF 并没有推翻 fair class 的整个对象体系——sched_entity、cfs_rq、vruntime、红黑树这些地基仍然存在。但它改进了**“从候选人中选出谁来运行”这个核心决策的逻辑**。它引入了两个新的关键概念:
- lag(滞后量):
lag > 0表示这个 task 还”欠着”CPU 时间,应该优先被服务;lag < 0表示它最近跑得偏多了,可以稍微往后排。 - virtual deadline(虚拟截止时间,VD):在所有”有资格被调度”的 entity 中,EEVDF 选择 virtual deadline 最早的那个。
如果用一句话对比:
- 经典 CFS:选 vruntime 最小的
- EEVDF:先看谁是 eligible(有资格的),再在 eligible 的人中选 virtual deadline 最早的
EEVDF 的好处在于它对延迟敏感型 task 更友好。同时,它还引入了机制来防止”一个 task 故意频繁短暂睡眠来洗刷自己的 lag”这种投机行为。
学习 fair class 的推荐顺序
如果你正在学习 fair class,推荐的路径是:先用经典 CFS 的模型建立起对象关系和记账方式的直觉,然后再补上”pick next 的现代实现正在向 EEVDF 迁移”这个认知。 sched_entity、cfs_rq、vruntime、rb-tree、min_vruntime、weight/nice、group scheduling 这些概念是不变的地基。在这个地基之上,pick_next 的策略在新内核中更像 EEVDF。
sched_class 的 hooks 在 fair class 中的体现
回到 sched_class 的多态接口设计,fair class 需要实现的几个关键 hook 包括:
enqueue_task(...)— task 从 blocked 变回 runnable 时,将其加入 cfs_rqdequeue_task(...)— task 不再 runnable 时,从 cfs_rq 中移除wakeup_preempt(...)— 有 task 醒来时,判断是否需要抢占当前 taskpick_next_task(...)— 真正选出下一个要运行的 entityset_next_task(...)— 设置即将运行的 tasktask_tick(...)— 时钟 tick 到达时,更新记账并判断是否需要重调度
理解这些 hook 的名字和语义,会让你在阅读 scheduler 源码时不至于迷失在一堆看似随机的函数名中。(来源:CFS Scheduler ↗)
RT 和 Deadline
调度类的优先级顺序再回顾一次:stop > deadline > rt > fair > idle。deadline 和 rt 都能压过普通 fair 任务。
rt class
Linux 的实时调度策略主要包含两种:SCHED_FIFO 和 SCHED_RR。 理解 rt class 的心智模型非常直接:
- 首先看静态优先级:高优先级的 runnable task 永远先于低优先级 task 获得 CPU。
- 同优先级内部:
- SCHED_FIFO:一个 task 会一直运行,直到它主动阻塞、显式让出、或者被更高优先级的 task 抢占。同优先级之间没有时间片轮转。
- SCHED_RR:同优先级之间会轮转——每个 task 运行一个时间片后让给下一个同优先级 task。
和 fair class 最本质的区别在于:rt class 不追求”公平分享 CPU”,它实行的是严格的固定优先级抢占式调度。 优先级高就先跑,没有任何公平性的考量。
这也是为什么滥用 rt 调度策略非常危险——一个高优先级的 rt task 如果一直处于 runnable 状态,普通 fair task 会被完全饿死,连内核的管理操作都可能受影响。为了防止完全失控,系统提供了 sched_rt_runtime_us / sched_rt_period_us 这类参数来限制 rt task 的最大 CPU 占用比例。(来源:RT Group Scheduling ↗)
deadline class
SCHED_DEADLINE 代表着一种更精细的实时调度模型。它的核心概念不是 nice 值或静态优先级,而是三个参数:
- runtime:在一个周期内,task 被保证获得的 CPU 时间预算
- deadline:task 希望在这个时间点之前完成一轮计算
- period:重复调度的周期
直觉上:每个 period 内,你保证给这个 task 最多 runtime 的 CPU 时间,并期望它在 deadline 到达之前完成工作。选择谁运行时使用的是 EDF(Earliest Deadline First) 策略——截止时间最近的那个 task 最先被调度。
但 Linux 不是裸用 EDF。它在此基础上加入了 CBS(Constant Bandwidth Server) 机制,其作用是做 bandwidth isolation——防止一个 deadline task 因为 bug 或设计失误而耗费过多 CPU,把其他所有人都拖垮。(来源:SCHED_DEADLINE ↗)
Context Switch
两层不同的”保存现场”
理解 context switch,最重要的是分清两件不同但衔接的事情:
第一层:trap 进入内核时保存的现场。 当一个 task 通过 syscall、中断或异常从用户态进入内核态时,内核首先要保存一份用户态的执行现场——包括用户态的 PC(程序计数器)、SP(栈指针)、通用寄存器、状态寄存器、以及 trap 原因相关的信息。这份数据通常保存在当前 task 的内核栈上,被称为 trap frame。它的作用是:确保将来能够正确地返回到用户态继续执行。
第二层:scheduler 做 context switch 时切换的现场。 这不是”再把所有用户寄存器完整地保存一遍”(trap frame 已经做过了),而是在两个 task 的内核执行现场之间做切换。调度路径中真正关键的操作是:
prev = rq->curr // 当前正在运行的 task
next = pick_next_task() // 选出下一个要运行的 task
context_switch(rq, prev, next) // 执行切换plaintext而 context_switch 内部主要做两件大事:
第一件:地址空间切换。 如果 prev 和 next 不属于同一个 mm_struct(即它们是不同进程的线程),就需要切换页表上下文——在 x86 上是切换 CR3 寄存器,在 RISC-V 上是切换 satp 寄存器。这会带来 TLB 和地址空间相关的成本。
这也解释了一个常见的说法:同一进程内的线程切换通常比不同进程之间的切换更轻量——因为同一进程的线程共享 mm,可以跳过地址空间切换。
第二件:内核执行上下文切换。 这是 architecture-specific 的 switch_to(...) 函数,它切换内核栈指针、callee-saved 寄存器和返回地址等少量架构相关的上下文。
context switch 的本质
把上述过程精炼成一句话:context switch 的本质是从 prev 的内核栈和内核执行现场,切换到 next 的内核栈和内核执行现场。
这也是为什么 Linux 内核中每个 task 都必须有自己的内核栈——没有独立的内核栈,就无法保存各自的内核执行状态,也就无法在 task 之间切换。
一个非常重要的细节值得反复强调: “trap 保存用户态现场”和”scheduler 切换 task”是两件不同但紧密衔接的事。trap frame 负责的是”将来怎么回到用户态”,而 switch_to 负责的是”现在 CPU 改跳到哪个 task 的内核代码继续执行”。前者保证了用户态的连续性,后者实现了内核态的 task 切换。
kernel thread 的特殊情况
内核线程(kernel thread)通常 mm == NULL——它们不运行在普通的用户地址空间中。但页表硬件要求总有一个有效的地址空间,所以内核线程会借用上一个用户进程的 active_mm。这也从侧面说明了为什么 task_struct 和 mm_struct 在 Linux 中是分离的对象而不是绑死的——因为并非所有 task 都有自己的用户地址空间。
完整的切换链路
把所有环节串起来,一次完整的 task 切换链路如下:
syscall / interrupt / fault
-> trap frame 保存用户态现场
-> 当前 task 在内核态中执行
-> 某个时刻调用 schedule()
-> 选出 next task
-> 如有必要,切换 mm(页表)
-> switch_to(prev, next) —— 切换内核栈和执行现场
-> CPU 继续在 next 的内核栈上执行
-> 以后某个时刻,next 从自己的 trap frame 返回用户态plaintext调度观测手段
理论知识再完善,如果不能在实际系统上观察调度行为,就永远无法验证你的理解是否正确。Linux 提供了多种强大的工具来观测调度器的行为。
A. Tracepoints:最稳定的调度事件入口
Tracepoints 是内核中预定义的静态插桩点,语义稳定、开销可控。对于调度观测,最值得记住的几个是:
sched:sched_switch— 记录了 task 切换事件:谁 → 谁sched:sched_wakeup— 记录了 task 被唤醒的事件sched:sched_wakeup_new— 记录了新创建的 task 首次被唤醒sched:sched_migrate_task— 记录了 task 被迁移到另一个 CPU
如果你想直接通过 tracefs/ftrace 来使用它们:
echo 1 > /sys/kernel/tracing/events/sched/sched_switch/enable
echo 1 > /sys/kernel/tracing/events/sched/sched_wakeup/enable
cat /sys/kernel/tracing/trace_pipebash(来源:Tracepoints ↗)
B. perf sched:从用户工具视角看调度
perf sched 非常适合做调度行为的”全景观察”:
perf sched record:录制调度事件perf sched timehist:以时间线的形式查看每个 task 何时运行、何时等待、runqueue delay 有多长perf sched map:可视化各 CPU 上 task 的流动情况
它特别擅长回答这类问题:某个线程从 wakeup 到真正运行经过了多久?某个 CPU 是否长期过载?调度延迟的瓶颈在哪里?
C. ftrace:追踪函数级的调度路径
如果你想看到 scheduler 内部更底层的执行路径——比如 try_to_wake_up 函数内部做了什么、enqueue_task 是怎么调的、pick_next_task 的决策过程——那 ftrace 是最强大的工具。两个最常用的模式:
function:记录哪些函数被调用了function_graph:以缩进的调用图形式展示函数的调用层次和耗时
尤其是 function_graph 对理解调度路径的内部结构非常有帮助:
echo function_graph > /sys/kernel/tracing/current_tracer
echo __schedule > /sys/kernel/tracing/set_graph_function
cat /sys/kernel/tracing/tracebash(来源:ftrace ↗)
D. BPF tracing:在调度事件上做自定义统计
BPF tracing 是观测能力的最强一层。它的独特优势在于:你不只是被动地”看日志”,而是可以在内核中直接运行自定义的统计和分析逻辑。
一个最经典的用例是计算 wakeup latency——在 sched_wakeup 事件发生时记录时间戳,在 sched_switch 事件中看到对应 task 真正开始运行时计算时间差:
tracepoint:sched:sched_wakeup
{
start[pid] = nsecs;
}
tracepoint:sched:sched_switch
/ start[args->next_pid] /
{
@lat_us = hist((nsecs - start[args->next_pid]) / 1000);
delete(start[args->next_pid]);
}plaintext这类 tracing 非常适合研究 wakeup latency 的分布、runqueue delay 的统计特征、CPU migration 的频率、以及某类 task 被哪种 workload 压制住了。
Scheduler 的本质
scheduler 到底是什么
到这里,我们有必要退后一步,审视一个根本性的问题:scheduler 到底是什么?
一个常见的误解是把 scheduler 想象成一个独立运行的后台进程——像一个”调度守护进程”一样常驻后台,不断地检查各 CPU 的状况然后做出决策。但实际上并非如此。
scheduler 不是一个 task。 它是内核中的一组代码、一组数据结构、以及一套决策规则的组合:
- 代码:
schedule()、__schedule()、pick_next_task()、各种 enqueue/dequeue/wakeup 路径 - 数据:每 CPU 的
rq、cfs_rq/rt_rq/dl_rq、sched_entity、各种统计和记账信息 - 规则:fair/rt/deadline 的选择逻辑、preemption 规则、load balancing 策略
scheduler 的执行方式是:当某个事件发生时(task 阻塞、中断返回、wakeup 触发),当前 CPU 上正在执行的代码流会”走进”scheduler 的逻辑,完成一次”选出下一个要运行的 task”的决策,然后切走。 它更像是一个被动响应事件的机制,而不是一个主动巡逻的 daemon。
更准确的定义:scheduler 是事件驱动的内核机制,不是常驻后台的 task。
“返回用户态前调度” 和 “内核态中途也能切换” 矛盾吗?
这是一个很好的问题,答案是不矛盾。只要把调度可能发生的位置分成三类,就能清晰地理解:
第一类:显式阻塞点。 内核代码在执行过程中遇到了无法立刻满足的条件——比如 read() 时数据还没到、mutex_lock() 时锁被别人持有、wait_event() 时条件未满足。这时当前 task 会将自己的状态改为 blocked,挂到 wait queue 上,然后显式调用 schedule()。这当然发生在内核态执行过程中——task 根本还没到”返回用户态”那一步就已经要调度了。
第二类:返回用户态前的调度点。 syscall、中断或异常已经处理完毕(或接近完毕),在真正切回用户态之前,内核检查 need_resched 标志。如果它已被设置,就在这个边界上调用 schedule()。这个时机特别自然——当前的 trap 处理已经接近完成,所有内核数据结构都处于一致的状态,不会把做了一半的敏感操作悬在中间。
第三类:内核抢占点。 在配置了内核可抢占(CONFIG_PREEMPT)的系统上,即使 task 正在内核态正常执行(既没有阻塞,也没到返回用户态的边界),也可能被切走。但前提条件很严格:need_resched 已被设置、preempt_count == 0、当前不在持有自旋锁、原子上下文或禁抢占区中。
所以更准确的表述是: “返回用户态前”只是最常见、最自然的重调度检查点之一,但内核态中间也完全可能发生调度——只是需要满足更严格的上下文条件。
一张完整的调度器地图
你应该已经拥有了 Linux 调度器的完整骨架:
- 调度类分层:Linux 调度器不是一个 CFS 独裁国。它先按
sched_class分层,再在每一层内部用各自的规则做决策。stop > deadline > rt > fair > idle。 - 对象体系:fair class 内部的核心概念是
sched_entity、cfs_rq、vruntime、rb-tree。新内核正在向 EEVDF 迁移。 - RT 和 Deadline:rt 是固定优先级抢占式调度;deadline 基于 EDF + CBS,提供截止时间和带宽保证。
- per-CPU runqueue:每个 CPU 有自己的
struct rq,包含当前 task、各类的 runnable 集合和本地记账信息。调度决策的核心是”处理 prev → 挑选 next → 切换 curr”。 - Wakeup path:wakeup 只是让 task 从 blocked 变回 runnable,给它重新参与竞争的资格——不等于立刻运行。
- Preemption:抢占是”当前 task 还能跑,但被调度器判定应该先让别人上”。它和主动阻塞是完全不同的机制。
need_resched是意图,schedule()是实施。 - 多核调度:负载均衡在防止 CPU 闲置和保留局部性之间做权衡。NUMA 让”算力放哪”和”数据放哪”必须联合考虑。
- Context switch:同时涉及内核执行现场切换和可能的地址空间(mm/页表)切换。trap frame 和 switch_to 是两件不同但衔接的事。
- 观测手段:
sched_switch、sched_wakeup、perf sched、ftrace、BPF tracing 是理解调度行为的核心工具链。