Kotlin 协程与结构化并发

协程是 Android 异步编程的事实标准,但结构化并发一旦理解错位,就会写出泄漏、错序与吞异常的代码。本文讲透协程的作用域与作业层级、挂起函数与状态机改写、启动与异步的取舍、异常传播与取消机制,以及冷热流与状态流在页面中的落地方式。文中给出调度器选型表与协程泄漏排查清单,帮你在视图模型作用域里写出可取消、可测试的并发代码。

Android 上的异步代码,今天几乎都写成协程:网络请求用 viewModelScope.launch,数据库查询用 withContext(Dispatchers.IO),UI 状态用 StateFlow。API 用起来确实简单,但线上问题也集中在这里——页面退出后请求还在跑、async 里的异常被静默吞掉、collect 在后台继续更新已经不存在的 View、取消操作被当成异常上报。

这些问题的根因不是 API 记不住,而是对「结构化并发」和「异常传播」两条规则理解不到位。本文按协程的实现原理到工程实践的顺序梳理一遍。

一句话总结: 协程的父子关系决定了取消与异常都会沿作用域向上传播;CoroutineScope 活多久、异常谁负责,是设计每个协程时首先要回答的两个问题。


一、suspend 与 CPS 变换

1.1 suspend 函数的本质

suspend 只是一个编译器标记,表示这个函数「可能挂起」。它不产生新的线程,也不代表函数一定异步。

suspend fun loadUser(id: Long): User {
    delay(100)                 // 挂起点
    return User(id, "leeting")
}

// fun bad() { loadUser(1) }        // 编译错误:普通函数不能调用挂起函数
fun good(scope: CoroutineScope) {
    scope.launch { loadUser(1) }    // 放进协程里才能调用
}

1.2 续体与状态机

编译器把挂起函数改写为「状态机 + 续体(Continuation)」。每个挂起点对应一个状态,挂起时保存局部变量并返回 COROUTINE_SUSPENDED,恢复时从上次的状态继续。

// 源码
suspend fun fetch(): String {
    val a = step1()
    val b = step2(a)
    return b
}

// 编译器大致改写为(伪代码):每个挂起点对应一个 label
fun fetch(cont: Continuation<String>): Any? {
    when (cont.label) {
        0 -> { cont.label = 1; val r = step1(cont); if (r == SUSPENDED) return SUSPENDED }
        1 -> { /* 恢复:拿到 step1 结果,进入下一步 */ }
        2 -> return b
    }
}

这条改写解释了三个现象:挂起点的局部变量会被打包进续体对象(因此大对象在挂起期间会延长生命周期)、协程可以恢复执行(续体保留了状态)、suspend 函数本身不分配线程。

1.3 suspend 不等于切换线程

suspend fun compute(): Int {
    var sum = 0
    repeat(1_000_000) { sum += it }   // 纯 CPU 计算,没有挂起点
    return sum
}

compute() 虽然标了 suspend,但如果调用方在 Dispatchers.Main 上,这段循环仍然阻塞主线程——因为它没有挂起点,不会让出线程。要切换线程必须显式使用 withContext(Dispatchers.Default)。


二、CoroutineScope 与 Job

2.1 结构化并发的三件套

一个协程的完整描述由三部分组成:

组件作用常用取值
CoroutineScope协程的生命周期边界viewModelScope、lifecycleScope、CoroutineScope(SupervisorJob())
CoroutineContext运行环境,含 Job、Dispatcher、CoroutineName、CoroutineExceptionHandler通过 + 组合
Job单个协程的句柄,负责取消与状态launch/async 的返回值

结构化并发的核心规则:父协程会等待所有子协程结束才结束;父协程被取消时,所有子协程一并取消;子协程异常会向上传播给父协程。

fun main() = runBlocking {
    val job = launch {
        launch { delay(1000); println("child A") }
        launch { delay(500); println("child B") }
    }
    job.join()
    println("all done")   // 必然在 child A 之后
}

2.2 Job 与 SupervisorJob

普通 Job 下,任意子协程失败会取消父协程,进而取消其余兄弟协程。SupervisorJob 切断了「子失败 → 父失败」这条链,兄弟之间互不影响。

fun main() = runBlocking {
    // 普通 Job:一个失败,兄弟全挂
    val normal = launch {
        launch { delay(100); error("boom") }
        launch { delay(500); println("sibling of normal") }   // 不会打印
    }
    normal.join()

    // SupervisorJob:互不影响
    val supervised = launch(SupervisorJob()) {
        launch { delay(100); error("boom") }
        launch { delay(500); println("sibling of supervised") }  // 会打印
    }
    supervised.join()
}

Android 的 viewModelScope 内部就是 SupervisorJob + Dispatchers.Main.immediate,因此一个请求失败不会连累其他正在进行的请求。

2.3 launch 与 async 的取舍

维度launchasync
返回值Job(无结果)Deferred<T>(可 await())
异常时机立即抛给父协程/异常处理器在 await() 时抛出(CoroutineStart.DEFAULT 下仍会立即传播)
典型用途发请求、写日志、UI 副作用并行计算、并行请求后合并结果
并发合并不适合awaitAll() 天然支持
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val profile = async { api.profile() }
    val orders = async { api.orders() }
    Dashboard(profile.await(), orders.await())   // 两个请求并行
}

关键陷阱:async 的异常在 await() 之前就已经传播给父协程(默认 CoroutineStart.DEFAULT)。如果只想在 await() 处拿到异常,需要 async(SupervisorJob()) 或 CoroutineStart.LAZY,后者配合 runCatching { d.await() } 可把异常收敛到调用点。


三、作用域函数与调度器

3.1 coroutineScope 与 supervisorScope

两者都会挂起当前协程直到内部全部完成,区别在异常处理。

suspend fun withCoroutineScope() = coroutineScope {
    launch { delay(50); error("A failed") }
    launch { delay(200); println("B finished") }   // 被取消
}
suspend fun withSupervisorScope() = supervisorScope {
    launch { delay(50); error("A failed") }
    launch { delay(200); println("B finished") }   // 正常完成
}
函数子协程失败时是否抛出异常给调用方适用场景
coroutineScope取消其余子协程是一组强关联的任务,任一失败即整体失败
supervisorScope其余子协程继续否(需自己处理)一组相互独立的任务,失败要隔离

3.2 Dispatchers 的选择

viewModelScope.launch(Dispatchers.Main.immediate) {
    val data = withContext(Dispatchers.IO) { repository.load() }   // 切到 IO
    val parsed = withContext(Dispatchers.Default) { parse(data) }  // 切到 Default
    render(parsed)                                                 // 回到 Main
}
调度器线程池适用注意
Dispatchers.Main主线程(Android 上基于 Looper)更新 UI阻塞会 ANR
Dispatchers.Main.immediate主线程,已在主线程时直接执行UI 更新、状态赋值避免一次不必要的 post
Dispatchers.IO最多 64 线程(kotlinx.coroutines.io.parallelism)网络、磁盘、数据库不要用于 CPU 密集计算
Dispatchers.DefaultCPU 核数(最少 2)解析、排序、加密长任务会挤占其他协程
Dispatchers.Unconfined不切换特殊场景生产代码慎用

Main.immediate 的价值在于:如果当前已经在主线程,它不会把任务再 post 一次到消息队列,从而避免「状态更新比下一帧慢一拍」的问题。

3.3 withContext 的线程切换

withContext 是挂起函数,会挂起当前协程、切换到目标调度器执行、完成后回到原调度器。

suspend fun readFromDb(): List<Item> = withContext(Dispatchers.IO) {
    dao.queryAll()
}

切换有成本:一次调度 + 一次续体恢复。高频小操作(如单次 SharedPreferences 读取)不值得单独切换,应合并成一次批量执行:withContext(Dispatchers.IO) { readA() to readB() },而不是连续写两次 withContext。


四、异常处理

4.1 异常传播规则

  • launch:异常立即抛出,走 CoroutineExceptionHandler 或交给父协程,最终可能导致应用崩溃。
  • async:异常被封装进 Deferred,但默认仍会传播给父协程;只有在 await() 时才重新抛出给调用者。
  • supervisorScope / SupervisorJob:切断向上传播,异常需要自行处理。
  • CancellationException:不会被当作错误处理,它是取消信号。
val handler = CoroutineExceptionHandler { _, e ->
    Log.e("App", "未捕获的协程异常", e)
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
scope.launch { error("会被 handler 捕获") }

4.2 CoroutineExceptionHandler 的边界

CoroutineExceptionHandler 只在「根协程」上生效:它是安装到 CoroutineScope 上下文里的,子协程异常向上冒泡到根时才会调用。在 async 上安装它无效,因为 async 的异常由 Deferred 承载,只会在 await() 处抛出。

4.3 try/catch 与 runCatching

对可预期的业务失败,推荐在挂起函数内部用 try/catch 转换成一个结果类型,而不是让它冒泡:withContext(Dispatchers.IO) { runCatching { api.article(id) } }。

注意两点:

  • catch (e: Exception) 会把 CancellationException 一并吞掉,破坏取消语义。正确写法是 catch (e: CancellationException) { throw e } 之后再 catch 其他异常。
  • runCatching 同样会捕获 CancellationException,在协程里直接用它包裹挂起调用是有风险的。
suspend fun safeCall() {
    try {
        doWork()
    } catch (e: CancellationException) {
        throw e                       // 必须重新抛出,保持取消传播
    } catch (e: Exception) {
        report(e)
    }
}

五、取消与 CancellationException

协程的取消是协作式的:cancel() 只是把 Job 置为 cancelling 状态,正在执行的代码需要检查取消状态或调用挂起函数才会真正停止。

val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000) {
        // 没有挂起点,cancel 无法中断这个循环
        i++
    }
}
job.cancel()

// 正确写法:显式检查取消状态
val cancellable = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000) {
        ensureActive()      // 或 isActive 判断
        i++
    }
}
cancellable.cancel()

三条实践规则:

  1. 不要捕获 CancellationException(除非重新抛出),否则取消会失效。
  2. finally 里的清理代码用 withContext(NonCancellable),否则清理本身也会被取消,例如 finally { withContext(NonCancellable) { cleanup(file) } }。
  3. suspend 函数必须在挂起点可被取消,用 delay()、yield() 替代 Thread.sleep()。

六、Flow

6.1 冷流与热流

Flow 是冷流:每个收集者(collector)触发一次独立的生产过程。StateFlow 与 SharedFlow 是热流:生产者独立于收集者存在。

fun ticker(): Flow<Int> = flow {          // 冷流:每个 collect 都从 0 开始
    var i = 0
    while (true) { emit(i++); delay(1000) }
}
维度Flow(冷)StateFlow(热)SharedFlow(热)
数据生产每个收集者独立触发独立于收集者独立于收集者
当前值无有(value)无
初始值无必须有可配置
去重无相同值不重复发射无
背压挂起覆盖(只保留最新)按 replay/extraBufferCapacity
典型用途数据库查询、网络请求UI 状态一次性事件、广播

6.2 stateIn 与 shareIn

把冷流转换成热流,让多个收集者共享同一次上游执行。

class ArticleViewModel(private val repo: ArticleRepository) : ViewModel() {

    val query = MutableStateFlow("")

    // stateIn:转成 StateFlow,始终持有最新值
    val results: StateFlow<List<Article>> = query
        .debounce(300)
        .flatMapLatest { q -> repo.search(q) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),  // 停止订阅 5 秒后停上游
            initialValue = emptyList(),
        )
}
参数取值含义
scopeviewModelScope上游执行的生命周期
startedSharingStarted.WhileSubscribed(5_000)有订阅才启动,配置变更时有 5 秒缓冲
startedSharingStarted.Eagerly立即启动,永不停止
startedSharingStarted.Lazily首次订阅后启动,之后不停止
initialValue任意stateIn 必填,shareIn 用 replay 代替

WhileSubscribed(5_000) 是 Android 上的推荐配置:屏幕旋转导致的短暂取消订阅不会重启网络请求,同时又能保证退到后台后停止上游。

6.3 flatMapLatest 与 collectLatest

flatMapLatest 会在新值到来时取消上一个内层流的执行,天然适合搜索框场景。collectLatest 则在收集端做同样的事。

viewModelScope.launch {
    query
        .flatMapLatest { q -> repo.search(q) }   // 新查询取消旧查询
        .catch { e -> emit(emptyList()) }        // 兜住上游异常
        .collect { list -> adapter.submitList(list) }
}

两者的区别:flatMapLatest 取消的是上游的生产过程,collectLatest 取消的是下游的处理过程,例如 flow.collectLatest { delay(100); println(it) } 中若期间来了新值,delay 之后的代码会被跳过。另外 catch 只能捕获上游异常,不能捕获 collect 内部的异常,后者需要用 try/catch 包住 collect。

6.4 Flow 的 Android 收集

在 Android 中收集 Flow 必须与生命周期绑定,否则会泄漏。正确的写法是 repeatOnLifecycle。

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.results.collect { list -> render(list) }
    }
}

repeatOnLifecycle(STARTED) 会在 STARTED 时启动收集、STOPPED 时取消收集,并在重新可见时重启,是官方推荐的唯一写法。flowWithLifecycle 是它的单流版本。


七、Android 集成与版本

7.1 内建作用域

作用域所属生命周期调度器
viewModelScopeandroidx.lifecycle:lifecycle-viewmodel-ktxViewModel.onCleared()Main.immediate + SupervisorJob
lifecycleScopeandroidx.lifecycle:lifecycle-runtime-ktxLifecycle 销毁Main.immediate + SupervisorJob
repeatOnLifecycle同上指定 Lifecycle.State需自行 launch 包裹

在 ViewModel 里启动的协程会在 onCleared() 时全部取消,这正是结构化并发在 Android 上的落点——不需要手动管理 Disposable。

7.2 Gradle 依赖与版本

// build.gradle.kts(AGP 8.x + Kotlin 2.0/2.1)
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}

kotlinx-coroutines-android 提供 Dispatchers.Main 的 Android 实现(基于 Handler/Looper),缺少它会报 Module with the Main dispatcher had failed to initialize。测试里必须用 kotlinx-coroutines-test 的 runTest,否则 Dispatchers.Main 在 JVM 上不可用;runTest 默认使用虚拟时间,delay(1000) 不会真的等一秒,advanceUntilIdle() 可推进到所有挂起任务完成。


八、常见坑清单

坑现象规避方式
用 GlobalScope协程泄漏、无法取消用 viewModelScope/lifecycleScope
catch (e: Exception) 吞掉取消取消失效、任务继续跑先 catch (e: CancellationException) { throw e }
async 异常无人 await异常静默或直接崩溃用 supervisorScope 或 awaitAll
在 async 上装 handler不生效handler 只对根协程生效
withContext 里做 CPU 重活阻塞 IO 线程池CPU 密集用 Dispatchers.Default
Dispatchers.IO 里做长任务线程池被占满、请求排队区分 IO 与计算任务
collect 未绑定生命周期Fragment 泄漏、后台更新 UI用 repeatOnLifecycle
stateIn 用 Eagerly后台仍持续请求用 WhileSubscribed(5_000)
catch 写在 collect 之后捕获不到下游异常catch 只能捕上游,下游用 try/catch
忘记 SupervisorJob一个请求失败取消全部根作用域用 SupervisorJob
Thread.sleep 替代 delay无法取消、阻塞线程一律用 delay
finally 清理被取消资源未释放包进 withContext(NonCancellable)

取消与异常的边界问题在 Java 世界里对应 Future.cancel 与 InterruptedException 的语义,两套模型可以对照理解,细节可参考 Java 并发与 JUC ;而协程中大量出现的可空返回值与 ?./?: 处理,沿用 Kotlin 语言基础与空安全 里的运算符层级即可。


小结

协程的 API 很少,但规则很硬。落到工程上记住五条:

  1. 结构化并发是地基:父子协程共享生命周期,父取消则子取消,子异常则父取消(除非 SupervisorJob)。GlobalScope 应视为禁用。
  2. launch 与 async 分工明确:不需要返回值就用 launch,需要并发结果就用 async + await/awaitAll,并留意 async 的异常时机。
  3. 异常有层级:CoroutineExceptionHandler 只处理根协程的未捕获异常,业务失败在函数内部转成 Result,取消异常必须重新抛出。
  4. 取消是协作式的:用 ensureActive() 检查、用 delay 替代 sleep、用 NonCancellable 保护清理逻辑。
  5. Flow 要绑定生命周期:冷流转热流用 stateIn/shareIn 配 WhileSubscribed,收集端一律走 repeatOnLifecycle,搜索类场景用 flatMapLatest 取消旧请求。

把这五条变成 Code Review 检查项,绝大多数「内存泄漏、UI 更新已销毁页面、请求无法取消」的问题都能在合入前拦下来。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试