第10讲 进程与线程II

第10讲 进程与线程II
agsd进程与线程 II:线程模型、TCB/TLS 与纤程协程的完整理解
本文根据《第10讲 进程与线程II.md》的对话内容整理而成,目标是把原始问答重构为一篇可独立阅读、可复习、可维护的正式学习文档。
引言:本讲的核心问题
“进程与线程 I”主要解释进程:程序如何变成进程,操作系统如何用 PCB 管理进程,以及 fork、exec、wait、exit 如何构成进程生命周期。
“进程与线程 II”的主线则进一步推进:
进程太重,所以引入线程;内核线程仍然有开销,所以进一步引入用户态线程、纤程和协程。
本讲要解决的核心问题不是“线程是什么”这一句定义,而是以下几个问题:
- 为什么进程内部还需要线程?
- 线程和进程到底共享什么、独占什么?
- 用户态线程和内核态线程如何区分?
- 多对一、一对一、多对多模型中的“对”到底是什么意思?
- TCB 和 TLS 分别解决什么问题?
- pthread 的创建、等待、退出和让出 CPU 如何理解?
- 线程上下文切换到底发生了什么?
- 纤程和协程为什么比线程更轻量?
整节课的逻辑可以概括为:
1 | 进程太重 |
1. 为什么需要线程
进程是操作系统管理程序运行的基本抽象,但进程本身比较重。一个进程不仅包含执行状态,还包含代码、数据、堆、栈、页表、文件描述符、地址空间等资源。创建一个进程,意味着操作系统要准备一整套运行环境。
这带来三个问题。
第一,创建进程开销大。
如果一个任务很短,却每次都创建一个新进程,创建和销毁本身就会变成明显负担。
第二,进程间通信开销大。
不同进程拥有不同地址空间,彼此不能直接访问对方的数据。如果要交互,通常需要 IPC。IPC 安全,但比同一地址空间内的变量访问、函数调用要重得多。
第三,单个进程内部需要多个执行流。
例如浏览器可能同时需要处理页面渲染、网络请求、音频播放、用户输入和脚本执行。如果一个进程内部只有一个执行流,这些任务就只能排队执行。
因此,操作系统引入线程:
线程是进程内部更轻量的执行流。
线程让一个进程内部可以同时存在多个执行路径。它们共享进程提供的资源,但各自拥有自己的运行状态。
2. 线程和进程的基本区别
可以用一句话区分进程和线程:
进程是资源分配单位,线程是 CPU 调度和执行单位。
进程像一家公司,拥有办公楼、资产、文件和制度;线程像公司里的员工,每个员工有自己的工作状态和执行路线,但共享公司的资源。
| 对比项 | 进程 | 线程 |
|---|---|---|
| 基本角色 | 资源分配单位 | 执行与调度单位 |
| 地址空间 | 进程之间通常独立 | 同一进程内线程共享 |
| 创建开销 | 较大 | 较小 |
| 通信方式 | IPC,较重 | 共享内存,较轻 |
| 隔离性 | 强 | 弱 |
| 崩溃影响 | 通常影响本进程 | 一个线程出错可能带崩整个进程 |
| 调度 | 早期可作为调度单位 | 现代系统主要调度线程 |
线程自己的内容主要包括:
- 程序计数器 PC;
- 寄存器状态;
- 用户栈;
- 内核栈;
- 线程状态;
- 线程优先级;
- 调度相关信息。
线程共享的内容主要包括:
- 代码段;
- 数据段;
- 堆;
mmap区域;- 文件描述符;
- 进程级资源;
- 同一个虚拟地址空间。
因此,多线程的本质是:
多个执行流共享同一套进程资源,但每个执行流有自己的运行现场。
3. “共享地址空间”不等于“所有变量都共享”
一个容易误解的点是:既然同一进程内多个线程共享地址空间,那是不是所有变量都共享?
答案是否定的。
更准确地说:
多线程共享同一个虚拟地址空间,但并不意味着所有变量实例都只有一份。
多线程中的变量可以分成三类。
3.1 普通全局变量:通常共享
例如:
1 | int global_count = 0; |
多个线程访问的是同一个 global_count。如果多个线程同时执行:
1 | global_count++; |
就可能发生数据竞争。因为 ++ 通常不是一个原子操作,而可能被拆成:
- 读取变量;
- 加一;
- 写回变量。
多个线程交错执行时,可能造成更新丢失。
3.2 栈变量:每个线程天然独有
例如:
1 | void *func(void *arg) { |
如果多个线程都执行 func,每个线程都有自己的用户栈,所以每个线程都有自己的 x。这些变量名字一样,但位于不同线程的栈上,不是同一个内存位置。
3.3 TLS 变量:形式像全局变量,但每个线程有一份
例如:
1 | __thread int id; |
这类变量看起来像全局变量,但每个线程都有自己的副本。
可以理解为:
1 | 线程 1 的 TLS 区:id = 1 |
代码里都写 id,但不同线程访问的是自己 TLS 区中的 id。
所以更精确的结论是:
同一进程内的多线程共享地址空间;普通全局变量和堆对象通常共享;每个线程的栈变量天然独立;被声明为 TLS 的变量虽然形式上像全局变量,但每个线程有自己的副本。
4. 用户态线程和内核态线程
线程可以按照“是否由内核直接管理”分成两类:
- 内核态线程;
- 用户态线程。
这里的“内核态线程”并不是说这个线程永远运行在内核态;“用户态线程”也不是说它只能执行普通用户代码。真正的区分点是:
谁知道它,谁管理它,谁调度它。
4.1 内核态线程
内核态线程是由操作系统内核创建、记录、调度和切换的线程。
它的特点是:
- 内核知道它的存在;
- 内核调度器可以直接调度它;
- 它通常有对应的内核数据结构;
- 它可以被分配到不同 CPU 核心上运行;
- 创建和切换通常涉及内核,开销相对更大。
例如,在主流 Linux 实现中,pthread_create 创建的 pthread 通常对应一个内核调度实体。这个线程执行用户函数时当然是在用户态运行,但它背后的调度对象由内核管理。
因此,“内核态线程”的重点不是它运行的代码位置,而是它是否是内核可见、内核可调度的对象。
4.2 用户态线程
用户态线程是由用户态线程库或语言运行时管理的轻量执行流。内核通常不知道每一个用户态线程的存在。
它的特点是:
- 内核不直接知道它;
- 用户态运行时或线程库管理它;
- 用户态调度器负责切换它;
- 切换通常不需要进入内核;
- 创建和切换开销较小;
- 是否能利用多核,取决于它映射到底层几个内核线程上。
可以用下面的结构理解:
1 | 内核眼中: |
内核只看见 K,但用户态运行时知道自己内部还有多个用户态执行流。
4.3 二者对比
| 对比项 | 用户态线程 | 内核态线程 |
|---|---|---|
| 内核是否直接可见 | 通常不可见 | 可见 |
| 谁创建 | 用户态线程库 / 语言运行时 | 操作系统内核 |
| 谁调度 | 用户态调度器 | 内核调度器 |
| 切换是否进入内核 | 通常不需要 | 通常需要 |
| 切换开销 | 小 | 较大 |
| 多核并行能力 | 取决于映射到几个内核线程 | 可以直接被内核分配到多核 |
| 阻塞影响 | 一个底层内核线程阻塞可能影响其上多个用户线程 | 一个线程阻塞通常不影响其他内核线程 |
| 典型例子 | ucontext、fiber、协程运行时内部任务 | pthread 背后的 Linux task |
核心结论是:
用户态线程和内核态线程的区别,不在于代码是否运行在用户态,而在于线程由谁管理、谁调度、内核是否看得见。
5. 三种线程模型中的“对”是什么意思
线程模型中的“多对一”“一对一”“多对多”,讨论的是:
用户态线程和内核态线程之间的映射关系。
这里的“对”可以理解为:
上层用户态执行流,如何借助下层内核调度实体获得 CPU 执行机会。
用户态线程本身不能直接占用 CPU。真正能被操作系统调度到 CPU 上执行的是内核态线程。因此,用户态线程必须映射或挂靠到某个内核线程上,才能真正运行。
可以把用户态线程理解为“任务”,把内核态线程理解为“跑道”:
| 模型 | 类比 |
|---|---|
| 多对一 | 很多任务共用一条跑道 |
| 一对一 | 一个任务配一条跑道 |
| 多对多 | 很多任务共用多条跑道 |
6. 多对一模型
多对一模型是:
多个用户态线程映射到一个内核态线程。
1 | 用户态: u1 u2 u3 u4 |
在这个模型中,内核只知道一个内核线程 K1,不知道用户态内部还有 u1、u2、u3、u4。
它的优点是:
- 用户态线程创建快;
- 用户态切换快;
- 不需要频繁进入内核;
- 内核管理简单。
它的缺点也很明显:
- 不能真正利用多核;
- 一个底层内核线程阻塞,可能影响其上的所有用户态线程;
- 可扩展性较差。
最直白地说:
多对一模型中,用户态觉得“我有很多线程”,但内核觉得“你只有一个线程”。
7. 一对一模型
一对一模型是:
一个用户态线程对应一个内核态线程。
1 | 用户态: u1 u2 u3 |
在这个模型中,每个用户态线程都有一个对应的内核线程。内核能看见每个线程,也能分别调度它们。
它的优点是:
- 可以利用多核;
- 一个线程阻塞,不一定阻塞其他线程;
- 调度由内核统一管理;
- 更符合现代操作系统的调度机制。
它的缺点是:
- 内核线程数量可能很多;
- 创建、销毁、切换都有内核开销;
- 大量短命线程会造成明显负担。
Linux、Windows、macOS 等主流系统通常采用一对一模型。
最直白地说:
一对一模型中,用户态觉得“我有几个线程”,内核也基本觉得“你有几个线程”。
8. 多对多模型
多对多模型是:
N 个用户态线程映射到 M 个内核态线程,通常 N > M。
1 | 用户态: u1 u2 u3 u4 u5 u6 |
它试图同时解决多对一和一对一的问题。
相比多对一,它可以利用多个内核线程,从而更好地利用多核。
相比一对一,它不要求每个用户态线程都对应一个内核线程,可以减少内核线程数量。
它的优点是:
- 能利用多核;
- 能控制内核线程数量;
- 适合大量用户态执行流;
- 理论上更加灵活。
它的缺点是:
- 管理复杂;
- 用户态调度器和内核调度器需要配合;
- 实现难度高。
最直白地说:
多对多模型中,用户态线程很多,但真正交给内核调度的线程数量被控制住。
9. TCB:线程控制块
之前学习进程时,有 PCB,即 Process Control Block。对应地,线程也有 TCB,即 Thread Control Block。
TCB 用于保存线程相关信息,例如:
- 线程 ID;
- 线程状态;
- 线程优先级;
- 寄存器上下文;
- 栈指针;
- TLS 信息;
- 调度相关信息。
如果说 PCB 是进程的“档案袋”,那么 TCB 就是线程的“档案袋”。
在线程模型中,TCB 可能分成内核态部分和用户态部分:
| 部分 | 作用 |
|---|---|
| 内核态 TCB | 由内核维护,支持调度、上下文切换、内核栈等 |
| 用户态 TCB | 由线程库维护,保存 pthread 结构、用户栈、TLS 等 |
在 Linux 中,进程和线程在内核层面都可以看作 task,常用同一种核心数据结构表示。它们的差别主要不在于“是不是 task”,而在于共享资源的程度不同。
可以概括为:
Linux 内核中,进程和线程都可被看作调度实体;线程与进程的差异主要体现在地址空间、文件表等资源是否共享。
10. TLS:线程本地存储
TLS 是 Thread Local Storage,即线程本地存储。它解决的问题是:
多线程共享地址空间,但有些变量需要每个线程各自拥有一份。
普通全局变量默认共享:
1 | int global_id; |
如果线程 1 修改它,线程 2 也能看到。
但有些变量虽然写起来像全局变量,却不应该被所有线程共享。例如 errno。如果多个线程同时调用系统库函数出错,每个线程都应该看到自己的错误码,而不是互相覆盖。
因此需要 TLS:
1 | __thread int id; |
它表示每个线程都有自己的 id。
TLS 的访问通常可以通过 TCB 或特定线程寄存器完成,寻址方式可以理解为:
1 | 线程本地区域基地址 + 变量偏移量 |
所以,TLS 的关键不是“变量名不同”,而是:
同一个变量名,在不同线程中对应不同的存储实例。
11. pthread 的基本操作
pthread 是 POSIX 线程库,常见操作包括:
pthread_createpthread_joinpthread_exitpthread_yield
11.1 pthread_create:创建线程
典型形式如下:
1 | pthread_create(&thread, NULL, function, arg); |
它的作用是创建一个新线程,并让新线程从指定函数开始执行。
在一对一模型下,pthread_create 往往涉及两层工作:
| 层次 | 工作 |
|---|---|
| 内核态 | 创建对应内核线程,准备内核栈,加入调度系统 |
| 应用态 | 创建 pthread 结构,准备用户栈,建立 TLS |
因此,pthread 虽然比进程轻,但并不是没有成本。
11.2 pthread_join:等待线程结束
典型形式如下:
1 | pthread_join(thread, NULL); |
它的作用是:
- 等待指定线程结束;
- 获取该线程的返回值;
- 回收相关线程资源。
可以把它理解为:
pthread_create分出一条执行流,pthread_join等这条执行流结束。
因此,join 有点像 fork 的逆向操作:fork/create 是分出去,join 是等回来。
11.3 pthread_exit:线程主动退出
典型形式如下:
1 | pthread_exit(retval); |
它让当前线程退出,并可以提供返回值。这个返回值可以被其他线程通过 pthread_join 获取。
11.4 pthread_yield:主动让出 CPU
pthread_yield 的含义是:
当前线程主动暂停,让调度器有机会运行其他线程。
它不是结束线程,而是当前线程说:“我现在可以先不运行,CPU 可以给其他线程。”
12. join 放在哪里:串行还是并行
线程题里,一个高频考点是 join 的位置。
如果写成:
1 | pthread_create(&p1, NULL, mythread, NULL); |
这基本不是并行执行。因为每创建一个线程,主线程马上等待它结束。执行顺序近似为:
1 | p1 完成 |
如果每个线程执行:
1 | for (int i = 0; i < 200; i++) { |
那么结果通常稳定为:
1 | p1 后 balance = 200 |
但如果写成:
1 | pthread_create(&p1, NULL, mythread, NULL); |
这时三个线程可能并行执行。由于 balance++ 不是原子操作,最终结果不一定可靠。循环次数越大,数据竞争越容易表现出来。
核心判断方法是:
创建一个就立刻 join:基本串行。
先全部 create,再全部 join:可能并行,需要考虑数据竞争。
13. 线程上下文切换
线程上下文切换是本讲较硬的部分。它回答的是:
CPU 如何从线程 A 切换到线程 B,并让 B 从上次暂停的位置继续执行?
线程上下文通常包括:
- 通用寄存器;
- 程序计数器 PC;
- 用户栈指针;
- 内核栈指针;
- CPU 状态寄存器;
- 页表相关寄存器;
- 其他体系结构相关状态。
以一对一线程模型为例,线程是内核调度对象,因此线程切换通常需要进入内核。
整体流程可以分为三步:
- 进入内核态并保存当前线程上下文;
- 切换页表和内核栈;
- 恢复目标线程上下文并返回用户态。
13.1 第一步:进入内核态并保存上下文
线程 A 正在用户态执行时,可能因为以下原因进入内核:
- 时钟中断;
- 系统调用;
- 异常;
- 缺页;
- I/O 事件。
进入内核后,硬件通常会自动保存一部分状态,例如:
- 用户态 PC;
- CPU 状态;
- 用户栈与内核栈切换相关信息。
然后,内核软件继续保存通用寄存器等状态到线程 A 的内核栈或 TCB 中。
13.2 第二步:切换页表和内核栈
调度器选择线程 B 后,内核需要切换到 B 的运行环境。
这一步包括:
- 切换地址空间或页表;
- 找到线程 B 的 TCB;
- 找到线程 B 的内核栈;
- 修改内核栈指针,切换到 B 的内核栈。
这里有一个非常重要的理解:
切换内核栈可以看作线程切换的分界点。
原因是:切换内核栈之后,接下来恢复上下文时,恢复代码会从目标线程 B 的内核栈中取出 B 之前保存的上下文。也就是说,从此刻开始,内核虽然还在执行同一段切换代码,但它已经站在 B 的执行现场上继续恢复了。
13.3 第三步:恢复上下文并返回用户态
内核从线程 B 的保存区域中恢复:
- 通用寄存器;
- 用户栈指针;
- 程序计数器;
- CPU 状态;
- 其他必要状态。
最后执行返回用户态的指令。此时 CPU 回到线程 B 的用户态执行位置,B 像是从上次被暂停的地方继续运行。
可以用一条链路表示:
1 | 线程 A 用户栈 |
这说明线程上下文切换不仅是“保存和恢复寄存器”,还涉及权限级别、栈、页表、调度实体等多个层面的切换。
14. 一对一线程模型的局限
一对一线程模型是主流系统常用模型,但它并不完美。
它的主要问题有两个。
第一,内核调度器不了解应用内部逻辑。
例如生产者-消费者模型中,生产者刚生产完数据后,消费者最好马上运行。但内核调度器未必知道这种应用级关系,因此不一定做出最符合业务逻辑的调度选择。
第二,短命线程开销太大。
如果一个 Web 请求只需要亚毫秒级时间,但每个请求都创建一个内核线程,那么创建、销毁和上下文切换的成本可能超过任务本身。
因此,需要更轻量、更接近用户态逻辑的执行流,这就引出了纤程和协程。
15. 纤程:用户态管理的轻量执行流
纤程可以理解为:
用户态管理的轻量级线程。
它不单独对应一个内核线程。一个内核线程上可以运行多个纤程。
内核眼中可能只有:
1 | 一个内核线程 K |
但用户态运行时眼中有:
1 | 纤程 f1、f2、f3、f4 |
纤程的优点包括:
- 不需要创建新的内核线程;
- 创建开销小;
- 切换不需要进入内核;
- 上下文切换快;
- 用户态可以自主调度;
- 更适合短任务和高并发场景。
但它也有限制:
- 内核不能直接调度每个纤程;
- 如果底层内核线程阻塞,其上的纤程可能受到影响;
- 是否能利用多核,取决于运行时如何映射到底层内核线程。
可以对比如下:
| 对象 | 谁管理 | 切换是否进内核 | 能否被内核直接调度 |
|---|---|---|---|
| 内核线程 | 操作系统 | 通常需要 | 可以 |
| 纤程 | 用户态库 / 运行时 | 通常不需要 | 不可以 |
| 协程 | 编程语言运行时 | 通常不需要 | 不可以直接调度 |
16. ucontext:用户态上下文切换
Linux 中曾提供 ucontext 相关接口,用于保存和切换用户态上下文。常见接口包括:
| 接口 | 作用 |
|---|---|
getcontext |
保存当前上下文 |
setcontext |
切换到指定上下文 |
makecontext |
创建新的上下文 |
每个 ucontext 可以看成一个用户态执行流的上下文。
经典例子:
1 | int x = 0; |
输出是:
1 | getcontext has been called |
原因如下:
- 第一次执行
getcontext(cp),保存当前上下文。 - 此时
x = 0,进入if,打印第一句。 - 调用
func(),执行x++,使x = 1。 setcontext(cp)回到之前保存的上下文。- 程序像是再次从
getcontext(cp)返回。 - 但此时
x = 1,所以进入else,打印第二句。
这个例子说明:
用户态上下文切换可以让程序跳回一个先前保存的执行现场,但全局状态的变化仍然保留。
17. 协程:语言级轻量执行流
协程和纤程非常接近,但使用语境略有不同:
- 纤程偏操作系统或运行时概念;
- 协程偏编程语言概念。
协程通常由语言运行时管理,常见于 Go、Python、Lua、C++20 等语言或标准中。
协程常见状态包括:
- 新生;
- 运行;
- 暂停;
- 终止。
协程的核心操作是:
| 操作 | 含义 |
|---|---|
yield |
主动暂停,把控制权交出去 |
resume |
恢复执行,从暂停位置继续 |
协程的优势在于:
- 创建轻量;
- 切换轻量;
- 适合大量并发任务;
- 可以由语言运行时根据应用逻辑调度;
- 不必每个任务都对应一个内核线程。
18. Go 协程:goroutine
Go 语言使用 go 关键字启动 goroutine:
1 | go asyncTask() |
这表示启动一个轻量执行流,由 Go 运行时管理。
典型网络服务器例子如下:
1 | func handler(c net.Conn) { |
每来一个连接,就启动一个 goroutine 处理。
如果使用传统内核线程,每个请求一个线程,开销可能很大。goroutine 则可以由 Go 运行时把大量轻量任务映射到较少的内核线程上,适合高并发网络服务。
因此,goroutine 可以看作一种语言运行时管理的轻量执行流。它不是简单等同于内核线程,而是由 Go runtime 调度和管理。
19. 核心概念总表
| 概念 | 核心含义 | 谁管理 | 是否共享地址空间 | 开销 |
|---|---|---|---|---|
| 进程 | 资源分配单位 | 内核 | 进程间通常不共享 | 最大 |
| 线程 | 进程内执行流 | 内核 / 线程库 | 同进程线程共享 | 中等 |
| 用户态线程 | 用户态管理的执行流 | 用户态库 / 运行时 | 通常共享 | 小 |
| 内核态线程 | 内核调度实体 | 内核 | 取决于所属进程 | 中等 |
| 纤程 | 用户态轻量线程 | 用户态库 / 运行时 | 通常共享 | 小 |
| 协程 | 语言级轻量执行流 | 语言运行时 | 通常共享 | 小 |
| TCB | 线程控制块 | 内核 / 线程库 | 不适用 | 用于管理线程 |
| TLS | 线程本地存储 | 运行时 / 编译器 / 系统支持 | 每线程一份变量实例 | 用于隔离线程变量 |
20. 本讲最重要的结论
本讲可以压缩成以下几句话:
- 进程是资源分配单位,线程是 CPU 执行和调度单位。
- 线程比进程轻,因为同一进程内线程共享代码、数据、堆和地址空间。
- 多线程共享地址空间,但不代表所有变量都共享。
- 每个线程有自己的栈、寄存器上下文和 TCB。
- TLS 让某些看似全局的变量变成每个线程一份。
- 用户态线程和内核态线程的区别在于谁管理、谁调度、内核是否看得见。
- 线程模型中的“对”表示用户态线程和内核态线程之间的映射关系。
- 多对一切换快但难以利用多核;一对一能利用多核但内核开销大;多对多理论灵活但实现复杂。
- pthread 的
create分出执行流,join等待执行流结束。 - 线程上下文切换的关键步骤是保存当前上下文、切换内核栈和页表、恢复目标上下文。
- 纤程和协程通过用户态调度降低切换成本,适合大量短任务和高并发场景。
结论
“进程与线程 II”的中心思想是执行流的层层轻量化。
最初,操作系统用进程管理程序运行。但进程太重,于是引入线程:线程共享进程资源,只保留独立执行所需的最小运行状态。随后,内核线程虽然能利用多核,但创建和切换仍有成本,于是用户态线程、纤程和协程继续把调度权上移到用户态或语言运行时。
这条主线可以总结为:
1 | 进程:拥有资源 |
真正理解本讲的关键,不是背下每个名词,而是抓住三个层次:
- 资源层次:进程提供地址空间、代码、数据、堆和文件等资源。
- 执行层次:线程在进程资源之上独立执行,拥有自己的栈和上下文。
- 调度层次:内核线程由操作系统调度,用户态线程、纤程和协程由用户态运行时调度。
把这三个层次区分清楚,用户态线程、内核态线程、三种线程模型、TCB、TLS、pthread、上下文切换、纤程和协程就能连成一套完整机制。




