Launch 与 Async:启动协程的两种方式
其实我一直觉得,这部分是协程入门最容易被绕晕的地方。你想想看,同样是启动一个协程,为什么 Kotlin 要搞出两个函数?
我当初刚学的时候,就踩过一个坑。业务里需要并发请求三个接口,然后合并结果。我稀里糊涂全用了 launch,结果发现拿不到返回值,只能在协程内部把结果塞到 MutableList 里,各种线程安全问题扑面而来。后来换成 async,一行 .await() 搞定。那感觉,就像拧瓶盖拧了半天发现方向反了。
基本签名与核心区别
我们先看定义。这俩函数都是 CoroutineScope 的扩展函数,都接受 CoroutineContext、CoroutineStart 和一个挂起 lambda。
launch返回Job—— 代表一个生命周期,你只能用它来取消、等待完成、或者检查状态。它不携带任何结果。async返回Deferred<T>—— 它继承自Job,但额外带了一个结果容器。你可以在之后调用.await()阻塞当前协程(非阻塞式挂起)拿到T类型的值。
// launch —— 只管启动,不管结果
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
// async —— 启动并承诺未来会返回一个值
fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T>
这里我多说一句:Deferred 本质上是一个带结果的 Job。你可以对它调用 .cancel(),也可以 .join() 等待完成。但更常用的还是 .await() —— 它会挂起当前协程直到结果就绪。
为什么会这样设计?说白了,就是为了区分两种场景:「点火就跑」和「点火,然后我要拿数据」。
使用场景对比
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 触发一次操作(日志、通知、UI更新) | launch |
不需要返回值,只关心协程执行完成 |
| 并发执行多个独立任务,等待全部完成 | async + await() |
自然支持结果聚合,代码结构清晰 |
| 其中一个失败需要影响其他任务 | async(配合supervisorScope调整) |
await() 会传播异常,可精确控制 |
| 只希望等待完成,不关心结果 | launch + join() |
语义更直观,代码更轻量 |
| 启动后主动取消 | 两者均可 | 都支持 cancel,但 async 需要额外处理已 await 部分 |
我个人习惯是:只要能明确区分出「我要拿结果」,一律用 async。哪怕结果是一个 Unit 对象,你改成 launch 也 OK,但 async 让你多了一个选择权——万一后续需要变成有返回值的呢?
代码示例:对比两种写法
下面我写一个实际的场景:从本地数据库和远程 API 同时加载用户信息,然后合并展示。
// 用 launch —— 别扭的做法
scope.launch {
var localUser: User? = null
var remoteUser: User? = null
val job1 = launch {
localUser = loadFromLocal() // 麻烦:需要外部变量
}
val job2 = launch {
remoteUser = loadFromRemote() // 同样问题
}
job1.join()
job2.join()
// 此时才能使用 localUser 和 remoteUser
showUser(merge(localUser!!, remoteUser!!))
}
// 用 async —— 自然的做法
scope.launch {
val deferredLocal = async { loadFromLocal() }
val deferredRemote = async { loadFromRemote() }
val localUser = deferredLocal.await()
val remoteUser = deferredRemote.await()
showUser(merge(localUser, remoteUser))
}
你看,后者省掉了 Join 和外部可变变量。而且 deferredLocal 和 deferredRemote 本身就是并发执行的,await() 会自动等待它们都完成。
异常传播差异
这个点很重要。我曾经因为在 async 内部抛异常,外层 try-catch 没抓住,导致整个协程树都炸了。
launch 内抛出的异常会立即传播到父级协程(除非使用 SupervisorJob)。而 async 不会主动传播异常 —— 异常被封装在 Deferred 里,直到你调用 .await() 时才抛出。
// launch 异常 —— 父级会被立即取消
scope.launch {
launch {
throw RuntimeException("crash") // 父协程当场取消
}
delay(1000)
println("这行不会执行")
}
// async 异常 —— 只有 await 时才会抛出
scope.launch {
val deferred = async {
throw RuntimeException("crash")
}
delay(1000) // 这行能执行,因为异常还没被消费
try {
deferred.await() // 在这里才抛出
} catch (e: Exception) {
// 可以优雅处理
}
}
async 启动后从未调用 .await(),异常会静默丢失!我曾经排查过这样一个 Production Bug:一个后台预加载任务用了 async 但是没有 await,导致失败时没有任何日志,用户数据一直显示空白。启动模式的影响
两个函数都支持 CoroutineStart 参数。默认是 DEFAULT 表示立即调度。
我常用的场景是 CoroutineStart.LAZY:
val deferred = async(start = CoroutineStart.LAZY) {
fetchExpensiveData()
}
// 此时协程并未启动
// 只有当第一次调用 await() 或者 start() 时才开始执行
// 适合「按需加载」场景
val job = launch(start = CoroutineStart.LAZY) {
doHeavyWork()
}
// 同样,需要 job.start() 或 job.join() 才会执行
但有一点要注意:LAZY 模式的 async,如果你在 await() 之前取消了它,会抛出 CancellationException —— 我记得踩过一次,以为取消就万事大吉,结果忘记在外层 catch。
什么时候用哪个?一张图说清楚
下面我画了一张结构图,把两种方式的抉择流程按场景拆成树状。如果你把这张图记牢了,以后就不会纠结了。
经验总结
项目实战中我一般遵循三条准则:
- 纯触发写 launch:发射一次事件,写个日志,更新某个 View,不需要结果。
- 取结果写 async:连数据库、调接口、异步计算,能拿 return 就别用变量传。
- 异常处理别偷懒:凡是
.await()的地方,务必层层包 try-catch,或者在外层用CoroutineExceptionHandler。你永远不知道服务器什么时候会返回 500。
哦对了,还有一点:别把 async 当成万能药。如果你只是要一个协程按顺序执行几个步骤,用 launch 加 delay 或 withContext 就够了。滥用 async 反而让代码多一层无意义的 Deferred 包装。
嗯,这一节实战性很强。理解了这两种启动方式,后面讲结构化并发和协程作用域的时候你就能直接上手了。