协程进阶:协程上下文与调度器、启动模式、取消与超时、异常处理

说实话,协程的基础用法学完之后,很多人会觉得「也就那样嘛」。但真正到了项目里,你会发现坑全藏在上下文、调度、取消和异常这些细节里。我当年第一次用协程写网络请求,结果用户切后台再回来,协程还在跑,直接内存泄漏——嗯,从那以后我再也不敢小看这些「进阶」内容了。

一、协程上下文与调度器

协程上下文,说白了就是一个「环境包」。它里面装着协程运行所需的各种信息,比如名字、Job、调度器等等。每个协程都有一个上下文,而且可以继承父协程的上下文。

我个人习惯把上下文理解成一个配置集合。你想想看,一个协程要跑起来,总得知道「在哪个线程上跑」「有没有父协程」「出错了怎么办」吧?这些信息全塞在上下文里。

1. 调度器(Dispatcher)

调度器是上下文里最重要的元素。它决定协程跑在哪个线程池上。Kotlin 内置了四种调度器:

调度器 说明 典型场景
Dispatchers.Main 主线程,Android 中用于 UI 更新 更新界面、弹 Toast
Dispatchers.IO 专为 IO 操作优化的线程池 网络请求、文件读写
Dispatchers.Default CPU 密集型任务,线程数 = CPU 核心数 排序、解析 JSON
Dispatchers.Unconfined 不指定线程,随调用者线程跑 极少用,调试时可能用到

核心原则:不要在主线程做耗时操作,也不要在 IO 线程更新 UI。用 withContext 切换调度器是最优雅的方式。

// 实际项目中的写法
viewModelScope.launch {
    val data = withContext(Dispatchers.IO) {
        // 网络请求
        api.fetchUserInfo()
    }
    // 回到主线程更新 UI
    textView.text = data.name
}

我在项目中遇到过一个问题:同事在 IO 线程里直接更新了 UI,结果偶尔闪退。排查了半天才发现是忘了切线程。后来我们定了个规矩——所有网络回调回来之后,必须显式切回 Main

2. 自定义上下文元素

除了调度器,你还可以往上下文里塞自己的东西。比如给协程起个名字,方便调试:

val myContext = CoroutineName("FetchUser") + Dispatchers.IO
launch(myContext) {
    // 这个协程的名字叫 FetchUser
}

小技巧:+ 运算符可以合并多个上下文元素。顺序不重要,因为每个元素类型是唯一的。

二、协程启动模式

启动模式决定了协程什么时候开始执行。Kotlin 提供了四种模式,但说实话,日常开发中常用的就两种。

模式 行为 使用频率
DEFAULT 立即调度,可取消 ⭐⭐⭐⭐⭐
LAZY 懒加载,需要手动调用 start() 或 await() ⭐⭐⭐
ATOMIC 立即调度,不可取消(直到第一个挂起点)
UNDISPATCHED 立即在当前线程执行,直到第一个挂起点 ⭐⭐

DEFAULT 是最常用的。你写 launch { ... } 时默认就是它。协程会立即被调度,但如果你在调度前调用了 cancel(),它就不会执行了。

LAZY 我偶尔会用。比如某个操作需要用户点击后才执行,但我想提前把协程建好:

val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
    // 耗时操作
    fetchData()
}

// 用户点击按钮后才真正执行
button.setOnClickListener {
    job.start()
}

为什么会有 ATOMIC?我记得有一次排查线上 bug,发现协程在启动前就被取消了,但业务逻辑要求「只要开始就必须执行完」。ATOMIC 模式就是干这个的——它保证协程一旦开始调度,就不会被取消,直到遇到第一个挂起点。

注意:ATOMIC 模式不是「不可取消」,而是「启动阶段不可取消」。一旦协程内部调用了 delay()withContext(),它就可以被取消了。

三、协程取消与超时

协程的取消机制,我觉得是 Kotlin 设计得最巧妙的地方之一。它不像 Java 的 Thread.stop() 那样暴力,而是通过协作式取消——协程自己检查是否被取消了,然后优雅地停下来。

1. 取消的基本用法

val job = launch {
    repeat(1000) { i ->
        println("Job: I'm working $i ...")
        delay(500) // delay 是挂起函数,会自动检查取消
    }
}

delay(1300)
println("main: I'm tired of waiting!")
job.cancel() // 取消协程
job.join()   // 等待协程结束
println("main: Now I can quit.")

这里的关键是 delay() 内部会检查 isActive 状态。如果协程被取消了,delay() 会抛出 CancellationException,协程就结束了。

2. 计算密集型任务的取消

但如果你在协程里做纯计算,没有调用任何挂起函数,那取消就失效了。你想想看,一个死循环的协程,它根本不检查取消状态,你怎么 cancel 都没用。

解决办法是手动检查 isActive

val job = launch(Dispatchers.Default) {
    var i = 0
    while (isActive) { // 每次循环检查是否被取消
        // 做计算
        i++
    }
}

最佳实践:如果你的协程里有循环,记得用 isActiveensureActive() 检查取消状态。这是协程协作式取消的核心。

3. 超时处理

超时其实就是「到时间自动取消」。用 withTimeoutwithTimeoutOrNull 实现:

// 超时抛出 TimeoutCancellationException
try {
    withTimeout(1000) {
        // 耗时操作
        fetchData()
    }
} catch (e: TimeoutCancellationException) {
    println("超时了!")
}

// 超时返回 null,不抛异常
val result = withTimeoutOrNull(1000) {
    fetchData()
}
if (result == null) {
    println("请求超时,请重试")
}

我个人更推荐 withTimeoutOrNull。为什么?因为 withTimeout 抛异常需要 try-catch,写起来啰嗦。而且 TimeoutCancellationExceptionCancellationException 的子类,如果你在父协程里统一 catch 了 CancellationException,可能会误吞。

四、异常处理

协程的异常处理,是新手最容易踩坑的地方。我刚开始用协程时,以为 try-catch 能包住一切,结果发现根本不是那么回事。

1. 异常的传播方式

协程的异常传播分两种:

  • 自动传播launch 启动的协程,异常会直接抛给父协程,父协程也会被取消。
  • 用户手动传播async 启动的协程,异常被封装在 Deferred 里,只有调用 await() 时才会抛出。
// launch 的异常会直接抛出
val job = GlobalScope.launch {
    throw RuntimeException("出错了")
}
// 这里会崩溃,因为异常没有被处理

// async 的异常被封装
val deferred = GlobalScope.async {
    throw RuntimeException("出错了")
}
// 这里不会崩溃,直到调用 await()
deferred.await() // 这里才抛出异常

2. 异常处理的方式

处理协程异常,有三种常用方式:

方式 适用场景 示例
try-catch 包裹 单个协程内的异常 launch { try { ... } catch { ... } }
CoroutineExceptionHandler 全局异常处理 launch(handler) { ... }
SupervisorJob 子协程异常不影响兄弟协程 SupervisorJob() + handler

CoroutineExceptionHandler 就像协程的「全局 catch」:

val handler = CoroutineExceptionHandler { _, exception ->
    println("捕获到异常: $exception")
}

GlobalScope.launch(handler) {
    throw RuntimeException("出错了")
}

但要注意,handler 只在 launch 中有效。对于 async,异常还是要靠 await() 时的 try-catch。

避坑指南:我曾经在项目里用 async 并发请求多个接口,其中一个失败了,结果整个协程树都崩了。后来发现是因为默认的 Job 会让子协程的异常向上传播。解决方案是用 SupervisorJob——子协程的异常不会影响兄弟协程。

// 使用 SupervisorJob,一个子协程失败不影响其他
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
    // 这个协程失败了
    throw RuntimeException("失败")
}
scope.launch {
    // 这个协程不受影响,继续执行
    delay(1000)
    println("我还在跑")
}

3. 异常处理的层级关系

我画了一张图,帮你理清协程异常处理的层级关系:

协程异常处理层级 CoroutineScope 父协程 (Parent Job) 子协程 A (launch) 子协程 B (async) 子协程 C (Supervisor) 异常向上传播,父协程取消 await() 时抛出 异常不传播,兄弟不受影响

五、总结

协程进阶这部分,说白了就是让你从「会用」变成「用好」。我个人的经验是:

  • 调度器:IO 和 Main 的切换是基本功,别搞反了。
  • 启动模式:DEFAULT 够用,LAZY 按需用,其他两个了解即可。
  • 取消与超时:记得在计算密集型任务里检查 isActive,用 withTimeoutOrNull 更安全。
  • 异常处理SupervisorJob 是你的好朋友,尤其是做并发请求时。

嗯,这些内容看起来多,但你在项目里踩过一两次坑之后,自然就记住了。别怕犯错,每个坑都是成长的阶梯。


公众号:蓝海资料掘金营,微信deep3321