Files
Operating-System/Thread/conclude.md
2026-03-22 17:40:44 +08:00

983 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
太棒了,这正是一个检验操作系统底层功底的好机会。为了让你能一次性串联起**进程管理、线程状态转换、中断处理和调度算法**,我为你设计了一个“高并发 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 正在运行进程 BCore 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. 同步与阻塞:争夺命运之锁
此时,网卡中断触发,网卡把数据塞进内存,唤醒了 T1Listen 线程。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 被修改,**硬件会自动清空FlushTLB**(如果不使用 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. **CFSLinux 完全公平调度)——工程实现**
3. **上下文切换Context Switch——底层机制**
尽量做到:不仅知道“是什么”,还知道“为什么这么设计”。
---
# 一、MLFQMulti-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
```
👉 永远停在高优先级
---
### ❌ 参数难调
* 队列数
* 时间片长度
* 提升周期
---
### ❌ 不够“精确公平”
只是启发式
---
# 二、CFSCompletely 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=0T1 进入 Blocked等待信号量此时触发 schedule(),原因是**主动阻塞**。调度器从 Ready 队列中选线程,如果用 MLFQ则从 Q0 选一个(假设 FIFO 取 T2 或 T4。如果此时发生 T1→T2同属 P1那么这是**线程切换但不切换地址空间(不换 CR3不影响 TLB**;如果选的是 T4P3那就是跨进程线程切换会切换 CR3TLB 可能失效。这里的关键结论是:**是否切页表只取决于是否跨进程,而不是调度原因**。
第二步假设运行的是 T4CPU 密集),它一直执行直到时间片耗尽,发生时钟中断,这是**被动抢占**T4 从 Running→Ready并且在 MLFQ 中被判定为“用满时间片”,因此从 Q0 降到 Q1。随后再次触发 schedule(),调度器优先从 Q0 选线程T2/T3/T5。这里你之前的错误是把这个说成“进程调度”实际上依然只是线程调度是否切页表要看选中的目标线程属于哪个进程例如 T4→T2P3→P1才会切 CR3。
第三步假设调度到 T2T2 执行 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 在低优先级队列 Q1T5 在 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/T3vruntime 较小因此更容易被选中。这体现了两种调度思想差异MLFQ 是行为推测IO → 高优先级CFS 是数学公平(谁“欠 CPU”多选谁
第八步假设 T3 被选中继续执行,它完成数据库操作后执行 unlock(M),此时内核会唤醒等待该 mutex 的线程,即 T2 从 Blocked→Ready。注意这一步只是把 T2 放入 Ready 队列,并不一定立即抢占当前线程(取决于是否支持优先级抢占)。此时 Ready 中线程更多了T1、T2、T5、T4。
第九步出现一个经典考点:调度器选择下一个线程,如果是 T1→T2同属 P1则只是线程切换不切换页表如果是 T3→T2P2→P1则是跨进程线程切换需要切换 CR3可能导致 TLB 失效。这个点是整个题目里最重要的“底层判定逻辑”。
第十步假设调度到 T5T5 执行 write(disk),再次发起 IOT5 从 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那就是接近内核源码级别理解了。