前言
你已经掌握了 async/await 的并发艺术,能用 LAZY 条件性地启动协程,避免不必要的计算。金丹初成,你感觉自己的并发代码越来越优雅。
但不知你是否留意过,launch 和 async 都有一个可选参数 start: CoroutineStart,默认值是 CoroutineStart.DEFAULT。除了你已经熟悉的 LAZY,还有两个神秘的模式:ATOMIC 和 UNDISPATCHED。
官方文档对它们的描述惜字如金。你可能翻遍 StackOverflow,也只找到零星几篇帖子,还都是模棱两可的猜测。
然而,在某些极端场景下,理解这两种模式可能是你排查诡异 Bug 的唯一线索:
- 为什么有时候协程明明被
cancel()了,它还是执行了前几行代码? - 为什么
UNDISPATCHED能让协程“黏”在当前线程,直到第一个挂起点? - 这些模式在 Kotlin 协程的调度器源码中,到底对应了哪些分支逻辑?
本讲是金丹境的中阶修炼。你将:
- 彻底拆解
CoroutineStart四种模式的语义与行为差异。 - 深入调度器源码,看透
ATOMIC是如何实现“启动后、挂起前不可取消”的。 - 掌握
UNDISPATCHED的性能优化场景与使用禁忌。 - 学会如何为你的自定义协程选择合适的启动模式。
准备好透视协程启动的每一个细节了吗?我们开始。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
什么是 CoroutineStart?
在 Kotlin 协程的官方定义中:
CoroutineStart是一个枚举类,定义了协程在创建后的启动策略。它决定了协程体何时开始执行、是否可以被立即取消,以及在哪个线程上执行最初的代码段(第一个挂起点之前)。
它是 launch 和 async 构建器的可选参数,默认值为 CoroutineStart.DEFAULT。
public enum class CoroutineStart {
DEFAULT,
LAZY,
ATOMIC,
UNDISPATCHED
}
这四种模式各自对应不同的调度行为,理解它们的差异是写出精细控制协程行为代码的关键。
为什么需要不同的启动模式?
DEFAULT 已经能满足 95% 的场景,那为什么还要提供另外三种模式?它们分别解决了什么痛点?
| 模式 | 解决的核心问题 | 典型场景 |
|---|---|---|
| LAZY | 避免不必要的计算,延迟启动 | 条件性加载、可能不会用到的结果 |
| ATOMIC | 保证启动阶段(第一个挂起点前)的原子性,不受外部取消影响 | 需要在协程开始时执行关键的初始化代码,且不能被中途取消 |
| UNDISPATCHED | 优化性能:在调用线程立即执行初始代码,避免一次调度开销 | 极轻量协程、对延迟极度敏感的代码、或需要特定线程上下文时 |
ATOMIC 和 UNDISPATCHED 是为极端场景设计的“手术刀”。日常开发中你可能永远用不到它们,但当需要排查为什么协程被取消后仍执行了部分代码时,你就必须理解 ATOMIC。
四种模式的深度拆解
DEFAULT:最熟悉的陌生人
DEFAULT 的行为可以概括为:立即调度,可响应取消。
val job = launch(start = CoroutineStart.DEFAULT) {
println("Step 1: 启动")
delay(100)
println("Step 2: 完成")
}
job.cancel() // 如果调度足够快,可能在 Step 1 前就取消了协程
协程被创建后,立即被提交给调度器。调度器会在某个线程上执行它。如果在协程体真正开始执行前调用了 cancel(),协程将直接被取消,一行代码都不会执行。
LAZY:条件启动的懒加载
你已经在前一讲中熟悉了 LAZY。它的核心行为是:不自动启动,需要手动调用 start() 或 join()/await() 才会启动。
val deferred = async(start = CoroutineStart.LAZY) {
fetchData()
}
// 此时协程尚未启动
if (needData) {
deferred.await() // 这里才真正启动并等待
}
ATOMIC:启动阶段的“金刚不坏”
ATOMIC 的行为是:立即调度,但在第一个挂起点之前,无法被取消。
val job = launch(start = CoroutineStart.ATOMIC) {
println("Step 1: 这段一定会执行")
// 即使在这一行之前调用了 job.cancel(),Step 1 依然会打印
delay(100) // 第一个挂起点
println("Step 2: 这段可能不会执行")
}
job.cancel() // 如果 cancel 发生在 delay 之前,Step 1 仍执行,Step 2 不会执行
为什么需要 ATOMIC?
想象一个场景:你需要在协程启动时必定执行一段初始化代码,例如向某个追踪器注册当前任务。如果使用 DEFAULT,cancel() 可能在初始化前就发生,导致追踪器漏记。ATOMIC 保证了启动代码的原子性。
sequenceDiagram
participant Caller as 调用方
participant Job as 协程 Job
participant Body as 协程体
Caller->>Job: launch(ATOMIC) { ... }
Job->>Body: 开始执行(进入 ATOMIC 区)
Caller-->>Job: cancel() 信号发出
Note over Body: ATOMIC 区:取消信号被暂缓
Body->>Body: 执行初始化代码(一定执行)
Body->>Body: 遇到第一个挂起点
Body-->>Job: 检查取消状态
alt 已被取消
Job-->>Body: 抛出 CancellationException
Body->>Body: 跳过后续代码
else 未被取消
Body->>Body: 继续执行
end
UNDISPATCHED:黏在调用线程上的协程
UNDISPATCHED 的行为最为特殊:立即在当前线程(调用 launch 的线程)上执行协程体,直到第一个挂起点;挂起恢复后,再交给调度器分配到合适的线程。
fun main() {
println("Main: ${Thread.currentThread().name}")
val job = launch(start = CoroutineStart.UNDISPATCHED) {
println("Step 1: ${Thread.currentThread().name}") // 在 main 线程
delay(100) // 第一个挂起点
println("Step 2: ${Thread.currentThread().name}") // 可能在别的线程
}
println("Main: launch 之后")
}
// 输出顺序:
// Step 1: main
// Main: launch 之后
// Step 2: DefaultDispatcher-worker-1
注意输出顺序:Step 1 在 launch 之后 之前打印!这说明 UNDISPATCHED 让协程体的初始部分同步执行了。
核心价值:节省了一次线程调度的开销。对于极轻量的协程(例如只做一次状态更新就结束),UNDISPATCHED 可以避免无谓的任务入队和上下文切换。
底层原理:调度器如何处理这些模式?
要真正理解这四种模式,我们需要潜入 DispatchedContinuation 和 CoroutineScheduler 的源码。
当调用 launch 时,协程体被包装为一个 DispatchedContinuation。它的 resumeWith 方法中有类似这样的逻辑(简化版):
// 简化版源码逻辑
override fun resumeWith(result: Result<T>) {
val context = continuation.context
val mode = ... // 从上下文中获取启动模式
if (mode == CoroutineStart.UNDISPATCHED) {
// UNDISPATCHED:直接在当前线程执行,直到挂起
executeUnconfined()
} else {
// DEFAULT / ATOMIC / LAZY:走正常调度
dispatcher.dispatch(context, block)
}
}
对于 ATOMIC,特殊处理在于取消标志的检查时机。通常,协程在每次进入一个挂起点之前会检查 isActive。而 ATOMIC 会在第一个挂起点之前跳过检查。
// 状态机的 invokeSuspend 伪代码
fun invokeSuspend(): Any? {
when (label) {
0 -> {
// 如果是 ATOMIC,这里即使收到取消信号,也不会抛出异常
// 继续执行,直到下一个挂起点
label = 1
// ... 执行代码
}
1 -> {
// 恢复正常检查
if (!isActive) throw CancellationException()
// ...
}
}
}
stateDiagram-v2
[*] --> Created : 协程创建
Created --> DEFAULT_MODE : start = DEFAULT
Created --> LAZY_MODE : start = LAZY
Created --> ATOMIC_MODE : start = ATOMIC
Created --> UNDISPATCHED_MODE : start = UNDISPATCHED
DEFAULT_MODE --> Schedule : 立即提交调度
LAZY_MODE --> WaitStart : 等待手动启动
WaitStart --> Schedule : start/join/await 调用
ATOMIC_MODE --> AtomicSchedule : 立即提交调度
Schedule --> NormalExec : 进入正常执行
AtomicSchedule --> AtomicExec : 进入原子执行区
UNDISPATCHED_MODE --> SyncExec : 当前线程同步执行
SyncExec --> AtomicExec : 执行初始代码(不可取消)
AtomicExec --> FirstSuspend : 到达第一个挂起点
FirstSuspend --> NormalExec : 恢复正常取消检查
NormalExec --> Completed : 执行完成
Completed --> [*]
state DEFAULT_MODE {
[*] --> 等待线程分配
}
state LAZY_MODE {
[*] --> 尚未调度
}
state ATOMIC_MODE {
[*] --> 准备原子执行
}
state UNDISPATCHED_MODE {
[*] --> 准备同步执行
}
实战场景:何时使用 ATOMIC 和 UNDISPATCHED?
ATOMIC 实战:保证清理代码注册一定执行
class ResourceLoader {
private val activeJobs = mutableSetOf<Job>()
suspend fun loadResource(id: String): Resource = coroutineScope {
val job = launch(start = CoroutineStart.ATOMIC) {
// 关键:这个注册操作必须执行,即使 loadResource 被快速取消
synchronized(activeJobs) {
activeJobs.add(coroutineContext[Job]!!)
}
try {
// 实际加载逻辑(可能被取消)
delay(5000)
fetchResource(id)
} finally {
synchronized(activeJobs) {
activeJobs.remove(coroutineContext[Job]!!)
}
}
}
// 即使这里立刻取消了协程,上面的注册代码也一定会执行
// 从而保证 activeJobs 集合的一致性
}
}
UNDISPATCHED 实战:立即响应 UI 状态更新
假设你有一个需要立即更新 UI 的轻量操作,你希望它在当前帧完成,而不是等到下一帧。
viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
// 立即在调用线程(主线程)执行,无调度延迟
_uiState.value = UiState.Loading
// 第一个挂起点后,交还给调度器(仍是主线程,但可能被延后)
val data = withContext(Dispatchers.IO) { fetchData() }
_uiState.value = UiState.Success(data)
}
对于这种场景,使用 UNDISPATCHED 可以避免 _uiState.value = Loading 被 post 到消息队列末尾,从而让 Loading 状态更快地显示出来。
5.3 性能对比图
gantt
title 启动模式对 UI 更新的延迟影响
dateFormat X
axisFormat %L ms
section DEFAULT
提交到队列 : 0, 1
等待 Looper : 1, 5
执行更新 : 5, 6
section UNDISPATCHED
同步立即执行 : 0, 1
常见错误与避坑指南
错误 1:误以为 ATOMIC 能保证整个协程不被取消
// ❌ 错误理解
val job = launch(start = CoroutineStart.ATOMIC) {
delay(100) // 第一个挂起点
doWork() // 这行仍可能因取消而跳过
}
job.cancel()
// ATOMIC 只保证 delay 之前的代码执行,delay 之后依然会被取消
正确理解:ATOMIC 的保护只持续到第一个挂起点。一旦遇到 delay、withContext 等挂起函数,取消检查就会恢复正常。
错误 2:在 UNDISPATCHED 协程中做耗时操作
// ❌ 危险:阻塞了调用线程(可能是主线程)
launch(start = CoroutineStart.UNDISPATCHED) {
Thread.sleep(5000) // 阻塞了主线程 5 秒!
println("Done")
}
UNDISPATCHED 的初始部分是同步执行的。如果你在其中调用了阻塞方法(如 Thread.sleep),调用线程会被直接卡死。
正确做法:UNDISPATCHED 只应用于极轻量的操作,或者确保第一个挂起点之前没有耗时逻辑。
错误 3:在 LAZY 模式下调用 cancel 后仍调用 start
val job = launch(start = CoroutineStart.LAZY) { doWork() }
job.cancel()
job.start() // 抛出 IllegalStateException:Job 已被取消
LAZY 协程被取消后,状态变为 Cancelled,无法再启动。
最佳实践
-
默认使用
DEFAULT:除非你有明确的理由,否则不要改动它。 -
条件性执行用
LAZY:当你需要“可能用到也可能不用”的结果时,LAZY是最佳选择。 -
初始化必须执行用
ATOMIC:当协程的启动阶段包含关键的注册/加锁操作,且不希望被外部取消打断时。 -
极致性能优化用
UNDISPATCHED:仅在测量到明显的调度延迟,且确认协程体初始部分足够轻量时使用。 -
理解而非滥用:
ATOMIC和UNDISPATCHED是协程的“黑魔法”,可读性较差。除非必要,否则不要轻易使用。
总结与下回预告
恭喜,你已经掌握了 CoroutineStart 四种模式的全部奥义,金丹境中阶修炼完成!
本讲核心收获:
DEFAULT:立即调度,可随时取消。LAZY:手动启动,避免不必要计算。ATOMIC:立即调度,第一个挂起点前不可取消。UNDISPATCHED:当前线程同步执行初始部分,优化调度延迟。- 四种模式对应调度器中的不同分支逻辑,理解它们有助于排查诡异取消行为。
在下一讲 【金丹境·后阶】 中,我们将深入 supervisorScope 与 coroutineScope 的异常隔离机制。届时你会明白:
- 为什么子协程的失败有时会让父协程崩溃,有时却不会?
SupervisorJob是如何在 Job 树中构建“防火墙”的?- 如何在 Android 中优雅地处理局部失败,让一个接口挂掉不影响整个页面?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 金丹境 · 中阶 | 1、ATOMIC原子启动2、 UNDISPATCHED零延迟3、四种启动模式全景 4、启动阶段不可取消 | 当前进度:75%修为: 750/1000下一境需要: [金丹境 · 后阶] (需领悟:supervisorScope 异常隔离原理、SupervisorJob 防火墙机制) | ATOMIC是启动阶段的护体罡气,UNDISPATCHED是黏在调用线程的手术刀。 |
【本讲思考题】
-
表象题:以下代码的输出是什么?为什么?
runBlocking { val job = launch(start = CoroutineStart.UNDISPATCHED) { println("A") delay(10) println("B") } println("C") job.join() } -
场景题:你需要在
ViewModel中启动一个协程,它首先必须向某个 Analytics 追踪器发送一个“任务开始”事件(同步 API 调用),然后执行耗时网络请求。如果用户快速返回导致协程被取消,你希望“任务开始”事件一定能被发送。应该如何选择启动模式?写出关键代码。 -
原理题:
ATOMIC模式是如何在状态机层面实现“跳过第一个挂起点前的取消检查”的?请结合invokeSuspend的label逻辑简述。
道友,金丹境的最后一重天已在眼前。掌握了 supervisorScope 之后,你将拥有对协程异常的绝对掌控力。金丹境·后阶见。
欢迎一键四连(
关注+点赞+收藏+评论)