协程用了一段时间,很多人的第一课是从launch开始的,然后写到一半发现代码不按顺序执行;换成runBlocking后界面卡死了;想拿返回值又硬着头皮用GlobalScope.async把程序搞崩了。这些我都经历过。Kotlin 协程的启动方式看着就几个函数,但选错入口和模式,代码表现会完全不一样。这篇内容我尽量把launch、async、runBlocking、coroutineScope这些入口背后的选型逻辑、执行时机和注意事项一次性讲透,特别是里面容易踩坑的差异点,希望能给你省下几个晚上的排查时间。
很多人以为协程启动方式只是“发个任务”而已,实际不是。它决定了你的代码是“阻塞线程”还是“挂起等待”,是“父协程等子协程”还是“各自飞”,是“异常能向上传播”还是“需要手动兜底”。所以这篇文章不只是罗列 API,更会解释每个启动姿势适用的业务场景和常见反模式,适合已经会用基础语法、但写复杂业务时总被各种奇奇怪怪时序问题困扰的 Android / JVM / 后端开发者。
1. 一路排错过来:两条最典型的“启动方式不对”现场
1.1 明明按顺序写的代码,为什么执行顺序是乱的
我收到过很多类似的提问:在onCreate里写了一段协程,结果界面跳过去了,日志里的协程代码才打印;或者一个函数里用launch连续发送两个网络请求,第二个请求居然可能比第一个先回调。初看像是“并发调度导致乱序”,但十次里有八次是启动方式本身选错了。
Kotlin 协程的启动不是“代码从上往下同步执行”,而是把协程体封装成一个任务交给调度器。调度器可以立刻执行、也可以排队执行、还可以指定在某个线程池或主线程的消息队列里等一下再执行。决定这套行为的有两个核心参数:CoroutineDispatcher和CoroutineStart。如果代码里只是写了launch { },没指定 Dispatcher,它默认会继承外面的上下文,而这个上下文是谁决定的,取决于启动所在的CoroutineScope。
举个例子:
fun main() { println("A") GlobalScope.launch { println("B") } println("C") }这段代码打印顺序大概率是 A、C、B。原因不是 B 被丢掉了,而是GlobalScope.launch默认使用Dispatchers.Default,协程任务被提交到后台线程池,主线程继续执行 C 之后逻辑。当我在现场排查这个问题时,第一件事就是问:你到底想让协程帮你做“异步任务”,还是想在一个顺序流程里插入一个可等待的耗时操作?如果只是想让耗时操作不阻塞当前线程,那 launch 本身没问题,但你要正确理解“不等”是它的默认行为;如果想在某个时刻等待它完成,就得用join()、await()或coroutineScope这类机制。
1.2 启动在错误的生命周期里,导致任务被取消或泄漏
另一个高频现场是:用户在ViewModel里拿到一个viewModelScope,然后在里面启动了一个协程做登录请求。协程启动方式是没问题的,可代码一执行就异常,或者网络请求返回后界面已经销毁,导致空指针。这不是启动函数选错了,而是“启动作用域”选错了。
协程启动方式在 Kotlin 里并不是“线程.start()”那样的裸启动。每次启动都必须基于一个CoroutineScope。这个作用域管的是协程生命周期的边界。如果你在Activity里直接用了GlobalScope.launch,任务会脱离 Activity 的生命周期,界面销毁了协程还在跑,回来的时候回调一个已经不存在的 UI 引用。这种崩溃经常被误以为是 Kotlin 协程的启动方式问题,实际上是作用域选错了。
我整理过一组排查口诀:
- 界面层启动,用生命周期绑定的
lifecycleScope或自己实现的MainScope。 - ViewModel 里启动,用
viewModelScope。 - 自定义长驻任务类里,再考虑自己创建 SupervisorJob + Dispatchers 的 scope。
GlobalScope只适合“整个进程生命周期内都要执行的极少数工具任务”,业务代码里基本别碰。
所以在细讲启动函数之前,先记住:写launch不是最难的,最是需要先想清楚这个协程的“爹”是谁——它的取消、异常、结构关系全由启动所在的 scope 决定。
2. launch 与 async:日常用最多的两个启动入口,选错一个就翻车
2.1launch:只负责启动,不负责结果,返回值怎么接
提到 Kotlin 协程启动,绕不开launch。它的签名是:
public fun CoroutineScope.launch( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job这函数干的事情简单理解就是:创建一个新的协程,并“立刻”按照给定调度策略尝试调度执行。它不会挂起当前调用者,不会阻塞当前线程,也不会把协程体最后一行计算值作为返回值返回给你。如果你需要的是一个“发出去就不管”的任务,用它没毛病。
但“发出去就不管”不能理解成完全失控。launch返回的是一个Job,你可以通过这个 Job 管理协程的生命周期:
val job = scope.launch { // 做一些耗时但又不需要把结果交给外面的事情 saveLogToRemote() } // 需要等待它完成时 job.join() // 需要取消时 job.cancel()很多初学者以为 launch 以后不能等,其实可以。join()的作用就是挂起当前协程,直到该 Job 完成。注意,只有在一个suspend上下文(比如另一个协程内)才能调用join(),因为它会挂起。
那 launch 的 block 里面如果写了返回值怎么办?看下面的示例:
val job = scope.launch { 1 + 2 }这个计算结果会被忽略。这是 launch 和 async 最大的区别。如果哪天你想要异步计算结果,直接把 launch 里的算术搬出来赋给变量,只会拿到Job,而不是数字。此时就需要 async。
2.2async:启动并返回 Deferred,延迟结果怎么取更安全
async和launch几乎是同一个家族的,但async会把协程体最后一条表达式包装成一个返回值,并通过Deferred暴露出来:
public fun CoroutineScope.async( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T ): Deferred<T>Deferred本身是个Job,扩展了await()方法。await 会挂起当前协程,等待异步任务完成,然后拿到结果。如果任务内部抛出异常,await 会把这个异常重新抛给当前等待方。
我见到很多人把 async 当成“并发神器”,一上来就写:
val result1 = scope.async { fetchUser() } val result2 = scope.async { fetchPosts() } val user = result1.await() val posts = result2.await()只看这段代码,两个 fetch 确实是并行发起的,因为两个协程都启动后,才轮到 await。但注意:如果当前 CoroutineScope 的 dispatcher 是一个单线程 dispatcher,那么两个任务并不会产生真正的并行,只是“并发切换”。要让网络请求等 IO 操作真正并行,应明确指定Dispatchers.IO或使用适合并行调度的上下文。否则两个任务排在同一个线程上,效率不会提升。
还有一点容易出问题:async的结构化并发。如果使用coroutineScope { }包裹两个 async,并且其中一个抛异常,那么整个coroutineScope会让另一个协程也失败,然后异常会向外抛。这是一种“快速失败”的设计,经常会让你摸不着头脑。如果你希望某个子协程失败不影响另一个,你需要用supervisorScope。
举一个实战场景:首页需要并发请求用户资料和每日推荐列表,任何一个失败都不应该影响另一个。这种情况下如果用普通 scope 里的两个 async,可能一个失败导致整个父任务取消。更好的方式是使用supervisorScope { }包裹,或者给任务添加独立的 job。当然,如果是 Android 的网络层,可能还有合并 Flow 之类的玩法,那就是另外的话题了。
其实从实现本质上说,launch和async创建的都是同一个协程族,二者只是“返回类型”和“失败处理”上的侧重点不同。选型时别默认二选一,我一般这么判断:
- 目标是“发送事件”“记录日志”“同步缓存”这类不需要关心结果的:用 launch。
- 目标是“我要拿某个异步计算值去填 UI 或参与下一步计算”:用 async + await。
- 目标是并行拉取多个数据再合并:用 async,但要注意异常隔离和 dispatcher。
- 目标是“等所有子任务完成就继续,但任何一个子任务失败则整体失败”:用 coroutineScope 包 async。
2.3 为什么说viewModelScope.launch不是你想的那种“随机异步”
Kotlin 协程的启动方式不是孤立存在的,它跟所在 scope 的调度策略紧密绑定。比如viewModelScope内部默认挂载了Dispatchers.Main.immediate,所以在 Android 主线程里敲viewModelScope.launch { }时,协程体里的代码会在主线程调度执行。如果没做特殊指定,launch 只负责建立协程,不负责“自动给你切到后台线程”。
网络请求为什么在viewModelScope.launch里写没报错?因为很多网络库(或者我们自己封装的函数)内部会通过withContext(Dispatchers.IO)切换线程,然后再切回主线程。这个机制并不是 launch 的默认配置。如果你不知道这一点,很可能会误以为“协程自动把耗时代码扔到后台”,然后直接在主线程上做 JSON 解析或数据库操作,最后照样卡 UI。
还有一点:launch如果不显式传CoroutineStart,默认是CoroutineStart.DEFAULT。这就是我开头说的“立即调度,但不保证立即执行”。如果你的协程体里有代码想“立刻同步执行一部分”,但当前又排在调度器队列末尾,表现会非常像丢帧。要解决这个问题,可以传CoroutineStart.UNDISPATCHED,它会让协程体立刻在当前线程执行到第一个真正挂起点。本质上,就是让你在启动方式上多了一层微调能力。
3. runBlocking、coroutineScope、supervisorScope:同样是等待,等待的起点和效果差异很大
3.1runBlocking:它是来“搭桥”的,不是给你当线程池用的
我发现很多教程会把runBlocking跟launch、async放在一起讲,然后看起来是这样:
fun main() { runBlocking { launch { delay(1000) println("World") } println("Hello") } }这个示例输出的确是 Hello 然后 World,但它没有解释一个核心问题:为什么一定用runBlocking包起来?原因在于 main 函数本身不是挂起函数,普通代码不能直接调用 suspend 函数。要把协程世界和普通阻塞世界连接起来,需要一个“阻塞点”,runBlocking就是干这个的。
runBlocking会启动一个协程,然后阻塞当前调用线程,直到协程体以及其内部所有子协程执行完。所以它常用于:
- main 函数或测试代码里写协程逻辑;
- 在 Java 层或非协程框架回调里,需要把结果同步等回来时做桥接;
- 演示/调试用。
但它不是用来“优化并发”的。一旦你在 Android 主线程调用了runBlocking,或者在后端请求线程里定义了runBlocking包住一个可能长时间运行的任务,这个线程就被白白占住了。并发不仅没提升,反而把原本可以异步执行的线程卡成了同步队列。尤其是当runBlocking内调用了一个会挂起很久的网络请求时,这个线程就阻塞在那里,极度影响吞吐。
实际项目里最常见的错误姿势是这样:
fun loadUser(): User { return runBlocking { // 内部用 async 发起网络请求 api.fetchUser().await() } }如果调用这个方法的是 Android 主线程,那主线程直接卡在等待网络返回上。下次用户滑动页面就会出现掉帧、卡死、ANR。这种场景正确做法是让整个调用链都走 suspend/async,不从函数签名里使用runBlocking阻断异步传播。协程的核心价值就是“用挂起替代阻塞”,你反过来又阻塞,等于白优化。
3.2coroutineScope:挂起父协程但不阻塞线程,等待所有子协程完成
如果说runBlocking是给“非协程世界”准备的门,那coroutineScope就是协程世界内部的专用门:
suspend fun fetchTwoThings(): Pair<A, B> = coroutineScope { val a = async { getA() } val b = async { getB() } a.await() to b.await() }调用coroutineScope时,外层协程会被挂起,等待这个 scope 里所有子协程全部完成。但跟runBlocking的根本区别是:它不会阻塞任何线程。挂起不等于阻塞,当前线程会释放出来做别的事,等到结果回来后再恢复。
所以当你写suspend函数,内部希望同时做多个事情并等待结果时,首选应该是coroutineScope而不是在外面套runBlocking。我见过不少把coroutineScope写成runBlocking的代码,原因是想在suspend函数里拿返回值,但发现launch拿不到结果,就病急乱投医。结果函数签名虽然变了,但线程阻塞问题又回来了。
coroutineScope的另一个特点是异常快速失败。任何一个子协程异常,整个 scope 都会失败,其他兄弟协程会被取消。这一点在正常业务里有时是需要的:比如下单时要同时扣除库存和创建订单,任何一个失败都应该回滚整体流程,使用coroutineScope非常合理。
但如果几个请求之间互不影响,比如“拉取用户资料”和“拉取Banner”,用一个崩溃就能拖垮另一个,显然不合理。这时候要上supervisorScope。
3.3supervisorScope:兄弟之间互不牵连,适合并行任务独立失败的场景
supervisorScope外表跟coroutineScope很像,但它的 Job 是SupervisorJob,子协程之间的失败不会相互取消。换句话说,里面的一个 async 挂了,其他兄弟仍然可以继续执行并成功返回。
实战例子:
suspend fun loadHomePageData(): HomeResult = supervisorScope { val userDeferred = async { api.getUserInfo() } val bannerDeferred = async { api.getBannerList() } try { val user = userDeferred.await() val banner = bannerDeferred.await() HomeResult(user, banner) } catch (e: Exception) { // 处理某个失败的情况 HomeResult(null, null) } }问题来了:如果api.getUserInfo()抛异常,在supervisorScope里 bannerDeferred 并不受影响,但你要是直接调用了bannerDeferred.await(),它的结果依然可以拿到。而当你 catch 里重新抛异常或做兜底时,需要看清异常到底来自哪个 Deferred。这种“失败独立”的特性非常适合:单个子任务失败不能拖垮整个页面的场景。
不过要谨慎:supervisorScope不要滥用。它把子协程的失败隔离了,但同时也破坏了快速失败的结构化模型。如果业务上要求“要么全部成功,要么回滚”,就绝不能使用 supervisorScope,否则一个分支失败后另外分支还可能继续改数据,造成状态不一致。
同样,launch和async也可通过CoroutineStart.LAZY来控制“启动”时机,让某个子协程不会立刻执行,而是在有人调用start()或await()时才真正进入调度。这又回到启动方式的主题了:不把作用范围理清楚,很容易在各种协程容器里面迷失。
4. 真正决定启动后行为的隐藏条件:Dispatcher、CoroutineStart 与结构化并发
4.1 同一个 launch,配上不同 Dispatcher 后完全是另一种并发效果
前面反复提 Dispatcher 但一直没系统展开,这里一定要讲透。Kotlin 协程的启动方式并不只是“从哪个函数启动”,还包含“把协程放在哪个调度器上运行”。你可以把一个协程任务想象成一份待办清单,启动函数负责把清单交给管家,而 Dispatcher 决定管家是把你安排到哪个工作区:
Dispatchers.Main:Android 主线程 / UI 线程,直接操作 View 必须用它。Dispatchers.IO:适合磁盘/网络/数据库操作,线程池会根据负载伸缩。Dispatchers.Default:CPU 密集型任务,例如集合排序、JSON 解析等,线程数一般是 CPU 核心数 + 1。Dispatchers.Unconfined:不限制协程运行的线程。启动后会立刻在当前线程执行到第一个挂起点,之后在挂起恢复时由恢复线程继续跑。因为线程不可预测,非特殊场景不要用。
这也是为什么实际项目里几乎都会看到一层封装函数:
suspend fun <T> safeApiCall(call: suspend () -> T): Result<T> { return withContext(Dispatchers.IO) { try { Result.success(call()) } catch (e: Exception) { Result.failure(e) } } }我想强调的重点是:launch(Dispatchers.IO) { }和launch(Dispatchers.Main) { }不只是运行线程不同,它们还影响协程的启动排队。Main上启动的任务要等主线程消息循环轮转,IO上启动的可能会直接被线程池某线程抢去执行。这就会导致你在Main上启动的两个协程,执行顺序未必是创建顺序;而你在IO上启动的两个协程,甚至可能在不同线程上同时执行。
我之前遇到过一个生产环境疑难:一段逻辑用了两个async(Dispatchers.IO)去读两个本地文件,结果第三个逻辑要等它们都读完再合并。因为async在Dispatchers.IO上启动,两个文件读取可能在同一个 IO 线程池并行执行,也可能一个线程执行到底。如果其中一个 await 时抛了异常,普通 coroutineScope 会立刻取消另一个读取。后来排查发现根因不是启动方式,而是缺少了 null 容忍和异常隔离。对症下药后改成 supervisorScope + Dispatchers.IO 才稳定。
4.2 CoroutineStart 不是摆设:DEFAULT、LAZY、ATOMIC、UNDISPATCHED 怎么选
协程的start参数平时大家都不写,用默认值就行。但当出现“启动后我不想立刻执行”“想无条件先执行一段再挂起”等需求时,这个参数就变得关键了。四种取值对比:
| 模式 | 行为 | 适用场景 |
|---|---|---|
| DEFAULT | 协程创建后,根据调度器开始调度,通常会尽快执行,但具体时机取决于当前调度器 | 绝大多数场景 |
| LAZY | 协程创建后不立即执行,直到调用 start/await/join 才真正开始 | 懒加载任务、按需请求、用户点击才触发的逻辑 |
| ATOMIC | 协程创建后立即按原计划调度,且在首个挂起点前不可被取消 | 需要在取消发生时也坚持执行完一部分非挂起耗时准备逻辑 |
| UNDISPATCHED | 协程体立刻在当前线程执行到第一个真正挂起点,恢复后的调度交给后续指定调度器 | 需要协程体开头对少量状态做同步处理,又希望后续切线程 |
CoroutineStart.LAZY是最容易让人疑惑的模式。它配合launch时,block 不会立刻执行,只会创建一个 Job,状态是 New。后续谁调用start()或join(),协程才会转入 Active 状态。这意味着如果你忘了触发,任务可能一直不会跑,日志上一片空白。这个模式和“异步任务默认会执行”的直觉有冲突。
举个例子:
val job = scope.launch(start = CoroutineStart.LAZY) { println("execute") } // 不调用 job.start() 或 job.join(),上面不会打印什么时候会用到它?比如首页有五个推荐位,不需要一次全部请求,可以等用户滚动到具体区域再触发对应请求。这时使用 LAZY + start 能精确控制“展示到哪,请求到哪”。又或者你维护了一个队列任务,希望手动控制每个协程的启动时机和优先级。
CoroutineStart.UNDISPATCHED则容易在 UI 代码里引发困惑。假设你在主线程调用了launch(context = Dispatchers.Main, start = CoroutineStart.UNDISPATCHED) { ... },协程体会立刻在当前线程里执行,不会等待下一帧的 main looper。这样看起来“启动即同步”,但一旦协程体里出现 delay 或 withContext 切换到其他调度器,恢复后又会按照指定的 Dispatcher 回主线程。如果你并不清楚这段逻辑,可能会觉得协程执行顺序不稳定。
我用到 UNDISPATCHED 的场景是:协程开头需要立刻设置一个 loading 状态,再把真正的耗时任务切去后台。如果不用 UNDISPATCHED,得先等主线程空闲了才设置 loading,就会出现一瞬间的空白闪烁。但注意,不是说 UNDISPATCHED 一定会避免闪屏,它只是让启动后那段代码立即同步执行,具体还要看整体 UI 刷新时机。
4.3 结构化并发为什么影响“启动”的取舍
协程有一个铁律:普通情况下,scope 里的子协程必须全部完成后,外层协程才算完成。这种“父等子”的约束就是结构化并发。它直接决定了你启动一个协程之后的等待语义。
看这段代码:
fun main() = runBlocking { launch { delay(1000) println("child finished") } println("parent finished") }运行结果是先输出 parent finished,但 runBlocking 不会退出,它会等待内部 launch 完成后才结束 main。这个运行结果来自 runBlocking 的特殊“等待机制”,而不是 launch 本身在做等待。如果我们换成自定义 scope:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope.launch { delay(1000) println("child finished") } // scope 不是一个挂起点,不会等子协程 Thread.sleep(2000) // 只能靠外部阻塞来维持进程这里协程启动后,外层代码并没有等待它。“scope.launch 的启动”只是发起任务,不跟随调用点;真正决定“要不要等”的是这个 scope 有没有被父协程结构化挂载。比如coroutineScope { launch {} }中,内部 launch 被挂在 coroutineScope 的 Job 下,所以 coroutineScope 等待所有子协程。
理解了这一点,你就明白了:为什么在 ViewModel 里用 viewModelScope.launch,即便你不手动 join,只要 ViewModel 没有 clear,任务就能在后台持续跑;但如果你在某个suspend函数里通过coroutineScope建的临时 scope 启动子任务,那父协程会等它,不等完不返回。这就是两种场景对应的“启动完成后生命周期”差异。
所以选启动方式之前,先回答三个问题:
- 这个协程该不该跟随调用者的生命周期?
- 调用者是否需要等待这个协程完成之后再继续?
- 多个并发的父级任务之间,失败要不要互相影响?
三个答案组合起来,基本就能定下用 launch 还是 async、用 coroutineScope 还是普通 scope。
5. 动手实战:几个启动场景的选型清单与踩坑复盘
5.1 选型清单:不同业务需求对应哪套启动组合
避免每次写协程都要重新陷入纠结,我日常维护了一份快速决策清单,在这里分享出来:
| 业务需求 | 推荐启动组合 | 不推荐/制止 |
|---|---|---|
| 一次性上报日志,不关心结果 | scope.launch { log() } | async(无谓创建 Deferred) |
| 串行请求两个接口并等待结果,需要失败快速取消 | suspend fun内使用coroutineScope { ... }连续调用 | 在外层runBlocking包起来 |
| 并行请求两个接口,失败互不影响 | supervisorScope { async { ... }; async { ... } } | 直接在coroutineScope里裸 async |
| 用户点击后执行某个耗时操作,点击后要取消之前没完成的任务 | 持有 Job 并cancel()旧 job,再scope.launch | 每一帧都无脑 launch,导致任务堆积 |
| 某个单元测试需要验证 suspend 函数 | runBlocking { testSuspendFn() } | 在 Android 主线程上使用 runBlocking |
| 想让某个协程延迟到特定时机再启动 | launch(start = CoroutineStart.LAZY) { ... },在真正需要时调用 start | 在协程里用一个无限 while 循环等待条件成立 |
这张表不是死的。比如“并行请求,失败互不影响”,还要考虑你到底想不想在某个子线程失败后继续更新 UI。在 Android 中,两个网络请求如果并行,而其中一个失败,页面可能只需要显示推荐位为空,用户信息正常展示。此时 supervisorScope 是合理的;如果你想的是“要么整页都能看,要么给出统一错误页”,那就该用 coroutineScope。
5.2 踩坑一:在 Main 线程里用 runBlocking 等网络,ANR 现场复盘
有一次朋友让我看他们 App 的启动页,为什么每次打开都卡顿。看了代码,启动流程大概是这样:
class StartupActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val config = runBlocking { repository.fetchRemoteConfig() } // 其余初始化 } }主线程里直接 runBlocking,等于强迫主线程等一个网络请求往返,这还没算超时重试时间。就算网络很快,也白白浪费了几百毫秒。这里完全应该把启动逻辑放进一个 lifecycleScope,通过 suspend 函数串行链路:
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { val config = repository.fetchRemoteConfig() refreshUI(config) } }如果担心后续初始化依赖 config,把“拿到 config 后做的事”继续写在同一个协程体内即可,不需要阻塞线程。这里本质就是启动方式的问题:原来代码选择了runBlocking这种“同步桥接型”启动入口,但场景并不需要一个同步桥,需要的是异步回调衔接。
5.3 踩坑二:异步接口并发改造,async 没加块级作用域导致到处爆炸
另一个典型现场是把一段线性流程“优化”成并发:
改造前:
suspend fun loadData(): Data { val user = api.getUser() val posts = api.getPosts() return combine(user, posts) }改造后,想当然写成了:
fun loadData(): Data { val user = scope.async { api.getUser() } val posts = scope.async { api.getPosts() } runBlocking { return combine(user.await(), posts.await()) } }这段代码的问题很多:非 suspend 函数里用scope.async启动协程,scope 没有明确的取消边界,runBlocking 又引入新的阻塞;更麻烦的是,如果api.getPosts()抛出异常,scope.async所在的 scope 内部异常处理规则会取消整个 scope 里的兄弟任务,而user.await()会抛出一个包裹异常,外层根本没有 catch 时机。
我的改进方案是把操作转移到 suspend 函数内部,并让并发范围控制在当前流程内:
suspend fun loadData(): Data = coroutineScope { val user = async { api.getUser() } val posts = async { api.getPosts() } combine(user.await(), posts.await()) }这样,loadData 被调用者取消时,内部 async 也会自动取消;发生异常时,整个 scope 会快速失败。它既拿到了并发,又保住了结构化并发带来的安全边界。如果为了让失败互不影响,就把coroutineScope换成supervisorScope,再单独用 try/catch 包住每个 await。这是两种常用形态。
5.4 踩坑三:主线程入口加了 Dispatchers.IO,做 UI 更新反而崩了
最后一个比较隐性:在 Android 点击事件里写:
binding.btn.setOnClickListener { lifecycleScope.launch(Dispatchers.IO) { val user = repo.getUser() binding.nameText.text = user.name // 崩,子线程更新 UI } }这是“启动方式选了调度器后忘记切回来”的问题。lifecycleScope默认调度器本来就是 Main,但 launch 里显式传了Dispatchers.IO,整个协程体都跑在 IO 线程里。协程启动后并没有自动切回主线程的魔法。正确做法是:
lifecycleScope.launch { val user = withContext(Dispatchers.IO) { repo.getUser() } binding.nameText.text = user.name }这里launch缺省继承 Main,主线程执行协程,withContext切到 IO 执行耗时调用,回来后自动回到主线程更新 UI。这种模式你也常会在网络库里看到封装好的suspend fun。
还有一种情况,协程体开头已经运行在后台线程,中间希望暂停 300ms 再继续执行另一段代码,用delay(300)会挂起。但如果你直接使用Thread.sleep(300),就会把当前线程给阻塞住。所以协程启动后的代码要遵守“挂起优先于阻塞”原则。启动方式虽然不限制你在内部怎么写,但正确习惯会大大减少排查事故的时间。
5.5 总结式笔记:协程启动方式对我项目的迁移帮助
我自己在重构一个旧项目时,花了一周时间统一协程启动风格。最核心的变化是,把原来“到处 GlobalScope.launch / Executors.newFixedThreadPool”的代码逐步收敛为三类统一封装:
- 界面触发的一次性任务:lifecycleScope.launch + 内部 withContext。
- repository 层的数据获取接口:挂起函数 + coroutineScope / supervisorScope 内部并发。
- 定时轮询或者后台数据同步:一个 Application 级 scope,由进程生命周期管理,通过 supervisor + Job 控制重试。
这个迁移过程中,“启动方式”成了代码评审里最高频的关键词。很多同事嘴上说着“用 launch 就行”,实际写出的是混乱的 async 嵌套。如果你也能先看懂每一种启动方式的“等与不等”“阻塞与挂起”“异常如何传播”,那么这种代码评审与改动就不会停留在表面替换。
最后再分享一个排错技巧:当协程行为不符合预期时,先别急着调 launch 或 async 的参数,建议先把日志打印点铺开——在协程启动前、代码块开头、挂起点恢复后各打一行,附带当前线程名。往往几十毫秒内你就能看出启动后的线程和调度时机是否符合预期。这比反复看源码、改东改西要高效得多。Kotlin 协程的启动方式并不复杂,复杂的永远是这些方式背后的调度、作用域和异常语义。把这一层想通了,你会发现原来很多“玄学”时序问题,其实从一开始就已经注定了。