Skip to content

Kotlin 协程并发总览 ​

在模块中的位置:并发与运行时底座 的 Kotlin 主线入口
相关主题:Java Thread / Future · Java 线程池 · Java JMM / 安全发布

一句话定位 ​

Kotlin 协程不是线程,而是编译器 + 运行时支持的可挂起任务模型:你用顺序 suspend 代码描述异步流程,编译器把它改写成 状态机 + Continuation,运行时再通过 Dispatcher / Scope / Job 决定它在哪条线程上恢复、如何取消、如何与 UI / 生命周期对齐。

代码索引 ​

主题Lab 说明源码
suspend / Continuation / withContext / launch / async / Dispatchercoroutine-execution-flowCoroutineExecutionFlowDemo.kt / AdvancedCoroutinePatternsDemo.kt / ManualCoroutineEquivalent.kt
Mutex / StateFlow / SharedFlow / 取消传播coroutine-mutex-stateflow · Mutex 与 Job / Deferred 专题CoroutineMutexStateFlowDemo.kt / MutexDemo.kt

学习顺序 ​

如果你是按文档系统复习,建议照这个顺序:

  1. 先读本页:建立 Kotlin 协程整体心智模型
  2. 再看专题页:按“执行与调度 → 治理与取消 → 共享状态保护”拆开复习
  3. 再跑对应 lab:把 Main → IO → Main、取消传播、Mutex 临界区这些现象跑出来
  4. 最后回看 Java 对照:把协程和 Java 共享内存并发模型放在一起比较

专题导航 ​

01. 执行与调度 ​

02. 治理与取消 ​

03. 共享状态保护 ​

04. 数据流 ​

说明:本页负责建立概念地图与复习路径;专题页先提供最小骨架,后续再按需要继续加深。

这篇总览覆盖什么 ​

本页负责串起 Kotlin 协程主线,帮助你先看清几件事:

  • suspend 本质:状态机 + Continuation
  • Dispatchers.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 销毁时取消自动向下传播;Java Future / 线程池没有父级治理,页面销毁后任务照跑。详见「概念地图 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.MainUI 更新、轻逻辑Android 主线程
Dispatchers.IO网络、磁盘、数据库为阻塞型 IO 准备的共享线程池
Dispatchers.DefaultCPU 密集计算更适合计算任务,不适合阻塞 IO

最小示例:

kotlin
viewModelScope.launch {
    val data = withContext(Dispatchers.IO) {
        repo.load()
    }
    render(data)
}
  • 可能执行顺序
    1. viewModelScope.launch 在 Main 启动协程
    2. 进入 withContext(Dispatchers.IO) 时,当前协程先挂起,提交 IO 段到 Dispatchers.IO
    3. repo.load() 在 IO 线程执行完成
    4. 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) 只切包裹块,不会把整个外层协程永久迁到 IO
    • repo.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 协程比裸线程更适合移动端,很大一部分原因不在语法,而在:

  • CoroutineScope
  • Job
  • 结构化并发
text
viewModelScope
  ├── launch { loadA() }
  └── launch { loadB() }
  • 可能执行顺序
    1. 父 viewModelScope 建立根 Job
    2. 两个 launch 分别注册为子协程,启动顺序通常按代码顺序,但真正执行可交错
    3. 父 scope 取消时,取消信号先到父 Job
    4. 取消再向下传播到两个子协程;子协程在下一个挂起点、取消检查点或可取消 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

  • 可能执行顺序
    1. 多个协程几乎同时进入 async(Dispatchers.Default)
    2. 某一个协程先拿到 mutex.withLock,进入临界区
    3. 其他协程挂起等待,不占着线程忙等
    4. 当前协程释放锁后,等待队列中的下一个协程恢复并进入临界区
  • 可能输出
    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") }
}
  • 可能执行顺序
    1. producer 更新状态值
    2. 当前活跃 collector 依次收到最新状态
    3. 新 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)
  • 可能执行顺序
    1. producer emit 一次事件
    2. 当时活跃的 collector 收到事件
    3. 之后才订阅的新 collector,默认不会自动重放旧事件(除非配置 replay)
  • 可能输出
    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 协程AndroidFlutter / DartWebJava 后台并发
默认心智挂起任务 + 调度器强依赖生命周期event loop / isolateevent loop / Promise共享内存 + 线程池
核心难点挂起恢复、取消传播、状态广播UI 生命周期对齐isolate 不共享内存顺序和回调链可见性、锁、线程池治理
真正并行借线程池 / Dispatcher借底层线程池isolateWorkerThread / pool
最容易误解把协程当线程以为协程自动避免 UI 卡顿把 Future 和协程混为一谈把 Promise 顺序当线程模型把 Future 当协程替代品

常见场景 ​

1. Android 页面加载 ​

  • 页面启动时在 viewModelScope.launch
  • 数据获取在 withContext(IO)
  • UI 更新回到 Main

这个场景是理解“顺序写法 + 非阻塞切换 + 生命周期托管”的最佳入口。

2. 并行 fan-out ​

比如:

  • 同时请求用户信息、权益信息、推荐列表
  • 最后统一聚合渲染

这时更适合:

  • coroutineScope / supervisorScope
  • async + await

3. UI 状态与一次性事件拆分 ​

最典型的移动端误区就是把:

  • 页面状态
  • Toast / Snackbar
  • 导航事件 混在一个状态通道里

这时应区分:

  • StateFlow:页面状态
  • SharedFlow / Channel:一次性事件

常见坑 ​

坑现象修法
以为协程自动不阻塞 UIMain 上重计算照样掉帧CPU 任务切 Default,IO 任务切 IO
GlobalScope.launch 滥用页面退出后任务还在跑绑定 viewModelScope / lifecycleScope
async 后不 await异常不易暴露,结果丢失明确聚合点,统一 await
StateFlow / SharedFlow 混用UI 状态丢失或重复消费页面状态和一次性事件分流
把 Mutex 当 Java 锁等价替代临界区设计混乱先分清“共享内存锁”与“协程挂起互斥”

与相近概念对比 ​

概念Kotlin 协程里对应什么本质区别
Java Futureasync/await协程 await 是挂起,不阻塞线程
Java 锁MutexMutex 等待时挂起协程,不阻塞线程
LiveDataStateFlowFlow 组合能力更强,状态语义更清晰
Dart Futuresuspend + continuationDart 偏单线程事件循环;Kotlin 偏多线程调度器

对应实验 ​

Lab说明状态
coroutine-execution-flowsuspend / Dispatcher / async-await / 状态机等效实现已有
coroutine-mutex-stateflowMutex / StateFlow / SharedFlow / Job已有

复习检查题 ​

  1. 为什么说 Kotlin 协程不是线程?

    答:因为协程本质上是可挂起任务;编译器会把 suspend 改写成状态机和 Continuation,运行时再决定它在哪条线程恢复,而不是给每个协程独占开一条 OS 线程。

  2. withContext(IO) 和 launch(IO) 的语义差别是什么?

    答:withContext(IO) 是在同一条协程里临时切换执行上下文,块结束后会恢复到原上下文;launch(IO) 是新启动一个子协程,重点在“开一个受父级治理的新任务”。

  3. 为什么 StateFlow 更适合页面状态,而 SharedFlow 更适合一次性事件?

    答:因为 StateFlow 会始终持有最新状态,新订阅者能立刻拿到当前快照;SharedFlow 默认更像事件广播,适合导航、Snackbar 这类“一次发生”的脉冲。

  4. Mutex 和 Java synchronized 的关键差异是什么?

    答:Mutex 在竞争时会挂起协程,不要求一直阻塞住线程;synchronized/Lock 面向共享内存线程模型,等待锁时通常直接阻塞线程。

  5. Android 页面销毁后,为什么 viewModelScope 比 GlobalScope 更安全?

    答:因为 viewModelScope 挂在生命周期相关的父 Job 上,页面或 ViewModel 结束时会自动取消子协程;GlobalScope 没有这层治理,任务容易越过页面生命周期继续跑,造成泄漏或回调打到已销毁页面。

速记 ​

  • 协程 = 可挂起任务,不是线程
  • suspend 靠状态机 + Continuation 实现挂起恢复
  • Dispatcher 决定恢复线程,Scope / Job 决定治理边界
  • StateFlow 管状态,SharedFlow 管事件,Mutex 管临界区
  • 总览先建图,专题再深挖

站点构建时间:2026/8/24 23:43:17