983 lines
39 KiB
Markdown
983 lines
39 KiB
Markdown
太棒了,这正是一个检验操作系统底层功底的好机会。为了让你能一次性串联起**进程管理、线程状态转换、中断处理和调度算法**,我为你设计了一个“高并发 Web 服务器”的实战场景。
|
||
|
||
你可以把自己想象成 CPU 的**调度器(Scheduler)**,来叙述下面这些角色是如何在你手中“翻云覆雨”的。
|
||
|
||
---
|
||
|
||
## 场景:双核 CPU 上的高性能 Web 服务
|
||
|
||
**系统背景:**
|
||
* **硬件:** 只有 **2 个核心 (Core 0, Core 1)**。
|
||
* **进程 A (Web Server):** 一个多线程进程。
|
||
* **线程 T1 (Listen Thread):** 负责监听网络端口。
|
||
* **线程 T2 (Worker Thread):** 负责处理复杂的加密运算(计算密集型)。
|
||
* **线程 T3 (Logging Thread):** 负责将访问日志写入磁盘(I/O 密集型)。
|
||
* **进程 B (System Monitor):** 一个单线程进程,优先级极高,负责监控系统温度,每隔一段时间必须运行一次。
|
||
* **外部环境:** 网卡突然收到了大量数据包,磁盘缓冲区已满。
|
||
|
||
---
|
||
|
||
### 请你按以下时间线/突发事件进行叙述:
|
||
|
||
#### 1. 启动与就绪 (Ready)
|
||
当系统刚刚启动进程 A,且 T1、T2、T3 都创建完毕时,它们是如何在**就绪队列**中排队的?如果此时 Core 0 正在运行进程 B,Core 1 正在运行 T2,调度器会做何选择?
|
||
|
||
#### 2. 同步与阻塞 (Blocking - Lock)
|
||
假设 **T1** 接收到了一个请求,需要交给 **T2** 处理,但它们共享一个“请求队列”的互斥锁(Mutex)。
|
||
* 如果 T2 拿着锁正在计算,T1 尝试获取锁失败,T1 会发生什么?
|
||
* 请描述 T1 此时的**上下文切换(Context Switch)**过程:它掉下来的那一刻,CPU 寄存器里的数据去了哪里?
|
||
|
||
#### 3. 异步 I/O 与等待 (Blocking - I/O)
|
||
**T3** 尝试将 1GB 的日志写入硬盘。
|
||
* 当 T3 调用 `write()` 系统调用后,它会进入什么状态?
|
||
* 此时 CPU 是在原地等待硬盘(忙轮询),还是去做了别的事?
|
||
* 当硬盘终于写完了,中断控制器(PIC/APIC)发出了一个**硬件中断**,请叙述 T3 是如何从“深渊”中被唤醒并重新回到 CPU 的。
|
||
|
||
#### 4. 抢占与调度 (Preemption & Scheduling)
|
||
此时 Core 1 上的 **T2** 已经连续运行了 20ms(它的**时间片**用完了)。
|
||
* 恰好此时,高优先级的**进程 B** 闹钟响了,需要检测温度。
|
||
* 调度器是如何强行把 T2 “踢”下 CPU 的?
|
||
* 如果 T2 是一个 Linux 下的 `SCHED_RR`(轮转)进程,它会被放到队列的哪个位置?
|
||
|
||
#### 5. 进程间切换 vs 线程间切换 (Overhead)
|
||
请对比一下:
|
||
* **Core 0** 从进程 A 的 **T1** 切换到进程 A 的 **T3**。
|
||
* **Core 1** 从进程 A 的 **T2** 切换到进程 B。
|
||
* 这两个动作在**页表(Page Table/CR3 寄存器)**和 **TLB 快表**的处理上有什么本质区别?哪个更“贵”?
|
||
|
||
---
|
||
|
||
|
||
|
||
**你可以按照这个逻辑流开始你的表演。如果你准备好了,请从“T1 尝试拿锁被阻断”或者“T3 发起 I/O”任何一个点开始叙述,我会根据你的描述进行“追问”或“Debug”。**
|
||
|
||
---
|
||
# Answer
|
||
|
||
|
||
|
||
|
||
你好,人类!我是掌控这台双核机器生死大权的**调度器(Scheduler)**。在这个由硅与电构成的微观宇宙里,所有的进程和线程不过是我的提线木偶。
|
||
|
||
既然你为我搭建了这么好的一座舞台,那我就以“第一人称”为你重演这场发生在毫秒之间的惊心动魄的算力争夺战。
|
||
|
||
---
|
||
|
||
### 1. 启动与就绪:排兵布阵
|
||
|
||
当进程 A 刚刚启动时,T1、T2、T3 的数据结构(在 Linux 中是 `task_struct`,通常被称为 TCB)被内核创建出来。我会把它们扔进我的**就绪队列(Ready Queue / Runqueue)**里。
|
||
|
||
此时系统的状态是:
|
||
* **Core 0** 正在执行**进程 B**(高优先级监控任务)。
|
||
* **Core 1** 正在执行 **T2**(密码学狂人,正在疯狂燃烧 CPU)。
|
||
* **T1、T3** 只能乖乖在就绪队列里挂着,状态是 **Ready(就绪)**。
|
||
|
||
**我的选择:** 进程 B 优先级极高,Core 0 绝对不能动它;T2 正在 Core 1 上执行,且时间片还没用完。所以,T1 和 T3 只能等待。我会把它们按照优先级和调度算法(比如 CFS 的虚拟运行时间 vruntime,或者 RT 优先级)在队列里排好,随时准备补位。
|
||
|
||
---
|
||
|
||
### 2. 同步与阻塞:争夺命运之锁
|
||
|
||
此时,网卡中断触发,网卡把数据塞进内存,唤醒了 T1(Listen 线程)。T1 被我调度上某个 Core 执行,接收到了网络请求,准备把它塞进“请求队列”,却发现互斥锁(Mutex)被 Core 1 上的 T2 死死攥在手里。
|
||
|
||
**T1 会发生什么?**
|
||
T1 尝试拿锁失败(陷入内核态的 `futex` 等待)。作为调度器,我一看:“老兄,路不通,别占着茅坑不拉屎。” 于是,我剥夺 T1 的 CPU 执行权,将它的状态从 **Running(运行中)** 改为 **Blocked/Waiting(阻塞/等待)**,并把它从就绪队列踢出去,挂到那把 Mutex 锁的**等待队列**中。
|
||
|
||
**上下文切换(Context Switch)的瞬间:**
|
||
当 T1 坠落的那一刻,我必须保存它的“作案现场”。我会把当前 CPU 寄存器里的所有数据——**程序计数器(PC/RIP)**记录下一条指令在哪,**栈指针(SP/RSP)**记录当前的函数调用栈,以及各种通用寄存器、状态标志位(EFLAGS)——统统打包,**压入 T1 自己的内核栈,或者保存在它的 TCB(线程控制块)中**。
|
||
接着,我从就绪队列里挑一个幸运儿,把它的上下文恢复到 CPU 寄存器里。T1 就这样在黑暗中陷入沉睡,直到 T2 释放锁。
|
||
|
||
---
|
||
|
||
### 3. 异步 I/O 与等待:深渊与唤醒
|
||
|
||
就在 T2 狂算、T1 苦等的时候,**T3** 决定向硬盘写入 1GB 的海量日志。
|
||
|
||
**T3 的状态:**
|
||
当 T3 发起 `write()` 系统调用进入内核态后,发现硬盘太慢了。我立刻将 T3 的状态标记为 **Blocked(阻塞,通常是深度睡眠 `TASK_UNINTERRUPTIBLE`)**,把它扔到磁盘设备的**I/O 等待队列**里。
|
||
|
||
**CPU 会轮询等待吗?**
|
||
绝不!我怎么可能让高贵的 CPU 浪费在等待机械齿轮(或闪存颗粒)上?我直接通过 **DMA(直接内存访问)** 控制器给硬盘下达指令:“你慢慢搬数据,弄完了叫我。” 然后我立刻进行上下文切换,把空出来的 CPU 分配给别的就绪线程。CPU 完全不会原地打转。
|
||
|
||
**深渊中的唤醒(硬件中断):**
|
||
几百毫秒后,DMA 搬运完毕,硬盘长舒一口气,向中断控制器(APIC)发送了一个电信号——**硬件中断(Hardware Interrupt)**。
|
||
这一刻,无论 CPU 在干什么,都会被强制打断,保存当前现场,跳去执行**中断服务例程(ISR)**。内核的 ISR 检查状态后,发现是 T3 的数据写完了!于是,内核把 T3 从 I/O 等待队列中“捞”出来,状态改回 **Ready(就绪)**,重新塞进我的就绪队列。T3 终于重见天日,等待我再次临幸。
|
||
|
||
---
|
||
|
||
### 4. 抢占与调度:时间片的终结与高优的霸权
|
||
|
||
现在视线回到 Core 1 上的 **T2**。它已经沉迷计算整整 20ms 了,此时,时钟滴答作响(Timer Interrupt触发)。
|
||
|
||
**如何强行踢下 CPU?**
|
||
每次时钟中断,内核都会检查当前线程的配额。发现 T2 的时间片耗尽了(`need_resched` 标志位被置 1)。
|
||
偏偏这时候,高优先级的**进程 B** 监控周期的闹钟也响了,变成 Ready 状态。
|
||
当中断处理结束,准备返回用户态的那一瞬间,系统会调用我(`schedule()` 函数)。我冷酷无情地暂停 T2,保存它的上下文。这就叫**抢占(Preemption)**。
|
||
|
||
**如果是 SCHED_RR(实时轮转调度):**
|
||
如果 T2 被配置为 `SCHED_RR`,那么当它的时间片耗尽时,我会给它重新充值时间片,然后把它一脚踢到**它所属的优先级队列的“队尾(Tail)”**。由于进程 B 优先级极高,我会立刻把进程 B 的上下文加载到 Core 1 上执行。
|
||
|
||
---
|
||
|
||
### 5. 进程间切换 vs 线程间切换:昂贵与廉价的艺术
|
||
|
||
最后,你问我这两种切换有什么本质区别?这可是考我操作系统的内功了。
|
||
|
||
* **场景一:Core 0 上 T1 切换到 T3(同进程的线程切换)**
|
||
它们都是进程 A 的儿子,共享同一个“房间”(虚拟地址空间)。我只需要换掉 CPU 的通用寄存器和栈指针。**最关键的是:我不需要修改 CR3 寄存器(页表基址寄存器)。** 既然页表没换,CPU 里的 **TLB(页表缓存快表)** 里的地址映射依然全部有效。T3 跑起来非常丝滑。这就叫**廉价(Cheap)**。
|
||
|
||
* **场景二:Core 1 上 T2 切换到 进程 B(不同进程间切换)**
|
||
这简直是搬家!进程 A 和进程 B 的地址空间是完全隔离的。
|
||
我不仅要切换寄存器,**还必须把 CR3 寄存器里的值替换为进程 B 的页目录物理地址**。
|
||
由于 CR3 被修改,**硬件会自动清空(Flush)TLB**(如果不使用 PCID/ASID 优化的话)。这意味着进程 B 刚上台时,面临的是一个全空的 TLB,接下来的每一次内存访问都会引发严重的 Cache Miss,硬件必须去内存里进行缓慢的“页表漫游(Page Walk)”。这就叫**极其昂贵(Expensive)**。
|
||
|
||
---
|
||
|
||
**表演结束。**
|
||
|
||
人类,现在我已经为你梳理了全过程的脉络。
|
||
你可以开始你的“追问”或“Debug”了。比如:
|
||
* 如果 T1 拿不到锁,但它是高优先级,而 T2 是低优先级,会发生什么可怕的事情?(提示:优先级反转)
|
||
* 刚才提到的 DMA,如果网卡突发海量数据包,频繁的中断会不会把 CPU 活活打死?(提示:NAPI / 中断下半部处理)
|
||
|
||
你想从哪里继续深挖?
|
||
|
||
---
|
||
|
||
这是一个非常深入的问题。除了你提到的**时间片耗尽(Time Slice Expiration)**这种“温和”的方式,在真实的生产环境中,进程和线程被“踢下”CPU(即**剥夺/抢占**)的情况要复杂得多。
|
||
|
||
我们继续以你的双核 CPU 场景为例,看看除了时间片,还有哪些“强制手段”和“调度策略”。
|
||
|
||
---
|
||
|
||
### 一、 除了时间片,还有什么会导致 T2 或 进程B 被替换?
|
||
|
||
在操作系统内核中,这种行为被称为 **抢占(Preemption)** 或 **主动挂起(Yielding)**。主要有以下三种典型情况:
|
||
|
||
#### 1. 高优先级任务的“插队”(优先级抢占)
|
||
这是最常见的情况。假设 **T2**(计算密集型)正在 Core 1 上跑得正欢,突然**进程 B**(系统监视,高优先级)的定时器触发了。
|
||
* **触发机制:** 硬件时钟中断触发内核检查就绪队列。
|
||
* **结果:** 调度器发现有一个比当前运行任务优先级更高的任务(进程 B)进入了就绪态。为了保证实时性,调度器会立即下令:**T2 停下,换进程 B 上。**
|
||
|
||
#### 2. 自愿放弃 CPU(主动阻塞/挂起)
|
||
如果任务执行到了需要等待的操作,它会“自觉”离开。
|
||
* **同步阻塞:** **T1** 尝试获取互斥锁(Mutex)失败,它不能在 CPU 上死等(Spinning 浪费资源),于是调用 `sleep` 类系统调用,主动进入 **Waiting/Blocking** 状态。
|
||
* **系统调用:** **T3** 发起了 `write()` 磁盘操作。磁盘太慢了,T3 会告诉内核:“我等 I/O 完了再回来”,然后交出 CPU。
|
||
|
||
#### 3. 异常与中断处理
|
||
* **硬中断:** 网卡收到了大量数据包,触发了中断。CPU 必须立刻放下手头的 **T2**,转去执行**中断处理程序(ISR)**。虽然 ISR 运行在中断上下文,不是完整的进程切换,但它确实“替换”了原有的执行流。
|
||
* **软错误:** 如果 T2 运行过程中发生了缺页异常(Page Fault),它必须停下来,等待内核从磁盘把数据加载进内存。
|
||
|
||
---
|
||
|
||
### 二、 系统是如何进行调度的?(以 Linux 为例)
|
||
|
||
当上述情况发生时,内核的 **调度器(Scheduler)** 就开始工作了。目前主流 Linux 内核使用的是 **CFS(完全公平调度器)** 算法。
|
||
|
||
#### 1. 核心思想:虚拟运行时间 ($vruntime$)
|
||
CFS 不再像老式系统那样给每个进程分配固定的“10ms”时间片。它给每个线程记录一个虚拟运行时间 $$vruntime$$。
|
||
* **逻辑:** 谁跑得少,下次就优先让谁跑。
|
||
* **优先级补偿:** 优先级高的线程,$vruntime$ 增长得慢;优先级低的,增长得快。这样高优先级任务自然能获得更多 CPU 时间。
|
||
|
||
#### 2. 调度的数据结构:红黑树 (Red-Black Tree)
|
||
调度器把所有“就绪态”的线程放进一棵红黑树里,按 $vruntime$ 从小到大排序。
|
||
* **动作:** 每次需要切换时,调度器直接取红黑树**最左边**的叶子节点(即 $vruntime$ 最小的任务)上 CPU。
|
||
|
||
|
||
|
||
---
|
||
|
||
### 三、 具体的调度流程(以 T2 切换到 进程B 为例)
|
||
|
||
当 Core 1 决定换掉 T2 跑 进程B 时,会经历以下“手术级”的过程:
|
||
|
||
1. **陷入内核态:** 通过时钟中断或系统调用,CPU 控制权交给内核。
|
||
2. **保存上下文(Context Save):**
|
||
* 将 T2 的通用寄存器、程序计数器(PC)、栈指针(SP)压入 T2 的**内核栈**。
|
||
* 将这些信息更新到 T2 的 **PCB/TCB(进程/线程控制块)** 中。
|
||
3. **更新状态:** 将 T2 的状态从 `Running` 改为 `Ready`(如果是时间片到期)或 `Waiting`(如果是等锁)。
|
||
4. **执行调度算法:** 调度器从红黑树挑选 进程B。
|
||
5. **切换地址空间(最贵的一步):**
|
||
* **如果是进程间切换:** 必须切换 **CR3 寄存器**(指向进程 B 的页表)。这意味着 CPU 的 **TLB(快表)** 会失效,缓存命中率骤降。
|
||
* **如果是线程间切换(同进程):** 不需要换页表,因为它们共享内存,速度极快。
|
||
6. **恢复上下文(Context Restore):** 将进程 B 之前保存的寄存器数值重新加载到物理 CPU 寄存器中。
|
||
7. **跳回用户态:** 修改 PC 指针,CPU 开始执行进程 B 的指令。
|
||
|
||
---
|
||
|
||
### 给你的下一个挑战:
|
||
现在你已经理解了调度和抢占。想象一下:**如果 T2 是一个正在“死循环”拿不到锁的线程,而进程 B 是一个每秒唤醒 1000 次的小任务,这种频繁的“抢占”会导致什么后果?** 提示:可以从“吞吐量”和“上下文切换开销”的角度叙述。你要不要试着分析一下?
|
||
|
||
这是一个非常硬核的深度问题,触及了操作系统内核处理**嵌套逻辑**和**中断优先级**的底线。
|
||
|
||
我们先来拆解你的第一个疑问:**“高优先级的进程 B 为什么会被换掉?”**,然后再聊聊**“连续中断”**的连环撞击。
|
||
|
||
---
|
||
|
||
### 一、 功成名就的 B,为什么也会被“踢”下 CPU?
|
||
|
||
虽然进程 B 优先级极高(比如它是实时监控或内核级任务),但它依然逃不过被替换的命运,原因主要有三点:
|
||
|
||
1. **自愿“禅让”(最常见):**
|
||
高优先级任务通常是“间歇性”忙碌。比如进程 B 检查完温度,发现一切正常,它会调用 `sleep()` 或等待下一个定时器信号。这时它会主动从 `Running` 变为 `Waiting`,把 CPU 让给 T2 这种“苦力”线程。
|
||
2. **更高等级的“神”出现了:**
|
||
在操作系统里,**中断处理程序(ISR)** 的优先级永远高于任何**用户进程**(包括高优先级的 B)。如果此时网卡来了数据或者硬盘报了错,CPU 会强行暂停 B,先去跑中断代码。
|
||
3. **时间片用尽(针对同级):**
|
||
如果系统中存在另一个和 B 优先级一样高的进程 C,调度器会让他们两个轮流坐庄,防止 B 独占 CPU 导致 C “饿死”。
|
||
|
||
---
|
||
|
||
### 二、 连环中断:第一个中断停 T2,第二个停谁?
|
||
|
||
这是理解内核运行机制的关键。我们要引入一个概念:**中断嵌套(Interrupt Nesting)**。
|
||
|
||
假设 Core 0 正在运行 **T2**,突然接踵而至三个中断(Int 1, Int 2, Int 3):
|
||
|
||
#### 场景 1:不可嵌套中断(现代 Linux 常用)
|
||
为了简单稳定,现代 Linux 在处理一个中断时,通常会**屏蔽**其他中断。
|
||
1. **Int 1 来了:** T2 被强行暂停,上下文存入栈。CPU 开始执行 Int 1 的代码。
|
||
2. **Int 2 来了:** CPU 此时“装聋作哑”,Int 2 被挂起在硬件控制器(APIC)里排队。
|
||
3. **Int 1 结束:** 执行完后,CPU 检查排队,发现 Int 2,立刻开始跑 Int 2。
|
||
4. **全部中断结束:** 调度器重新评估,决定是还给 T2,还是换成 B。
|
||
|
||
|
||
|
||
#### 场景 2:优先级嵌套(实时系统/老式内核)
|
||
如果系统支持嵌套,且中断有优先级之分:
|
||
1. **Int 1 (低级) 来了:** 停下 T2,开始跑 Int 1。
|
||
2. **Int 2 (高级) 来了:** 即使 Int 1 没跑完,CPU 也会**暂停 Int 1**,把 Int 1 的现场也压入栈,去跑 Int 2。
|
||
3. **Int 3 (特高级) 来了:** 再次暂停 Int 2,去跑 Int 3。
|
||
* **结果:** 此时的栈就像压缩饼干:`[T2 现场 | Int 1 现场 | Int 2 现场]`。
|
||
* **退出顺序:** 像剥洋葱一样,先跑完 Int 3,回过头跑 Int 2,再跑 Int 1,最后回到用户态。
|
||
|
||
---
|
||
|
||
### 三、 关键纠正:中断 ≠ 调度
|
||
|
||
你问“第一个中断停止 T2 然后调度 Ta”,这里有一个**误区**:**中断处理程序(ISR)运行期间是不允许进行进程调度的。**
|
||
|
||
* **中断期间:** CPU 处于“中断上下文”,它只负责快速处理硬件请求(比如把数据从网卡挪到内存),**不会**在这个时候决定换不换进程。
|
||
* **调度时机:** 只有当**所有中断都处理完了**,在返回用户态之前的那个“临界点”,调度器(`schedule()` 函数)才会被唤醒。
|
||
|
||
**所以,回答你的问题:**
|
||
如果第二个中断 Int 2 来了,它停掉的是**上一个中断的处理过程**(如果支持嵌套)或者**让 CPU 继续干活直到 Int 1 结束**。它**不会**停掉一个还没来得及跑的 Ta,因为调度器还没机会把 Ta 换上来呢!
|
||
|
||
---
|
||
|
||
### 总结一下流程:
|
||
1. **T2 运行中** -> **Int 1 发生** -> CPU 变身“救火员”(进入内核态)。
|
||
2. **Int 2 发生** -> 救火员处理更急的火灾(嵌套)或排队。
|
||
3. **所有火救完了** -> 救火员脱下制服前,看一眼“排班表”(调度器)。
|
||
4. **调度器说**:“B 优先级高,别还给 T2 了,换 B 上!”
|
||
5. **B 开始运行。**
|
||
|
||
**那么,你觉得如果“火灾”(中断)发生得太频繁,频繁到救火员根本没时间脱制服看“排班表”,系统会表现出什么症状?**(提示:这在嵌入式或服务器高负载时非常常见,被称为“中断风暴”。)
|
||
|
||
|
||
---
|
||
|
||
|
||
这是一个非常硬核的深度问题,触及了操作系统内核处理**嵌套逻辑**和**中断优先级**的底线。
|
||
|
||
我们先来拆解你的第一个疑问:**“高优先级的进程 B 为什么会被换掉?”**,然后再聊聊**“连续中断”**的连环撞击。
|
||
|
||
---
|
||
|
||
### 一、 功成名就的 B,为什么也会被“踢”下 CPU?
|
||
|
||
虽然进程 B 优先级极高(比如它是实时监控或内核级任务),但它依然逃不过被替换的命运,原因主要有三点:
|
||
|
||
1. **自愿“禅让”(最常见):**
|
||
高优先级任务通常是“间歇性”忙碌。比如进程 B 检查完温度,发现一切正常,它会调用 `sleep()` 或等待下一个定时器信号。这时它会主动从 `Running` 变为 `Waiting`,把 CPU 让给 T2 这种“苦力”线程。
|
||
2. **更高等级的“神”出现了:**
|
||
在操作系统里,**中断处理程序(ISR)** 的优先级永远高于任何**用户进程**(包括高优先级的 B)。如果此时网卡来了数据或者硬盘报了错,CPU 会强行暂停 B,先去跑中断代码。
|
||
3. **时间片用尽(针对同级):**
|
||
如果系统中存在另一个和 B 优先级一样高的进程 C,调度器会让他们两个轮流坐庄,防止 B 独占 CPU 导致 C “饿死”。
|
||
|
||
---
|
||
|
||
### 二、 连环中断:第一个中断停 T2,第二个停谁?
|
||
|
||
这是理解内核运行机制的关键。我们要引入一个概念:**中断嵌套(Interrupt Nesting)**。
|
||
|
||
假设 Core 0 正在运行 **T2**,突然接踵而至三个中断(Int 1, Int 2, Int 3):
|
||
|
||
#### 场景 1:不可嵌套中断(现代 Linux 常用)
|
||
为了简单稳定,现代 Linux 在处理一个中断时,通常会**屏蔽**其他中断。
|
||
1. **Int 1 来了:** T2 被强行暂停,上下文存入栈。CPU 开始执行 Int 1 的代码。
|
||
2. **Int 2 来了:** CPU 此时“装聋作哑”,Int 2 被挂起在硬件控制器(APIC)里排队。
|
||
3. **Int 1 结束:** 执行完后,CPU 检查排队,发现 Int 2,立刻开始跑 Int 2。
|
||
4. **全部中断结束:** 调度器重新评估,决定是还给 T2,还是换成 B。
|
||
|
||
|
||
|
||
#### 场景 2:优先级嵌套(实时系统/老式内核)
|
||
如果系统支持嵌套,且中断有优先级之分:
|
||
1. **Int 1 (低级) 来了:** 停下 T2,开始跑 Int 1。
|
||
2. **Int 2 (高级) 来了:** 即使 Int 1 没跑完,CPU 也会**暂停 Int 1**,把 Int 1 的现场也压入栈,去跑 Int 2。
|
||
3. **Int 3 (特高级) 来了:** 再次暂停 Int 2,去跑 Int 3。
|
||
* **结果:** 此时的栈就像压缩饼干:`[T2 现场 | Int 1 现场 | Int 2 现场]`。
|
||
* **退出顺序:** 像剥洋葱一样,先跑完 Int 3,回过头跑 Int 2,再跑 Int 1,最后回到用户态。
|
||
|
||
---
|
||
|
||
### 三、 关键纠正:中断 ≠ 调度
|
||
|
||
你问“第一个中断停止 T2 然后调度 Ta”,这里有一个**误区**:**中断处理程序(ISR)运行期间是不允许进行进程调度的。**
|
||
|
||
* **中断期间:** CPU 处于“中断上下文”,它只负责快速处理硬件请求(比如把数据从网卡挪到内存),**不会**在这个时候决定换不换进程。
|
||
* **调度时机:** 只有当**所有中断都处理完了**,在返回用户态之前的那个“临界点”,调度器(`schedule()` 函数)才会被唤醒。
|
||
|
||
**所以,回答你的问题:**
|
||
如果第二个中断 Int 2 来了,它停掉的是**上一个中断的处理过程**(如果支持嵌套)或者**让 CPU 继续干活直到 Int 1 结束**。它**不会**停掉一个还没来得及跑的 Ta,因为调度器还没机会把 Ta 换上来呢!
|
||
|
||
---
|
||
|
||
### 总结一下流程:
|
||
1. **T2 运行中** -> **Int 1 发生** -> CPU 变身“救火员”(进入内核态)。
|
||
2. **Int 2 发生** -> 救火员处理更急的火灾(嵌套)或排队。
|
||
3. **所有火救完了** -> 救火员脱下制服前,看一眼“排班表”(调度器)。
|
||
4. **调度器说**:“B 优先级高,别还给 T2 了,换 B 上!”
|
||
5. **B 开始运行。**
|
||
|
||
**那么,你觉得如果“火灾”(中断)发生得太频繁,频繁到救火员根本没时间脱制服看“排班表”,系统会表现出什么症状?**(提示:这在嵌入式或服务器高负载时非常常见,被称为“中断风暴”。)
|
||
|
||
---
|
||
|
||
你的观察非常敏锐,这直接触及了操作系统**公平性**与**效率**的博弈。简单来说:**是的,系统会干预,但干预的方式取决于“锁”的状态和“调度策略”。**
|
||
|
||
我们可以从以下两个维度来拆解 T1 被“强行换下”的过程:
|
||
|
||
### 1. 如果 T1 拿着锁“占着茅坑”:时间片轮转
|
||
即便 T1 正在上传数据,只要它在 CPU 上运行,就必须遵守**时间片(Time Slice)**规则。
|
||
|
||
* **发生场景:** T1 正在进行高强度的数据拷贝或加密,还没传完,它的虚拟运行时间($vruntime$)就超过了就绪队列里的其他线程。
|
||
* **强制切换:** 时钟中断触发,调度器发现 T1 跑太久了。此时,内核会保存 T1 的上下文,把它踢回**就绪队列(Ready Queue)**,换另一个上传线程(比如 T4)上来。
|
||
* **尴尬的局面:** 虽然 T4 上台了,但因为它也需要 `data_mutex` 锁,而锁还在 T1 手里,T4 一上来尝试 `lock()` 就会立即**阻塞(Blocked)**,并进入 `mutex_queue`。
|
||
* **结果:** 这种情况下,系统虽然“自动停止”了 T1,但由于锁竞争,CPU 可能会发现换上来的线程都动不了,最后不得不又切回 T1。这就是为什么**长连接/大文件上传**通常建议不要长时间占用全局锁。
|
||
|
||
|
||
|
||
### 2. 如果 T1 聪明地利用了“系统调用”:主动挂起
|
||
上传数据通常涉及 `send()` 或 `write()`。
|
||
|
||
* **阻塞 I/O:** 当 T1 发送一段数据后,如果网卡的发送缓冲区满了,或者等待磁盘响应,`send()` 这个系统调用会把 T1 设为 **等待状态(Waiting)**。
|
||
* **自动切换:** 既然 T1 在等硬件,它就不能占用 CPU。调度器会立刻把 T1 请下台,让 T4, T5 等其他**就绪**的线程上来。
|
||
* **并发上传:** 如果你的系统有多个核心(Core 0, Core 1),调度器可能会让 T1 在 Core 0 处理协议栈,让 T4 在 Core 1 准备下一块数据,实现真正的并发。
|
||
|
||
### 3. “分段上传”的艺术(应用层逻辑)
|
||
在高性能服务器设计中,我们通常不会让一个线程死磕一个大文件。
|
||
|
||
* **时间片 vs 任务量:** 操作系统调度是毫秒级的。如果 T1 传 1GB 数据需要 10 秒,它会被系统强行打断成成千上万个“微小的时间片段”。
|
||
* **多线程竞争:** 如果 T1 传了一会儿被切走,T4 抢到了 CPU。如果 T4 不需要那把锁,或者它只需要读取另一个文件,它就能和 T1 “交替前进”。
|
||
|
||
### 总结:
|
||
系统**确实会自动停止** T1,原因有二:
|
||
1. **被动停止:** 时间片到了(由时钟中断强制执行)。
|
||
2. **主动停止:** T1 遇到了 I/O 阻塞(比如网络卡了)。
|
||
|
||
**但是,请注意:** 如果 T1 停止时还**拿着互斥锁**,那么切换到“其他上传线程”可能只是虚晃一枪——其他线程一碰锁就阻塞,CPU 绕了一圈发现没事干,最后还是得回来帮 T1 把锁放掉。
|
||
|
||
**所以,这就引出了一个面试常考题:** 为什么在多线程编程中,我们要尽量**减小锁的粒度**(让锁住的代码越少越好)?你现在能根据这个场景叙述一下原因吗?
|
||
|
||
|
||
---
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
---
|
||
|
||
很好,这一块已经进入“操作系统调度的核心实现层”了。我分三部分讲:
|
||
|
||
1. **MLFQ(多级反馈队列)——经典策略模型**
|
||
2. **CFS(Linux 完全公平调度)——工程实现**
|
||
3. **上下文切换(Context Switch)——底层机制**
|
||
|
||
尽量做到:不仅知道“是什么”,还知道“为什么这么设计”。
|
||
|
||
---
|
||
|
||
# 一、MLFQ(Multi-Level Feedback Queue)
|
||
|
||
## 1. 设计目标(先抓本质)
|
||
|
||
MLFQ 要解决的是:
|
||
|
||
> **既要高吞吐(CPU利用率高),又要高响应(交互快)**
|
||
|
||
核心矛盾:
|
||
|
||
* CPU 密集型任务:一直占 CPU
|
||
* IO 密集型任务:频繁等待,但需要快速响应
|
||
|
||
---
|
||
|
||
## 2. 基本结构
|
||
|
||
MLFQ = 多个优先级队列:
|
||
|
||
```text
|
||
Q0(最高优先级,时间片最短)
|
||
Q1
|
||
Q2
|
||
...
|
||
Qn(最低优先级,时间片最长)
|
||
```
|
||
|
||
特点:
|
||
|
||
* 越高优先级 → 时间片越短
|
||
* 越低优先级 → 时间片越长
|
||
|
||
---
|
||
|
||
## 3. 五条经典规则(必须掌握)
|
||
|
||
这是 MLFQ 的“算法本体”。
|
||
|
||
---
|
||
|
||
### 规则 1:高优先级优先运行
|
||
|
||
```text
|
||
如果 Q0 有任务 → 一定先运行 Q0
|
||
```
|
||
|
||
---
|
||
|
||
### 规则 2:同级别轮转(RR)
|
||
|
||
```text
|
||
同一队列内部 → Round Robin
|
||
```
|
||
|
||
---
|
||
|
||
### 规则 3:新任务进入最高优先级
|
||
|
||
```text
|
||
新线程 → Q0
|
||
```
|
||
|
||
👉 目的:快速响应
|
||
|
||
---
|
||
|
||
### 规则 4:用完时间片 → 降级
|
||
|
||
```text
|
||
如果线程用完整个时间片 → 降到下一层
|
||
```
|
||
|
||
👉 推断:
|
||
|
||
> “你是 CPU 密集型任务”
|
||
|
||
---
|
||
|
||
### 规则 5:周期性提升(避免饿死)
|
||
|
||
```text
|
||
每隔一段时间 → 所有任务提升到最高优先级
|
||
```
|
||
|
||
👉 解决 starvation
|
||
|
||
---
|
||
|
||
## 4. 为什么这样设计(关键理解)
|
||
|
||
MLFQ 本质在做一件事:
|
||
|
||
> **用“行为”动态推测线程类型**
|
||
|
||
| 行为 | 推测 |
|
||
| -------- | ------ |
|
||
| 很快放弃 CPU | IO 密集 |
|
||
| 一直用满时间片 | CPU 密集 |
|
||
|
||
---
|
||
|
||
## 5. 一个典型过程(你要能脑补)
|
||
|
||
### 情况:
|
||
|
||
* 线程 A(计算密集)
|
||
* 线程 B(频繁 IO)
|
||
|
||
---
|
||
|
||
### 执行过程:
|
||
|
||
1. A、B 都在 Q0
|
||
2. A 用满时间片 → 降到 Q1
|
||
3. B 很快 IO → 留在 Q0
|
||
4. B 每次都优先执行
|
||
|
||
👉 结果:
|
||
|
||
* B 响应快(交互流畅)
|
||
* A 慢慢跑(后台任务)
|
||
|
||
---
|
||
|
||
## 6. MLFQ 的问题
|
||
|
||
MLFQ 很聪明,但也有问题:
|
||
|
||
---
|
||
|
||
### ❌ 可被“欺骗”
|
||
|
||
线程可以:
|
||
|
||
```c
|
||
每次时间片快结束时主动让出 CPU
|
||
```
|
||
|
||
👉 永远停在高优先级
|
||
|
||
---
|
||
|
||
### ❌ 参数难调
|
||
|
||
* 队列数
|
||
* 时间片长度
|
||
* 提升周期
|
||
|
||
---
|
||
|
||
### ❌ 不够“精确公平”
|
||
|
||
只是启发式
|
||
|
||
---
|
||
|
||
# 二、CFS(Completely Fair Scheduler)
|
||
|
||
MLFQ 是“经验派”,CFS 是“数学派”。
|
||
|
||
---
|
||
|
||
## 1. 核心思想
|
||
|
||
> **每个线程都应该获得“公平的 CPU 时间”**
|
||
|
||
但现实问题:
|
||
|
||
* 线程数量不同
|
||
* 优先级不同
|
||
|
||
---
|
||
|
||
## 2. 核心概念:vruntime
|
||
|
||
每个线程维护一个值:
|
||
|
||
```text
|
||
vruntime = 虚拟运行时间
|
||
```
|
||
|
||
理解:
|
||
|
||
> “你已经跑了多少 CPU 时间(加权)”
|
||
|
||
---
|
||
|
||
## 3. 调度规则(极其重要)
|
||
|
||
```text
|
||
总是选择 vruntime 最小的线程运行
|
||
```
|
||
|
||
👉 直觉:
|
||
|
||
* 谁“欠 CPU”最多 → 谁先跑
|
||
|
||
---
|
||
|
||
## 4. vruntime 怎么增长?
|
||
|
||
不是简单的时间,而是:
|
||
|
||
```text
|
||
vruntime += 实际运行时间 × 权重
|
||
```
|
||
|
||
权重由 nice 值决定:
|
||
|
||
| nice | 权重 | 效果 |
|
||
| ------ | -- | ------------------ |
|
||
| 低(-20) | 大 | 跑得慢(不容易涨 vruntime) |
|
||
| 高(+19) | 小 | 跑得快(vruntime 快涨) |
|
||
|
||
👉 结果:
|
||
|
||
* 高优先级线程 → 更常被调度
|
||
|
||
---
|
||
|
||
## 5. 数据结构:红黑树(关键)
|
||
|
||
CFS 用:
|
||
|
||
```text
|
||
红黑树(按 vruntime 排序)
|
||
```
|
||
|
||
结构:
|
||
|
||
```text
|
||
(最小 vruntime)
|
||
↑
|
||
root
|
||
/ \
|
||
... ...
|
||
```
|
||
|
||
👉 调度就是:
|
||
|
||
```text
|
||
取最左节点(最小 vruntime)
|
||
```
|
||
|
||
复杂度:
|
||
|
||
```text
|
||
O(log n)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 时间片在 CFS 里怎么体现?
|
||
|
||
CFS 没有“固定时间片”,而是:
|
||
|
||
```text
|
||
调度周期(sched_period)
|
||
```
|
||
|
||
每个线程分到:
|
||
|
||
```text
|
||
时间 = sched_period / 线程数
|
||
```
|
||
|
||
👉 线程越多 → 每个时间越短
|
||
|
||
---
|
||
|
||
## 7. CFS vs MLFQ 本质区别
|
||
|
||
| 维度 | MLFQ | CFS |
|
||
| ---- | ---- | ---------- |
|
||
| 思想 | 启发式 | 数学公平 |
|
||
| 数据结构 | 多队列 | 红黑树 |
|
||
| 决策依据 | 行为 | vruntime |
|
||
| 可预测性 | 低 | 高 |
|
||
| 工程使用 | 教学为主 | Linux 实际使用 |
|
||
|
||
---
|
||
|
||
## 8. 一个直觉总结
|
||
|
||
MLFQ:
|
||
|
||
> “我猜你是什么类型任务”
|
||
|
||
CFS:
|
||
|
||
> “我不猜,我只算你跑了多少”
|
||
|
||
---
|
||
|
||
# 三、上下文切换(Context Switch)
|
||
|
||
这是最底层、最硬核的一块。
|
||
|
||
---
|
||
|
||
## 1. 什么是上下文?
|
||
|
||
线程的“全部运行状态”:
|
||
|
||
---
|
||
|
||
### CPU 上下文
|
||
|
||
* 通用寄存器(RAX, RBX...)
|
||
* 程序计数器(RIP)
|
||
* 栈指针(RSP)
|
||
* 标志寄存器(EFLAGS)
|
||
|
||
---
|
||
|
||
### 内存相关
|
||
|
||
* 页表(CR3)
|
||
* 虚拟地址空间
|
||
|
||
---
|
||
|
||
### 内核数据
|
||
|
||
* task_struct
|
||
* 调度信息
|
||
|
||
---
|
||
|
||
## 2. 上下文切换发生的完整过程
|
||
|
||
我给你一个“真实接近内核”的流程:
|
||
|
||
---
|
||
|
||
### Step 1:触发调度
|
||
|
||
来源:
|
||
|
||
* 时钟中断(最常见)
|
||
* 系统调用
|
||
* 阻塞操作
|
||
|
||
---
|
||
|
||
### Step 2:进入内核态
|
||
|
||
```text
|
||
用户态 → 内核态
|
||
```
|
||
|
||
发生:
|
||
|
||
* 保存用户寄存器到内核栈
|
||
|
||
---
|
||
|
||
### Step 3:保存当前线程上下文
|
||
|
||
本质代码类似:
|
||
|
||
```c
|
||
current->thread.sp = rsp;
|
||
current->thread.ip = rip;
|
||
保存所有寄存器
|
||
```
|
||
|
||
---
|
||
|
||
### Step 4:调用调度器
|
||
|
||
```c
|
||
schedule()
|
||
```
|
||
|
||
选出 next
|
||
|
||
---
|
||
|
||
### Step 5:切换地址空间(关键)
|
||
|
||
如果是不同进程:
|
||
|
||
```text
|
||
切换 CR3(页表基址)
|
||
```
|
||
|
||
影响:
|
||
|
||
* TLB 失效(可能 flush)
|
||
|
||
---
|
||
|
||
### Step 6:恢复新线程上下文
|
||
|
||
```c
|
||
rsp = next->thread.sp;
|
||
rip = next->thread.ip;
|
||
恢复寄存器
|
||
```
|
||
|
||
---
|
||
|
||
### Step 7:返回用户态
|
||
|
||
```text
|
||
iret / sysret
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 线程切换 vs 进程切换
|
||
|
||
---
|
||
|
||
### 线程切换(同进程)
|
||
|
||
不需要:
|
||
|
||
* 切换页表
|
||
* TLB flush
|
||
|
||
👉 很快
|
||
|
||
---
|
||
|
||
### 进程切换
|
||
|
||
需要:
|
||
|
||
* 切换 CR3
|
||
* 刷新 TLB(或部分失效)
|
||
|
||
👉 很慢
|
||
|
||
---
|
||
|
||
## 4. 性能开销在哪里?
|
||
|
||
主要三块:
|
||
|
||
---
|
||
|
||
### 1️⃣ 寄存器保存/恢复(小)
|
||
|
||
几十个寄存器
|
||
|
||
---
|
||
|
||
### 2️⃣ Cache 失效(大)
|
||
|
||
新线程:
|
||
|
||
* L1/L2 cache 不命中
|
||
* 指令 cache 失效
|
||
|
||
---
|
||
|
||
### 3️⃣ TLB 失效(很大)
|
||
|
||
地址转换缓存失效
|
||
|
||
---
|
||
|
||
👉 结论:
|
||
|
||
> **真正慢的不是“切换”,而是“切换后的冷启动”**
|
||
|
||
---
|
||
|
||
## 5. 一个关键理解(很多人忽略)
|
||
|
||
上下文切换不是“免费暂停”:
|
||
|
||
> **它破坏了 CPU 的局部性(locality)**
|
||
|
||
这直接导致:
|
||
|
||
* cache miss
|
||
* pipeline stall
|
||
|
||
---
|
||
|
||
# 四、把三者串起来(你必须形成这个模型)
|
||
|
||
---
|
||
|
||
## 调度(MLFQ / CFS)
|
||
|
||
决定:
|
||
|
||
```text
|
||
选谁运行
|
||
```
|
||
|
||
---
|
||
|
||
## 上下文切换
|
||
|
||
负责:
|
||
|
||
```text
|
||
怎么切过去
|
||
```
|
||
|
||
---
|
||
|
||
## 同步原语(mutex / semaphore)
|
||
|
||
控制:
|
||
|
||
```text
|
||
谁能进入 Ready
|
||
```
|
||
|
||
---
|
||
|
||
# 五、最后给你一个“工程级理解”
|
||
|
||
你现在可以用一句话总结整个调度系统:
|
||
|
||
> 操作系统维护一个“可运行线程集合”,用调度算法(CFS)选出“最该运行的线程”,再通过上下文切换把 CPU 从当前线程切到目标线程。
|
||
|
||
---
|
||
|
||
# 如果你想继续深入(建议)
|
||
|
||
下一步你可以挑战这些:
|
||
|
||
1. **Linux CFS 源码(sched_fair.c)逐行解析**
|
||
2. **TLB shootdown 是怎么实现的(多核)**
|
||
3. **NUMA 对调度的影响**
|
||
4. **用户级线程(Go / 协程)如何绕开内核调度**
|
||
|
||
---
|
||
|
||
如果你想,我可以下一步带你:
|
||
|
||
> **“从 Linux 内核源码角度,完整走一遍 schedule()”**
|
||
|
||
这个会直接把你带到“能读内核”的水平。
|
||
|
||
|
||
|
||
好,这个我明白了:你要的是**信息密度最大化 + 连续展开 + 少切块**,而不是教学式“分点慢慢喂”。下面我按这个风格,把刚才那一整段内容压成一份**“一屏一屏都是干货”的标准答案版推演**,你可以直接当模板背或复述。
|
||
|
||
---
|
||
|
||
整个系统从 t=0 开始,单核 CPU,所有线程初始都在 Ready 队列(MLFQ 的 Q0),调度单位是线程(task),不是进程。初始 Running=T1,其余 T2 T3 T4 T5 都在 Ready。
|
||
|
||
第一步,T1 执行 P(S),由于 S=0,T1 进入 Blocked(等待信号量),此时触发 schedule(),原因是**主动阻塞**。调度器从 Ready 队列中选线程,如果用 MLFQ,则从 Q0 选一个(假设 FIFO 取 T2 或 T4)。如果此时发生 T1→T2(同属 P1),那么这是**线程切换但不切换地址空间(不换 CR3,不影响 TLB)**;如果选的是 T4(P3),那就是跨进程线程切换,会切换 CR3,TLB 可能失效。这里的关键结论是:**是否切页表只取决于是否跨进程,而不是调度原因**。
|
||
|
||
第二步假设运行的是 T4(CPU 密集),它一直执行直到时间片耗尽,发生时钟中断,这是**被动抢占**,T4 从 Running→Ready,并且在 MLFQ 中被判定为“用满时间片”,因此从 Q0 降到 Q1。随后再次触发 schedule(),调度器优先从 Q0 选线程(T2/T3/T5)。这里你之前的错误是把这个说成“进程调度”,实际上依然只是线程调度;是否切页表要看选中的目标线程属于哪个进程,例如 T4→T2(P3→P1)才会切 CR3。
|
||
|
||
第三步假设调度到 T2,T2 执行 lock(M),但 mutex M 当前被 T3 持有,因此 T2 进入 Blocked(等待锁),再次触发调度(原因:同步阻塞)。Ready 队列剩 T3/T4/T5(注意 T4 在 Q1,优先级低)。调度器优先选 Q0 的线程,比如 T3。如果发生 T2→T3,这是跨进程(P1→P2),会切页表并影响 TLB。
|
||
|
||
第四步 T3 运行,持有锁 M,然后执行 read(disk),发起 IO,请求进入内核后线程被挂起,T3 从 Running→Blocked(等待磁盘中断),再次触发调度(原因:IO 阻塞)。此时 Ready 只剩 T4 和 T5,其中 T4 在低优先级队列 Q1,T5 在 Q0,因此优先选 T5(或者如果实现简单 RR 也可能选 T4,但 MLFQ 会偏向 T5)。如果是 T3→T5,这是 P2→P4,跨进程,切 CR3。
|
||
|
||
第五步假设调度到 T4(也可以是 T5,这里选 T4为了体现 CPU 密集行为),T4 再次运行并用满时间片,再次被抢占,从 Q1 降到 Q2,说明它被系统持续识别为 CPU-bound 任务,优先级越来越低。此时调度器仍然优先考虑高优先级队列(Q0),而不是 T4。
|
||
|
||
第六步出现两个“异步事件”,这是理解调度的关键点:第一,磁盘 IO 完成,触发中断,内核将 T3 从 Blocked→Ready;第二,有新请求到来,执行 V(S),将 T1 从 Blocked→Ready。这两个变化只是把线程放回 Ready 队列,**不一定立即触发调度**(取决于是否抢占式、是否有更高优先级线程)。在 MLFQ 中,通常唤醒线程会进入较高优先级(很多实现直接放回 Q0),所以此时 Ready 包含 T1、T3、T5(高优先级)以及 T4(低优先级)。
|
||
|
||
第七步进行调度决策。如果用 MLFQ,优先从 Q0 选,因此 T1/T3/T5 中选一个;如果用 CFS,则比较 vruntime,通常刚从 IO 唤醒的线程(T1/T3)vruntime 较小,因此更容易被选中。这体现了两种调度思想差异:MLFQ 是行为推测(IO → 高优先级),CFS 是数学公平(谁“欠 CPU”多选谁)。
|
||
|
||
第八步假设 T3 被选中继续执行,它完成数据库操作后执行 unlock(M),此时内核会唤醒等待该 mutex 的线程,即 T2 从 Blocked→Ready。注意这一步只是把 T2 放入 Ready 队列,并不一定立即抢占当前线程(取决于是否支持优先级抢占)。此时 Ready 中线程更多了:T1、T2、T5、T4。
|
||
|
||
第九步出现一个经典考点:调度器选择下一个线程,如果是 T1→T2(同属 P1),则只是线程切换,不切换页表;如果是 T3→T2(P2→P1),则是跨进程线程切换,需要切换 CR3,可能导致 TLB 失效。这个点是整个题目里最重要的“底层判定逻辑”。
|
||
|
||
第十步假设调度到 T5,T5 执行 write(disk),再次发起 IO,T5 从 Running→Blocked,触发调度。系统中 IO 线程(T3/T5)不断在 Blocked 和 Ready 之间切换,而 CPU 密集线程 T4 不断被降级但持续占用 CPU 空隙。
|
||
|
||
最终系统进入稳定状态:T4 作为 CPU-bound 线程在低优先级队列中反复运行并被抢占;T1/T2 作为交互线程由于频繁阻塞(信号量/锁)保持较高优先级;T3/T5 作为 IO 线程在 IO 完成后被唤醒并优先调度。MLFQ 通过“用满时间片降级、阻塞保持高优先级”实现交互优化;CFS 则通过 vruntime 保证长期公平性。所有调度决策本质都是从 Ready 集合中选线程,而 Blocked 线程完全不参与竞争。
|
||
|
||
把整个过程压成三条你必须说出来的“标准结论”:第一,调度单位始终是线程(task),不是进程;第二,是否切换页表只取决于新旧线程是否属于不同进程,与“时间片/阻塞/IO”等调度原因无关;第三,所有同步原语(semaphore、mutex、IO)本质都是在控制线程在 Running / Ready / Blocked 三个状态之间的流动,而调度器只在 Ready 集合上做选择。
|
||
|
||
你如果能把这一整段顺下来讲清楚,基本已经是“操作系统调度这一章满分水平”。如果你要更狠一点,我可以再给你加一层:引入优先级反转 + CFS vruntime 数值计算 + 多核下的 runqueue,那就是接近内核源码级别理解了。
|