协程调度器原理
# 03.协程调度器原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 挂起函数本质
- 4. Continuation内幕
- 5. Dispatcher调度机制
- 6. 结构化并发
- 7. 异常传播规则
- 8. Flow与协程关系
- 9. 性能与线程对比
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段反常代码
以下是一段生产事故——某 Android App 上线后偶发"点了保存按钮什么都没发生",用户投诉但无法复现:
// SettingsViewModel.kt
class SettingsViewModel : ViewModel() {
fun save(config: Config) {
viewModelScope.launch(Dispatchers.IO) { // ① 切到 IO
val ok = repo.save(config) // ② suspend 调用
withContext(Dispatchers.Main) { // ③ 切回主线程
if (ok) view.showToast("保存成功")
else throw IllegalStateException("保存失败") // ④ 这里抛异常
}
}
}
}
症状:throw 后 App 既不崩溃、也不弹 Toast——异常仿佛消失了。日志里也没有堆栈。测试环境永远走 ok = true 分支,QA 一次都没触发过——只在生产的 5% 边缘用户手里出现。
排查现场的三个诡异现象:
- 手动在
throw之前Log.e(...)能打出日志——说明代码执行到了这一行 - Firebase Crashlytics 没有一条相关记录——异常从未上报
try { ... } catch (e: Throwable) { Log.e(...) }包住整个launch块,也捕获不到——异常仿佛发生在另一个宇宙
真实事故上报数据(某电商 Android App 3 个月复盘):
- 类似"协程内异常静默丢失"的现象累计 27 例,占 P2 及以下故障的 8%
- Firebase 未捕获率 100%——因为异常从未走到
Thread.setDefaultUncaughtExceptionHandler - 平均 MTTR(平均修复时间)5 小时——远超普通 Bug 的 40 分钟,因为堆栈无处可寻
# 1.2 顺藤摸到根因
表象:为什么 throw 出的异常没有传播出来?
我们把整段 launch 反编译(Kotlin showBytecode → Decompile 成 Java):
// 关键伪代码(简化)
public final void save(Config config) {
BuildersKt.launch(
ViewModelKt.getViewModelScope(this),
Dispatchers.getIO(), // context
CoroutineStart.DEFAULT,
(Function2) new SaveKt$save$1(this, config, null)
);
}
// SaveKt$save$1 就是编译器生成的状态机类
static final class SaveKt$save$1 extends SuspendLambda {
Object invokeSuspend(Object $result) {
try {
switch (this.label) {
case 0:
this.label = 1;
Object r = this.repo.save(config, this);
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
case 1:
if (ok) view.showToast("保存成功");
else throw new IllegalStateException("保存失败"); // ← 这里
}
} catch (Throwable e) {
$result = new Result.Failure(e); // ← 异常被包成 Result.Failure
}
// 返回给上层的 continuation 一个 Result
completion.resumeWith($result);
}
}
发现:throw 出的异常没有被抛到调用栈,而是被状态机的 try/catch 抓住、包装成 Result.Failure,通过 resumeWith 传给了上层——最终传到 viewModelScope 的根 Job。
再看 viewModelScope 的定义:
// androidx.lifecycle
public val ViewModel.viewModelScope: CoroutineScope
get() = ... CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
关键:viewModelScope 用的是 SupervisorJob + 无 CoroutineExceptionHandler。异常传到根 Job 后,走 handleCoroutineException → handleUncaughtCoroutineException——没有 handler 时就打一条 Log 后丢弃。
对照实验:加一个 CoroutineExceptionHandler:
val h = CoroutineExceptionHandler { _, e -> Log.e("save", "err", e) }
viewModelScope.launch(Dispatchers.IO + h) { ... }
现在日志里能看到堆栈了——异常并没有真的消失,只是没人接收。
# 1.3 我们要回答什么
看到这里,我们至少积累了 7 个疑问:
suspend关键字在字节码上到底做了什么?- 一段挂起函数在字节码里如何"暂停并恢复"?
Dispatchers.IO与Dispatchers.Default各自的线程池长什么样?viewModelScope.launch里抛出的异常去哪里了?Job与SupervisorJob差在哪?- 协程真的比线程"轻"吗?轻多少?
CancellationException为什么被特殊处理?
带着这些问题,我们从 CoroutineContext 的架构出发,一路挖到 CPS 变换、状态机、Dispatcher 线程池,最后揭示"异常静默丢失"的完整链路。
# 2. 架构概览
# 2.1 协程五层结构
┌──────────────────────────────────────────────────────┐
│ CoroutineContext(上下文) │
│ ┌──────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ Job │ │Dispatcher│ │ExcHandler │ │Elements │ │
│ └──────┘ └──────────┘ └──────────────┘ └─────────┘ │
└──────────────────────────────────────────────────────┘
↑
结构化并发
↓
┌──────────────────────────────────────────────────────┐
│ CoroutineScope(作用域) │
│ 持有 coroutineContext │
│ 提供 launch / async 入口 │
└──────────────────────────────────────────────────────┘
↓
launch / async / withContext
↓
suspend fun 状态机执行
↓
Continuation.resumeWith(Result<T>)
五层各自的核心接口:
| 层 | 核心接口 | 关键责任 |
|---|---|---|
| Element | CoroutineContext.Element | 上下文的每个元素(Job/Dispatcher/Name/Handler) |
| Context | CoroutineContext | Element 的集合,支持 + / [Key] |
| Scope | CoroutineScope | 承载 Context,提供协程入口 |
| Builder | launch / async / runBlocking | 创建协程实体 |
| State Machine | SuspendLambda / ContinuationImpl | 协程执行主体,编译器生成 |
# 2.2 为什么这么切
疑惑:为什么把 Job、Dispatcher、ExceptionHandler 全塞进一个 CoroutineContext Map?
论证:
- 协程执行时需要多种正交能力:调度(线程决定)、生命周期(Job)、异常处理(Handler)、名字(Name)、追踪(DebugTrace)……如果每一个都作为函数参数,
launch签名会长到 10 个参数 - 用一个通用的
Element接口 +Map抽象,支持"任意扩展":第三方库(比如 MDC 日志上下文、OpenTelemetry Trace)可以自定义Element塞进去,运行时能取出 - 上下文可以通过
+组合,通过[]取值:语法自然val ctx = Job() + Dispatchers.IO + CoroutineName("worker") + handler ctx[Job] // 取 Job ctx[CoroutineName] // 取 Name - 传递上下文只需传一个引用:子协程通过
newContext = parent.coroutineContext + newElements继承+覆盖,语义清晰
结论:CoroutineContext 是"能力集合的开放式携带"设计——每个协程都是一颗上下文树。父协程的上下文向下继承,子协程可以覆盖任意 Element 而不影响其它。
对比 Java 线程:Java 用 ThreadLocal 挂能力,但 ThreadLocal 是线程绑定,协程会跨线程执行,ThreadLocal 天然不适配。CoroutineContext 是"跟着协程走"的,切线程不丢信息。
# 3. 挂起函数本质
# 3.1 suspend编译签名
suspend fun fetchUser(id: Long): User { ... }
编译后 Java 侧签名:
public static Object fetchUser(long id, Continuation<? super User> $completion);
变化:
- 参数末尾加了一个
Continuation<T>——这就是"下一步"的回调引用 - 返回类型变成
Object——要么是User(同步完成),要么是特殊哨兵COROUTINE_SUSPENDED(挂起)
这是 CPS(Continuation-Passing Style)变换的语言级实现——编译器帮我们做了"回调化"的所有脏活。
跨语言对比:
| 语言 | 异步方案 | 语法侵入 |
|---|---|---|
| JavaScript | async/await + Promise | 需要 async 关键字 + 返回 Promise |
| C# | async/await + Task | 同 JS |
| Rust | async/await + Future | 需要显式 poll |
| Kotlin | suspend + Continuation | 无侵入——直接返回 User,编译器帮 CPS |
| Go | Goroutine + Channel | 无关键字,运行时调度 |
Kotlin 的独特之处:看起来像同步代码,实际是异步——val u = fetchUser(1) 这一行看着是阻塞调用,但字节码里是异步 continuation。
# 3.2 CPS变换机理
原函数: A → B → C → return R
CPS 变换后: A(cont) → B(cont') → C(cont'') → cont.resumeWith(R)
每一个"下一步"都作为参数传给当前步。
具体例子:
原始 Kotlin:
suspend fun demo(): Int {
val a = fetchA() // A:挂起点 1
val b = fetchB() // B:挂起点 2
return a + b // C:不挂起
}
手写 CPS 版本(不用 suspend,等效于编译器做的事):
fun demoCps(callback: (Int) -> Unit) {
fetchACallback { a -> // 拿到 a 后
fetchBCallback { b -> // 拿到 b 后
callback(a + b) // 交回给最外层
}
}
}
嵌套 callback 层层缩进——这就是臭名昭著的 callback hell。
CPS vs Callback Hell 的区别:
| 维度 | 手写 Callback | Kotlin suspend/CPS |
|---|---|---|
| 缩进层数 | N 个挂起点 = N 层缩进 | 一直是 0 层,看起来是同步代码 |
| 错误处理 | 每个 callback 自己处理 | try/catch 直接包住多个挂起点 |
| 局部变量 | 靠闭包捕获,难跨层复用 | 直接用 val/var |
| 循环里的异步 | 需要递归 callback | for + await 直觉可用 |
| 类型推断 | 回调参数类型难推 | 编译器完全推导 |
编译器自动做 CPS 的三步:
- 给每个
suspend fun生成一个匿名ContinuationImpl子类(状态机) - 把函数体切成 N+1 段(N = 挂起点数量),每段是一个 case
- 挂起点前后跨段共享的局部变量提升为状态机字段
# 3.3 状态机反汇编
suspend fun demo(): Int {
val a = suspendCall1() // 挂起点 1
val b = suspendCall2() // 挂起点 2
return a + b
}
编译成状态机(伪代码,还原自 kotlinc 输出):
public final class DemoStateMachine extends ContinuationImpl {
Object result; // 保存中间 Result
int label; // 状态编号
int a; // 跨挂起点的局部变量
int b;
Object invokeSuspend(Object $result) {
switch (this.label) {
case 0:
// 首次进入
ResultKt.throwOnFailure($result); // 若上一步失败就抛
this.label = 1;
Object r1 = suspendCall1(this); // 传入自己作为 continuation
if (r1 == COROUTINE_SUSPENDED)
return COROUTINE_SUSPENDED; // 挂起:控制权交回 Dispatcher
// 若同步完成,落到 case 1
this.a = (int) r1;
// fall through
case 1:
ResultKt.throwOnFailure($result);
this.label = 2;
Object r2 = suspendCall2(this);
if (r2 == COROUTINE_SUSPENDED)
return COROUTINE_SUSPENDED;
this.b = (int) r2;
// fall through
case 2:
ResultKt.throwOnFailure($result);
return this.a + this.b; // 完成,返回真实值
default:
throw new IllegalStateException("call to 'resume' before 'invoke'");
}
}
}
结论:一个挂起函数 = 一个状态机 = 一个类。每个挂起点是一个 case。
关键观察点:
label是状态机的程序计数器(PC):label=0表示"刚开始",label=N表示"卡在第 N 个挂起点"- 局部变量提升为字段:
val a = ...跨越挂起点必须保存到this.a,因为函数会 return 后重入 - 每次进入都从 switch 开始:
invokeSuspend可能被调用多次,每次接着上次的 label 继续 ResultKt.throwOnFailure处理异步异常:上一步返回Result.Failure会在这里 throw,进入catch分支
# 3.4 挂起点即分支
一个 suspend fun 有 N 个挂起点,就有 N+1 个 case——label=0 是"首次进入",label=N 是"从第 N 个挂起点恢复",label=N+1 是"完成"。
label 字段的三重身份:
- 恢复入口:下次
resumeWith调用invokeSuspend时,直接跳到case label - 进度指示:外部可以从
label值看出协程执行到哪一步 - 状态一致性锚点:如果状态机被序列化(Kotlin/Native 支持),
label就是回放起点
举例:3 挂起点的函数:
suspend fun demo3() {
val a = fetch1() // 挂起点 1
println(a) // 不挂起
val b = fetch2() // 挂起点 2
println(b)
val c = fetch3() // 挂起点 3
println(c)
}
状态机会有 4 个 case:0, 1, 2, 3(3 是"完成后返回 Unit")。不挂起的代码(println)永远和它前面的挂起点共处同一个 case。
编译器的优化:
- 相邻两个不挂起代码块被合并到一个 case,减少 label 数量
- 尾挂起优化(第 4.4 节):若函数体只是"调另一个 suspend 然后 return",直接把 continuation 转发过去,不生成状态机
- 无挂起点函数:即使标
suspend,若函数体内没有任何挂起调用,编译器不生成状态机(只是签名带 Continuation 而已)
# 4. Continuation内幕
# 4.1 状态机字段布局
编译器为每个 suspend fun 生成一个继承 ContinuationImpl 的类,字段包括:
label:int,状态编号result:Object,暂存resumeWith传入的Result<T>- 跨挂起点的局部变量:如
a、b(会被字节码分析器识别) - 外层参数:
this$this(外层函数的 receiver)、闭包捕获的变量 completion(继承自ContinuationImpl):上层 Continuation 引用
javap 输出真实结构(对 demo 函数):
final class SampleKt$demo$1 extends kotlin.coroutines.jvm.internal.ContinuationImpl {
// 继承字段
// completion: Continuation
// context: CoroutineContext(通过 completion.getContext())
// 新增字段
Object result; // 每次 resumeWith 的入参
int label; // 状态编号
int I$0; // 编译器编号的 int 局部变量 0(对应 a)
int I$1; // int 局部变量 1(对应 b)
Object L$0; // Object 局部变量 0
Object L$1; // Object 局部变量 1
// 方法
public final Object invokeSuspend(Object $result);
}
字段编号规则:
I$N:第 N 个int/short/byte/char/boolean类型的局部变量L$N:第 N 个Object类型的局部变量J$N:第 N 个long类型D$N:第 N 个double类型
编译器按 JVM 类型槽位聚类,减少字段数量。
对比手写状态机的复杂度:一个有 3 个挂起点 + 5 个跨点变量的函数,手写状态机需要:
- 一个 switch + 4 个 case
- 5 个字段声明 + 5 处 this.xxx 赋值
- 3 处
COROUTINE_SUSPENDED短路 return
约 80~120 行 Java 代码。Kotlin 编译器自动生成,源代码只有 10 行。
# 4.2 label与resumeWith
完整的挂起-恢复流程:
挂起(协程内部):
┌─────────────────────────────────────────────┐
│ 1. suspend 调用返回 COROUTINE_SUSPENDED │
│ 2. 状态机 return COROUTINE_SUSPENDED │
│ 3. Dispatcher 拿回控制权,执行队列里下一个任务 │
└─────────────────────────────────────────────┘
↓
异步操作完成(另一个线程/回调)
↓
┌─────────────────────────────────────────────┐
│ 4. continuation.resumeWith(Result.success(v)) │
│ 5. resumeWith 内部: │
│ - dispatch 到 continuation 所在的 Dispatcher │
│ - 调用 continuation.invokeSuspend(result) │
│ 6. invokeSuspend 根据 label 跳到 case,继续 │
└─────────────────────────────────────────────┘
↓
再遇挂起 → 回到步骤 1 循环
完成 → return 真实值 → 通知父 Continuation
resumeWith 的关键代码(DispatchedContinuation):
override fun resumeWith(result: Result<T>) {
val context = continuation.context
val state = result.toState()
if (dispatcher.isDispatchNeeded(context)) {
_state = state
resumeMode = MODE_ATOMIC
dispatcher.dispatch(context, this) // ← 投递到 Dispatcher 的队列
} else {
executeUnconfined(state, MODE_ATOMIC) {
continuation.resumeWith(result)
}
}
}
关键规则:
Dispatcher.dispatch决定协程在哪个线程恢复isDispatchNeeded == false时(如Dispatchers.Unconfined)直接在当前线程执行
# 4.3 COROUTINE_SUSPENDED
COROUTINE_SUSPENDED 是 kotlin.coroutines.intrinsics 里定义的哨兵对象:
// kotlin.coroutines.intrinsics
public val COROUTINE_SUSPENDED: Any get() = CoroutineSingletons.COROUTINE_SUSPENDED
internal enum class CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED }
它是一个枚举常量——== 比较即可判定,JIT 可以内联为常量比较。
语义:
- 函数返回它 → "我暂停了,等下再来"(
COROUTINE_SUSPENDED) - 函数返回其他值 → "我完成了,值就是这个"(同步返回路径)
双路径的价值:
| 场景 | 返回值 | 后续行为 |
|---|---|---|
suspend fun 内容立即 return | 真实值(如 User(...)) | 状态机直接进入下一 case,无需重入 |
suspend fun 里 await 未完成的 Deferred | COROUTINE_SUSPENDED | 状态机 return,等 resumeWith |
suspend fun 里 withContext 换线程 | COROUTINE_SUSPENDED | 状态机 return,切线程后被 resume |
delay(0) | 真实值 Unit | 直接继续,不切线程(fast path) |
性能关键:同步返回路径避免了一次 dispatch——大量"同步完成的 suspend"是零 overhead。这是"suspend 不比同步慢"的秘密。
# 4.4 尾调用优化
suspend fun 若只调用另一个 suspend fun 并直接返回,会做"尾挂起优化",不生成完整状态机——只是转发 continuation。
示例:
suspend fun fetchUserWrapper(id: Long): User {
return userRepo.fetchUser(id) // 尾挂起
}
优化后字节码等价于:
public static Object fetchUserWrapper(long id, Continuation cont) {
return userRepo.fetchUser(id, cont); // 直接把 cont 转发过去
}
没有生成 FetchUserWrapper$1 状态机类——因为整个函数没有跨挂起点的状态,只是一次委托。
对比未优化的情况:
suspend fun fetchUserWrapper(id: Long): User {
val u = userRepo.fetchUser(id) // 有中间变量
return u.copy(name = u.name.trim()) // 挂起点后还有操作
}
这里 u 需要跨挂起点保存 → 必须生成状态机,多一次对象分配。
优化条件:
- 函数体是"单表达式" 或 "只有一个挂起调用作为最后一步"
- 没有跨挂起点的局部变量
- 挂起调用的返回值直接被返回或丢弃
实践建议:写 wrapper 函数时保持它们"薄"——一层调用,不加中间变量,能享受尾挂起优化。
# 5. Dispatcher调度机制
# 5.1 Main调度实现
Android 上 Dispatchers.Main 由 AndroidDispatcherFactory(在 kotlinx-coroutines-android 里)提供,底层是 Handler.post(...)——把 continuation 放到主线程 Looper 队列。
AndroidDispatcherFactory 的 ServiceLoader 机制:
kotlinx-coroutines-android.jar
└─ META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
└─ kotlinx.coroutines.android.AndroidDispatcherFactory
MainDispatcherLoader 在类加载时通过 ServiceLoader 找到平台实现:
// kotlinx.coroutines.internal.MainDispatcherLoader
internal object MainDispatcherLoader {
val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()
private fun loadMainDispatcher(): MainCoroutineDispatcher {
return ServiceLoader.load(MainDispatcherFactory::class.java).toList()
.maxByOrNull { it.loadPriority }
?.tryCreateDispatcher(...)
?: MissingMainCoroutineDispatcher(...)
}
}
为什么这样设计:kotlinx-coroutines-core 是平台无关的(JVM/Android/JS/Native)——不能硬编码 Android Handler。用 ServiceLoader 让平台自己注册。
AndroidDispatcher 底层:
internal class HandlerContext(
private val handler: Handler,
private val name: String?
) : HandlerDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
if (!handler.post(block)) { // ← 就是 Handler.post
cancelOnRejection(context, block)
}
}
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
return !skipCheck && Looper.myLooper() == handler.looper
}
}
Dispatchers.Main.immediate 变体:
val Dispatchers.Main: MainCoroutineDispatcher // dispatch 总是走 Handler.post
val Dispatchers.Main.immediate: MainCoroutineDispatcher // 已在主线程则同步执行
关键差异:immediate 检查当前线程,已经是主线程就直接执行,避免一次不必要的 post。适合"UI 事件处理"(用户点击回调本来就在主线程)——viewModelScope 默认用 Dispatchers.Main.immediate 就是为了这个优化。
# 5.2 IO线程池模型
Dispatchers.IO 底层是 DefaultDispatcher 共享的 CoroutineScheduler,但允许阻塞线程数扩展到 max(64, 核数)。
CoroutineScheduler 的核心结构(kotlinx.coroutines.scheduling):
┌─────────────────────────────────────────────────────┐
│ CoroutineScheduler │
│ │
│ Global Queue(多生产者-多消费者队列) │
│ [task] [task] [task] ... │
│ │
│ Workers(工作线程数组) │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Local │ │ Local │ │ Local │ ... │
│ │ Queue │ │ Queue │ │ Queue │ │
│ └────────┘ └────────┘ └────────┘ │
│ ↑ ↑ ↑ │
│ 工作窃取(steal)互相偷取 │
└─────────────────────────────────────────────────────┘
关键设计:
- 每个 Worker 有一个本地队列(
WorkQueue)——单生产者多消费者的 ring buffer,无锁 - 全局队列——溢出用
- 工作窃取:本地队列空时,随机从其它 worker 偷一半——负载均衡
- 两类任务:
- CPU 任务(
TASK_NON_BLOCKING):占用 CPU permit,最多同时CORE_POOL_SIZE个 - IO 任务(
TASK_PROBABLY_BLOCKING):不占 CPU permit,允许扩容到MAX_POOL_SIZE(默认 64 或max(2, 核数))
- CPU 任务(
- CPU permit:一个整型信号量——
CORE_POOL_SIZE = 核数,保证 CPU 密集任务不过度并发
Dispatchers.IO 的特殊性:
// kotlinx.coroutines.scheduling.DefaultScheduler
override val io: CoroutineDispatcher = LimitingDispatcher(
this,
max(2, min(availableProcessors * 2, MAX_POOL_SIZE))
)
IO 是 Default 之上的 LimitingDispatcher——共享 worker,但打上 PROBABLY_BLOCKING 标记,允许扩容超出 CPU 核数。
弹性扩容:一个 IO 任务在阻塞时,Scheduler 检测到 "CPU permit 已用完但仍有 IO 任务" → 创建新 worker 线程(不超过 MAX_POOL_SIZE)。这就是为什么"IO 里做 100 个网络请求"不会打满 CPU。
默认参数(SystemProps.kt):
| 属性 | 默认值 | 可覆盖 |
|---|---|---|
CORE_POOL_SIZE | max(2, 核数) | -Dkotlinx.coroutines.scheduler.core.pool.size |
MAX_POOL_SIZE | max(核数, 64) | -Dkotlinx.coroutines.scheduler.max.pool.size |
| IDLE_WORKER_KEEP_ALIVE_NS | 60_000_000_000(60s) | -Dkotlinx.coroutines.scheduler.keep.alive.sec |
# 5.3 Default线程池
Dispatchers.Default 用同一个 CoroutineScheduler,线程数 = 核数(CORE_POOL_SIZE),专供 CPU 密集任务。
与 Dispatchers.IO 的关系:共享同一个线程池,只是任务打标不同。这意味着:
- 一个 worker 线程可能这一秒跑 Default 任务,下一秒跑 IO 任务
- 好处:无需两个独立线程池,减少总线程数
- 坏处:Default 任务被 IO 任务"稀释"?不会——Default 任务有 CPU permit 优先权
与 Java ForkJoinPool.commonPool() 的对比:
| 维度 | ForkJoinPool.commonPool | CoroutineScheduler |
|---|---|---|
| 工作窃取 | 有 | 有 |
| 本地队列 | 双端队列(LIFO 消费) | Ring buffer(FIFO) |
| 阻塞任务支持 | ManagedBlocker 手动声明 | 自动打标 + 弹性扩容 |
| 默认大小 | 核数 - 1 | max(2, 核数) |
| 用途 | CompletableFuture / parallel Stream | 协程调度 |
| 阻塞 IO 影响 | 会耗尽线程池 | 独立弹性 IO 池 |
为什么 Kotlin 不复用 ForkJoinPool:ForkJoinPool 对阻塞任务不友好——一个 IO 阻塞就吃掉一个 worker,池子容易被打空。Kotlin 自研 Scheduler 就是为了优雅处理 CPU + IO 混合负载。
# 5.4 Unconfined特殊性
危险调度器——在当前线程立即执行,直到第一个挂起点为止;恢复时在恢复线程执行,不切回原线程。
runBlocking {
launch(Dispatchers.Unconfined) {
println("A: ${Thread.currentThread().name}") // main
delay(100)
println("B: ${Thread.currentThread().name}") // DefaultDispatcher-worker-1(delay 恢复线程)
}
}
执行轨迹:
launch提交任务 → Unconfined 检测isDispatchNeeded=false→ 在当前线程直接跑- 遇到
delay(100)→ 挂起,控制权交回 - 100ms 后
DefaultDispatcher.Timer触发resumeWith→ 在 timer 线程继续执行 println("B")在 timer 线程输出
危险性:
- 不确定的线程亲和性:恢复线程完全看是谁
resume的 - 若被恢复到主线程的关键路径(例如从 Main 触发的挂起),下一段代码就锁死在主线程
- 不适合 UI 逻辑——恢复到什么线程都可能
唯一合理用途:
- 单元测试:
kotlinx-coroutines-test里runTest(老 APIrunBlockingTest)会用 Unconfined 让测试同步化 - 性能极限场景:某些框架内部 dispatcher,作者非常清楚自己在做什么
结论:日常开发 99% 不该用 Unconfined。
# 6. 结构化并发
# 6.1 CoroutineScope层级
每个 CoroutineScope 持有一个 Job,Job 之间形成父子树。
Application scope(Job A)
└─ viewModelScope(Job V,parent=A)
└─ launch { ... } ← Job L1,parent=V
└─ launch { ... } ← Job L2,parent=L1
└─ async { ... } ← Job A1,parent=L1
└─ launch { ... } ← Job L3,parent=V
父子关系的建立:
public fun CoroutineScope.launch(context: CoroutineContext, ...): Job {
val newContext = newCoroutineContext(context)
val coroutine = StandaloneCoroutine(newContext, ...)
coroutine.start(...)
return coroutine
}
// newCoroutineContext 内部:
public fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
val combined = coroutineContext + context // 继承父 context
// Job 会 attach 到父 Job:
val newJob = combined[Job]!!
parentJob.attachChild(newJob) // 建立父子关系
return combined
}
关键规则:
- 子协程的 Job 必然是父 Job 的 child
- 父 Job 完成 = 等所有 child 完成才算完成
- 子协程默认继承父的 Dispatcher,可以在 launch(customDispatcher) 覆盖
# 6.2 Job父子关系
Job 的状态机(kotlinx.coroutines.Job):
New
↓ start
Active ←──────────────┐
↓ │
Completing ←── waitChildren
↓
Completed / Cancelled
| 状态 | isActive | isCompleted | isCancelled |
|---|---|---|---|
| New | false | false | false |
| Active | true | false | false |
| Completing | true | false | false |
| Cancelling | false | false | true |
| Cancelled | false | true | true |
| Completed | false | true | false |
父子传播规则:
- 父 Job 取消 → 所有 child 取消
- 任一 child 抛异常(非
CancellationException)→ 传给父 → 父根据类型决定:- 父是
Job:父自己也取消(连带兄弟取消)——一荣俱荣一损俱损 - 父是
SupervisorJob:只影响自己(兄弟不受牵连)
- 父是
# 6.3 取消传播机制
Cancel 向下:
val job = scope.launch {
val c1 = launch { taskA() }
val c2 = launch { taskB() }
}
job.cancel() // 会级联取消 c1 和 c2
Complete 向上:
val job = scope.launch {
launch { delay(1000) } // c1
launch { delay(2000) } // c2
}
job.join() // 等到 c1 和 c2 都完成才返回,约 2000ms
父子树的完整生命周期图:
┌─── parent.launch(...) 创建 child
│ ↓
│ child.attachTo(parent) ← 挂到父的 children 列表
│ ↓
│ child.start() ← Active
│ ↓
│ child 挂起/恢复循环...
│ ↓
│ child.complete() ← child 完成
│ ↓
│ child.detachFromParent() ← 从父列表移除
│ ↓
│ parent.children 全空 && parent.body done → parent.complete
关键实现(JobSupport.kt):
private fun cancelParent(cause: Throwable): Boolean {
val parent = parentHandle ?: return false
return (parent as ParentJob).childCancelled(cause)
}
# 6.4 协作取消要点
launch {
while (isActive) { // 必须自查
yield() // 或调用挂起函数
heavyCompute()
}
}
忘记检查 isActive 是最常见的取消不响应原因。
协程取消的三种检查方式:
| 方式 | 用法 | 触发行为 |
|---|---|---|
while (isActive) | 循环体自查 | false 时正常退出 |
ensureActive() | 显式检查 | 抛 CancellationException |
调用任意 suspend fun | 例如 yield / delay | 抛 CancellationException |
为什么协程需要"协作取消":JVM 层 Thread.interrupt() 是建议性的(只设置 flag,线程可以忽略)——协程沿用了这个哲学。协程不能被强制杀死,只能被"请求取消",代码需要配合响应。
常见反例:
launch {
while (true) { // ❌ 不检查 isActive
heavyCompute() // ❌ 无挂起点
}
}
cancel() 后 heavyCompute 会一直跑完,无法中止。
修复:
launch {
while (isActive) {
heavyCompute()
yield() // 引入挂起点,让取消能生效
}
}
Android 场景常见坑:
- 不响应取消的 IO 库:OkHttp
execute()是阻塞的,不会响应协程 cancel——要用withTimeout+okhttp.cancel()手动配合 - 数据库事务:Room 的 suspend query 会响应取消,但传统
execSQL不会
# 7. 异常传播规则
# 7.1 Job与Supervisor差异
| 类型 | child 抛异常 | 影响 |
|---|---|---|
Job | 传播到父 → 兄弟被取消 | 一荣俱荣一损俱损 |
SupervisorJob | 只影响自己 | 独立故障 |
演示:
// Job 场景
runBlocking {
val parent = launch {
launch { delay(100); println("A ok") }
launch { delay(50); throw RuntimeException("B fail") }
}
parent.join()
// 输出:只有 B 的异常打印,A 不会执行完 —— 被兄弟牵连
}
// SupervisorJob 场景
runBlocking {
val parent = launch(SupervisorJob()) { // 或用 supervisorScope
launch { delay(100); println("A ok") }
launch { delay(50); throw RuntimeException("B fail") }
}
parent.join()
// 输出:A ok(不受 B 影响)
}
使用场景:
Job(默认):一组必须一起完成的任务(如"并发拉 3 接口拼数据"——任一失败其它无意义)SupervisorJob:一组独立子任务(如viewModelScope里多个不相关的 launch)
supervisorScope 与 SupervisorJob() 的差别:
supervisorScope { ... }是结构化并发原语——会等待所有子协程完成SupervisorJob()是一个 Job 元素——需要手动 attach 到 scope
viewModelScope 内部就是 CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)——ViewModel 里多个 launch 相互独立,一个网络失败不该导致其它任务全崩。
# 7.2 ExceptionHandler生效
val handler = CoroutineExceptionHandler { _, e -> Log.e("Coro", "err", e) }
scope.launch(handler) { throw IllegalStateException() }
关键规则:
CoroutineExceptionHandler只对"根协程"launch生效——child launch 里加 handler 会被忽略(异常先传给父)async不用 handler——异常在.await()时抛出(延迟到消费方)SupervisorJob的每个 child 都算"根"——每个 child 都可以配自己的 handler
为什么只对根 launch 生效:异常在协程树里先向上传播——child 抛的异常先传给父,父是 Job 会连带取消(继续传上去),最终到根 Job 才 dispatch 给 handler。中间层的 handler 会被"跳过"。
正确姿势:
// ✅ 顶层 handler
scope.launch(CoroutineExceptionHandler { _, e -> report(e) }) {
launch { throw RuntimeException() } // 异常向上传,被顶层 handler 收
}
// ❌ 中层 handler 无效
scope.launch {
launch(CoroutineExceptionHandler { _, e -> ... }) { throw ... }
// 这个 handler 不生效!
}
默认 handler 兜底:
- Android:
Thread.setDefaultUncaughtExceptionHandler(触发 Crash) - JVM:
Thread.getDefaultUncaughtExceptionHandler+printStackTrace viewModelScope没有默认 handler——异常就地丢弃(1.1 案例的根因)
# 7.3 async异常延迟
val deferred = scope.async { throw RuntimeException() }
// 这里不会崩,直到:
deferred.await() // 才抛出
根因:async 返回 Deferred<T>,异常存在 Deferred 里,等到 .await() 时"结算"。这与 Java CompletableFuture 的语义一致。
危险场景:
val d1 = scope.async { throw RuntimeException("boom") }
delay(10000)
// 忘记 await() —— 异常永远不出来,静默丢失
规避:总是 await——或者用 supervisorScope + 主动异常处理:
supervisorScope {
val d1 = async { risky1() }
val d2 = async { risky2() }
try { d1.await() } catch (e: Exception) { ... }
try { d2.await() } catch (e: Exception) { ... }
}
特殊约定:如果 async 的父是 Job(不是 SupervisorJob),异常还是会传给父——即使没 await,父也会取消。这是"结构化并发保护"的一部分。
只有在 supervisorScope + async 组合下,异常才真的会等到 await 才结算。
# 7.4 CancellationException
特殊约定:CancellationException 不算错误——它是取消信号。
- 抛出
CancellationException不会触发父 Job 取消 - 不会走
CoroutineExceptionHandler - 会被结构化并发机制"静默"处理
为什么这样设计:取消是正常控制流——job.cancel() 就是通过抛 CancellationException 实现的。如果它触发全家 crash,cancel() 就无法使用。
踩坑:自定义异常继承 CancellationException
class MyException : CancellationException()
scope.launch {
throw MyException("something broken") // 被静默吞掉!
}
// 不会崩、不会走 handler、看不到日志
血泪教训:永远不要让业务异常继承 CancellationException——它是协程的保留基类,业务用它 = 自动被吞。
协程内 try/catch 的正确写法:
scope.launch {
try {
riskyOp()
} catch (ce: CancellationException) {
throw ce // ★ 重新抛出!不能吞
} catch (e: Throwable) {
report(e)
}
}
try { ... } catch (e: Throwable) 会把 CancellationException 也捕获——必须先分开处理它,否则协程取消机制会失效。
# 8. Flow与协程关系
# 8.1 Flow是冷流
val flow = flow { emit(1); emit(2) } // 冷流,还未开始
flow.collect { println(it) } // 触发时才开始生产
Flow 每次 collect 都会独立执行 flow { } 内部——因此叫"冷流"。
验证:
val f = flow {
println("start")
emit(1)
emit(2)
}
f.collect { println("A: $it") } // 输出:start, A:1, A:2
f.collect { println("B: $it") } // 输出:start, B:1, B:2 —— 又跑一遍!
Flow 的核心接口(简化):
public interface Flow<out T> {
public suspend fun collect(collector: FlowCollector<T>)
}
public interface FlowCollector<in T> {
public suspend fun emit(value: T)
}
极简实现:Flow 只有一个 suspend 方法 collect——flow { emit(x) } 就是把 lambda 存起来,collect 时执行 lambda。
# 8.2 与Sequence差别
| 特性 | Sequence | Flow |
|---|---|---|
| 是否支持挂起 | 不支持 | 支持 |
| 是否有背压 | 无(同步) | 有(挂起即背压) |
| 消费方 | 同步 | 协程内 |
| 迭代驱动 | 拉(pull) | 拉(collect 挂起 → emit 挂起) |
| 中间操作 | 惰性 | 惰性 |
| 异常处理 | try/catch | .catch { } 操作符 |
| 线程切换 | 全同步 | .flowOn(Dispatcher) |
为什么 Flow 是"内置背压":emit 是 suspend 函数——消费方处理慢时,emit 挂起,生产方自然被阻塞。这与响应式流的 request-based 背压不同,但本质等价。
Sequence 不能替代 Flow 的场景:
- 涉及 IO / 网络的流(需要 suspend)
- 需要切换线程的流
- 与 Android LiveData / Compose State 集成
# 8.3 StateFlow与SharedFlow
StateFlow:热流,保留最新值,替代 Android LiveDataSharedFlow:热流,可配置 replay buffer,替代 EventBus
StateFlow 用法:
class UserViewModel : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun load() {
viewModelScope.launch {
_user.value = repo.fetchUser() // 更新
}
}
}
// 消费方(Fragment/Compose)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.user.collect { u -> updateUI(u) }
}
}
StateFlow 特性:
- 有一个"当前值"(构造时必须给初值)
- 新订阅者立即收到当前值(replay 1)
- 相同值不重发(
distinctUntilChanged内置) - 线程安全,
value可以直接读
SharedFlow 用法:
class EventBus {
private val _events = MutableSharedFlow<Event>(replay = 0, extraBufferCapacity = 16)
val events = _events.asSharedFlow()
suspend fun send(e: Event) { _events.emit(e) }
}
StateFlow vs SharedFlow:
| 维度 | StateFlow | SharedFlow |
|---|---|---|
| 初值 | 必须 | 可选 |
| replay | 固定 1 | 可配 |
| 缓冲 | 无 extraBuffer | 可配 |
| 值去重 | 内置 distinct | 无 |
| 主要用途 | UI 状态 | 事件流 |
| 生命周期 | 一直持有最新 | 事件消费一次 |
订阅生命周期:两者都是冷启动懒惰的——没订阅者时不做任何工作。stateIn / shareIn 操作符可以把 Flow 提升成 StateFlow / SharedFlow:
val users = repo.userFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialUser)
WhileSubscribed(5000) = "无订阅者 5s 后停止上游"——避免 orientation change 期间抖动。
# 8.4 背压处理机制
Flow 的默认策略是"生产方等消费方"——emit 会挂起等到消费完成。当生产速度远快于消费时,可以选择:
buffer(N):加一个 N 大小的缓冲区,生产方最多领先 N 个conflate():只保留最新值,丢弃中间值collectLatest:新值到来时取消上一次的处理sample(period):按时间窗口采样
代码演示:
flow {
(1..100).forEach { emit(it); delay(1) } // 快速生产
}
.buffer(10) // 缓冲 10
.collect { delay(100); println(it) } // 慢速消费
// buffer(10):生产者最多领先 10 个,然后挂起等消费方跟上
tickerFlow(10.milliseconds)
.conflate() // 只保留最新
.collect { drawUI(it) }
// 生产方每 10ms 一个,消费方每 100ms 一个 —— 中间的 9 个被丢弃
searchQueryFlow
.collectLatest { query ->
val results = api.search(query) // 慢
showResults(results)
}
// 用户敲键时旧的 search 被取消,只保留最新一次
选择原则:
| 场景 | 推荐 |
|---|---|
| 网络流不能丢数据 | buffer |
| UI 只关心最新值 | conflate |
| 搜索/输入防抖 | collectLatest / debounce |
| 定时采样 | sample |
# 9. 性能与线程对比
# 9.1 协程创建成本
协程 = 一次状态机对象分配——大约 80~200 字节堆内存,无系统调用。
对比线程:
- 线程 = 一个内核对象 + 1MB 栈(
-Xss1m默认)+ 一次pthread_create系统调用 - 大约 1MB 内存 + 100μs 创建时间
数量级对比:
| 指标 | 线程 | 协程 |
|---|---|---|
| 单个内存占用 | ~1MB | 100~200 字节 |
| 创建耗时 | ~100μs | ~200ns |
| 上下文切换 | ~1μs(内核态) | ~50ns(用户态) |
| 每 GB 内存承载数 | ~1000 | ~5,000,000 |
协程比线程轻 100~10000 倍,取决于场景。
# 9.2 百万协程实测
fun main() = runBlocking {
val time = measureTimeMillis {
val jobs = List(1_000_000) {
launch { delay(1000) }
}
jobs.joinAll()
}
println("time=${time}ms")
println("mem=${Runtime.getRuntime().totalMemory() / 1024 / 1024}MB")
}
本机实测数据(8 核 M1 MacBook,JVM -Xmx2g):
| 项目 | 值 |
|---|---|
| 完成时间 | ~1500 ms(几乎等于 delay(1000) 本身) |
| 堆内存峰值 | ~400 MB |
| GC 次数 | 2~3 次 |
| CPU 峰值 | ~40% |
对比线程(同样任务,1_000_000 个 Thread { Thread.sleep(1000) }):
- 直接 OOM:默认 1MB 栈 × 100 万 = 1TB 栈内存,JVM 直接崩
- 即使把栈设为
-Xss64k,也需要 64GB——普通机器无法运行
结论:协程让"百万并发"从不可能变成可行——特别适合海量 IO 场景(WebSocket 长连接、Web 请求 fan-out)。
# 9.3 上下文切换代价
协程切换 = 一次方法调用 + 一次状态机 label 跳转 + 一次可能的 dispatch。
分解:
suspend返回COROUTINE_SUSPENDED:一条 return 指令- Dispatcher 从队列取下一任务:一次原子读+方法调用
resumeWith触发:一次方法调用 + label 跳转
用户态操作,约 50~200ns。
线程切换:
- 内核态陷入
- 保存当前线程完整 CPU 寄存器(约 200 字节)
- 调度器选择下一线程
- 恢复目标线程寄存器
- 返回用户态
约 1~10μs,比协程慢 20~200 倍。
为什么协程这么快:
- 没有内核态切换——纯用户态跳转
- 没有寄存器保存/恢复——状态在 Continuation 字段里
- 没有 TLB flush——同一进程内同一虚拟空间
# 9.4 与Java虚拟线程
Java 21(2023 GA)引入 Virtual Threads(Project Loom),也是"运行时挂起 + 载体线程"模式。
用法:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(1000);
return null;
});
});
}
一百万个"虚拟线程",行为像线程但底层是用户态调度。
Virtual Thread vs Kotlin 协程的哲学差异:
| 维度 | Java Virtual Thread | Kotlin Coroutine |
|---|---|---|
| 挂起触发 | JVM 层拦截阻塞调用(Thread.sleep / Socket.read) | 语言层 suspend 关键字 |
| 语法侵入 | 完全零侵入(用 Thread API) | 需要 suspend 关键字 |
| 挂起载体 | JVM 内建 continuation | Kotlin 编译器生成状态机 |
| 与旧代码兼容 | 阻塞 IO 自动变异步 | 需要改造为 suspend |
| 状态管理 | JVM 隐藏 | 显式(Job / Scope) |
| 结构化并发 | 有(StructuredTaskScope) | 有(coroutineScope) |
| 平台依赖 | JVM 21+ | Kotlin runtime,全平台 |
何时哪个更合适:
| 场景 | 推荐 |
|---|---|
| Kotlin 主导项目 | Coroutine(生态完备) |
| Java 主导项目升级到 21+ | Virtual Thread(零改造) |
| Android(JVM 版本旧) | Coroutine(Android 默认 8/11 JDK) |
| 需要极其显式的取消控制 | Coroutine(结构化并发原生) |
| 需要与 JDK 阻塞 API 无缝互操作 | Virtual Thread |
关键洞察:两者是互补而不是竞争——它们解决"如何处理大量并发"的同一问题,只是切入点不同。Kotlin 协程给了程序员显式的挂起点(好处:可预测),Virtual Thread 给了隐式挂起(好处:兼容老代码)。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到 1.1 节的 7 个疑问:
suspend关键字触发 CPS 变换——函数末尾加Continuation参数,返回类型变Object。- 挂起函数是状态机——每个挂起点是一个 case,
label是状态编号。 Dispatchers.IO与Default共享 CoroutineScheduler,只是 IO 允许阻塞线程弹性扩容到 64。viewModelScope.launch内抛异常——被viewModelScope的 SupervisorJob 捕获,向上传播到viewModelScope.coroutineContext里的CoroutineExceptionHandler——如果没有 handler,就走handleUncaughtCoroutineException打条日志后丢弃。Job传播异常连累兄弟,SupervisorJob隔离异常。- 协程比线程轻 100~1000 倍——线程栈 1MB,协程状态机 100~200 字节;切换代价 50ns vs 1μs。
CancellationException是取消信号,不是错误——被协程库特殊处理,不会走 handler、不会让父取消。
案例根因:viewModelScope 用的是 SupervisorJob + Dispatchers.Main.immediate——launch 抛出的普通异常本应传播到 CoroutineExceptionHandler,但 viewModelScope 默认没配置 handler——异常被 handleCoroutineException 里的 handleUncaughtCoroutineException 记录(Debug 才可见)后丢弃。
修复三选一:
修复 A:try/catch 包 throw 处
fun save(config: Config) {
viewModelScope.launch(Dispatchers.IO) {
try {
val ok = repo.save(config)
withContext(Dispatchers.Main) {
if (ok) view.showToast("保存成功")
else view.showError("保存失败")
}
} catch (e: Exception) {
withContext(Dispatchers.Main) { view.showError(e.message.orEmpty()) }
}
}
}
修复 B:加 CoroutineExceptionHandler
private val handler = CoroutineExceptionHandler { _, e ->
Log.e("Save", "err", e)
Firebase.crashlytics.recordException(e)
}
fun save(config: Config) {
viewModelScope.launch(Dispatchers.IO + handler) {
// 原代码
}
}
修复 C(推荐):用 Result / sealed class 显式传递错误
sealed class SaveResult {
object Success : SaveResult()
data class Failure(val error: Throwable) : SaveResult()
}
fun save(config: Config) {
viewModelScope.launch(Dispatchers.IO) {
val result = runCatching { repo.save(config) }
.fold(
onSuccess = { if (it) SaveResult.Success else SaveResult.Failure(...) },
onFailure = { SaveResult.Failure(it) }
)
withContext(Dispatchers.Main) {
when (result) {
SaveResult.Success -> view.showToast("保存成功")
is SaveResult.Failure -> view.showError(result.error.message)
}
}
}
}
推荐 C——把"错误"编码进类型系统,编译期强制处理,杜绝静默丢失。
# 10.2 一个suspend一生
[Source] 写下 suspend fun fetchUser(id: Long): User
↓
[Compile] kotlinc CPS 变换:
1. 生成 fetchUser(id: Long, cont: Continuation) → Object 签名
2. 生成 FetchUser$Continuation extends ContinuationImpl 状态机类
3. 生成 label 字段 + 局部变量字段
4. 生成 invokeSuspend(Object result) 方法(含 switch-case)
↓
[Invoke] 调用点 launch { val u = fetchUser(1) }
1. launch 分配一个 SuspendLambda 实例(本身是 Continuation)
2. 提交到 Dispatcher 的队列
↓
[Dispatch] Dispatcher 分派到 worker 线程
1. Worker 从队列取任务
2. 执行 SuspendLambda.resumeWith(Result.success(Unit))
3. invokeSuspend 从 case 0 开始
↓
[Suspend] 遇到 delay/withContext/await 未完成
1. 挂起调用返回 COROUTINE_SUSPENDED
2. 状态机 return COROUTINE_SUSPENDED
3. Worker 拿到 SUSPENDED,去队列取下一任务
↓
[Wait] 异步事件回调
1. 网络返回 / 定时器触发 / IO 完成
2. cont.resumeWith(Result.success(value))
↓
[Resume] resumeWith 内部
1. 判断 dispatcher.isDispatchNeeded
2. 需要 → 重新提交到 Dispatcher 队列
3. 不需要 → 当前线程直接执行 invokeSuspend
↓
[Continue] Worker 再次执行状态机
1. invokeSuspend(result) 进入 switch
2. 根据 this.label 跳到对应 case
3. 继续执行后续代码
↓
[Done] 最终返回 User
1. return User(...) 从 invokeSuspend 出来
2. 父 Continuation.resumeWith(Result.success(user))
3. 父继续执行,Job 完成
# 10.3 设计哲学回扣
- CPS 变换是并发抽象的语言级实现:不引入新关键字(除
suspend)、不引入运行时(除 Continuation),编译期把"回调"这个复杂机器藏起来 - 结构化并发消灭 Goroutine 泄漏:每个协程必属于一个 scope,scope 销毁自动取消子协程——没有"僵尸协程"
- 上下文即能力集合:
CoroutineContext用"开放式携带"取代多参数——第三方能力(日志上下文、trace ID)可以透明传递 - 协程不是"更轻的线程",是"用状态机模拟的线程":本质是内存里的状态而非 OS 资源——所以创建成本低几个数量级
- 异常处理是三层过滤:try/catch → CoroutineExceptionHandler → 默认兜底——每层都容易被跳过,业务代码要显式处理
- CancellationException 是控制流不是错误:这条约定让
cancel()变成可用的原语——但也带来了"业务异常继承 CancellationException 会被吞"的坑 - 与 Java Virtual Thread 互补:Kotlin 协程给了显式挂起点(可预测),Virtual Thread 给了隐式挂起(兼容老代码)——不是竞争关系
# 10.4 协程速查表
| 场景 | 推荐 API |
|---|---|
| 发射后不管 | launch + handler |
| 要拿返回值 | async + await |
| 切线程 | withContext(dispatcher) |
| 并发拉多接口 | coroutineScope { async; async; await; await } |
| 独立故障子协程 | supervisorScope { launch { ... }; launch { ... } } |
| 只关心最新一次 | collectLatest / mapLatest |
| 超时控制 | withTimeout / withTimeoutOrNull |
| UI 事件流 | SharedFlow |
| UI 状态流 | StateFlow + stateIn |
| Android UI 生命周期 | viewLifecycleOwner.lifecycleScope + repeatOnLifecycle |
| 一次性操作 | viewModelScope.launch |
| 应用级长任务 | Application 里自建 CoroutineScope(SupervisorJob() + Dispatchers.IO) |
| CPU 密集 | Dispatchers.Default |
| 网络/文件 IO | Dispatchers.IO |
| 测试 | runTest { } + TestDispatcher |
别名对照:
| 传统写法 | 协程替代 |
|---|---|
Handler.post { } | launch(Dispatchers.Main) { } |
AsyncTask.execute | launch(Dispatchers.IO) { } |
Callback<T> | suspend fun ... : T |
CompletableFuture<T> | Deferred<T> |
Thread.sleep(t) | delay(t) |
RxJava Single | suspend fun |
RxJava Observable | Flow |
RxJava BehaviorSubject | StateFlow |
RxJava PublishSubject | SharedFlow |
EventBus | SharedFlow |
LiveData | StateFlow |
下一篇预告:04.inline与reified原理 —— 我们看看 inline 如何让 Lambda 消失于字节码,reified 如何在泛型擦除的世界里"复活"类型信息。