Kotlin 协程的本质:与线程的区别、原理剖析与详细使用指南
深入讲解 Kotlin 协程的本质——编译器 CPS 变换 + 状态机,而非"轻量级线程"这么简单;对比协程与线程的核心区别;基于 kotlinx.coroutines 1.11 系统讲解协程的完整用法:构建器、作用域、调度器、取消、异常处理、结构化并发与 Android 实践,文末附高频面试问答。
“协程是轻量级线程”——这句话你一定听过,但它其实是一个方便入门却容易误导人的类比。协程根本不是线程,甚至不是操作系统层面的东西,它是编译器和库在用户态玩的一场”魔术”。这篇文章会先把协程的本质讲透,再对比它和线程的区别,然后基于 kotlinx.coroutines 最新稳定版(1.11.0)系统过一遍协程的完整用法,最后附上高频面试问答。
本文代码基于 Kotlin 2.x + kotlinx.coroutines 1.11.0,Android 部分基于
lifecycle-runtime-ktx/lifecycle-viewmodel-ktx最新版。
一、协程的本质是什么
1.1 一句话回答
协程的本质是”可挂起、可恢复的计算”,由 Kotlin 编译器通过 CPS 变换 + 状态机实现,运行在线程之上,由协程库负责调度。
拆开来说包含三层:
- 语言层(编译器):
suspend函数会被编译器做 CPS(Continuation-Passing Style,续体传递风格)变换,函数体被改写成一个状态机。这是协程能”暂停再恢复”的根本原因。 - 库层(kotlinx.coroutines):提供
launch、async、Dispatchers、Job等基础设施,负责把这些”可挂起的计算”调度到具体线程上执行。 - 线程层:协程最终依然跑在线程上。协程不能脱离线程存在,它只是把”任务”和”线程”解耦了——任务挂起时释放线程,恢复时可以在任意线程继续。
1.2 挂起函数的真面目:CPS 变换
我们写的这段代码:
1
2
3
4
5
suspend fun fetchUser(id: String): User {
val token = getToken() // 挂起点 1
val user = getUser(token, id) // 挂起点 2
return user
}
经过编译器处理后,签名会变成(近似的 Java 视角):
1
2
3
// suspend 关键字消失,末尾多了一个 Continuation 参数
// 返回值变成 Object:可能返回 User,也可能返回 COROUTINE_SUSPENDED 标记
public static Object fetchUser(String id, Continuation<? super User> completion)
这就是 CPS 变换:每个挂起函数都会多出一个隐藏参数 Continuation(续体)。Continuation 的定义非常简单:
1
2
3
4
5
6
public interface Continuation<in T> {
// 协程上下文(Job、Dispatcher 等都存在这里)
public val context: CoroutineContext
// 恢复执行的回调:挂起结束后,通过它把结果传回来,继续执行后面的代码
public fun resumeWith(result: Result<T>)
}
你可以把 Continuation 理解为“回调”——resumeWith 被调用,就意味着”异步操作完成了,从上次暂停的地方继续往下跑”。所以有一个经典说法:
协程 = 编译器帮你自动写回调。你用同步的写法写代码,编译器把它翻译成回调嵌套,从而消灭了”回调地狱”。
1.3 状态机:挂起与恢复的实现
编译器不会真的生成嵌套回调,而是生成一个状态机。上面 fetchUser 有两个挂起点,编译后大致等价于:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 编译器生成的伪代码(简化)
fun fetchUser(id: String, completion: Continuation<User>): Any? {
// sm 是编译器生成的状态机对象,继承自 ContinuationImpl
// 它既保存状态(label、局部变量),又是一个 Continuation(能被 resume)
val sm = completion as? FetchUserStateMachine ?: FetchUserStateMachine(completion)
when (sm.label) {
0 -> {
sm.label = 1
// 把状态机自己作为 Continuation 传下去
val result = getToken(sm)
// 返回 COROUTINE_SUSPENDED 表示真的挂起了,当前线程到此为止
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
sm.token = result // 没挂起就直接落到下一个状态
}
1 -> {
sm.token = sm.result // 从挂起中恢复,取回结果
sm.label = 2
val result = getUser(sm.token, id, sm)
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
return result
}
2 -> return sm.result // 全部完成
}
}
关键点:
label记录执行到了哪个挂起点,局部变量被提升为状态机的字段保存下来;- 挂起时函数直接 return,当前线程立刻被释放,去干别的事;
- 异步结果就绪后,调用状态机的
resumeWith,状态机根据label跳到下一个分支继续执行——可能在另一个线程上; - 如果挂起函数没有真正挂起(比如缓存命中直接返回),就不会返回
COROUTINE_SUSPENDED,代码顺序落到下一状态,没有任何线程切换开销。
所以”挂起”的本质是:函数返回 + 现场保存到堆上的状态机对象里;”恢复”的本质是:拿着状态机对象重新调用一次这个函数。整个过程没有阻塞任何线程——这就是文档里说的”挂起不阻塞线程”的真正含义。
1.4 协程体是怎么跑到线程上的
launch { ... } 里的 lambda 会被编译成一个 SuspendLambda(本质也是 Continuation),启动流程大致是:
launch创建一个StandaloneCoroutine(一个Job),把协程体包装成Continuation链;- 通过
CoroutineDispatcher(本质是拦截器ContinuationInterceptor,包装出DispatchedContinuation)把”首次执行/每次恢复”这个动作封装成一个Runnable; Runnable被扔进调度器对应的线程池(或 Android 主线程的 Handler)执行。
所以调度器切线程的本质就是:把 continuation.resumeWith(...) 这个调用 post 到目标线程去执行。withContext 切换回来同理。
二、协程和线程的区别
先给结论表格,再逐条解释:
| 维度 | 线程 Thread | 协程 Coroutine |
|---|---|---|
| 归属层级 | 操作系统内核调度的执行单元 | 用户态的语言/库级构造,跑在线程之上 |
| 调度方 | OS 内核,抢占式调度 | 协程库,协作式调度(只在挂起点让出) |
| 切换成本 | 内核态上下文切换,微秒级,涉及寄存器/内核栈 | 用户态方法调用 + 对象状态读写,纳秒~百纳秒级 |
| 内存占用 | 每个线程默认约 1MB 栈(JVM) | 一个状态机对象,几十字节到 KB 级(无独立栈) |
| 数量级 | 几百上千个就会拖垮系统 | 单机轻松跑几十万个 |
| 阻塞 vs 挂起 | 阻塞时线程被占住,什么也干不了 | 挂起时立刻释放线程给其他协程用 |
| 取消 | stop() 已废弃,interrupt() 依赖任务自己检查 | 结构化取消,cancel() 沿 Job 树自动传播 |
| 代码风格 | 异步需要回调/Future,易回调地狱 | 同步写法表达异步逻辑 |
| 错误传播 | 线程间异常不传播,需手动处理 | 沿结构化并发的父子关系自动传播 |
2.1 最核心的区别:谁在调度、在哪一层调度
- 线程是 OS 的资源。创建、销毁、切换都要经过内核,切换时要保存/恢复寄存器、切内核栈,还可能引发 CPU 缓存失效。
- 协程是”用户态的任务”。它的”切换”就是一次函数返回加一次
resumeWith调用,OS 根本不知道协程的存在。
一个经典的直观对比——启动 10 万个并发任务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun main() = runBlocking {
// 10 万个协程,稳定运行,内存占用很小
repeat(100_000) {
launch {
delay(5000L)
print(".")
}
}
}
// 换成 10 万个线程,大概率直接 OutOfMemoryError
repeat(100_000) {
thread {
Thread.sleep(5000L)
print(".")
}
}
2.2 阻塞 vs 挂起
这是理解协程价值的关键。同样是”等 5 秒”:
Thread.sleep(5000):线程被占住 5 秒。如果 100 个任务都在 sleep,就需要 100 个线程干等着。delay(5000):协程挂起,函数直接返回,线程立刻空出来执行其他协程;5 秒后由调度器的定时器触发resumeWith恢复。10 万个协程 delay,可能只用了几个线程。
所以协程真正解决的问题是:用少量线程承载海量并发任务,同时保持同步代码的可读性。
2.3 协程不是用来替代线程的
必须澄清一个误区:协程不能提升 CPU 密集型计算的速度。CPU 密集型任务的并行度取决于核心数和线程数,协程只是任务的组织方式。协程的收益集中在 IO 密集型/等待型场景(网络、磁盘、定时、事件等待)——这些场景里线程大部分时间在”干等”,协程能把这些等待变成”零线程占用”。
三、协程详细使用讲解
3.1 添加依赖
1
2
3
4
5
6
7
8
9
10
11
12
// build.gradle.kts —— 通用
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}
// Android 额外添加(提供 Dispatchers.Main)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// lifecycleScope / viewModelScope / repeatOnLifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.1")
}
3.2 三个协程构建器:launch、async、runBlocking
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun main() = runBlocking { // ③ runBlocking:阻塞当前线程直到协程结束,只用于 main 函数和测试
// ① launch:启动"发射后不管"的协程,返回 Job
val job: Job = launch {
delay(1000L)
println("launch 完成")
}
// ② async:启动"有返回值"的协程,返回 Deferred<T>
val deferred: Deferred<Int> = async {
delay(1000L)
42
}
println("结果 = ${deferred.await()}") // await() 挂起等待结果
job.join() // join() 挂起等待 Job 结束
}
三者的选择原则:
launch:不需要返回值的并发任务(刷新 UI、上报日志、写数据库);async:需要返回值、尤其是多个任务并行再汇总的场景;runBlocking:只在main函数、单元测试等”世界的边缘”使用,业务代码里出现runBlocking基本都是错的——它会阻塞线程,违背协程初衷。
async 并行请求的经典写法:
1
2
3
4
5
6
suspend fun loadProfile(): Profile = coroutineScope {
// 两个请求同时发出,总耗时 ≈ max(两者耗时),而不是两者之和
val user = async { api.getUser() }
val posts = async { api.getPosts() }
Profile(user.await(), posts.await())
}
join():等待协程完成
join() 是 Job 上的挂起函数,作用是挂起当前协程,直到目标 Job 执行完成才继续往下走:
1
2
3
4
5
6
7
8
9
runBlocking {
val job = launch {
delay(1000L)
println("子任务完成")
}
println("等待子任务...")
job.join() // 挂起在这里,直到 job 结束
println("继续执行") // 一定在"子任务完成"之后打印
}
理解 join() 的四个要点:
- 它是挂起而不是阻塞。和
Thread.join()名字一样但机制完全不同:Thread.join()会阻塞当前线程干等;job.join()只是挂起当前协程,线程被释放去执行其他协程,job 完成后再恢复。 - 它只等待,不取结果。
launch返回的Job没有返回值,只能join()等它结束;需要结果就用async+await()——await()相当于”join + 取回结果”。 - 无论 Job 正常完成、失败还是被取消,
join()都会正常返回,它不会把 Job 的异常抛给你(这点和await()不同,await()会把失败的异常抛出来)。常见组合是job.cancelAndJoin():先取消,再等它真正清理完毕,确保后续代码执行时该协程已彻底停止。 - 不是每个
launch后面都要join()。结构化并发下父协程本来就会等所有子协程完成,只有当你需要在代码的某个特定位置”卡住等它完成”时才用。等待一批任务用jobs.joinAll()。
3.3 suspend 函数与挂起点
1
2
3
4
5
6
7
8
9
10
11
/**
* 获取用户信息。
* Fetch user info.
* @param id 用户 ID
* @return User 用户实体
*/
suspend fun fetchUser(id: String): User {
return withContext(Dispatchers.IO) { // 切到 IO 线程池执行
api.getUser(id) // 耗时操作
}
}
关于 suspend 的几个要点:
suspend只是一个标记 + 编译器契约:它让函数获得调用其他挂起函数的能力,并且只能被挂起函数或协程调用;suspend本身不会切线程、也不会让函数变异步。一个suspend fun里如果全是普通代码,它就是个普通函数;真正产生挂起的是内部调用的delay、withContext、await等;- 编写规范(官方约定):挂起函数应该是”主线程安全”的——函数内部自己负责切到合适的调度器(如上例的
withContext(Dispatchers.IO)),调用方可以放心在主线程直接调用。
如果要把回调式 API 包装成挂起函数,用 suspendCancellableCoroutine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 将回调式定位 API 包装为挂起函数。
* Wrap callback-based location API into a suspend function.
* @return Location 定位结果
*/
suspend fun awaitLocation(): Location = suspendCancellableCoroutine { cont ->
val listener = object : LocationListener {
override fun onSuccess(location: Location) {
cont.resume(location) // 恢复协程并返回结果
}
override fun onError(e: Exception) {
cont.resumeWithException(e) // 以异常恢复
}
}
locationClient.requestLocation(listener)
// 协程被取消时清理回调,防止泄漏
cont.invokeOnCancellation { locationClient.removeListener(listener) }
}
3.4 CoroutineScope 与 CoroutineContext
每个协程都运行在某个作用域(CoroutineScope)中,作用域的核心就是持有一个 CoroutineContext。Context 是一个类似 Map 的元素集合,常见元素有四个:
| 元素 | 作用 |
|---|---|
Job | 协程的生命周期句柄,父子关系的载体 |
CoroutineDispatcher | 决定协程跑在哪个线程/线程池 |
CoroutineName | 调试用名字 |
CoroutineExceptionHandler | 未捕获异常的最终处理器 |
Context 用 + 组合,子协程默认继承父 Context 并新建自己的 Job:
1
2
3
4
5
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + CoroutineName("MyScope"))
scope.launch(Dispatchers.IO + CoroutineName("upload")) {
// 继承 scope 的上下文,但 Dispatcher 和 Name 被覆盖
}
不要用
GlobalScope。它的生命周期是整个进程,启动的协程不受任何结构化并发管理,容易造成泄漏。Android 中优先用viewModelScope/lifecycleScope,纯 Kotlin 中自己创建与业务生命周期绑定的 scope 并在合适时机cancel()。
3.5 Dispatchers:调度器详解
1
2
3
4
5
launch(Dispatchers.Main) { } // Android 主线程(UI 操作)
launch(Dispatchers.Main.immediate) { } // 已在主线程时不重新 post,立即执行(viewModelScope 默认)
launch(Dispatchers.IO) { } // IO 密集型:网络、磁盘、数据库,线程数默认 max(64, 核心数)
launch(Dispatchers.Default) { } // CPU 密集型:排序、解析、计算,线程数 = CPU 核心数
launch(Dispatchers.Unconfined) { } // 不限定线程,在恢复它的线程上继续跑,业务基本不用
几个进阶点:
Default和IO共享底层线程池,withContext(Dispatchers.IO)从 Default 切到 IO 通常不会真的切线程,只是换个”计费额度”,开销极小;limitedParallelism(n):从现有调度器切出一个并行度受限的”视图”,替代自建线程池的大多数场景:
1
2
3
4
5
6
// 数据库写操作串行化:并行度为 1,天然免锁
val dbDispatcher = Dispatchers.IO.limitedParallelism(1)
suspend fun writeDb(entity: Entity) = withContext(dbDispatcher) {
dao.insert(entity)
}
withContext是切换调度器的标准方式:它挂起当前协程、在目标调度器执行完代码块后自动切回,取代了”回调 + post 回主线程”的整套样板:
1
2
3
4
5
6
viewModelScope.launch { // 主线程
val data = withContext(Dispatchers.IO) { // 切到 IO
repository.loadFromNetwork()
} // 自动切回主线程
_uiState.value = UiState.Success(data) // 主线程更新 UI
}
3.6 结构化并发:协程最重要的设计
结构化并发(Structured Concurrency)是协程区别于线程、RxJava 的核心设计:每个协程都有父协程,父子构成一棵 Job 树,并遵守三条铁律:
- 父协程取消 → 所有子协程递归取消;
- 父协程要等所有子协程完成后才算完成;
- 子协程异常失败 → 默认取消父协程和所有兄弟协程(
SupervisorJob可改变这一条)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun main() = runBlocking {
val parent = launch {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
println("子协程 1 被取消")
}
}
launch {
try {
delay(Long.MAX_VALUE)
} finally {
println("子协程 2 被取消")
}
}
}
delay(100L)
parent.cancel() // 取消父协程,两个子协程都会被取消
parent.join()
println("全部结束")
}
coroutineScope 与 supervisorScope 的区别:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// coroutineScope:任一子协程失败 → 整个 scope 失败,其余子协程被取消
suspend fun loadAll() = coroutineScope {
val a = async { loadA() } // 若 loadA 抛异常
val b = async { loadB() } // loadB 会被取消
a.await() to b.await()
}
// supervisorScope:子协程之间互不影响,失败需各自处理
suspend fun loadAllIndependently() = supervisorScope {
val a = async { loadA() }
val b = async { loadB() }
// 注意:supervisorScope 中 async 的异常在 await() 时抛出,必须逐个 catch
val resultA = runCatching { a.await() }.getOrNull()
val resultB = runCatching { b.await() }.getOrNull()
resultA to resultB
}
3.7 取消与超时
协程的取消是协作式的:cancel() 只是把 Job 标记为取消状态,并在挂起点抛出 CancellationException。如果协程里是一段不挂起的死循环,它根本”感知”不到取消:
1
2
3
4
5
6
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 5_000_000) { // ❌ 没有挂起点,cancel() 对它无效
i++
}
}
配合取消的三种方式:
1
2
3
4
5
6
7
8
val job = launch(Dispatchers.Default) {
var i = 0
while (isActive) { // ① 检查 isActive
i++
}
// ② ensureActive():已取消时立即抛 CancellationException
// ③ yield():既检查取消,又让出执行权
}
取消时的资源清理与”善后”:
1
2
3
4
5
6
7
8
9
10
11
12
val job = launch {
val file = openFile()
try {
process(file)
} finally {
file.close() // 取消时 finally 一定执行
// 若善后逻辑本身需要挂起(如上报"已取消"),必须包在 NonCancellable 里
withContext(NonCancellable) {
reportCancelled()
}
}
}
不要吞掉
CancellationException。写catch (e: Exception)时如果捕到了CancellationException,必须重新抛出,否则协程会”取消失败”,继续执行后面的代码,造成极隐蔽的 bug。
为什么吞掉 CancellationException 会出大问题
要理解这条规则,得先明白一个反直觉的事实:CancellationException 不是”错误”,而是协程取消机制的实现手段本身。
前面说过,cancel() 只做了一件事——把 Job 标记为取消状态。真正让协程体停止执行的,是下一个挂起点检测到取消状态后抛出的 CancellationException:这个异常沿着调用栈一路向上传播,途经的每一层 finally 得以执行清理,最终协程体退出,Job 才能进入”已取消完成”的终态。换句话说,这个异常向上传播的过程,就是协程”停下来”的过程。你把它 catch 住不重新抛出,等于亲手掐断了停止信号——Job 状态是”已取消”,但协程体还在继续跑。
更隐蔽的是它的继承链:kotlinx.coroutines 的 CancellationException 就是 java.util.concurrent.CancellationException,继承自 IllegalStateException → RuntimeException → Exception。所以不只 catch (e: Exception),连 catch (e: RuntimeException)、catch (e: IllegalStateException),甚至 Kotlin 的 runCatching { } 都会把它捕获。
看一个真实场景的 bug:
1
2
3
4
5
6
7
8
9
viewModelScope.launch {
try {
val data = api.load() // 用户退出页面 → 协程被取消 → 这里抛 CancellationException
show(data)
} catch (e: Exception) { // ❌ 把"取消信号"当成"网络错误"捕获了
showError(e) // 页面都销毁了,还在弹"加载失败"
}
saveToCache() // ❌ 取消后本不该执行的代码,继续执行了
}
吞掉取消信号的具体后果:
- 取消之后的代码继续执行——页面销毁后还在更新 UI 状态、写数据库、发请求,产生资源泄漏或崩溃(比如访问已置空的 view);
- 把”取消”误报成”失败”——用户只是正常返回上一页,却看到”网络错误”的 toast,因为取消被 catch 分支当成异常处理了;
- 重试循环变成死循环空转——Job 已处于取消态后,协程内每一个后续挂起点都会立即再抛一次
CancellationException(delay连等都不等,直接抛)。如果重试逻辑写成while (true) { try { ... } catch (e: Exception) { delay(1000) } },取消后delay立即抛异常又立即被吞掉,循环疯狂空转烧 CPU,且永远停不下来; - 拖慢结构化并发的收尾——父协程
cancelAndJoin()或作用域关闭时要等子协程真正结束,被吞掉信号的协程会把整个取消流程拖住。
正确的写法有三种,按推荐程度排序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ① 只捕获你真正能处理的具体异常(最推荐)
try {
doWork()
} catch (e: IOException) {
handleNetworkError(e)
}
// ② 必须写 catch (e: Exception) 时,先把 CancellationException 放行
try {
doWork()
} catch (e: CancellationException) {
throw e // 必须重新抛出,让取消流程继续
} catch (e: Exception) {
handleError(e)
}
// ③ 在 catch 块开头检查取消状态:若因取消而进入 catch,重新抛出取消异常
try {
doWork()
} catch (e: Exception) {
currentCoroutineContext().ensureActive() // 已取消则在此重新抛出
handleError(e)
}
runCatching { }/Result风格的代码同样会吞掉CancellationException,在协程中使用时要么避免包裹挂起调用,要么在onFailure里做同样的放行处理。
超时控制:
1
2
3
4
5
// 超时抛 TimeoutCancellationException
val result = withTimeout(3000L) { api.load() }
// 超时返回 null,更常用
val result = withTimeoutOrNull(3000L) { api.load() }
3.8 异常处理
协程的异常处理规则可以总结为一张决策图:
launch:异常在发生处立刻沿 Job 树向上传播,最终交给CoroutineExceptionHandler;没有 handler 就走线程的UncaughtExceptionHandler(Android 上崩溃)。async(作为根协程或在 supervisorScope 下):异常被存进Deferred,在await()时抛出。SupervisorJob/supervisorScope:切断”子协程失败取消兄弟”的传播,但不会替你处理异常,异常仍需 handler 或 try/catch 兜底。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CoroutineExceptionHandler 只在"根协程"上生效
val handler = CoroutineExceptionHandler { _, e ->
Log.e("App", "未捕获异常", e)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
scope.launch {
throw RuntimeException("A 挂了") // 被 handler 捕获
}
scope.launch {
delay(100L)
println("B 不受影响") // SupervisorJob:兄弟失败不牵连
}
日常业务代码最推荐的方式仍然是在协程内部用 try/catch 就地处理:
1
2
3
4
5
6
7
8
9
10
viewModelScope.launch {
_uiState.value = UiState.Loading
_uiState.value = try {
UiState.Success(repository.load())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
UiState.Error(e.message)
}
}
3.9 协程间的并发安全
多个协程可能在不同线程上并发修改共享状态,线程安全问题依然存在。协程世界的推荐工具:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ① Mutex:协程版的锁,lock 时挂起而非阻塞
val mutex = Mutex()
var counter = 0
suspend fun increment() = mutex.withLock {
counter++
}
// ② 并行度为 1 的调度器:把共享状态限定在单线程访问
val counterDispatcher = Dispatchers.Default.limitedParallelism(1)
// ③ 不可变数据 + StateFlow.update:CAS 原子更新
private val _state = MutableStateFlow(UiState())
fun setName(name: String) {
_state.update { it.copy(name = name) }
}
不要在协程里用
synchronized包住挂起调用——挂起点前后可能换线程,锁语义会被破坏;ThreadLocal同理(需要时用threadLocal.asContextElement())。
3.10 Android 中的最佳实践
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
/**
* 加载用户数据。
* Load user data.
*/
fun load() {
// viewModelScope:ViewModel 清除时自动取消,默认 Dispatchers.Main.immediate
viewModelScope.launch {
_uiState.value = try {
UiState.Success(repo.fetchUser())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
UiState.Error(e)
}
}
}
}
class UserFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
// repeatOnLifecycle:进入 STARTED 启动收集,退到后台自动取消,回前台重新收集
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
}
}
要点:
- UI 层收集 Flow 必须配
repeatOnLifecycle(STARTED)(或flowWithLifecycle),否则应用退到后台时收集仍在继续,白白消耗资源甚至崩溃; - 需要”页面销毁也要完成”的任务(如埋点上报、草稿保存),不要放
viewModelScope,应交给WorkManager或注入的应用级CoroutineScope; - Room、Retrofit、DataStore 等主流库的挂起 API 内部已经切好线程,调用方不需要再包
withContext(Dispatchers.IO)。
3.11 协程的启动模式与惰性启动
1
2
3
4
// CoroutineStart.LAZY:调用 start()/join()/await() 时才真正启动
val deferred = async(start = CoroutineStart.LAZY) { expensiveComputation() }
// ... 某个时刻确实需要了
val value = deferred.await()
3.12 测试协程
1
2
3
4
5
6
7
8
9
class UserViewModelTest {
@Test
fun `load success updates state`() = runTest { // 虚拟时间,delay 不真等
val vm = UserViewModel(FakeRepository())
vm.load()
advanceUntilIdle() // 推进虚拟时钟直到所有协程完成
assertEquals(UiState.Success(fakeUser), vm.uiState.value)
}
}
runTest 使用 StandardTestDispatcher 与虚拟时间:delay(10_000) 在测试里瞬间完成,测试既快又确定。Android 中还需用 Dispatchers.setMain(testDispatcher) 替换主调度器。
四、几个容易被忽视的进阶点
4.1 挂起函数的”隐藏成本”
每个挂起函数调用点,编译器都要生成状态机分支、把局部变量存到堆上。极端热点路径(每帧调用的绘制逻辑)不适合大量细碎的挂起调用。但对常规业务(网络、数据库、事件)完全可以忽略。
4.2 协程泄漏与”作用域越界”
结构化并发只在你遵守它时生效。以下写法会悄悄破坏结构:
1
2
3
4
5
6
7
8
9
10
suspend fun bad(scope: CoroutineScope) {
// ❌ 挂起函数内部往外部 scope launch:函数返回了任务还在跑,
// 调用方 catch 不到它的异常,取消也管不到它
scope.launch { doWork() }
}
suspend fun good() = coroutineScope {
// ✅ 需要并发就自己开 coroutineScope,函数返回时保证所有子任务已结束
launch { doWork() }
}
原则:挂起函数不应该让工作”逃逸”出自己的调用栈;确实需要”发射后不管”的后台任务,应显式注入一个应用级 scope 并说明其生命周期。
4.3 Dispatchers.Main.immediate 的意义
viewModelScope 默认用 Main.immediate:如果当前已经在主线程,协程体立即执行而不是 post 一个消息等下一次消息循环。这避免了”launch 之后本帧读不到最新状态”的时序问题,也减少一次不必要的调度。
4.4 delay 的实现:为什么它不占线程
delay 底层不是 sleep,而是向调度器注册一个定时任务(Android 上是 Handler.postDelayed,Default 调度器上是内部的时间队列),然后协程直接挂起、线程释放。时间到了,定时器回调 resumeWith 把协程重新投递到调度器。这就是”10 万个协程同时 delay 只需要几个线程”的原因。
五、高频面试问答
Q1:协程的本质是什么?”轻量级线程”这个说法准确吗?
不准确,只是入门类比。协程的本质是编译器通过 CPS 变换 + 状态机实现的”可挂起、可恢复的计算”:suspend 函数编译后多一个 Continuation 参数,函数体变成用 label 记录执行位置的状态机;挂起 = 保存现场后函数返回(释放线程),恢复 = 调用 resumeWith 从上个状态继续。它是纯用户态的机制,OS 感知不到,最终仍由线程执行。
Q2:协程和线程的核心区别?
① 层级:线程是内核调度的执行单元,协程是用户态构造,跑在线程之上;② 调度:线程抢占式(内核决定),协程协作式(只在挂起点让出);③ 成本:线程默认 1MB 栈、切换需内核上下文切换,协程只是一个堆上对象、切换是普通方法调用,可轻松创建几十万个;④ 阻塞与挂起:线程阻塞时被占住,协程挂起时立即释放线程;⑤ 协程自带结构化并发(取消/异常自动沿父子树传播),线程没有。
Q3:suspend 关键字做了什么?它会切线程吗?
suspend 本身不切线程。它做两件事:编译期 CPS 变换(追加 Continuation 参数、生成状态机、返回值变为 Any? 以便返回 COROUTINE_SUSPENDED 标记),以及限制该函数只能在协程或其他挂起函数中调用。真正切线程的是 withContext / 调度器;真正产生挂起的是内部调用的 delay、await 等真挂起函数。
Q4:挂起和阻塞的区别?
阻塞(Thread.sleep):线程停在原地被占用,其他任务不能用这个线程。挂起(delay):协程保存现场后函数返回,线程立刻被释放去执行其他协程;条件满足后协程被重新调度恢复。挂起是”任务让出线程”,阻塞是”任务霸占线程”。
Q5:launch 和 async 的区别?
launch 返回 Job,无结果,异常发生时立即沿 Job 树向上传播;async 返回 Deferred<T>,通过 await() 获取结果,作为根协程/supervisor 子协程时异常延迟到 await() 才抛出。需要返回值或多任务并行汇总用 async,其余用 launch。等待方式上:job.join() 只挂起等待完成、不返回结果也不抛出 Job 的失败异常;deferred.await() 相当于”join + 取结果”,失败异常会抛给调用方。注意:在普通 coroutineScope 里,async 的异常即使不 await 也会取消整个作用域。
Q6:什么是结构化并发?解决了什么问题?
协程必须在 CoroutineScope 中启动,父子协程构成 Job 树,遵守三条规则:父取消则子递归取消;父等待所有子完成;子失败默认取消父与兄弟。它解决了三个经典问题:任务泄漏(忘记取消)、异常丢失(异步任务的异常没人处理)、生命周期错配(页面销毁任务还在跑)。GlobalScope 之所以被反对,就是因为它逃脱了结构化并发。
Q7:协程的取消机制?为什么 cancel 了协程还在跑?
取消是协作式的:cancel() 只是标记 Job 状态,并让协程在下一个挂起点抛出 CancellationException。如果协程里是无挂起点的计算循环,就感知不到取消——需要主动配合:循环中检查 isActive、调用 ensureActive() 或 yield()。另外,catch 住 CancellationException 不重新抛出也会导致”取消失效”。清理资源放 finally,需要挂起的清理逻辑包在 withContext(NonCancellable) 中。
Q8:CoroutineExceptionHandler 能捕获所有异常吗?
不能。它只对根协程的 launch 生效(或安装在 scope 上),且只是”最后的记录者”,不能阻止协程失败。async 的异常要在 await() 处 try/catch;子协程上安装 handler 无效(异常会先传给父协程);coroutineScope 内的异常会重新抛出,用普通 try/catch 即可。SupervisorJob 只负责”不牵连兄弟”,不负责”处理异常”。
Q9:Dispatchers.IO 和 Dispatchers.Default 的区别?为什么切换它们很廉价?
Default 面向 CPU 密集型,线程数 = CPU 核心数(避免过多线程争抢 CPU);IO 面向 IO 密集型,线程数默认可到 64(IO 等待不占 CPU,多开线程提高吞吐)。两者共享同一个底层线程池,withContext 在两者间切换通常不需要真正的线程切换,只是更换并发配额,所以开销极小。
Q10:viewModelScope 和 lifecycleScope 用的是什么调度器?为什么是 Main.immediate?
都默认 Dispatchers.Main.immediate + SupervisorJob。immediate 表示当前已在主线程时直接执行协程体,不再 post 到消息队列,避免一帧的延迟和时序问题。SupervisorJob 保证一个协程失败不会取消 scope 里的其他协程。
Q11:如何把回调 API 转成挂起函数?
用 suspendCancellableCoroutine:在 block 中注册回调,成功时 cont.resume(value),失败时 cont.resumeWithException(e),并在 cont.invokeOnCancellation { } 中注销回调、释放资源,保证协程被取消时不泄漏。一次性事件也可以用 CompletableDeferred。
Q12:协程一定比线程快吗?
不一定。协程不提升单个任务的执行速度,CPU 密集型任务的并行度仍取决于线程和核心数。协程的优势在于:高并发等待型任务下的资源占用(少量线程承载海量任务)、代码组织(同步写法 + 结构化并发)和生命周期管理。如果只有一两个后台任务,线程和协程性能差别可以忽略。
参考资料
- Kotlin 官方文档:Coroutines guide
- kotlinx.coroutines GitHub(1.11.0)
- KotlinConf: Deep dive into Coroutines on JVM (Roman Elizarov)
- Android Developers: Kotlin coroutines on Android
- 《Kotlin Coroutines: Deep Dive》— Marcin Moskała