【Kotlin 协程修仙录 · 金丹境 · 中阶】 | 启动密法:CoroutineStart 四种模式与底层调度玄机

0 阅读10分钟

image_11.png

前言

你已经掌握了 async/await 的并发艺术,能用 LAZY 条件性地启动协程,避免不必要的计算。金丹初成,你感觉自己的并发代码越来越优雅。

但不知你是否留意过,launchasync 都有一个可选参数 start: CoroutineStart,默认值是 CoroutineStart.DEFAULT。除了你已经熟悉的 LAZY,还有两个神秘的模式:ATOMICUNDISPATCHED

官方文档对它们的描述惜字如金。你可能翻遍 StackOverflow,也只找到零星几篇帖子,还都是模棱两可的猜测。

然而,在某些极端场景下,理解这两种模式可能是你排查诡异 Bug 的唯一线索:

  • 为什么有时候协程明明被 cancel() 了,它还是执行了前几行代码?
  • 为什么 UNDISPATCHED 能让协程“黏”在当前线程,直到第一个挂起点?
  • 这些模式在 Kotlin 协程的调度器源码中,到底对应了哪些分支逻辑?

本讲是金丹境的中阶修炼。你将:

  • 彻底拆解 CoroutineStart 四种模式的语义与行为差异。
  • 深入调度器源码,看透 ATOMIC 是如何实现“启动后、挂起前不可取消”的。
  • 掌握 UNDISPATCHED 的性能优化场景与使用禁忌。
  • 学会如何为你的自定义协程选择合适的启动模式。

准备好透视协程启动的每一个细节了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


什么是 CoroutineStart

在 Kotlin 协程的官方定义中:

CoroutineStart 是一个枚举类,定义了协程在创建后的启动策略。它决定了协程体何时开始执行、是否可以被立即取消,以及在哪个线程上执行最初的代码段(第一个挂起点之前)。

它是 launchasync 构建器的可选参数,默认值为 CoroutineStart.DEFAULT

public enum class CoroutineStart {
    DEFAULT,
    LAZY,
    ATOMIC,
    UNDISPATCHED
}

这四种模式各自对应不同的调度行为,理解它们的差异是写出精细控制协程行为代码的关键。

deepseek_mermaid_20260513_bac90c.png

为什么需要不同的启动模式?

DEFAULT 已经能满足 95% 的场景,那为什么还要提供另外三种模式?它们分别解决了什么痛点?

模式解决的核心问题典型场景
LAZY避免不必要的计算,延迟启动条件性加载、可能不会用到的结果
ATOMIC保证启动阶段(第一个挂起点前)的原子性,不受外部取消影响需要在协程开始时执行关键的初始化代码,且不能被中途取消
UNDISPATCHED优化性能:在调用线程立即执行初始代码,避免一次调度开销极轻量协程、对延迟极度敏感的代码、或需要特定线程上下文时

ATOMICUNDISPATCHED 是为极端场景设计的“手术刀”。日常开发中你可能永远用不到它们,但当需要排查为什么协程被取消后仍执行了部分代码时,你就必须理解 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? 想象一个场景:你需要在协程启动时必定执行一段初始化代码,例如向某个追踪器注册当前任务。如果使用 DEFAULTcancel() 可能在初始化前就发生,导致追踪器漏记。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 1launch 之后 之前打印!这说明 UNDISPATCHED 让协程体的初始部分同步执行了。

核心价值:节省了一次线程调度的开销。对于极轻量的协程(例如只做一次状态更新就结束),UNDISPATCHED 可以避免无谓的任务入队和上下文切换。

deepseek_mermaid_20260513_016c05.png


底层原理:调度器如何处理这些模式?

要真正理解这四种模式,我们需要潜入 DispatchedContinuationCoroutineScheduler 的源码。

当调用 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 {
        [*] --> 准备同步执行
    }

实战场景:何时使用 ATOMICUNDISPATCHED

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 = Loadingpost 到消息队列末尾,从而让 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 的保护只持续到第一个挂起点。一旦遇到 delaywithContext 等挂起函数,取消检查就会恢复正常。

错误 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,无法再启动。


最佳实践

  1. 默认使用 DEFAULT:除非你有明确的理由,否则不要改动它。

  2. 条件性执行用 LAZY:当你需要“可能用到也可能不用”的结果时,LAZY 是最佳选择。

  3. 初始化必须执行用 ATOMIC:当协程的启动阶段包含关键的注册/加锁操作,且不希望被外部取消打断时。

  4. 极致性能优化用 UNDISPATCHED:仅在测量到明显的调度延迟,且确认协程体初始部分足够轻量时使用。

  5. 理解而非滥用ATOMICUNDISPATCHED 是协程的“黑魔法”,可读性较差。除非必要,否则不要轻易使用。

deepseek_mermaid_20260513_186f60.png ---

总结与下回预告

恭喜,你已经掌握了 CoroutineStart 四种模式的全部奥义,金丹境中阶修炼完成!

本讲核心收获

  • DEFAULT:立即调度,可随时取消。
  • LAZY:手动启动,避免不必要计算。
  • ATOMIC:立即调度,第一个挂起点前不可取消。
  • UNDISPATCHED:当前线程同步执行初始部分,优化调度延迟。
  • 四种模式对应调度器中的不同分支逻辑,理解它们有助于排查诡异取消行为。

在下一讲 【金丹境·后阶】 中,我们将深入 supervisorScopecoroutineScope 的异常隔离机制。届时你会明白:

  • 为什么子协程的失败有时会让父协程崩溃,有时却不会?
  • SupervisorJob 是如何在 Job 树中构建“防火墙”的?
  • 如何在 Android 中优雅地处理局部失败,让一个接口挂掉不影响整个页面?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
金丹境 · 中阶1、ATOMIC原子启动
2、UNDISPATCHED零延迟
3、四种启动模式全景
4、启动阶段不可取消
当前进度75%
修为750/1000
下一境需要[金丹境 · 后阶] (需领悟:supervisorScope 异常隔离原理、SupervisorJob 防火墙机制)
ATOMIC是启动阶段的护体罡气,UNDISPATCHED是黏在调用线程的手术刀。

【本讲思考题】

  1. 表象题:以下代码的输出是什么?为什么?

    runBlocking {
        val job = launch(start = CoroutineStart.UNDISPATCHED) {
            println("A")
            delay(10)
            println("B")
        }
        println("C")
        job.join()
    }
    
  2. 场景题:你需要在 ViewModel 中启动一个协程,它首先必须向某个 Analytics 追踪器发送一个“任务开始”事件(同步 API 调用),然后执行耗时网络请求。如果用户快速返回导致协程被取消,你希望“任务开始”事件一定能被发送。应该如何选择启动模式?写出关键代码。

  3. 原理题ATOMIC 模式是如何在状态机层面实现“跳过第一个挂起点前的取消检查”的?请结合 invokeSuspendlabel 逻辑简述。


道友,金丹境的最后一重天已在眼前。掌握了 supervisorScope 之后,你将拥有对协程异常的绝对掌控力。金丹境·后阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论