1 缘起
在软件世界里,性能与并发始终是绕不开的主题。从最早的操作系统线程,到语言层的协程,再到更轻量的纤程,以及近年来备受关注的虚拟线程,每一次抽象层级的提升,都是为了让程序能以更低的成本、更高的效率处理更多任务。随着硬件发展放缓、应用规模不断扩大,我们不再单纯依赖"更快的 CPU",而是必须学会更聪明地使用计算资源。理解线程、协程、纤程与虚拟线程的演进,不只是掌握几个名词,而是看清现代并发模型背后的设计哲学:如何在复杂性、性能与开发体验之间找到平衡。也正因此,重新梳理这些概念,成为每一个开发者迈向更高层次并发编程的必经之路。

2 内容角度
- 概念
- 调度方式
- 上下文切换
- 栈模型
- 存在问题及解决方案
3 概念
3.1 一句话概念
-
线程:OS 调度的最小执行单元
-
协程:用户态调度的轻量执行单元
-
纤程:协程的一种底层实现方式(用户态栈切换)
-
虚拟线程:JVM 提供的轻量线程(语义是线程,性能像协程)
3.2 概念详解
3.2.1 线程
- 线程运行在内核态,由操作系统负责调度(抢占式)。
- 多个线程共享同一进程的内存空间,但每个线程有独立的栈。
- 创建成本高、切换成本高(需要内核态切换)。
- 适合 CPU 密集型任务。
3.2.2 协程
- 不由操作系统调度,而是由语言运行时或框架调度(协作式)。
- 切换不需要进入内核态 → 成本极低(纳秒级)。
- 栈空间小(KB 级),可支持百万级并发。
- 适合 I/O 密集型任务(如网络请求、爬虫、服务端高并发)。
3.2.3 纤程
- 纤程是协程的一种更底层的形式,强调"用户态栈切换"。
- 切换时保存/恢复寄存器、栈指针等,不进入内核。
- 需要开发者或运行时显式让出执行权(协作式)。
- 常见于 C++、游戏引擎、Boost Fiber 等。
3.2.4 虚拟线程
- 虚拟线程不是 OS 线程,而是 JVM 内部结构。
- JVM 会把虚拟线程挂在少量 OS 线程上执行(类似 M:N 模型)。
- 阻塞不会占用 OS 线程(JVM 会自动挂起虚拟线程)。
- 创建成本极低,可支持百万级并发。
- 适合 Java 服务端高并发场景(Web 服务、数据库访问)。
3.2.5 对比
| 特性 | 线程 | 协程 | 纤程 | 虚拟线程 |
|---|---|---|---|---|
| 调度者 | OS | 用户态 | 用户态 | JVM |
| 调度方式 | 抢占式 | 协作式 | 协作式 | 混合式 |
| 切换成本 | 高 | 极低 | 极低 | 极低 |
| 栈大小 | MB | KB | KB | KB(分段) |
| 并发数量 | 万级 | 百万级 | 百万级 | 百万级 |
| 阻塞行为 | 占用 OS 线程 | 需非阻塞 I/O | 需非阻塞 I/O | 不占用 OS 线程 |
| 典型语言 | C++/Java | Go/Python/Kotlin | C++/系统库 | Java Loom |
4 调度方式
4.1 线程
- 调度者:操作系统内核(Kernel Scheduler)
- 调度方式:抢占式调度(Preemptive Scheduling)
(1)OS 会根据时间片、优先级等策略强制切换线程
(2)线程不需要主动让出 CPU
(3)切换时必须进入内核态,保存/恢复寄存器、栈指针等 - 特点
(1)公平性强:不会因为某个线程不让出 CPU 而卡死
(2)成本高:内核态切换开销大(微秒级)
(3)适合 CPU 密集型任务
4.2 协程
- 调度者:用户态运行时(Runtime / Event Loop)
- 调度方式:协作式调度(Cooperative Scheduling)
(1)协程必须 主动让出执行权(如 await、yield)
(2)调度器在用户态切换协程,不进入内核态
(3)切换只需保存少量寄存器和栈指针 - 特点
(1)切换成本极低(纳秒级)
(2)不公平:如果协程不让出,会阻塞整个调度器
(3)适合 I/O 密集型任务
4.3 纤程
- 调度者:用户态(通常由库或框架)
- 调度方式:协作式调度(Cooperative)
(1)纤程本质上是协程的一种底层实现,因此调度方式类似:
(2)完全用户态
(3)手动切换栈(保存/恢复寄存器、指令指针、栈指针)
(4)必须主动让出执行权 - 特点
(1)比协程更底层
(2)切换成本极低
(3)常用于 C++、游戏引擎、Boost Fiber 等
4.4 虚拟线程
- 调度者:JVM(而不是 OS)
- 调度方式:混合式调度(Cooperative + OS Scheduling)
虚拟线程的调度方式非常独特:
(1) 运行时由 JVM 调度(类似协程)虚拟线程不是 OS 线程,JVM 会把大量虚拟线程挂在少量 OS 线程上执行(M:N 模型)。
(2)阻塞时自动挂起(协作式)
当虚拟线程执行阻塞 I/O,JVM 会自动把它挂起,不占用 OS 线程,I/O 完成后再恢复。
(3)最终仍由 OS 调度底层载体线程(抢占式)虚拟线程运行在 OS 线程上,OS 线程仍然是抢占式调度。 - 特点
(1)语义是线程,性能像协程
(2)阻塞不会占用 OS 线程
(3)适合 Java 高并发服务
4.5 对比
| 模型 | 调度者 | 调度方式 | 是否抢占 | 是否进入内核态 | 切换成本 |
|---|---|---|---|---|---|
| 线程 | OS | 抢占式 | 是 | 是 | 高(微秒) |
| 协程 | 用户态 | runtime | 协作式 | 否 | 否 |
| 纤程 | 用户态 | 协作式 | 否 | 否 | 极低(纳秒) |
| 虚拟线程 | JVM | 混合式(协作 + 抢占) | 部分 | 阻塞时否 | 极低 |
5 上下文切换
5.1 线程
-
切换发生位置:内核态(Kernel Mode)
-
切换内容(非常多)
线程切换时,操作系统需要保存和恢复:
- 寄存器(通用寄存器、程序计数器 PC)
- 栈指针(SP)
- 内核栈
- CPU 状态字
- 内存管理信息(如页表)
- 调度器元数据(优先级、时间片等)
-
切换过程
(1)OS 抢占当前线程
(2)保存当前线程上下文到内核
(3)加载另一个线程的上下文
(4)切回用户态继续执行
-
特点
(1)必须进入内核态 → 成本高(微秒级)
(2)切换内容多
(3)线程栈大(MB),切换时需要更多维护
5.2 协程
-
切换发生位置:用户态(User Mode)
-
切换内容(非常少)
协程切换只需保存:
- 少量寄存器(如 PC、SP)
- 协程栈指针
- 协程局部状态(由 runtime 管理)
-
切换过程
(1)协程主动让出(await / yield)
(2)runtime 保存当前协程的寄存器和栈指针
(3)runtime 恢复另一个协程的寄存器和栈指针
(4)继续执行
-
特点
(1)不进入内核态 → 成本极低(纳秒级)
(2)切换内容少
(3)栈小(KB),切换轻量
5.3 纤程
-
切换发生位置:用户态
-
纤程是协程的底层实现,因此切换方式类似,但更底层。
切换内容
- 保存/恢复寄存器
- 保存/恢复指令指针(IP)
- 保存/恢复栈指针(SP)
- 切换用户态栈(纤程有独立栈)
-
切换过程
(1)纤程切换通常通过 手动切换栈 完成,例如:
(2)保存当前栈指针
(3)切换到另一个纤程的栈
(4)恢复寄存器
(5)跳转到新纤程的执行点
-
特点
(1)完全用户态 → 不进入内核
(2)切换成本与协程相当(纳秒级)
(3)更接近底层,常用于 C++、游戏引擎
5.4 虚拟线程
-
切换发生位置:JVM 内部(用户态 + 少量内核态)
-
虚拟线程的切换方式最特别,因为它结合了线程语义和协程性能。
-
两种切换场景
(1) 虚拟线程之间的切换(用户态)
当虚拟线程主动挂起(如 I/O 阻塞)时:
- JVM 保存虚拟线程的栈帧(分段栈)
- JVM 切换到另一个虚拟线程
- 类似协程切换 → 不进入内核态
(2)虚拟线程绑定的 OS 线程切换(内核态)
虚拟线程最终运行在 OS 线程上,因此:
- OS 线程之间的切换仍然是内核态
- 但虚拟线程数量远大于 OS 线程数量
- 大部分切换发生在 JVM 内部,而不是 OS
-
特点
(1)阻塞不会占用 OS 线程(JVM 会自动挂起虚拟线程)
(2)大部分切换在 JVM 内部 → 成本接近协程
(3)保留线程语义(同步代码也能高并发)
5.5 对比
| 模型 | 切换位置 | 是否进入内核态 | 切换成本 | 切换内容 | 本质 |
|---|---|---|---|---|---|
| 线程 | 内核态 | 是 | 高(微秒) | 寄存器 + 栈 + 内核状态 | OS 调度 |
| 协程 | 用户态 | 否 | 极低(纳秒) | 寄存器 + 栈指针 | 用户态调度 |
| 纤程 | 用户态 | 否 | 极低(纳秒) | 寄存器 + 栈切换 | 协程底层实现 |
| 虚拟线程 | JVM 内部 + 少量内核 | 部分 | 低 | 分段栈 + JVM 状态 | JVM 版协程 |
6 栈模型
6.1 线程
-
栈大小:固定且大(通常 1MB 左右)
每个线程创建时,OS 会为它分配一个 固定大小的大栈(如 1MB、2MB)。
栈空间一旦分配,不能动态缩小。
-
栈结构
(1)调用栈(函数调用帧)
(2)局部变量
(3)返回地址
(4)异常处理信息
-
特点
(1)占用内存大 → 限制线程数量(一般几千到几万)
(2)切换成本高 → 栈大,切换时需要更多状态维护
(3)适合 CPU 密集型任务
6.2 协程
-
栈大小:小且可动态扩容(几 KB 起)
协程的栈通常只有 几 KB,并且可以按需增长。
例如:
Go goroutine:初始栈 2KB,可动态扩容
Kotlin 协程:栈分段 + continuation 保存状态
Python asyncio:状态保存在对象中,不依赖传统栈
-
栈结构
协程的栈通常是:
(1)小栈(stacklet)
(2)分段栈(segmented stack)
(3)或状态机(async/await 转换)
-
特点
(1)占用内存极小 → 可创建百万协程
(2)切换成本低 → 栈小,切换轻量
(3)适合 I/O 密集型任务
6.3 纤程
-
栈大小:小栈(KB 级),独立栈,手动切换
纤程是协程的底层实现,因此栈模型类似协程,但更底层。
-
栈结构
(1)每个纤程有独立的用户态栈
(2)栈大小通常在 KB 级
(3)切换时需要手动保存/恢复栈指针(SP)
-
特点
(1)完全用户态栈
(2)切换时直接切换栈指针
(3)比协程更底层、更灵活
-
常见于:
(1)C++ Boost Fiber
(2)Windows Fiber API
(3)游戏引擎任务系统
6.4 虚拟线程
-
栈大小:小栈 + 分段栈(Stack Chunking)
虚拟线程的栈不是一次性分配,而是 按需分段分配。
类似协程的分段栈模型。
-
栈结构
(1)栈被拆成多个小块(chunk)
(2)JVM 会在挂起虚拟线程时把栈帧"拆开"并存储到堆中
(3)恢复时再重新组装
-
特点
(1)初始栈很小 → 可创建百万虚拟线程
(2)阻塞时栈会被挂起到堆中 → 不占用 OS 线程
(3)保留线程语义 → 同步代码也能高并发
6.5 对比
| 模型 | 栈大小 | 是否可扩容 | 栈位置 | 切换成本 | 并发能力 |
|---|---|---|---|---|---|
| 线程 | MB 级 | 否 | OS 分配 | 高 | 万级 |
| 协程 | KB 级 | 是 | 用户态 | 极低 | 百万级 |
| 纤程 | KB 级 | 是 | 用户态 | 极低 | 百万级 |
| 虚拟线程 | KB 级(分段) | 是 | JVM 管理 | 低 | 百万级 |
7 应用场景
7.1 实际场景
-
CPU 密集型
线程(或线程池)
-
I/O 密集型
→ 协程 / 纤程 / 虚拟线程
-
Java 高并发服务
虚拟线程(Loom)
7.2 编程语言实现
Go:goroutine(协程) + M:N 调度
Python:asyncio 协程
Java:传统线程池 → 虚拟线程(Loom)
C++:Boost Fiber / C++20 coroutine
Rust:async/await + executor
8 常见问题
8.1 线程
8.1.1 问题1:线程数量受限(无法支持高并发)
- 原因:
每个线程占用 1MB 左右栈空间
OS 调度成本高
创建/销毁成本大 - 解决:
使用线程池(Thread Pool)
控制最大线程数
使用异步 I/O 或协程替代
8.1.2 问题 2:线程上下文切换开销大
-
原因:
切换需要进入内核态
保存/恢复大量寄存器、栈、调度信息
-
解决:
减少线程数量
使用无锁结构、减少竞争
使用协程/虚拟线程替代
8.1.3 问题 3:线程泄漏(Thread Leak)
- 表现:
线程不断创建但不退出,导致系统资源耗尽。 - 原因:
线程未正确结束
死循环
阻塞 I/O
未关闭线程池 - 解决:
使用线程池统一管理
设置超时机制
避免无限阻塞
监控线程数量
8.2 协程
8.2.1 问题 1:协程泄漏(Coroutine Leak)
-
表现:
协程数量不断增加,最终 OOM。
-
原因:
协程未正确结束
异步任务未 await
无限挂起(suspend forever)
忘记取消协程(如 Kotlin 的 Job)
-
解决:
使用结构化并发(structured concurrency)
设置超时(timeout)
必须 await 或 cancel
使用协程作用域(scope)管理生命周期
8.2.2 问题 2:协程阻塞导致整个调度器卡死
- 原因:
协程是协作式调度,如果一个协程执行阻塞操作(如 sleep、IO),会阻塞整个事件循环。 - 解决:
使用非阻塞 I/O
在专用线程池执行阻塞任务(如 Kotlin 的 Dispatchers.IO)
使用 async/await 正确让出执行权
8.2.3 问题 3:协程调度不公平
- 原因:
协作式调度依赖协程主动让出,如果某个协程不让出,会饿死其他协程。 - 解决:
避免长时间 CPU 计算
使用 yield
将 CPU 密集型任务放入线程池
8.3 纤程
8.3.1 问题 1:纤程需要手动管理栈,容易出错
- 原因:
纤程切换需要手动保存/恢复栈指针、寄存器,容易出现:
栈溢出
栈损坏
未对齐导致崩溃 - 解决:
使用成熟的纤程库(Boost Fiber、Windows Fiber)
避免手写纤程切换逻辑
使用更高级的协程抽象
8.3.2 问题 2:纤程无法利用多核(默认单线程)
- 原因:
纤程调度通常在单线程执行,除非手动绑定多个 OS 线程。 - 解决:
使用 M:N 调度模型(如 Go)
或使用协程框架而不是裸纤程
8.3.3 问题 3:纤程阻塞会阻塞整个线程
- 原因:
纤程是用户态调度,如果执行阻塞 I/O,会阻塞整个 OS 线程。 - 解决:
使用非阻塞 I/O
使用 I/O 线程池
使用更高级的协程框架(如 Go runtime)
8.4 虚拟线程
8.4.1 问题 1:虚拟线程数量过多导致调度压力
- 原因:
虽然虚拟线程轻量,但创建百万级虚拟线程仍会给 JVM 带来调度压力。 - 解决:
控制虚拟线程数量
使用虚拟线程池(Executor.newVirtualThreadPerTaskExecutor)
避免无限创建
8.4.2 问题 2:阻塞操作仍然可能导致性能下降
- 原因:
虚拟线程可以挂起,但底层 OS 线程仍可能被某些 native 调用阻塞。 - 解决:
避免使用阻塞的 JNI 调用
使用 Loom 兼容的非阻塞 I/O
避免 synchronized 长时间锁持有
8.4.3 问题 3:虚拟线程泄漏
-
原因:
虚拟线程创建后未结束
无限等待
锁竞争导致线程挂起但不退出
-
解决:
使用 try-with-resources 管理虚拟线程
使用结构化并发(StructuredTaskScope)
监控虚拟线程数量
8.4.4 问题 4:虚拟线程与传统线程池混用导致问题
-
原因:
虚拟线程不需要池,但开发者习惯性使用线程池,导致:
虚拟线程被限制
失去高并发优势
-
解决:
不要为虚拟线程使用固定大小线程池
使用 per-task executor
8.5 对比
| 模型 | 常见问题 | 根本原因 | 解决方案 |
|---|---|---|---|
| 线程 | 数量受限、泄漏、切换慢 | 栈大、内核态调度 | 线程池、减少阻塞 |
| 协程 | 泄漏、阻塞调度器、不公平 | 协作式调度、用户态 | 非阻塞 I/O、结构化并发 |
| 纤程 | 栈管理复杂、阻塞问题 | 手动栈切换、用户态 | 使用库、避免阻塞 |
| 虚拟线程 | 调度压力、阻塞 JNI、泄漏 | JVM 调度、分段栈 | 结构化并发、避免阻塞调用 |