Appearance
Kotlin 协程并发总览
在模块中的位置:并发与运行时底座 的 Kotlin 主线入口
相关主题:Java Thread / Future · Java 线程池 · Java JMM / 安全发布
一句话定位
Kotlin 协程不是线程,而是编译器 + 运行时支持的可挂起任务模型:你用顺序 suspend 代码描述异步流程,编译器把它改写成 状态机 + Continuation,运行时再通过 Dispatcher / Scope / Job 决定它在哪条线程上恢复、如何取消、如何与 UI / 生命周期对齐。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
suspend / Continuation / withContext / launch / async / Dispatcher | coroutine-execution-flow | CoroutineExecutionFlowDemo.kt / AdvancedCoroutinePatternsDemo.kt / ManualCoroutineEquivalent.kt |
Mutex / StateFlow / SharedFlow / 取消传播 | coroutine-mutex-stateflow · Mutex 与 Job / Deferred 专题 | CoroutineMutexStateFlowDemo.kt / MutexDemo.kt |
学习顺序
如果你是按文档系统复习,建议照这个顺序:
- 先读本页:建立 Kotlin 协程整体心智模型
- 再看专题页:按“执行与调度 → 治理与取消 → 共享状态保护”拆开复习
- 再跑对应 lab:把
Main → IO → Main、取消传播、Mutex临界区这些现象跑出来 - 最后回看 Java 对照:把协程和 Java 共享内存并发模型放在一起比较
专题导航
01. 执行与调度
02. 治理与取消
03. 共享状态保护
04. 数据流
说明:本页负责建立概念地图与复习路径;专题页先提供最小骨架,后续再按需要继续加深。
这篇总览覆盖什么
本页负责串起 Kotlin 协程主线,帮助你先看清几件事:
suspend本质:状态机 +ContinuationDispatchers.Main / IO / Default在执行恢复时扮演什么角色withContext / launch / async的职责差异CoroutineScope / SupervisorJob / 取消传播为什么适合移动端生命周期治理Flow / StateFlow / SharedFlow / Channel分别更适合什么通信语义Mutex为什么只是并发控制手段,不是“用了协程就线程安全”
当前尚未单独拆专题:
- actor 风格(可后续补专门对照页)
为什么需要
很多工程师会用协程,但真正问到下面这些问题时就开始模糊:
- 为什么
suspend不是线程?- 一句话答:
suspend只是「可挂起任务」的语法;编译器把它改写成状态机 + Continuation,既不创建也不独占 OS 线程,恢复时才借调度器挑一条线程执行。详见下文「概念地图 1」与复习题 1。
- 一句话答:
- 为什么
withContext(IO)能把 IO 段切出去,但函数看起来还是顺序写法?- 一句话答:
withContext(IO)在挂起点把块内这段临时切到 IO 线程执行,块结束自动恢复回原上下文;编译器做 CPS 变换把「切线程」藏进挂起/恢复,所以源码仍是顺序写法。详见「概念地图 2」与复习题 2。
- 一句话答:
- 为什么
StateFlow适合 UI 状态,而SharedFlow更适合一次性事件?- 一句话答:
StateFlow始终持有最新值、新订阅者立即拿到当前快照,适合页面状态;SharedFlow默认不补发旧事件,适合导航 / Snackbar 这类一次性脉冲。详见「概念地图 5」与复习题 3。
- 一句话答:
- 为什么 Kotlin 协程比 Java
Future + thread pool更容易跟 Android 生命周期对齐?- 一句话答:协程有结构化并发——子协程挂在父
Job树上,viewModelScope销毁时取消自动向下传播;JavaFuture/ 线程池没有父级治理,页面销毁后任务照跑。详见「概念地图 4」与复习题 5。
- 一句话答:协程有结构化并发——子协程挂在父
如果这些问题说不清,工程上就很容易出现:
- 主线程上做重计算,误以为“用了协程就不会卡”
- 用
GlobalScope发任务,页面销毁后协程还在跑 - 把
StateFlow、SharedFlow、Channel混用,导致 UI 丢状态或重复消费 - 以为
Mutex可以机械替代 Java 锁,却不理解可挂起互斥和共享内存锁的差异
概念地图
1. suspend 的本质:状态机 + Continuation
你写的:
kotlin
suspend fun loadUser(): User {
val raw = api.fetchUser()
return parse(raw)
}编译器并不是给它开新线程,而是把它大致改写成:
- 一个带
label的状态机 - 一个
Continuation对象 - 在挂起点保存“执行到哪了、局部变量是什么、下次从哪里恢复”
这就是为什么:
- 协程可以挂起后再恢复
- 看起来是顺序写法,但底层并不是同步阻塞
- 同一个协程在生命周期里可能先后跑在不同线程上
对应专题:Kotlin Dispatchers / 上下文切换 / 协程构建器
对应 Lab:coroutine-execution-flow
2. Dispatcher:决定在哪条线程继续执行
协程默认不是“自己拥有线程”,而是要借运行时提供的调度器执行:
| Dispatcher | 典型用途 | 你该怎么理解 |
|---|---|---|
Dispatchers.Main | UI 更新、轻逻辑 | Android 主线程 |
Dispatchers.IO | 网络、磁盘、数据库 | 为阻塞型 IO 准备的共享线程池 |
Dispatchers.Default | CPU 密集计算 | 更适合计算任务,不适合阻塞 IO |
最小示例:
kotlin
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
repo.load()
}
render(data)
}- 可能执行顺序
viewModelScope.launch在Main启动协程- 进入
withContext(Dispatchers.IO)时,当前协程先挂起,提交 IO 段到Dispatchers.IO repo.load()在 IO 线程执行完成- continuation 恢复,后续
render(data)回到原调用上下文(通常是Main)
- 可能输出text
showUser() start on thread=main-dispatcher-1 enter IO block on thread=DefaultDispatcher-worker-1 repo.loadUser() on thread=DefaultDispatcher-worker-1 back to caller context on thread=main-dispatcher-1 render(User(name=Alice)) on thread=main-dispatcher-1 - 预期现象
withContext(IO)只切包裹块,不会把整个外层协程永久迁到 IOrepo.load()跑在 IO worker,但render()恢复到原上下文
- 观察重点
- 重点看“挂起点前后是同一条协程,但线程名可变”
- 重点看恢复顺序:先 IO 段完成,再回主上下文继续执行后半段
对应专题:Kotlin Dispatchers / 上下文切换 / 协程构建器
对应 Lab:CoroutineExecutionFlowDemo.kt
3. launch / async / withContext 的职责不同
| API | 解决什么问题 | 常见误区 |
|---|---|---|
withContext | 同一条协程内切执行上下文 | 把它当“并行启动任务” |
launch | 启动一个新协程,不关心返回值 | 到处 fire-and-forget,最后不好管生命周期 |
async | 启动一个有返回值的协程,配合 await() | 不 await 就以为异常会自动暴露 |
这三者的差别,决定了你是在:
- 改写同一条任务的执行上下文
- 新开一个受父协程治理的并行子任务
- 还是显式做一次有返回值的 fan-out / fan-in
4. Scope / Job:让协程能被治理
Kotlin 协程比裸线程更适合移动端,很大一部分原因不在语法,而在:
CoroutineScopeJob- 结构化并发
text
viewModelScope
├── launch { loadA() }
└── launch { loadB() }- 可能执行顺序
- 父
viewModelScope建立根Job - 两个
launch分别注册为子协程,启动顺序通常按代码顺序,但真正执行可交错 - 父 scope 取消时,取消信号先到父
Job - 取消再向下传播到两个子协程;子协程在下一个挂起点、取消检查点或可取消 API 处结束
- 父
- 可能输出text
child A start on thread=main child B start on thread=main ViewModel cleared child A cancelled child B cancelled - 预期现象
- 子协程不是“脱离管理的后台线程”,而是挂在父
Job树上 - 取消传播是结构化并发的核心:父级结束,子级不能继续无边界泄漏
- 子协程不是“脱离管理的后台线程”,而是挂在父
- 观察重点
- 看清“启动顺序”和“完成顺序”不是一回事,兄弟协程可交错恢复
- 看清取消通常不是立即硬打断,而是在挂起恢复边界生效
当 ViewModel 销毁时:
- 父 scope 取消
- 子协程一并取消
这和 Java 里 new Thread() 或“没人管的 Future”很不一样。
对应专题:Kotlin Scope / Job / 取消传播 / 状态流
对应 Lab:coroutine-mutex-stateflow · CoroutineMutexStateFlowDemo.kt · MutexDemo.kt · Mutex 与 Job / Deferred 专题
5. Mutex / StateFlow / SharedFlow:协程世界里的并发控制与状态传播
Mutex
- 解决协程临界区互斥
- 拿不到锁时是挂起协程,不是阻塞 OS 线程
对应 Lab:coroutine-mutex-stateflow · MutexDemo.kt
- 可能执行顺序
- 多个协程几乎同时进入
async(Dispatchers.Default) - 某一个协程先拿到
mutex.withLock,进入临界区 - 其他协程挂起等待,不占着线程忙等
- 当前协程释放锁后,等待队列中的下一个协程恢复并进入临界区
- 多个协程几乎同时进入
- 可能输出text
====== 1. 无 Mutex:竞态(结果常 < 100)====== counter = 93 ====== 2. 有 Mutex:协程安全累加 ====== counter = 100 Mutex OK - 预期现象
- 无锁时
counter++可能丢更新 - 加
Mutex后结果稳定,但临界区仍串行,吞吐受锁范围影响
- 无锁时
- 观察重点
- 观察“等待锁”是协程挂起,不等于 worker 线程被整段阻塞
- 观察正确性和并发度的交换:结果变稳,但临界区顺序执行
StateFlow
StateFlow更适合表示UI 当前状态快照。它的核心语义不是“广播每一次历史事件”,而是“始终持有一个当前值”。- 因此新订阅者一旦开始 collect,就会先立刻拿到当前最新值,再继续接收后续更新。
kotlin
val state = MutableStateFlow("Loading")
launch {
state.collect { println("collector A -> $it") }
}
state.value = "Success(data)"
println("collector B subscribe")
launch {
state.collect { println("collector B -> $it") }
}- 可能执行顺序
- producer 更新状态值
- 当前活跃 collector 依次收到最新状态
- 新 collector 订阅时,先立刻拿到当前最新值,再继续等后续更新
- 可能输出text
collector A -> Loading collector A -> Success(data) collector B subscribe collector B -> Success(data) - 预期现象
- 新订阅者不会从“空白历史”开始,而是先看到当前快照
- 观察重点
- 重点看它更像“状态寄存器”,不是一次性消息队列
SharedFlow
SharedFlow更适合一次性事件,比如 Snackbar、导航、点击事件脉冲。它更像“事件广播”,而不是“当前状态寄存器”。- 和
StateFlow的核心差异是:默认情况下,后来才订阅的 collector 不会自动补收旧事件;只有配置了replay,它才会重放历史事件。
kotlin
val events = MutableSharedFlow<String>()
launch {
events.collect { println("collector A -> $it") }
}
delay(100)
events.emit("NavigateToDetail")
println("collector B subscribe")
launch {
events.collect { println("collector B -> $it") }
}
delay(100)- 可能执行顺序
- producer
emit一次事件 - 当时活跃的 collector 收到事件
- 之后才订阅的新 collector,默认不会自动重放旧事件(除非配置
replay)
- producer
- 可能输出text
collector A -> NavigateToDetail collector B subscribe collector B -> (默认无历史事件) - 预期现象
- 它更适合脉冲事件,而不是需要“永远记住当前值”的页面状态
- 观察重点
- 重点看 replay / buffer 配置如何影响是否补发旧事件、是否丢事件
对应专题:Kotlin Scope / Job / 取消传播 / 状态流 · Kotlin Mutex / 共享状态保护
对应 Lab:coroutine-mutex-stateflow
Android / Flutter / Web / Backend 对照
| 维度 | Kotlin 协程 | Android | Flutter / Dart | Web | Java 后台并发 |
|---|---|---|---|---|---|
| 默认心智 | 挂起任务 + 调度器 | 强依赖生命周期 | event loop / isolate | event loop / Promise | 共享内存 + 线程池 |
| 核心难点 | 挂起恢复、取消传播、状态广播 | UI 生命周期对齐 | isolate 不共享内存 | 顺序和回调链 | 可见性、锁、线程池治理 |
| 真正并行 | 借线程池 / Dispatcher | 借底层线程池 | isolate | Worker | Thread / pool |
| 最容易误解 | 把协程当线程 | 以为协程自动避免 UI 卡顿 | 把 Future 和协程混为一谈 | 把 Promise 顺序当线程模型 | 把 Future 当协程替代品 |
常见场景
1. Android 页面加载
- 页面启动时在
viewModelScope.launch - 数据获取在
withContext(IO) - UI 更新回到 Main
这个场景是理解“顺序写法 + 非阻塞切换 + 生命周期托管”的最佳入口。
2. 并行 fan-out
比如:
- 同时请求用户信息、权益信息、推荐列表
- 最后统一聚合渲染
这时更适合:
coroutineScope/supervisorScopeasync+await
3. UI 状态与一次性事件拆分
最典型的移动端误区就是把:
- 页面状态
- Toast / Snackbar
- 导航事件 混在一个状态通道里
这时应区分:
StateFlow:页面状态SharedFlow/ Channel:一次性事件
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 以为协程自动不阻塞 UI | Main 上重计算照样掉帧 | CPU 任务切 Default,IO 任务切 IO |
GlobalScope.launch 滥用 | 页面退出后任务还在跑 | 绑定 viewModelScope / lifecycleScope |
async 后不 await | 异常不易暴露,结果丢失 | 明确聚合点,统一 await |
StateFlow / SharedFlow 混用 | UI 状态丢失或重复消费 | 页面状态和一次性事件分流 |
把 Mutex 当 Java 锁等价替代 | 临界区设计混乱 | 先分清“共享内存锁”与“协程挂起互斥” |
与相近概念对比
| 概念 | Kotlin 协程里对应什么 | 本质区别 |
|---|---|---|
Java Future | async/await | 协程 await 是挂起,不阻塞线程 |
| Java 锁 | Mutex | Mutex 等待时挂起协程,不阻塞线程 |
| LiveData | StateFlow | Flow 组合能力更强,状态语义更清晰 |
Dart Future | suspend + continuation | Dart 偏单线程事件循环;Kotlin 偏多线程调度器 |
对应实验
| Lab | 说明 | 状态 |
|---|---|---|
| coroutine-execution-flow | suspend / Dispatcher / async-await / 状态机等效实现 | 已有 |
| coroutine-mutex-stateflow | Mutex / StateFlow / SharedFlow / Job | 已有 |
复习检查题
为什么说 Kotlin 协程不是线程?
答:因为协程本质上是可挂起任务;编译器会把
suspend改写成状态机和Continuation,运行时再决定它在哪条线程恢复,而不是给每个协程独占开一条 OS 线程。withContext(IO)和launch(IO)的语义差别是什么?答:
withContext(IO)是在同一条协程里临时切换执行上下文,块结束后会恢复到原上下文;launch(IO)是新启动一个子协程,重点在“开一个受父级治理的新任务”。为什么
StateFlow更适合页面状态,而SharedFlow更适合一次性事件?答:因为
StateFlow会始终持有最新状态,新订阅者能立刻拿到当前快照;SharedFlow默认更像事件广播,适合导航、Snackbar 这类“一次发生”的脉冲。Mutex和 Javasynchronized的关键差异是什么?答:
Mutex在竞争时会挂起协程,不要求一直阻塞住线程;synchronized/Lock面向共享内存线程模型,等待锁时通常直接阻塞线程。Android 页面销毁后,为什么
viewModelScope比GlobalScope更安全?答:因为
viewModelScope挂在生命周期相关的父Job上,页面或 ViewModel 结束时会自动取消子协程;GlobalScope没有这层治理,任务容易越过页面生命周期继续跑,造成泄漏或回调打到已销毁页面。
速记
- 协程 = 可挂起任务,不是线程
suspend靠状态机 +Continuation实现挂起恢复Dispatcher决定恢复线程,Scope / Job决定治理边界StateFlow管状态,SharedFlow管事件,Mutex管临界区- 总览先建图,专题再深挖