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() 会自动等待它们都完成。

这里有个小技巧:如果你并发启动的任务数量超过 3 个,可以把 Deferred 对象放进 List 里,然后统一 awaitAll。

异常传播差异

这个点很重要。我曾经因为在 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 与 Async 抉择流程图 启动协程 需要协程返回值吗? launch 返回 Job,不携带结果 async 返回 Deferred,可 await 结果 适用场景 · 触发一次性操作(日志/通知/UI刷新) · 执行副作用,不关心结果 适用场景 · 并发请求,聚合结果 · 预加载数据,按需 await ⚠️ 异常传播差异 launch:异常立即传播到父协程 async:异常延迟到 await() 时抛出 未 await 的 async 异常会静默丢失 建议:统一用 try-catch 包裹 await()

经验总结

项目实战中我一般遵循三条准则:

  1. 纯触发写 launch:发射一次事件,写个日志,更新某个 View,不需要结果。
  2. 取结果写 async:连数据库、调接口、异步计算,能拿 return 就别用变量传。
  3. 异常处理别偷懒:凡是 .await() 的地方,务必层层包 try-catch,或者在外层用 CoroutineExceptionHandler。你永远不知道服务器什么时候会返回 500。

哦对了,还有一点:别把 async 当成万能药。如果你只是要一个协程按顺序执行几个步骤,用 launch 加 delay 或 withContext 就够了。滥用 async 反而让代码多一层无意义的 Deferred 包装。

嗯,这一节实战性很强。理解了这两种启动方式,后面讲结构化并发和协程作用域的时候你就能直接上手了。