Kotlin 协程:几个曾经被我误解的点

一、协程到底解决了什么问题?

在 Kotlin 出现之前,处理并发最常用的方案是多线程。但多线程编程有一个公认的痛点:回调嵌套。一个网络请求里再发起另一个请求,代码很快会变成"回调地狱",阅读和维护成本都很高。

Kotlin 给出的答案是协程(Coroutine) 。它的核心优势是:让我们能够用顺序、同步的写法来表达异步逻辑。一段典型的协程代码长这样:

scss 复制代码
val user = api.fetchUser()      // 网络请求,挂起而非阻塞
val orders = api.fetchOrders(user.id)
display(orders)

代码从上到下读,就像普通同步代码一样自然,但实际上 fetchUser() 不会卡住线程。

几个需要澄清的说法

关于协程,我听过不少"教科书式"的解释,但有些说法其实并不准确。

说法一:协程比线程更轻量,一个线程能跑成千上万个协程。

这个说法要分场景看。协程确实轻量,但"一个线程上能跑成千上万个协程"并不等于它比线程方案优越多少。线程方案同样可以用线程池 来复用线程,让多个任务共享少量线程。反过来,如果你把每个协程都 launch(Dispatchers.IO),那它最终还是要占用线程资源,也不可能无限创建。所以协程真正的优势不在于"比线程省资源",而在于写法的优雅与可维护性

说法二:线程是阻塞的,协程是非阻塞的。

这里的"阻塞"需要明确对象。它指的并不是阻塞主线程,而是阻塞执行耗时操作的那个工作线程

  • 线程方案:在子线程里发起网络 I/O,线程会进入等待状态,这段时间内这个线程什么事都做不了。
  • 协程方案:遇到 I/O 时,协程会被挂起(suspend) ,但它不会占用线程,线程可以切换去执行其他协程任务。

所以"非阻塞"的含义是:线程资源不被空等浪费,而不是说协程本身不会等待结果。


二、启动协程的几种方式

Kotlin 里启动协程常见有三种方式:launch、async、runBlocking。

方式 返回值 用途
launch Job 启动一个"fire-and-forget"的任务,不关心结果
async Deferred 启动一个会返回结果的任务,通过 await() 获取
runBlocking T 阻塞当前线程,把协程代码桥接到普通阻塞代码,常用于 main() 或测试

async 的异常,真的是"延迟抛出"吗?

很多资料会说:async 内部抛出的异常不会立即抛出,而是被捕获并封装到 Deferred 中,只有调用 .await() 时才会重新抛出。

这句话只对了一半

1. 普通 Job 父协程下,async 异常会立即上抛

kotlin 复制代码
fun main() = runBlocking {
    val deferred = async {
        throw RuntimeException("出错")
    }
    // 这里程序已经崩溃,异常传播到了 runBlocking
}

原因正是结构化并发:async 作为子协程,失败会立即取消父协程并抛出异常,不需要等到 await()。

2. 只有 SupervisorJob 下,异常才会被延迟到 await()

kotlin 复制代码
fun main() = runBlocking {
    val supervisor = SupervisorJob()
    val scope = CoroutineScope(coroutineContext + supervisor)

    val deferred = scope.async {
        throw RuntimeException("出错") // 这里不会崩溃
    }
    deferred.await() // 这里才抛出
}

3. 顶层 / 全局 async,异常也延迟到 await()

kotlin 复制代码
fun main() {
    val deferred = GlobalScope.async {
        throw RuntimeException("出错")
    }
    // 不会立即崩溃
}

await() 会阻塞什么?

await() 会阻塞当前协程的后续代码执行,但它不会阻塞底层线程。当前协程挂起后,线程可以去执行其他协程任务。


三、协程的结构化管理

协程的结构化管理,核心规则就一条:子协程的生命周期受父协程约束,父结束则子取消,子失败则父感知。

这个设计让协程的并发变得可控:你不需要手动跟踪每一个后台任务,只要父协程退出,所有子协程都会被自动清理。

但 SupervisorJob 是一个例外。它让子协程的失败不会向上传播,也不会取消其他兄弟协程。

scss 复制代码
supervisorScope {
    launch {
        delay(100)
        throw RuntimeException("子1失败")
    }
    launch {
        delay(200)
        println("子2完成") // 会执行
    }
    delay(300)
    println("supervisorScope 结束") // 会执行
}

为什么 SupervisorJob 能"隔绝"异常?

在源码层面,普通 Job 的 childCancelled() 方法会返回 true,表示"子协程取消会触发父协程取消"。而 SupervisorJob 内部实现了一个特殊的 Job(SupervisorJobImpl),它重写了关键方法,使得 childCancelled 返回 false,意味着父 Job 不会因为子 Job 失败而进入取消状态。

注意:异常本身并没有消失,它只是不再向上传播。对于 async 来说,异常会被暂存在 Deferred 里,等你调用 await() 时再抛出。

实际应用:viewModelScope

Android 开发中常用的 viewModelScope,内部就使用了 SupervisorJob()。它的构造大致等价于:

scss 复制代码
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

这也是为什么 ViewModel 里的某个协程崩溃了,不会把整个 ViewModel 的协程作用域一起拖垮。


四、CoroutineExceptionHandler

CoroutineExceptionHandler 是协程提供的全局异常处理器,用于捕获那些没有被 try/catch 或 await() 处理的未捕获异常。

协程抛异常的完整处理流程如下:

csharp 复制代码
协程抛异常
  ↓
try/catch 是否捕获? → 是 → 结束
  ↓ 否
是否是 CancellationException? → 是 → 正常取消流程
  ↓ 否
async/await 模式? → await() 处抛出
  ↓ 否
向上传播到 Job 树顶层
  ↓
调用 CoroutineExceptionHandler
  ↓
没有 handler → 交给线程 UncaughtExceptionHandler

不要把 handler 装在子协程上

这是一个常见的坑:

scss 复制代码
val scope = CoroutineScope(Job())

scope.launch {
    launch(handler) {  // handler 不会生效
        throw Exception("失败")
    }
}

执行结果是:子协程抛异常 → 因为父 Job 是普通 Job,异常立刻向上传播到父 launch → 父 launch 被取消 → 子协程上配置的 handler 不会被调用 → 最终异常交给 scope 层或线程未捕获异常处理器。

为什么子协程的 handler 不生效?

CoroutineExceptionHandler 的设计逻辑是:只在异常不再需要向上传播、到达"处理终点"时才触发。对于普通 Job 的子协程,异常默认要上报给父协程,所以框架不会在当前子协程层面调用它的 handler,而是继续往上抛。

换句话说,CoroutineExceptionHandler 通常只在根协程或 Scope 级别生效,不是任意子协程加上就生效的。

正确用法是把 handler 装在 scope 上:

kotlin 复制代码
val handler = CoroutineExceptionHandler { _, exception ->
    println("捕获到异常:$exception")
}
val scope = CoroutineScope(Job() + handler)

scope.launch {
    throw Exception("失败") // 会被 handler 捕获
}

小结

  • 协程最大的价值是以同步风格编写异步代码,而不是单纯比线程省资源。
  • async 的异常是否延迟抛出,取决于它的父 Job 类型。普通 Job 下会立即上抛,SupervisorJob 或 GlobalScope 下才会延迟到 await()。
  • 协程通过结构化并发管理生命周期,父协程退出会自动取消子协程。
  • SupervisorJob 可以隔离子协程异常,常用于 viewModelScope 等需要"局部容错"的场景。
  • CoroutineExceptionHandler 应该配置在 scope 或根协程上,而不是子协程上。
相关推荐
plainGeekDev2 天前
libs 目录 → Gradle 依赖管理
android·java·kotlin
监督者修2 天前
从零构建 GIS 数据引擎:方案驱动架构的设计与实践
android·架构·kotlin
urkay-3 天前
Kotlin Flow分类
android·开发语言·kotlin
alexhilton4 天前
Kotlin DSL深度解析:从Gradle脚本到构建你自己的DSL
android·kotlin·android jetpack
Kapaseker4 天前
Android 开放这扇门,正在被 Google 从里面反锁
android·kotlin
唐青枫4 天前
别再把 Util 类堆成杂物间:Kotlin Extension 扩展函数与扩展属性实战详解
kotlin
plainGeekDev5 天前
依赖管理 → Version Catalog
android·java·kotlin
黄林晴5 天前
KMP 正式支持 SwiftPM:CocoaPods 迁移配置指南
android·ios·kotlin
花燃柳卧6 天前
跨平台路由组件工程源码补充上传
android·flutter·kotlin