Kotlin 协程的工作原理
协程解决的本质问题
Android 开发中所有的卡顿根源只有一个:主线程被阻塞。网络请求、磁盘读写、数据库查询——这些耗时操作一旦在主线程执行,UI 就会冻结,16ms 的帧预算被打破,用户感知到明显的掉帧。
传统的解法无非两种:回调(Callback)和响应式流(RxJava)。回调导致"回调地狱",代码层层嵌套,错误处理支离破碎;RxJava 学习曲线陡峭,操作符繁多,一条链上调度器用错就是隐蔽的线程安全 Bug。
协程的设计目标极其明确:让异步代码看起来像同步代码。用 suspend 函数把"等待"这个概念优雅地表达出来,代码按从上到下的顺序书写,逻辑一目了然——但底层不会阻塞线程。
// 传统回调写法(嵌套地狱)
fun loadUser(id: String) {
api.getUser(id) { user ->
db.saveUser(user) {
ui.showUser(user)
}
}
}
// 协程写法(线性,清晰)
suspend fun loadUser(id: String) {
val user = api.getUser(id) // 非阻塞地等待
db.saveUser(user)
ui.showUser(user)
}
两段代码的业务逻辑完全一样,但协程版本的控制流是平坦的、可 try-catch 的——异常处理不再散落在回调里。问题是:JVM 上没有"暂停一个函数然后稍后恢复"的原语,协程是怎么做到的?
suspend 的编译原理:CPS 变换
suspend 关键字不是运行时魔法——它完全是编译器的变换。编译器对每一个 suspend 函数做两件事:
- CPS 变换(Continuation-Passing Style):在函数签名末尾追加一个隐藏的
Continuation<T>参数 - 状态机变换:将函数体改写为一个有限状态机
CPS 变换:追加隐藏参数
CPS 变换就像快递代收:你告诉快递员"送到了打我电话"(传入 Continuation),快递员不需要在你门口干等(不阻塞线程),送完后回拨你完成后续动作。
以这个函数为例:
suspend fun fetchUser(id: String): User
编译器将签名改写为:
// 编译后的 JVM 签名
public Object fetchUser(String id, Continuation<User> $completion)
两处关键变化:
| 变化 | 原因 |
|---|---|
追加 Continuation<User> 参数 |
这是"结果发给谁"的回调——CPS 的核心思想 |
返回类型变为 Object(Any?) |
因为函数可能返回真正的结果,也可能返回一个特殊标记 COROUTINE_SUSPENDED |
COROUTINE_SUSPENDED 是一个内部标记对象,它的语义是:"我还没执行完,结果稍后通过 Continuation 回调给你"。当调用者收到这个标记时,它知道不该继续往下执行了——应该把线程还给调度器,去干别的事。
Continuation 接口的源码
Continuation 是整个协程体系的基石接口,定义在 kotlin.coroutines 包中:
// kotlin.coroutines.Continuation —— 协程的核心接口
public interface Continuation<in T> {
/**
* 这个 Continuation 关联的协程上下文。
* 包含了 Dispatcher(在哪个线程恢复)、Job(生命周期管理)等信息。
*/
public val context: CoroutineContext
/**
* 恢复执行——将挂起函数的结果传递过来,让状态机跳到下一个状态。
* Result<T> 封装了成功值或异常。
*/
public fun resumeWith(result: Result<T>)
}
两个关键设计细节:
context持有全部元数据:当协程需要恢复时,调度器从context中获取目标线程信息,决定在哪里调用resumeWithresumeWith接受Result<T>:它同时处理成功和异常,避免了需要两个独立回调(类似 Java 的CompletionHandler.completed()+failed())的冗余设计
标准库还提供了便捷的扩展函数:
// kotlin.coroutines 包中的扩展
public inline fun <T> Continuation<T>.resume(value: T) {
resumeWith(Result.success(value))
}
public inline fun <T> Continuation<T>.resumeWithException(exception: Throwable) {
resumeWith(Result.failure(exception))
}
Continuation本质上就是一个"带上下文的回调"。但与裸回调不同,编译器会自动管理它的创建、保存和调用——开发者永远不需要手动构造Continuation对象。
状态机变换:编译器的核心技巧
CPS 变换解决了"函数签名怎么改"的问题。但更关键的问题是:当 suspend 函数在某个挂起点暂停后,它需要"记住"自己执行到哪了、局部变量的值是多少——这样才能在将来恢复时从断点继续。
JVM 没有"冻结栈帧"的能力。编译器的解法是:不用栈帧——把整个函数改写为状态机,把所有需要跨越挂起点的状态存进堆对象。
一个完整例子
suspend fun fetchAndSave(): String {
val data = fetchFromNetwork() // 挂起点 1
val id = saveToDB(data) // 挂起点 2
return "Saved: $id"
}
编译器将其改写为等价的状态机(以下是反编译后的简化 Java 代码):
// 编译产物——状态机伪代码
public Object fetchAndSave(Continuation<String> $completion) {
// ① 首次进入:创建状态机对象
// 后续恢复:复用同一个状态机对象
FetchAndSaveContinuation sm;
if ($completion instanceof FetchAndSaveContinuation) {
sm = (FetchAndSaveContinuation) $completion;
} else {
sm = new FetchAndSaveContinuation($completion);
}
switch (sm.label) {
case 0:
// ── 状态 0:初始状态 ──
sm.label = 1; // 标记下一个状态
Object result = fetchFromNetwork(sm); // 传入状态机作为回调
if (result == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED; // 让出线程
}
// 如果 fetchFromNetwork 没有真正挂起(同步返回),直接 fall through
case 1:
// ── 状态 1:从挂起点 1 恢复 ──
// 恢复时,sm.result 中保存着 fetchFromNetwork 的结果
ResultKt.throwOnFailure(sm.result); // 如果上一步是异常,在此抛出
String data = (String) sm.result;
sm.data = data; // 把局部变量保存到状态机的字段
sm.label = 2; // 标记下一个状态
Object result2 = saveToDB(data, sm);
if (result2 == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED;
}
case 2:
// ── 状态 2:从挂起点 2 恢复 ──
ResultKt.throwOnFailure(sm.result);
int id = (int) sm.result;
return "Saved: " + id; // 最终返回值
}
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
状态机对象的结构
编译器为每个 suspend 函数生成一个 ContinuationImpl 子类(即上面的 FetchAndSaveContinuation):
// 编译器生成的状态机类(简化)
final class FetchAndSaveContinuation extends ContinuationImpl {
int label = 0; // 当前状态(执行到哪个挂起点之后)
Object result; // 上一个挂起点的返回值
// ↓ 需要跨越挂起点的局部变量被"提升"为字段
String data; // 对应源码中的 val data
final Continuation<String> $completion; // 外层的 Continuation(最终的结果接收者)
FetchAndSaveContinuation(Continuation<String> $completion) {
super($completion);
this.$completion = $completion;
}
@Override
protected Object invokeSuspend(Object result) {
this.result = result;
return fetchAndSave(this); // 重新进入状态机
}
}
执行流程图解
首次调用 fetchAndSave(outerContinuation)
│
├─ 创建 sm(label=0)
│
├─ case 0: 调用 fetchFromNetwork(sm)
│ ├─ 返回 COROUTINE_SUSPENDED → 线程被释放 🔓
│ │ ......(网络请求进行中)......
│ │ 网络完成 → sm.resumeWith(Result.success(data))
│ │ → invokeSuspend(data) → 重新进入 fetchAndSave(sm)
│ │ → switch 命中 case 1
│ │
│ └─ 直接返回结果(无需真正挂起)→ fall through 到 case 1
│
├─ case 1: 恢复 data,调用 saveToDB(data, sm)
│ ├─ 返回 COROUTINE_SUSPENDED → 线程被释放 🔓
│ │ ......(数据库写入中)......
│ │ 完成 → sm.resumeWith(Result.success(id))
│ │ → invokeSuspend(id) → 重新进入 fetchAndSave(sm)
│ │ → switch 命中 case 2
│ │
│ └─ 直接返回结果 → fall through 到 case 2
│
└─ case 2: 恢复 id,返回 "Saved: $id" ✅
核心洞察
整个状态机机制可以总结为三句话:
- 挂起 = 保存状态 + 返回标记:把
label推进到下一状态,把局部变量存入字段,返回COROUTINE_SUSPENDED - 恢复 = 重新进入 + 跳到断点:
resumeWith触发invokeSuspend,状态机从存储的label处继续执行 - 协程不是线程,是可恢复的计算:挂起期间,协程不占用任何线程——它的全部状态都在堆上的那个状态机对象中
线程就像一辆出租车——乘客(协程)下车(挂起)后,车可以立刻拉新客人(执行其他协程)。乘客的行李(状态机对象)放在路边等着,下一辆空车(可能是同一辆,也可能是另一辆)来了接他继续走。
CoroutineContext:协程的"环境变量"
每个协程都有一个 CoroutineContext,它决定了协程"在哪执行"、"谁管生死"、"出错了怎么办"。理解 CoroutineContext 的数据结构设计,才能真正理解协程的配置和传播机制。
数据结构:类型安全的异构索引集
CoroutineContext 不是简单的 Map<String, Any>——它是一个用**复合模式(Composite Pattern)**实现的、以类型为索引键的不可变集合。
// kotlin.coroutines.CoroutineContext —— 核心接口(简化)
public interface CoroutineContext {
// 按 Key 查找元素(类型安全的索引)
public operator fun <E : Element> get(key: Key<E>): E?
// 遍历所有元素(fold 操作)
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
// 合并两个 Context(+ 运算符)
public operator fun plus(context: CoroutineContext): CoroutineContext
// 移除某个 Key 对应的元素
public fun minusKey(key: Key<*>): CoroutineContext
// Element 本身也是 CoroutineContext(复合模式的关键)
public interface Element : CoroutineContext {
public val key: Key<*>
}
// Key 是伴生对象——每种 Element 类型有且只有一个 Key
public interface Key<E : Element>
}
这段设计有三个精妙之处:
第一,Element 继承 CoroutineContext。一个单独的 Job 或 Dispatcher 本身就是一个"只包含自己的 Context"。这意味着你可以直接把 Dispatchers.IO 当作 Context 传入——不需要先"包装"成某种容器。
第二,Key 是类型级别的索引。每种 Context 元素都在伴生对象中声明自己的 Key:
// Job 的 Key 声明方式
public interface Job : CoroutineContext.Element {
// 伴生对象本身就是 Key 的实例——全局唯一
public companion object Key : CoroutineContext.Key<Job>
}
// 使用时,编译器通过 Key 的泛型参数 <Job> 推导出返回类型
val job: Job? = coroutineContext[Job] // 类型安全,无需强转
第三,+ 运算符的底层是链表拼接。当你写 Job() + Dispatchers.IO + CoroutineName("test") 时,底层创建了一个 CombinedContext 链表:
CombinedContext
├── left: CombinedContext
│ ├── left: Job()
│ └── element: Dispatchers.IO
└── element: CoroutineName("test")
查找时从右向左遍历,后添加的同类型元素覆盖先添加的——行为类似于不可变 Map 的 put 语义。
常见的 Context 元素
| Element 类型 | Key | 职责 |
|---|---|---|
Job |
Job |
协程的生命周期管理和取消传播 |
CoroutineDispatcher |
ContinuationInterceptor |
决定协程在哪个线程(池)上执行 |
CoroutineName |
CoroutineName |
调试标识,出现在日志和异常堆栈中 |
CoroutineExceptionHandler |
CoroutineExceptionHandler |
顶层协程的未捕获异常处理器 |
注意 CoroutineDispatcher 的 Key 不是 CoroutineDispatcher 本身,而是 ContinuationInterceptor——因为 Dispatcher 在协程体系中扮演的角色是拦截 Continuation 的恢复,将其调度到目标线程。这一层抽象意味着你可以自定义任何"拦截恢复动作"的机制,不限于线程调度。
CoroutineDispatcher:线程调度的幕后机制
Dispatcher 决定了协程"在哪个线程执行",但它的实现远比"选一个线程池"要精巧。
Dispatcher 的工作原理
CoroutineDispatcher 继承自 ContinuationInterceptor。当协程启动或从挂起点恢复时,拦截器会把 Continuation 包装成一个 DispatchedContinuation,然后调用 dispatch() 方法把恢复动作投递到目标线程:
// CoroutineDispatcher 核心方法(简化)
public abstract class CoroutineDispatcher : ContinuationInterceptor {
/**
* 判断是否需要调度。如果当前线程已经是目标线程,可以返回 false 避免不必要的线程切换。
*/
public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true
/**
* 将可运行块(Runnable)投递到目标线程执行。
* 这是唯一需要子类实现的抽象方法。
*/
public abstract fun dispatch(context: CoroutineContext, block: Runnable)
}
整个调度流程:
协程恢复(resumeWith)
│
├─ ContinuationInterceptor.interceptContinuation(continuation)
│ └─ 返回 DispatchedContinuation(包装了原始 Continuation + Dispatcher)
│
├─ DispatchedContinuation.resumeWith(result)
│ ├─ dispatcher.isDispatchNeeded(context)?
│ │ ├─ true → dispatcher.dispatch(context, this) // 投递到目标线程队列
│ │ └─ false → 直接在当前线程执行 continuation.resumeWith(result)
│
└─ 目标线程从队列取出 Runnable,执行 continuation.resumeWith(result)
四种标准 Dispatcher 的内幕
| Dispatcher | 线程策略 | 核心实现 |
|---|---|---|
Dispatchers.Default |
CPU 密集型,线程数 = max(2, CPU 核心数) |
共享 CoroutineScheduler(工作窃取算法) |
Dispatchers.IO |
I/O 密集型,线程数最多 max(64, CPU 核心数) |
与 Default 共享同一个 CoroutineScheduler |
Dispatchers.Main |
Android 主线程 | 底层是 Handler(Looper.getMainLooper()).post() |
Dispatchers.Unconfined |
不指定线程(在恢复点的线程继续) | isDispatchNeeded() 返回 false |
一个常见的误解是 Default 和 IO 使用两个独立的线程池。
事实是:它们共享同一个 CoroutineScheduler 实例,只是限流策略不同。CoroutineScheduler 是 kotlinx.coroutines 内部实现的一个高性能、无锁、工作窃取的线程池调度器。Default 和 IO 通过 LimitingDispatcher 为自己设置不同的并发上限:
CoroutineScheduler(共享线程池)
│
├── Default 视图:最多并发 CPU_CORES 个任务
│ (超出的排队等待,保证 CPU 密集型任务不过度竞争)
│
└── IO 视图:最多并发 64 个任务
(允许大量阻塞 I/O 并发,因为阻塞线程不消耗 CPU)
这种共享设计的好处:当 Default 任务少而 IO 任务多时,空闲的 CPU 线程可以被 IO 复用——不存在"一个池空闲一个池忙"的资源浪费。
withContext 的线程切换原理
withContext(Dispatchers.IO) { ... } 是最常用的线程切换手段。它的本质是:挂起当前协程 → 在新 Dispatcher 上恢复 → 执行块 → 挂起 → 切回原 Dispatcher 恢复。
// withContext 的简化实现逻辑
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T = suspendCoroutineUninterceptedOrReturn { uCont ->
// 1. 合并新旧 Context
val newContext = uCont.context + context
// 2. 创建一个新的 DispatchedCoroutine
val coroutine = DispatchedCoroutine(newContext, uCont)
// 3. 在新的 Dispatcher 上启动 block
coroutine.initParentJob()
block.startCoroutineCancellable(coroutine, coroutine)
// 4. 返回 COROUTINE_SUSPENDED,让出当前线程
coroutine.getResult()
}
关键点:withContext 不会创建新协程,它只是在同一个协程内切换了执行的 Dispatcher。这意味着它不会破坏结构化并发的层级关系。
协程构建器:launch 与 async 的源码剖析
理解了 suspend、Continuation、CoroutineContext 和 Dispatcher,我们终于可以看看协程是怎么被"启动"的。
launch:启动并遗忘
// kotlinx.coroutines.Builders.kt(简化)
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
// 步骤 1:合并 Scope 的 Context 和传入的 Context
val newContext = newCoroutineContext(context)
// 步骤 2:创建协程对象(StandaloneCoroutine 是 AbstractCoroutine 的子类)
val coroutine = if (start.isLazy) {
LazyStandaloneCoroutine(newContext, block)
} else {
StandaloneCoroutine(newContext, active = true)
}
// 步骤 3:建立父子关系(结构化并发的关键)
coroutine.start(start, coroutine, block)
// 步骤 4:返回 Job(调用者可以用它取消或等待)
return coroutine
}
StandaloneCoroutine 继承自 AbstractCoroutine,它同时实现了三个接口:
Job:管理生命周期(Active → Completing → Completed / Cancelled)Continuation<Unit>:接收协程体的最终结果CoroutineScope:提供子协程启动的上下文
coroutine.start(start, coroutine, block) 内部会根据 CoroutineStart 枚举决定启动策略:
| CoroutineStart | 行为 |
|---|---|
DEFAULT |
立即通过 Dispatcher 调度执行 |
LAZY |
不启动,等到 job.start() 或 job.join() 时才启动 |
ATOMIC |
立即调度,但在第一个挂起点之前不可取消 |
UNDISPATCHED |
立即在当前线程执行直到第一个挂起点(绕过 Dispatcher) |
async:启动并返回结果
async 的实现几乎与 launch 完全相同,唯一的区别是它创建的是 DeferredCoroutine,并返回 Deferred<T> 而不是 Job:
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy) {
LazyDeferredCoroutine(newContext, block)
} else {
DeferredCoroutine<T>(newContext, active = true)
}
coroutine.start(start, coroutine, block)
return coroutine
}
Deferred<T> 继承自 Job,额外提供了 await() 挂起函数:
public interface Deferred<out T> : Job {
/**
* 挂起等待结果。如果协程已完成,立即返回;
* 如果协程已失败,抛出异常;如果协程仍在运行,挂起直到完成。
*/
public suspend fun await(): T
}
并发执行的正确姿势
async 的真正价值在于并发分解——把多个独立的异步任务同时启动,然后等待所有结果:
// ✅ 真正的并发:两个请求同时进行,总耗时约为较慢的那一个
suspend fun loadDashboard(userId: String): Dashboard = coroutineScope {
val userDeferred = async { fetchUser(userId) } // 立即启动
val ordersDeferred = async { fetchOrders(userId) } // 立即启动(不等 user)
// 两个请求并行,这里才开始等待
val user = userDeferred.await()
val orders = ordersDeferred.await()
Dashboard(user, orders)
}
// ❌ 假并发:两个请求顺序执行,总耗时是两者之和
suspend fun loadDashboardWrong(userId: String): Dashboard {
val user = fetchUser(userId) // 等这个完成后...
val orders = fetchOrders(userId) // ...才开始这个
return Dashboard(user, orders)
}
coroutineScope { } 在这里的作用是创建一个子 Scope,保证两个 async 都完成后才返回——这是结构化并发的体现。
结构化并发:从设计动机到 Job 树
结构化并发(Structured Concurrency)是 Kotlin 协程最重要的设计决策之一。它的核心原则可以用一句话概括:
子协程的生命周期不能超过父 Scope。
为什么需要结构化并发
在没有结构化并发的世界里(比如使用裸线程或 GlobalScope),异步任务的生命周期是"自由"的——你启动一个后台任务,它可能在 Activity 销毁后还在执行,持有着 Activity 的引用导致内存泄漏,或者在已销毁的 UI 上调用 setText() 触发崩溃。
非结构化并发就像生了孩子但没人管——孩子可以跑到任何地方,你不知道他们在哪,更无法叫他们回来。结构化并发则是"家长制":每个孩子都有明确的家(Scope),家长离开时所有孩子必须跟着离开。
Job 树:父子关系的实现
每个协程都有一个 Job,Job 之间形成树状层级:
viewModelScope.coroutineContext[Job]
│
├── launch { loadUser() } → StandaloneCoroutine (child Job)
│ │
│ └── withContext(IO) { fetchFromNetwork() } → DispatchedCoroutine
│
├── launch { loadOrders() } → StandaloneCoroutine (child Job)
│
└── async { computeStats() } → DeferredCoroutine (child Job)
│
└── launch { logProgress() } → StandaloneCoroutine (grandchild)
建立父子关系的代码发生在 AbstractCoroutine.initParentJob() 中——它调用 parentJob.attachChild(this) 把自己注册为父 Job 的子节点。
取消与异常的传播规则
父子关系建立后,取消和异常沿着 Job 树传播:
取消的传播是向下的:
父 Job 取消(如 viewModelScope 在 ViewModel.onCleared 中取消)
↓
所有子 Job 收到取消信号(CancellationException)
↓
所有孙 Job 也收到取消信号
↓ ...一直传播到叶子节点
异常的传播是向上的(默认行为):
子 Job 抛出异常(非 CancellationException)
↓
通知父 Job
↓
父 Job 取消自己和所有其他子 Job
↓
异常继续向上传播
这个"一个孩子出事,全家受影响"的行为是合理的默认值——它对应的场景是:多个子任务组成一个原子操作,任何一个失败都意味着整体失败。但有些场景需要隔离:
SupervisorJob:故障隔离
SupervisorJob 改变了异常传播的规则:子协程的失败不会向上传播,也不会取消兄弟协程。
// supervisorScope:每个子协程独立运行,互不影响
suspend fun loadDashboard() = supervisorScope {
val userJob = launch {
fetchUser() // 即使这个失败了...
}
val ordersJob = launch {
fetchOrders() // ...这个仍然继续执行
}
}
适用场景:UI 中多个独立板块各自加载数据——新闻推荐崩了不应该影响天气小组件。
| 传播行为 | coroutineScope / 普通 Job |
supervisorScope / SupervisorJob |
|---|---|---|
| 子协程异常 → 父协程 | ✅ 传播,父协程取消 | ❌ 不传播,父协程不受影响 |
| 父协程取消 → 子协程 | ✅ 传播 | ✅ 传播(取消始终向下) |
| 兄弟协程互相影响 | ✅(通过父协程间接取消) | ❌ 完全隔离 |
Android 生命周期集成
理解了 Scope 和 Job 树之后,Android 的生命周期集成就自然了:
viewModelScope
// 在 ViewModel 内部使用(viewModelScope 在 ViewModel 销毁时自动取消)
class UserViewModel : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
try {
val user = withContext(Dispatchers.IO) {
userRepository.getUser(id) // 在 IO 线程执行
}
// withContext 返回时自动回到 Main 线程(因为 viewModelScope 的 Dispatcher 是 Main)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
viewModelScope 的底层实现很简单:
// lifecycle-viewmodel-ktx 库
public val ViewModel.viewModelScope: CoroutineScope
get() {
// 缓存在 ViewModel 的 CloseableCoroutineScope 中
val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Main.immediate
)
// 注册在 ViewModel.onCleared() 时取消
addCloseable(scope)
return scope
}
两个设计细节:
- 默认 Dispatcher 是
Dispatchers.Main.immediate:协程启动后直接在主线程执行,需要 IO 时显式withContext(Dispatchers.IO) - 使用
SupervisorJob:viewModelScope 中一个请求失败不会取消其他请求
lifecycleScope 与 repeatOnLifecycle
// Fragment/Activity 中
lifecycleScope.launch {
// repeatOnLifecycle 在 STARTED 时启动收集,STOPPED 时取消
// 重新回到 STARTED 时再次启动——实现"只在前台收集"
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
updateUI(state)
}
}
}
repeatOnLifecycle 是防止 Flow 在后台持续收集(浪费资源甚至触发崩溃)的最佳实践。
协程与线程的资源对比
协程最终在线程上执行,但它们的资源模型截然不同:
内存开销
| 资源 | 线程 | 协程 |
|---|---|---|
| 栈空间 | 每个线程 ~1MB(OS 分配的固定栈) | 无独立栈,状态机对象几十字节到几 KB |
| 上下文切换 | 内核态切换(保存/恢复寄存器、TLB 刷新) | 用户态切换(只是一次 resumeWith 方法调用) |
| 创建开销 | 创建 OS 线程需要系统调用 | 创建协程只是 new 一个状态机对象 |
| 并发上限 | 受 OS 和内存限制,通常数千个 | 轻松支撑数十万个 |
直观演示
// 线程版本:启动 100,000 个线程 → OutOfMemoryError
fun threadVersion() {
val threads = List(100_000) {
thread {
Thread.sleep(5000) // 模拟等待
println("Thread $it done")
}
}
threads.forEach { it.join() }
}
// 协程版本:启动 100,000 个协程 → 正常运行
fun coroutineVersion() = runBlocking {
val jobs = List(100_000) {
launch {
delay(5000) // 挂起,不占线程
println("Coroutine $it done")
}
}
jobs.forEach { it.join() }
}
线程版本大概率在创建几千个线程后因为内存不足而崩溃(每个线程 1MB 栈,10 万个就是 100GB)。协程版本只需要几十 MB 内存——因为 10 万个协程的状态机对象总共也就这么大,而它们共享底层的少量线程。
Dispatcher 内部的线程复用
Dispatchers.IO 内部 CoroutineScheduler:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Worker-1 │ │ Worker-2 │ │ Worker-3 │ ...(最多 64 个活跃)
└──────────┘ └──────────┘ └──────────┘
协程 A 协程 B 协程 C
协程 A 挂起(等待网络响应)
→ Worker-1 的任务队列为空
→ Worker-1 从 Worker-2 的队列"窃取"一个任务(工作窃取算法)
→ Worker-1 开始执行协程 D
协程 A 的网络响应到达
→ resumeWith 被调用
→ Dispatcher 把恢复动作投递到 CoroutineScheduler 的队列
→ 任意一个空闲 Worker 取出并执行
这就是协程高效的根本原因:线程不会因为"等待"而空闲,挂起的协程不占用线程资源,恢复时由调度器分配给任何可用的线程。
常见错误模式
错误一:在协程里用阻塞 API
// ❌ 在 IO 协程里用 Thread.sleep → 阻塞了底层线程,协程的优势全无
viewModelScope.launch(Dispatchers.IO) {
Thread.sleep(1000) // 线程被白白占用 1 秒
}
// ✅ 用 delay 挂起 → 线程被释放去做其他事
viewModelScope.launch {
delay(1000) // 非阻塞挂起
}
同样,如果你必须调用阻塞的 Java 库(如 JDBC),必须在 Dispatchers.IO 上执行——IO 的线程池正是为此设计的,它允许最多 64 个线程同时被阻塞。
错误二:使用 GlobalScope
// ❌ GlobalScope 不受任何 Scope 管理,生命周期等于进程生命周期
GlobalScope.launch { loadData() } // 谁来取消它?
// ✅ 用 viewModelScope / lifecycleScope → 自动跟随生命周期取消
viewModelScope.launch { loadData() }
GlobalScope 破坏了结构化并发——你启动的协程可能在 Activity 销毁后仍在执行,持有着过时的引用。唯一合理使用 GlobalScope 的场景是真正需要"全局生命周期"的任务(如应用级别的日志上报),但即便如此,也建议自定义一个明确命名的 Scope。
错误三:在 collect 中做耗时操作
// ❌ collect 在 Main 线程,processHeavily 会卡 UI
flow.collect { data ->
processHeavily(data) // 耗时操作在主线程!
}
// ✅ 用 flowOn 把耗时操作切到后台线程
flow
.map { data -> processHeavily(data) } // 这里在 IO 线程
.flowOn(Dispatchers.IO)
.collect { result ->
updateUI(result) // collect 在 Main 线程,只做轻量 UI 操作
}
flowOn 只影响它上游的操作符——这是与 RxJava 的 subscribeOn / observeOn 的关键区别。collect 始终在调用者所在的线程上执行。
错误四:在 launch 中使用 async 但忽略异常
// ❌ async 的异常只在 await() 时才抛出
// 如果从不 await,异常被静默吞掉
viewModelScope.launch {
async { riskyOperation() } // 异常去哪了?
}
// ✅ 方案 A:在 async 内部 try-catch
viewModelScope.launch {
val result = async {
try { riskyOperation() } catch (e: Exception) { fallback() }
}
result.await()
}
// ✅ 方案 B:如果不需要返回值,直接用 launch
viewModelScope.launch {
launch { riskyOperation() } // launch 会自动传播异常
}
本章小结
本文从编译器变换的角度,完整剖析了 Kotlin 协程的底层工作原理:
| 知识点 | 核心结论 |
|---|---|
| CPS 变换 | 编译器在 suspend 函数末尾追加 Continuation 参数,返回类型改为 Any?,允许返回结果或 COROUTINE_SUSPENDED 标记 |
| 状态机 | 每个 suspend 函数的函数体被改写为 switch-case 状态机,局部变量提升为 ContinuationImpl 子类的字段 |
| 挂起与恢复 | 挂起 = 保存 label + 返回标记;恢复 = resumeWith 触发 invokeSuspend,状态机从断点继续 |
| CoroutineContext | 复合模式实现的类型安全异构索引集,Element + Key 的设计让查找是 O(n) 但类型安全 |
| Dispatcher | Default 和 IO 共享 CoroutineScheduler(工作窃取线程池),只是并发上限不同 |
| launch vs async | 几乎相同的源码结构,区别在于返回 Job 还是 Deferred<T> |
| 结构化并发 | Job 树 + 向下取消 + 向上异常传播 = 不泄漏、不遗忘的协程管理 |
| SupervisorJob | 切断异常向上传播,实现子协程故障隔离 |
现在你已经完整地理解了协程"如何运行"的底层机制。下一篇文章《协程的取消与异常处理机制》将聚焦于协作式取消的实现细节、CancellationException 的特殊语义、以及如何正确地在协程中处理异常——避免那些"异常被静默吞掉"的隐蔽 Bug。