第 3 天:好几个程序都想运行,CPU 先给谁?
昨天的 launcher 创建了子进程,然后调用 waitpid 等它结束。等待期间,父进程没有事情可做。如果它一直占着 CPU,反复检查“结束了吗”,其他程序就白白少了一份运行机会。
操作系统会让它暂时等待,把 CPU 交给别的任务。等子进程结束,父进程才重新获得运行资格。
决定下一个运行谁,就是 CPU 调度。
“同时运行”到底是什么意思?
先只看一个 CPU 核心。同一时刻,它只能执行一个任务的指令。如果浏览器、音乐播放器和编译器都需要它,操作系统就要在它们之间切换。切换足够快时,我们感觉它们在同时运行。
多核 CPU 可以让多个任务真正并行;但只要想运行的任务多于可用核心,仍然需要调度。
严格地说,现代操作系统通常调度的是线程。今天先把它理解为“可以交给 CPU 运行的任务”;进程和线程的区别留到明天。
一个任务不运行,不代表它在排队
讨论调度前,要分清三种情况:
就绪:现在可以运行,只是在等 CPU
运行:正在 CPU 上执行
阻塞:目前运行不了,要等某件事发生
比如,编译器正在计算,可能处于运行或就绪状态;程序调用 sleep(10) 后,要等计时结束,属于阻塞状态。昨天父进程调用 waitpid,也可能因为子进程尚未结束而阻塞。
阻塞的任务不能靠“多分一点 CPU 时间”解决问题。把它安排上 CPU,它仍然要等。因此调度器主要在已经就绪的任务之间作选择。
状态会随事件变化:
就绪 ──获得 CPU──→ 运行
↑ │
│ 时间用完
└────────────────────┘
运行 ──等待 I/O 等事件──→ 阻塞
就绪 ←──等待的事件发生── 阻塞
这是一张简化图。它足够解释:为什么一个程序开着却几乎不占 CPU,以及为什么另一个程序会持续占用 CPU。
用三个任务算一遍
假设只有一个 CPU 核心,A、B、C 在同一时刻到达,而且运行期间都不需要等待 I/O:
| 任务 | 需要的 CPU 时间 |
|---|---|
| A | 8 毫秒 |
| B | 4 毫秒 |
| C | 2 毫秒 |
为了比较安排方式,先约定两个指标:
- 响应时间:从任务到达,到第一次获得 CPU,等了多久。
- 周转时间:从任务到达,到整个任务完成,过了多久。
这里所有任务都在第 0 毫秒到达,所以完成时刻也就是它们的周转时间。
方法一:先到先运行
如果按 A、B、C 的顺序,一个做完再做下一个:
时间 0 8 12 14
|--- A ---|-- B --|- C -|
A 立即开始,B 等了 8 毫秒,C 等了 12 毫秒。虽然 C 只需要 2 毫秒,却被长任务 A 挡在后面。
这种安排很容易理解,问题也明显:排在前面的任务一旦很长,后面短任务的响应就会变慢。
方法二:每次最多运行 2 毫秒
现在给每个任务一个 2 毫秒的时间片。用完后,如果还没做完,就回到队列里等下一轮:
时间 0 2 4 6 8 10 12 14
|A |B |C |A |B |A |A |
C 在第 6 毫秒完成,B 在第 10 毫秒完成,A 在第 14 毫秒完成。它们第一次获得 CPU 的时间分别是 0、2、4 毫秒。
对比一下:
| 安排方式 | A、B、C 的响应时间 | 平均响应时间 |
|---|---|---|
| 一个做完再做下一个 | 0、8、12 毫秒 | 约 6.7 毫秒 |
| 每次最多运行 2 毫秒 | 0、2、4 毫秒 | 2 毫秒 |
时间片让每个任务更早获得第一次运行机会。对于需要及时回应用户操作的程序,这很重要。
但时间片也不是越短越好。切换任务要花时间;切得太频繁,CPU 就会把更多时间用在切换上,而不是完成工作。
还有一种想法是让最短的任务先做。按 C、B、A 的顺序,它们分别在第 2、6、14 毫秒完成,平均周转时间更短。问题在于:操作系统通常无法提前准确知道一个任务究竟要运行多久;如果短任务不断到来,长任务也可能等得过久。
这几个例子说明,调度没有一个对所有场景都最好的顺序。让用户尽快得到回应、让任务尽快完成、让大家公平地获得 CPU,这些目标有时会互相牵制。真实系统的调度也比上面的队列复杂得多。
CPU 是怎样从 A 切到 B 的?
假设 A 正运行,操作系统决定让 B 运行。A 不能下次回来就从头开始,它得接着刚才的位置往下做。
因此,切换时需要保存 A 的运行现场,例如它执行到哪里、寄存器里是什么;再恢复 B 上次留下的现场,让 B 继续。这叫上下文切换。
切换可能发生在任务主动等待时:昨天的 sleep、waitpid 就属于这类情况。运行中的任务也可能被打断,例如计时器中断让内核有机会重新安排 CPU。这使得一个持续计算的程序不能只因“自己从不主动停下”就一直独占 CPU。这种由操作系统打断并重新安排的能力,叫抢占。
需要注意,进入内核处理一次系统调用,和切换到另一个任务,不是同一件事。程序可以进入内核,又返回原来的程序继续执行;只有换了运行对象,才发生这里讨论的任务上下文切换。
在自己的电脑上看一眼
下面的实验在 Linux 或 WSL 中运行。sleep 会等待计时结束;yes 则会不停产生输出,我们把输出丢进 /dev/null,让它持续做事又不刷屏:
sleep 20 & idle=$!
yes > /dev/null & busy=$!
sleep 2
ps -o pid,stat,pcpu,comm -p "$idle","$busy"
kill "$idle" "$busy"
wait "$idle" "$busy" 2>/dev/null
重点看 STAT 和 %CPU:sleep 通常显示为睡眠状态,CPU 占用很低;yes 通常会占用明显更多的 CPU。状态只是 ps 查看那一瞬间的快照,具体字母和数值可能随环境变化。
这两个程序都“开着”,区别是一个在等时间到来,一个不断有工作可做。调度器面对的,正是这种不断变化的情况。
今天留下一个判断方法:看到程序没有运行,先问它是在等 CPU,还是在等别的事。前者靠调度获得运行机会;后者要等事件发生。明天我们看同一个进程里开出多个线程后,事情为什么会变得更难。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)