【Kotlin 协程修仙录 · 金丹境 · 初阶】 | 并发艺术:async/await 与并发组合的优雅之道

67 阅读9分钟

image_11.png

前言

你已经打通了筑基境的全部经脉。你掌握了结构化并发的根本大法,看透了 CoroutineContext 的内部构造,驯服了 Dispatchers 这匹烈马。你写的协程代码,生命周期安全、线程切换自如、异常处理得当。

但现在,你面临一个更复杂的场景:

商品详情页需要同时加载商品信息用户评价推荐列表。你希望它们并发执行以缩短总耗时——如果串行执行,总耗时是三个请求之和;如果并发执行,总耗时只取决于最慢的那个请求。

你用 launch 分别启动了三个协程,但问题来了:你怎么知道它们都执行完了?你怎么拿到各自的返回值?你怎么保证任何一个失败时正确地处理异常?

你可能会想:用三个 Boolean 标志位,配合 Job.join(),再搞一个 ArrayList 收集结果……写着写着你发现,代码开始向回调地狱的方向滑坡。

协程给出的优雅答案,是两个看似简单却威力巨大的武器:asyncawait

本讲是金丹境的初阶修炼。你将:

  • 彻底搞懂 async/awaitlaunch/join 的本质区别。
  • 掌握 async 的四种启动模式,特别是 LAZY 的妙用。
  • 学会用 coroutineScope + async 实现结构化并发组合。
  • 理解 async 的异常传播机制,以及它和 launch 在异常处理上的根本不同。

准备好凝结金丹,驾驭并发了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


什么是 async?它与 launch 有何本质区别?

在 Kotlin 协程的官方定义中:

async 是一个协程构建器,它创建一个新的协程并返回一个 Deferred<T> 对象。Deferred 是一个轻量级的、非阻塞的 Future,代表一个将在未来某个时刻产生结果的异步计算。你可以通过调用 Deferred.await()挂起当前协程,等待结果返回。

如果说 launch 是“发射后不管”的火箭,那么 async 就是“发射后还要回收返回舱”的航天飞机。

对比维度launchasync
返回值JobDeferred<T>(继承自 Job
是否产生结果
等待方式join() 等待完成,不返回结果await() 等待完成,并返回结果
异常传播未捕获异常立即抛出,或交给 CoroutineExceptionHandler未捕获异常在 await() 时重新抛出
适用场景不关心结果的“发后即忘”任务需要返回值的并发计算
flowchart LR
    subgraph Launch[launch 发射后不管]
        L1[launch] --> L2[Job]
        L2 --> L3[join 等待完成]
        L3 --> L4[无返回值]
    end
    
    subgraph Async[async 需要返回值]
        A1[async] --> A2[Deferred]
        A2 --> A3[await 等待结果]
        A3 --> A4[返回 T]
    end
    
    style Launch fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Async fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style L1 fill:#90caf9
    style L2 fill:#90caf9
    style L3 fill:#90caf9
    style L4 fill:#90caf9
    style A1 fill:#a5d6a7
    style A2 fill:#a5d6a7
    style A3 fill:#a5d6a7
    style A4 fill:#81c784

为什么需要 async?并发组合的价值

假设你需要调用三个接口,然后将结果组合展示。用 launch 实现的代码可能是这样的:

// ❌ 用 launch 实现并发组合的糟糕尝试
suspend fun loadDataWithLaunch(): Triple<Data1, Data2, Data3> {
    var data1: Data1? = null
    var data2: Data2? = null
    var data3: Data3? = null
    
    coroutineScope {
        launch { data1 = fetchData1() }
        launch { data2 = fetchData2() }
        launch { data3 = fetchData3() }
    }
    // 这里能保证三个 launch 都完成了吗?不能!coroutineScope 只等待它的子协程
    // 但 launch 内部的赋值是异步的,这里可能拿到 null
    
    return Triple(data1!!, data2!!, data3!!) // 危险!
}

这段代码有两个致命问题:

  1. 你需要手动声明可变变量(var)来收集结果,破坏了不可变性。
  2. 你无法确定 coroutineScope 返回时三个 launch 内部的赋值是否已经完成。

async 优雅地解决了这两个问题:

// ✅ 用 async 实现的优雅并发组合
suspend fun loadDataWithAsync(): Triple<Data1, Data2, Data3> = coroutineScope {
    val deferred1 = async { fetchData1() }
    val deferred2 = async { fetchData2() }
    val deferred3 = async { fetchData3() }
    
    // await() 会挂起当前协程,等待对应 async 完成并返回结果
    Triple(deferred1.await(), deferred2.await(), deferred3.await())
}

代码简洁、安全、不可变。这就是 async 的核心价值:让你能够安全地并发执行多个任务,并优雅地组合它们的结果。

sequenceDiagram
    participant Caller as 调用方协程
    participant Scope as coroutineScope
    participant A1 as async1
    participant A2 as async2
    participant A3 as async3

    Caller->>Scope: 进入 coroutineScope
    Scope->>A1: 启动 async1
    Scope->>A2: 启动 async2
    Scope->>A3: 启动 async3
    
    par 并发执行
        A1->>A1: fetchData1()
    and
        A2->>A2: fetchData2()
    and
        A3->>A3: fetchData3()
    end
    
    Caller->>A1: await() 挂起等待
    A1-->>Caller: 返回 data1
    Caller->>A2: await() 挂起等待
    A2-->>Caller: 返回 data2
    Caller->>A3: await() 挂起等待
    A3-->>Caller: 返回 data3
    
    Caller->>Scope: 返回组合结果

async 的四种启动模式

async 有一个可选参数 start: CoroutineStart,它决定了协程的启动时机。默认值是 CoroutineStart.DEFAULT,但还有三种模式值得掌握。

启动模式行为适用场景
DEFAULT立即启动,调度器决定何时执行绝大多数场景,默认值
LAZY不立即启动,仅在 await()start() 被调用时才启动需要条件性执行,或计算开销大、不确定是否会用到结果
ATOMIC立即启动,但在第一个挂起点之前不可取消需要在启动阶段保证原子性(极少用)
UNDISPATCHED立即在当前线程执行,直到第一个挂起点需要减少调度开销,或需要特定线程上下文(极少用)

LAZY 模式的妙用

suspend fun loadUserData(shouldLoadAvatar: Boolean): UserData = coroutineScope {
    val userDeferred = async { fetchUser() }
    
    // avatar 的 async 是 LAZY 的,此时还没有开始执行
    val avatarDeferred = async(start = CoroutineStart.LAZY) {
        fetchAvatar()
    }
    
    val user = userDeferred.await()
    
    // 根据条件决定是否真的需要加载头像
    val avatar = if (shouldLoadAvatar) {
        avatarDeferred.await() // 这里才会真正启动并等待
    } else {
        null // 如果不需要,async 里的代码根本不会执行
    }
    
    UserData(user, avatar)
}

LAZY 的核心价值:避免不必要的计算。如果你不确定是否会用到某个结果,用 LAZY 可以省去无谓的资源消耗。

四种模式对比图

    stateDiagram-v2
    [*] --> Created : 协程创建
    Created --> DEFAULT_MODE : start = DEFAULT
    Created --> LAZY_MODE : start = LAZY
    Created --> ATOMIC_MODE : start = ATOMIC
    Created --> UNDISPATCHED_MODE : start = UNDISPATCHED
    
    DEFAULT_MODE --> Schedule : 立即提交调度器
    LAZY_MODE --> WaitStart : 等待 start/join/await
    WaitStart --> Schedule : 手动触发后提交
    ATOMIC_MODE --> AtomicExec : 立即调度,首个挂起点前不可取消
    UNDISPATCHED_MODE --> SyncExec : 当前线程同步执行
    
    Schedule --> NormalExec : 进入正常执行
    SyncExec --> AtomicExec : 执行初始代码
    AtomicExec --> FirstSuspend : 到达第一个挂起点
    FirstSuspend --> NormalExec : 恢复正常取消检查
    NormalExec --> Completed : 执行完成
    Completed --> [*]

async 的异常传播机制

asynclaunch 在异常处理上有根本性的不同

构建器异常传播方式如何处理
launch异常立即抛出,或交给 CoroutineExceptionHandler在根协程设置 CoroutineExceptionHandler
async异常被包装在 Deferred 中,在调用 await()重新抛出await() 调用处用 try-catch 包裹
fun main() = runBlocking {
    // launch:异常立即传播
    val job = launch {
        throw RuntimeException("launch 中的异常")
    }
    // 程序可能在这里就崩溃了(如果没有 handler)
    
    // async:异常在 await 时抛出
    val deferred = async {
        throw RuntimeException("async 中的异常")
    }
    try {
        deferred.await() // 异常在这里抛出
    } catch (e: Exception) {
        println("捕获到:${e.message}")
    }
}

核心结论

  • 如果你需要“发后即忘”且不关心异常,用 launch + CoroutineExceptionHandler
  • 如果你需要拿到结果并自己处理异常,用 async + try-catch await()
flowchart TD
    subgraph Launch异常["launch 异常传播"]
        L1[launch 抛出异常] --> L2{有 CoroutineExceptionHandler?}
        L2 -->|是| L3[Handler 处理]
        L2 -->|否| L4[崩溃]
    end
    
    subgraph Async异常["async 异常传播"]
        A1[async 抛出异常] --> A2[异常存入 Deferred]
        A2 --> A3[调用 await]
        A3 --> A4[异常重新抛出]
        A4 --> A5[try-catch 捕获]
    end
    
    style Launch异常 fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style Async异常 fill:#c8e6c9,stroke:#1b5e20,stroke-width:2px
    style L4 fill:#e57373
    style L3 fill:#ffb74d
    style A5 fill:#a5d6a7

结构化并发与 async 的组合:coroutineScope 内的 async

async 最优雅的用法是与 coroutineScope 结合。这种组合产生了一个非常强大的特性:任何一个 async 失败,coroutineScope 会自动取消所有其他还在执行的 async,并抛出异常。

这正是结构化并发的精髓——一个失败,全体遭殃,避免资源浪费。

suspend fun loadAllData(): CombinedData = coroutineScope {
    val deferred1 = async { fetchData1() }
    val deferred2 = async { fetchData2() }
    val deferred3 = async { fetchData3() }
    
    // 如果任何一个 await 抛出异常,coroutineScope 会:
    // 1. 立即取消 deferred1、deferred2、deferred3 中还在执行的
    // 2. 将该异常重新抛出
    CombinedData(
        deferred1.await(),
        deferred2.await(),
        deferred3.await()
    )
}

如果你希望某个 async 的失败不影响其他兄弟(例如推荐列表失败,商品信息仍然显示),你应该使用 supervisorScope(这将在金丹境·后阶深入讲解)。

sequenceDiagram
    participant Scope as coroutineScope
    participant A1 as async1
    participant A2 as async2
    participant A3 as async3

    Scope->>A1: 启动
    Scope->>A2: 启动
    Scope->>A3: 启动
    
    A2-->>A2: 抛出异常!
    A2-->>Scope: 异常向上传播
    
    Scope->>A1: 取消信号
    Scope->>A3: 取消信号
    
    A1-->>Scope: CancellationException
    A3-->>Scope: CancellationException
    
    Scope->>Scope: 重新抛出 A2 的异常

实战:商品详情页的并发数据加载

让我们用一个完整的 Android 实战案例来巩固 async 的用法。场景是商品详情页,需要并发加载三项数据,并优雅处理加载状态和异常。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

class ProductDetailViewModel(
    private val productRepo: ProductRepository,
    private val reviewRepo: ReviewRepository,
    private val recommendRepo: RecommendRepository
) : ViewModel() {

    sealed class UiState {
        object Loading : UiState()
        data class Success(
            val product: Product,
            val reviews: List<Review>,
            val recommends: List<Product>
        ) : UiState()
        data class Error(val message: String) : UiState()
    }

    var uiState by mutableStateOf<UiState>(UiState.Loading)
        private set

    fun loadProductDetails(productId: String) {
        viewModelScope.launch {
            uiState = UiState.Loading
            
            uiState = try {
                // coroutineScope 保证并发执行 + 异常自动取消
                val result = coroutineScope {
                    val productDeferred = async { productRepo.getProduct(productId) }
                    val reviewsDeferred = async { reviewRepo.getReviews(productId) }
                    val recommendsDeferred = async { 
                        // 推荐接口可能较慢,使用 LAZY 避免不必要的等待?
                        // 这里不需要 LAZY,因为我们确实需要它的结果
                        recommendRepo.getRecommends(productId) 
                    }
                    
                    UiState.Success(
                        product = productDeferred.await(),
                        reviews = reviewsDeferred.await(),
                        recommends = recommendsDeferred.await()
                    )
                }
                result
            } catch (e: Exception) {
                UiState.Error("加载失败:${e.message}")
            }
        }
    }
}

配合 Compose UI:

@Composable
fun ProductDetailScreen(viewModel: ProductDetailViewModel = viewModel()) {
    when (val state = viewModel.uiState) {
        is ProductDetailViewModel.UiState.Loading -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        is ProductDetailViewModel.UiState.Success -> {
            Column {
                Text("商品:${state.product.name}")
                Text("评价数:${state.reviews.size}")
                Text("推荐:${state.recommends.joinToString { it.name }}")
            }
        }
        is ProductDetailViewModel.UiState.Error -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text("出错了:${state.message}", color = Color.Red)
            }
        }
    }
}

常见错误与避坑指南

错误 1:忘记调用 await(),以为 async 已经执行

// ❌ 错误:没有调用 await(),协程可能还没执行完
suspend fun loadData(): Data = coroutineScope {
    val deferred = async { fetchData() }
    // 没有 await()!coroutineScope 返回时 deferred 可能还没完成
    Data() // 返回了空数据
}

正确做法:必须调用 deferred.await() 来等待结果。

错误 2:在 async 块外捕获异常,以为能保护 await

// ❌ 错误:try-catch 包裹了整个 async,但异常在 await 时抛出
try {
    val deferred = async { throw RuntimeException() }
    val result = deferred.await() // 异常在这里抛出,没有被 catch
} catch (e: Exception) {
    // 实际上这里能捕获!因为 await 在 try 块内
}

这段代码其实是正确的。常见的误解是以为异常在 async 块内就被抛出了。记住:异常在 await() 时抛出,所以 try-catch 包裹 await() 是正确的。

错误 3:在 LAZY 模式下多次调用 await

val deferred = async(start = CoroutineStart.LAZY) { fetchData() }
val result1 = deferred.await() // 第一次:启动并等待
val result2 = deferred.await() // 第二次:立即返回缓存结果(没问题)

LAZY 的 Deferred 在第一次 await 后结果会被缓存,后续 await 立即返回。这没有问题,但要知道这个行为。

错误 4:用 GlobalScope.async 替代 viewModelScope.async

// ❌ 危险:生命周期不受控制
fun loadData() {
    GlobalScope.async {
        // 这个协程不受 ViewModel 生命周期约束
    }
}

始终使用 viewModelScopelifecycleScope 来启动协程。


最佳实践

  1. coroutineScope + async 实现结构化并发组合:这是最安全的并发模式,自动处理异常和取消。

  2. 需要条件性执行时使用 CoroutineStart.LAZY:避免不必要的计算开销。

  3. await() 调用处用 try-catch 处理异常:这是 async 异常处理的标准姿势。

  4. Deferred 的声明和 await 分开:先声明所有 Deferred(让它们并发启动),再逐个 await。如果声明一个就 await 一个,就变成串行了。

    // ❌ 串行执行
    val r1 = async { f1() }.await()
    val r2 = async { f2() }.await()
    
    // ✅ 并发执行
    val d1 = async { f1() }
    val d2 = async { f2() }
    val r1 = d1.await()
    val r2 = d2.await()
    
  5. 理解 asynclaunch 的异常传播差异:根据是否需要返回值来选择。


总结与下回预告

恭喜,你已经掌握了 async/await 的并发艺术,金丹初成!

本讲核心收获

  • async 返回 Deferred<T>,通过 await() 获取结果;launch 返回 Job,无返回值。
  • CoroutineStart.LAZY 让你可以条件性地启动协程,避免不必要的计算。
  • async 的异常在 await() 时重新抛出,可以用 try-catch 捕获。
  • coroutineScope + async 是结构化并发的黄金组合,任一失败自动取消其他。

在下一讲 【金丹境·中阶】 中,我们将深入 CoroutineStart 的另外三种模式:ATOMICUNDISPATCHED,以及它们的底层实现原理。届时你会明白:

  • ATOMIC 是如何在启动阶段保证不可取消的?
  • UNDISPATCHED 为什么能在当前线程立即执行,直到第一个挂起点?
  • 这些模式在什么极端场景下才会用到?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
金丹境 · 初阶1、async/await并发组合
2、Deferred结果等待
3、CoroutineStart.LAZY
4、结构化并发与async
当前进度:25%
修为:250/1000
下一突破
[金丹境 · 中阶] (需领悟:CoroutineStart.ATOMIC
UNDISPATCHED 的底层原理)
launch发射后不管,
async发射后还用await回收。
数据结构化的并发,从Deferred开始。

【本讲思考题】

  1. 表象题:以下代码的输出顺序是什么?

    suspend fun test() = coroutineScope {
        val d1 = async { delay(1000); println("A") }
        val d2 = async { delay(500); println("B") }
        d1.await()
        d2.await()
        println("C")
    }
    
  2. 场景题:你需要在 ViewModel 中并发加载 10 个接口的数据。但其中有一个接口非常慢(可能 10 秒),你希望如果它超过 3 秒还没返回,就放弃它,只展示其他 9 个接口的数据。如何用 asyncwithTimeoutOrNull 实现?

  3. 原理题Deferred 接口继承自 Job。这意味着你可以对一个 async 返回的对象调用 cancel()。如果一个 async 协程被取消后,再调用 await() 会发生什么?请查阅文档并简述。


道友,金丹初成,你已经能用 async 优雅地驾驭多任务并发了。下一讲,我们将深入 CoroutineStart 的底层,看透协程启动的每一个细节。金丹境·中阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论