跳转至

CH4 补充读物:Threads(先看当前代码里“执行流”的基础设施)

1. 这份读物怎么用

这份文档是给你课后独立看进程代码用的,不是课堂提纲。

读完后,你应该能回答下面 7 个问题:

  1. 当前仓库真正被调度的对象是什么?
  2. 一个“执行流”至少需要保存哪些现场?
  3. 为什么 contexttrapframe 和栈都不能少?
  4. 如果以后把这套内核扩展为线程内核,哪些资源应该共享,哪些应该私有?
  5. 为什么 CPUS=1CPUS=2 能帮助你区分并发与并行?
  6. 为什么线程章节还必须读 sleep/wakeup 与锁?
  7. 当前仓库为什么还不能算“完整线程实现”?

2. 先把边界说清:当前仓库还没有完整线程实现

直接先说结论:

  1. 当前用户态没有 pthread_* 线程库接口。
  2. kernel/core/proc.cscheduler() 目前按 struct proc 调度。
  3. 当前也没有“同一地址空间内多个执行流分别拥有独立用户栈和 trapframe”的实现。

这一章的正确读法

读 CH4 时,不要去代码里硬找一个现成的“线程子系统”。更有价值的问题是:PPT 里说线程需要哪些底层能力,而当前仓库已经在哪些文件里把这些能力的雏形实现出来了。

也就是说,这一章的目标不是证明“仓库已经实现了 pthread”,而是帮你看清:

  1. 线程为什么比进程更轻。
  2. 一个可调度执行流至少需要什么状态。
  3. 当前代码如果将来要支持线程,最可能从哪里改起。

3. 线程的最小抽象:一个执行流至少要有什么

PPT 里说线程至少要有:

  1. 程序计数器(program counter)。
  2. 一组寄存器。
  3. 一段自己的栈。
  4. 可被调度、阻塞和恢复执行的状态。

把这几项落到当前代码里,对应关系很清楚:

  1. kernel/include/proc.h:struct context:保存内核态调度切换时的寄存器现场。
  2. kernel/include/proc.h:struct trapframe:保存用户态陷入内核时的寄存器现场与 epc
  3. kstack_base / kstack_top:每个当前可调度实体自己的内核栈。
  4. state / chan:执行流当前是否 RUNNABLERUNNINGSLEEPING 等。

所以,虽然当前仓库主要以“进程”为单位调度,但线程所需的最核心底座——执行现场 + 栈 + 状态机——你已经能直接在代码里看到。

4. struct proc 现在扮演了“可调度执行实体”的角色

理解 CH4 时,最重要的一步是接受一个现实:

  1. 理论上,thread 是调度单位。
  2. 在当前仓库里,真正进入 scheduler() 的对象是 struct proc
  3. 因此 struct proc 现在同时承载了“进程资源容器”和“执行流描述符”两部分职责。

你可以直接看这些字段:

  1. pagetablesz:地址空间。
  2. ofile[]cwd:打开文件与当前目录。
  3. context:调度切换上下文。
  4. trapframe:用户态返回现场。
  5. statechankilled:调度与阻塞状态。

为什么这和线程章节有关

在线程系统里,往往需要把“进程共享资源”和“线程私有执行现场”拆开;而当前 struct proc 恰好把这两类信息放在了一起。看懂这一点,你就知道未来若要实现线程,第一步往往就是“把该共享的和该私有的拆分出来”。

5. 如果把当前代码扩展成线程系统,哪些东西该共享

你可以把下面这张表当作“CH4 理论和当前代码的对照表”:

当前代码里的对象/字段 在当前仓库中的归属 如果扩展为同进程多线程,通常应如何理解
pagetablesz 每个 struct proc 一份 同一进程内多个线程通常共享
ofile[]cwd 每个 struct proc 一份 同一进程内多个线程通常共享
trapframe 每个 struct proc 一份 每个线程应各自拥有
context 每个 struct proc 一份 每个线程应各自拥有
内核栈 kstack_* 每个 struct proc 一份 每个线程应各自拥有
statechan 每个 struct proc 一份 调度时仍应按线程分别维护

这张表能帮你把 PPT 中“线程共享什么、私有什么”的理论,真正落到当前代码字段上。

6. 并发与并行:先用当前调度器看懂,再回头理解线程

虽然当前调度的是进程,不是线程,但 CH4 里的 concurrency / parallelism 区分依然可以直接观察。

你可以尝试:

make qemu CPUS=1

进入系统后运行:

stsched

再用多 hart 运行:

make qemu CPUS=2

理解重点:

  1. CPUS=1 时,多个执行流只能交错推进,这更接近并发。
  2. CPUS=2 时,不同 hart 可以同时执行不同 RUNNABLE 实体,这才接近并行。
  3. kernel/core/proc.c:scheduler() 是每个 hart 自己在跑的调度循环。

所以 CH4 里“多线程能利用多核”这句话,在当前仓库里虽然还要先借助多进程来观察,但硬件层和调度层的区别已经能看清楚了。

7. 上下文切换:这是理解线程的第一现场

建议你直接连读下面 4 个位置:

  1. kernel/include/proc.h 中的 struct context
  2. kernel/arch/riscv/swtch.S
  3. kernel/core/proc.c:sched()
  4. kernel/core/proc.c:scheduler()

你要看明白的是:

  1. scheduler() 选中一个 RUNNABLE 实体。
  2. swtch() 保存当前内核上下文,恢复目标实体的上下文。
  3. 目标实体继续在自己的内核栈上执行。
  4. 若该实体之后 yield()sleep(),又会切回调度器。

这正是 PPT 中“线程切换比进程切换轻”的前置知识。即使你现在看到的还是进程级切换,也已经能理解执行流切换的本质。

8. 阻塞与唤醒:线程系统必须依赖的另一半基础设施

线程章节不能只看“创建”,还必须看“何时阻塞、如何恢复”。

当前仓库最值得读的接口是:

  1. kernel/core/proc.c:sleep(chan, lk)
  2. kernel/core/proc.c:wakeup(chan)
  3. kernel/core/trap.c:ticks_sleep()
  4. kernel/core/pipe.c:pipewrite() / piperead()
  5. kernel/core/sleeplock.c:acquiresleep()

它们共同说明了一件事:

  1. 执行流并不是一直忙等。
  2. 条件不满足时,可以把自己置为 SLEEPING 并让出 CPU。
  3. 条件满足后,再由别人把它唤醒回 RUNNABLE

为什么 CH4 一定要读这部分

无论你以后实现的是内核线程、用户线程还是线程池,只要系统里存在多个执行流,就一定绕不开阻塞、唤醒和同步。sleep/wakeup 正是这套机制在当前仓库里的最小核心。

9. 锁为什么在 CH4 里反而更重要

线程比进程更容易共享状态,所以线程章节和锁天然绑在一起。

在当前仓库里:

  1. kernel/core/spinlock.c 提供短临界区的自旋锁。
  2. kernel/core/sleeplock.c 提供会阻塞等待的睡眠锁。
  3. pipe.cproc.c、文件系统路径里都在依赖这些锁保护共享状态。

你至少要建立两个判断:

  1. 共享状态越多,同步压力越大。
  2. 线程“轻”并不代表并发程序“简单”;恰恰相反,共享越方便,越容易出错。

10. 把 Pthreads 和当前仓库放在一起时,应该怎么类比

理论课会讲 pthread_create()pthread_join() 等 API,但当前仓库没有现成用户线程库,所以正确姿势是“类比,不等同”。

可以先这样记:

线程世界的概念 当前仓库里最接近的类比物 关键差别
pthread_create() fork() fork() 会复制出新地址空间,不是同进程线程
pthread_join() wait() wait() 回收的是子进程,不是同进程线程
线程私有执行现场 context + trapframe + 栈 当前这些都仍挂在 struct proc
线程共享进程资源 pagetableofile[]cwd 当前没有“多个执行流共享这些字段”的实现

所以读 CH4 时,真正应该建立的是“线程语义与当前进程语义的边界感”。

11. 线程模型在当前仓库里应该怎么理解

PPT 里的 many-to-oneone-to-onemany-to-many,本质上都在讨论:

  1. 用户线程由谁管理。
  2. 内核调度器到底看见谁。
  3. 阻塞语义最终落在哪一层。

而当前仓库的状态是:

  1. 没有用户线程库,因此谈不上现成的 many-to-one
  2. 调度器直接面向 struct proc,也没有“一个进程内多个内核线程共享地址空间”的现成实现。
  3. 所以它更适合作为“线程底层机制的准备阶段”来理解,而不是某种完整线程模型的成品。

如果以后要把它扩展成接近 one-to-one 的线程实现,最可能的改动点会是:

  1. 让多个执行实体共享同一个 pagetable 和大部分进程资源。
  2. 为每个线程单独分配 contexttrapframe、用户栈和内核栈。
  3. 重新设计 exit() / wait() 一类接口,区分“线程退出”和“进程整体退出”。

12. 建议的代码阅读主线

按下面顺序读,会最顺:

  1. kernel/include/proc.h
  2. kernel/core/proc.c 中的 yield()sleep()wakeup()scheduler()
  3. kernel/arch/riscv/swtch.S
  4. kernel/core/trap.c
  5. kernel/core/spinlock.c
  6. kernel/core/sleeplock.c

如果你已经读过 CH3,再把 fork()wait() 和这里的“线程类比”对照起来看,效果会更好。

13. 最常见误解

  1. 以为 CH4 就一定能在当前仓库里找到完整 pthread 实现。
  2. 把“进程是资源容器”和“线程是执行流”这两个层次混为一谈。
  3. contexttrapframe 当成同一种现场。
  4. 以为“多执行流”只需要创建接口,不需要阻塞、唤醒和锁。
  5. 以为“线程更轻”就等于“并发程序更容易写对”。

14. 最小自测(仅自检)

你可以问自己这 7 个问题:

  1. 当前仓库真正被 scheduler() 调度的对象是什么?
  2. contexttrapframe 分别保存哪一层的现场?
  3. 为什么每个可调度实体都需要自己的栈?
  4. 如果多个线程共享同一地址空间,pagetable 应该归谁?
  5. sleep/wakeup 为什么是线程系统的底层基础设施?
  6. 当前仓库为什么还不能算完整的线程内核?
  7. 如果以后要实现线程,struct proc 里哪些字段最可能被拆成“共享部分”和“线程私有部分”?

如果这些问题都能结合源码回答,CH4 的核心就真正掌握了。