Published on

Linux 调度、进程、线程与协程

Authors
  • avatar
    Name
    Vegetog
    Twitter

打开浏览器、播放音乐、编译代码,这些事情可以同时进行。但 CPU 能同时执行的线程数量有限,其他线程只能等待。

Linux 调度器要解决的就是:现在让谁使用 CPU,运行多久,什么时候换人。

理解这件事之后,进程、线程、协程和上下文切换就可以连起来了。本文从普通 Linux 用户程序出发,重点讲它们的关系和开销,不展开内核源码。

1. Linux 到底在调度什么?

先记住一句话:进程提供资源,线程获得 CPU,协程在线程上被安排执行。

进程是程序运行时的资源容器,包含地址空间、打开的文件等。线程是其中真正执行代码的单位。协程则是程序内部可以暂停和恢复的一段执行流程,通常由语言运行时或协程库安排执行。

对于常见的 Linux 原生线程,内核会分别调度每一个线程。Linux 调度手册也明确以可运行的线程作为调度对象。

例如,有两个进程:

进程 A:线程 A1、线程 A2
进程 B:线程 B1

Linux 调度器选择可运行的线程:
CPU 0:运行 A1
CPU 1:运行 B1
A2:暂时等待

这里并不存在一条固定规则,要求“先选进程 A,再选 A 里面的线程”。内核最终选中的是具体线程。不过,cgroup 等分组机制也会影响一组任务能获得多少 CPU 时间。

那么,为什么经常听到“进程调度”?因为普通进程启动时就有一个主线程。如果它一直只有这一个线程,调度这个线程,看起来就等于调度整个进程。

所谓跨进程切换,实际是从一个进程的线程,切换到另一个进程的线程。

2. 调度器什么时候换人?

为了理解调度,可以先把线程分成三种情况:

状态简单解释
运行中正在 CPU 上执行
就绪已经可以执行,但还没轮到 CPU
阻塞正在等待某个条件,暂时不能继续执行

这是方便理解的分类,不是完整列举 Linux 的内部状态。比如,Linux 内部的 TASK_RUNNING 同时涵盖正在运行和就绪的任务。

假设线程 A 要从网络读取数据,但数据还没到。使用阻塞式读取时,它就可能进入等待状态,让 CPU 去执行其他线程。等数据到达,A 被唤醒,重新变成可运行状态,再由调度器决定何时执行。

就绪 → 获得 CPU → 运行
运行 → 等待 I/O → 阻塞
阻塞 → 条件满足、被唤醒 → 就绪
运行 → 被抢占 → 就绪

常见的切换原因包括:当前线程等待 I/O、睡眠或等待某些锁;当前线程运行了一段时间,需要给其他任务机会;或者更高优先级的任务变得可运行,需要抢占 CPU。

这里有两个细节。第一,被唤醒不代表马上执行,只代表可以参与调度。第二,等待锁也不一定阻塞,例如自旋锁会让线程继续占用 CPU 等待。

3. 调度器根据什么选择线程?

不同任务有不同需求。桌面程序希望及时响应,编译任务希望提高吞吐量,有些任务则关心截止时间。Linux 因此提供了不同的调度策略。

类型核心思路
普通任务按权重分配 CPU 时间,同时兼顾响应速度
实时任务按实时优先级安排执行,常见策略是 SCHED_FIFOSCHED_RR
Deadline 任务根据运行预算、周期和截止时间安排执行

普通任务可以通过 nice 值影响 CPU 份额,范围为 -20~19。数值越小,权重通常越高,在与其他任务竞争时更有利。它不是“必须先执行”的命令,也不保证固定比例的 CPU 时间。在 Linux 中,不同线程可以有不同的 nice 值。

SCHED_FIFO 不会仅仅因为用完一个轮转时间片,就让同优先级任务接替;SCHED_RR 则会让同优先级任务按时间片轮流执行。实时优先级和 nice 值是两套不同的机制,不能直接比较数值。具体规则见 Linux 调度策略说明

在多核系统中,每个逻辑 CPU 有自己的运行队列。调度器还需要做负载均衡,尽量避免一边忙不过来、一边闲着,同时考虑 CPU 亲和性,也就是线程允许在哪些 CPU 上运行。

它也不会为了绝对平均而不停搬动线程:线程换到另一个 CPU 核,之前积累的缓存优势可能就用不上了。

4. 一个线程包含哪些东西?

线程可以理解成:一份能够暂停,并在之后继续执行的程序状态。

看一个简单的 C++ 例子:

void work() {
    int x = 10;
    calculate(x);
}

如果线程在 calculate(x) 内部被暂停,之后还要接着执行,系统至少需要知道:执行到了哪条指令,计算到一半的数据是什么,当前正在调用哪些函数。

内容保存什么
程序计数器 PC执行到哪里
寄存器状态当前计算使用的数据、地址和标志等
栈指针 SP、用户栈栈的位置,以及函数调用、局部变量、返回地址等
线程局部存储 TLS每个线程独立的数据,例如自己的 errno
内核栈线程在内核中执行时使用的栈
内核管理信息线程 ID、状态、调度策略、优先级、CPU 亲和性等

PC 和 SP 本身也是寄存器,这里单独列出,是为了说明它们的作用。局部变量也不一定全部放在栈上,编译器可能把它们放到寄存器中,或者直接优化掉。

同一进程的线程通常共享代码、全局变量、堆和打开的文件,但分别使用自己的栈和线程局部数据。这些共享与独立属性可以在 Pthreads 手册中查到。

线程有自己的用户栈,不代表栈受到独立地址空间的保护。 各线程的用户栈仍然位于同一个进程地址空间中;拿到有效地址时,其他线程也可能访问它。因此,共享内存上的并发读写仍然需要正确同步。

还要区分“线程拥有的东西”和“切换时需要搬动的东西”。栈、TLS 数据、管理结构平时就留在内存中,不需要每次切换都复制一份。

5. 一个进程包含哪些东西?

线程解决“代码怎么接着执行”,进程则提供执行所需的整体环境。

内容具体例子
虚拟地址空间代码、全局数据、堆、共享库映射、各线程的用户栈
内存管理信息内存映射和页表等
一个或多个线程每个线程的执行状态、栈和调度信息
文件描述符表打开的文件、socket、管道等
身份与权限进程 ID、用户和组身份等
其他运行环境工作目录、信号处理方式、资源限制等

可以把它画成下面这样:

进程 A
├── 共享的运行资源
│   ├── 地址空间:代码、全局数据、堆、用户栈……
│   ├── 内存映射和页表
│   ├── 文件描述符表:文件、socket……
│   └── 工作目录、权限、资源限制……
├── 线程 A1:执行状态、自己的栈、调度信息……
└── 线程 A2:执行状态、自己的栈、调度信息……

图中的用户栈既属于进程地址空间,又由对应线程使用,并不是两份不同的栈。

不同进程通常拥有各自独立的地址空间,但也能显式共享部分内存。Linux 的底层接口允许控制地址空间、文件描述符表等资源是否共享,因此上述分类是理解普通程序的模型,而不是说所有资源都必须硬性绑在一个结构中。clone 接口说明介绍了这些共享关系。

6. 上下文切换到底切换了什么?

“上下文”就是恢复执行所需要的状态。先看同一进程中,从线程 A1 切换到线程 A2:

A1 正在运行
内核调度器决定运行 A2
保存 A1 恢复执行所需的状态
切换线程相关状态,恢复 A2
A2 从原来的位置继续执行

这是概念上的顺序。真实内核中,寄存器状态的保存和恢复分布在进入内核、切换任务、返回用户态等路径上,不是一个函数一次性处理所有内容。

关键是:切换线程不会复制整个栈,更不会复制整个堆。 A1 的栈原本就在内存里,暂停后仍然留在那里;切换到 A2 时,恢复对应的执行状态,让 CPU 接着使用 A2 的栈即可。

如果从进程 A 的线程 A1,切到进程 B 的线程 B1,还需要切换地址空间相关状态:

保存 A1 的执行状态
切换到进程 B 的地址空间
恢复 B1 的执行状态
继续执行 B1

为什么要换地址空间?因为两个进程里的同一个虚拟地址,可能代表不同的内存。

进程 A:虚拟地址 0x1000 → 物理位置 X
进程 B:虚拟地址 0x1000 → 物理位置 Y

这里的地址仅用于示意。CPU 切换进程后,需要使用正确的地址映射。通常涉及切换页表相关的 CPU 状态,并不是复制整张页表,也不是把进程 B 的所有数据重新载入内存。

文件描述符表、工作目录等资源也不需要在切换时全部复制。内核处理 B1 的请求时,会找到它所对应的资源。

还有一个常见误解:进入内核,不等于切换线程。 线程发起系统调用后,可能由同一个线程在内核中完成工作,再回到用户态;只有发生任务切换,才是这里讨论的线程上下文切换。

7. 上下文切换的开销在哪里?

可以把开销分成两类:完成切换本身的开销,以及切换之后恢复运行效率的开销。

完成切换本身的开销

内核需要选择下一个任务,维护运行队列,保存和恢复必要的寄存器状态,切换内核栈和其他线程相关状态。浮点、向量寄存器等状态也需要按照架构和内核实现正确处理。

跨进程切换通常还需要切换地址空间相关状态。这些工作都要消耗 CPU 时间,而这段时间没有用来执行应用的业务计算。

但不能把前面两张资源表里的所有内容,都算成每次切换必须复制的数据。资源留在内存里,CPU 主要是在更换接下来使用的执行状态和相关引用。

切换之后的缓存开销

CPU 缓存保存最近使用的代码和数据,访问它通常比访问内存快。

A 正在运行:缓存里有很多 A 经常使用的数据
切换到 B:B 要使用的数据可能不在缓存里
         → 缓存未命中
         → 去更低级缓存或内存读取
         → 执行暂时变慢

这不代表每次切换都会清空缓存。问题是:缓存里的内容可能对新任务不够有用,新任务随后还可能挤掉旧任务的数据。

即使两个线程属于同一进程,如果它们访问完全不同的数据,也会遇到这种情况。线程迁移到另一个 CPU 核时,原来那个核的私有缓存也未必能直接帮上忙。

地址翻译的开销

程序使用虚拟地址,CPU 最终要访问物理内存。页表记录地址映射,TLB 缓存最近用过的地址翻译结果。

如果 TLB 中没有需要的映射,就需要查询页表,增加访问成本。跨进程切换后,新进程需要的映射可能还没有缓存,或者某些旧条目需要失效处理。TLB 的失效操作和之后的重新填充都可能产生成本,内核文档也专门讨论了这两种成本之间的取舍。Linux TLB 文档

现代 CPU 可以通过 PCID、ASID 一类的地址空间标识,区分不同地址空间的条目,因此不能笼统地说“切换进程一定清空整个 TLB”。具体行为与 CPU 架构、内核实现及配置有关。

因此,同一进程内切换线程,通常少了更换地址空间的负担;跨进程切换通常有额外成本。但两者不存在一个适用于所有机器和负载的固定耗时,也不能直接说一定相差多少倍。

8. 协程由谁调度?为什么通常更轻?

前面讲的是内核调度。对于常见的用户态协程,内核通常看不到每个协程,只看到承载它们的线程。

Linux 内核:决定线程 T 何时获得 CPU
协程运行时:在线程 T 上选择哪个协程执行
                 协程 A → 协程 B → 协程 C

于是形成两层安排:Linux 调度线程,程序里的运行时或协程库调度协程。

比如,协程 A 正在请求网络数据:

协程 A 发起异步请求
结果没到,A 挂起
同一个线程继续执行协程 B
结果到达,A 被标记为可以继续执行
运行时在合适的时候恢复 A

这里,协程 A 挂起,不意味着承载它的线程也阻塞了。只要还有其他可执行的协程,线程就可以继续工作。如果所有协程都在等待,运行时也可能让线程进入等待状态,避免空转。

不过,如果协程直接调用了普通的阻塞操作,而运行时又没有把它转换成异步操作或转交给其他线程,承载它的线程仍然可能被一起卡住。

协程也需要保存恢复状态,但实现有不同方式。有栈协程通常保留自己的栈,并切换栈指针和必要寄存器;无栈协程通常由编译器将挂起位置和需要保留的数据放进协程状态对象中。不能认为所有协程都拥有一份独立的大栈。像 Boost.Context 这样的库,就提供了在同一线程中保存、恢复执行上下文的基础能力。

同一线程内的纯协程切换通常可以在用户态完成,不需要为了这次切换调用内核调度器,也不需要更换进程地址空间。 这就是它通常更轻的主要原因。

但协程不是零开销:运行时调度、状态维护、内存分配和缓存影响仍然存在。协程之间交替执行,也不会自动把计算速度变快。

至于并发和并行,可以这样区分:

  • 多个协程在同一个线程中交替执行,是并发;它们不能依靠这一个线程,在多个 CPU 核上同时执行。
  • 多个协程由多个线程承载时,在运行时和程序允许的情况下,可以在不同 CPU 核上并行执行。

协程的切换时机也取决于具体实现。很多协程依靠 awaityield 等挂起点交出执行权,有些运行时还支持抢占机制,不能把所有协程都理解成同一种调度方式。

9. 用一个例子把它们连起来

假设一个服务进程 P,有两个工作线程 T1、T2,以及三个处理请求的协程 A、B、C:

进程 P:提供地址空间、堆、文件和 socket 等资源
├── 线程 T1:承载协程 A、B
│   ├── A 等待异步网络请求,挂起
│   └── B 在线程 T1 上继续执行
└── 线程 T2:承载协程 C
    └── C 可以在另一个 CPU 核上执行

这时,三种切换就很清楚了:

切换场景谁安排主要变化
T1 内的协程 A → B协程运行时更换协程执行状态,仍然是线程 T1
进程 P 内的线程 T1 → T2Linux 内核更换线程执行状态,通常沿用同一地址空间
进程 P 的 T1 → 进程 Q 的线程Linux 内核更换线程执行状态,并切换地址空间

表中的线程切换,是指在某个逻辑 CPU 上发生的先后接替;T1 和 T2 也可以各自在不同 CPU 上同时运行。

以后分析一段程序的调度与切换,可以依次问三个问题:谁在安排执行?保存和恢复了哪些状态?有没有更换地址空间? 再看缓存、TLB 和 CPU 迁移的影响,就能说清大部分常见开销。