协程任务的组织方式:launch、async、withContext与结构化并发

协程任务的组织方式:launchasyncwithContext与结构化并发

协程代码中的任务关系,不能只通过"有没有返回值"判断。launchasyncwithContext分别表达启动任务、并发获取结果和顺序执行步骤;CoroutineScope与Job则把这些任务组织成具有生命周期边界的树。

如果只记住API差异,代码很容易出现两个问题:本应并发的任务被顺序执行,或者已经离开页面的任务仍然没有明确归属。完整判断需要同时看任务是否独立、调用方是否等待、结果如何返回以及任务由谁取消。

内容摘要

  1. launch创建新协程并返回Job,适合不返回业务结果的独立任务。
  2. async创建带结果的并发任务并返回Deferred;并发不等于一定并行。
  3. withContext在当前协程内执行一个顺序步骤,不建立独立业务任务。
  4. CoroutineScope提供管理边界,Job建立父子任务树。
  5. 结构化并发保证父任务等待子任务,并让取消和失败沿可预测的方向传播。

一、三个API表达三种任务关系

launch:启动不返回业务结果的任务

ini 复制代码
val job = viewModelScope.launch {
    // 启动一个不向调用点返回业务结果的子任务
    repository.syncTasks()
}

launch会创建新协程并立即返回Job。Job不携带业务结果,但可以用于取消、等待和观察状态:

scss 复制代码
// 发出取消信号;任务需要通过挂起点或主动检查响应
job.cancel()
​
// 挂起等待任务真正结束,不阻塞当前线程
job.join()

因此,launch并不是"启动后不管"。它只是把业务结果从函数返回值中移除,任务本身仍受Scope和Job管理。

常见用途包括启动监听、同步本地数据或执行不需要向调用点返回值的工作。任务如果需要把成功或失败反馈给上层,应通过明确的状态模型或结果通道表达,不能依赖隐藏的副作用。

async/await:并发启动并汇总结果

scss 复制代码
coroutineScope {
    // 两个 async 连续启动,让两个请求的执行时间可以重叠
    val userDeferred = async {
        api.loadUser()
    }
​
    val tasksDeferred = async {
        api.loadTasks()
    }
​
    // await 只在对应结果尚未完成时挂起
    val user = userDeferred.await()
    val tasks = tasksDeferred.await()
​
    // coroutineScope 会等待全部子任务结束后再返回结果
    Dashboard(user, tasks)
}

async返回Deferred<T>。Deferred既是Job,也是一个未来结果的容器,await()会挂起等待对应结果。

两个async在调用await()之前都已经启动,因此它们的执行时间可以重叠。是否真的在多个线程上同时运行,还取决于Dispatcher、可用线程和CPU资源:

复制代码
并发:多个任务的执行时间可以重叠
并行:多个任务在多个执行资源上同时运行

async表达并发关系,不负责为任务创建专属线程。

并发最容易退化成顺序执行

下面的代码虽然使用了两次async,两个请求却没有形成并发:

scss 复制代码
coroutineScope {
    // 第一个 await 完成前,代码不会继续创建第二个 async
    val user = async { api.loadUser() }.await()
​
    // 此时第二个任务才开始,整体耗时接近两个请求之和
    val tasks = async { api.loadTasks() }.await()
​
    Dashboard(user, tasks)
}

并发的前提是先创建所有相互独立的任务,再等待结果:

ini 复制代码
coroutineScope {
    // 两个任务先后创建,但不立即等待,因此执行时间可以重叠
    val userDeferred = async { api.loadUser() }
    val tasksDeferred = async { api.loadTasks() }
​
    // 结果真正需要使用时再等待
    Dashboard(
        user = userDeferred.await(),
        tasks = tasksDeferred.await()
    )
}

这并不意味着所有顺序代码都应该改成async。如果第二个请求依赖第一个结果,例如必须先获得用户ID才能加载详情,顺序执行就是正确的业务关系。async只适用于任务彼此独立、调用方又需要汇总结果的场景。

withContext:当前任务中的顺序步骤

scss 复制代码
viewModelScope.launch {
    // withContext 是当前任务中的顺序步骤,不是独立业务任务
    val config = withContext(Dispatchers.IO) {
        // 阻塞式文件读取交给 IO 调度器
        file.readText()
    }
​
    // 代码块完成后恢复到 viewModelScope 的上下文
    render(config)
}

withContext不会额外建立一个需要独立管理的业务任务。调用它的协程会挂起,代码块完成后直接获得结果,再继续执行下一行。

它表达的关系是:

复制代码
当前任务
→ 进入指定上下文执行一个步骤
→ 等待步骤完成
→ 回到原上下文继续

是否发生真实线程切换取决于当前上下文和目标Dispatcher。withContext切换的是CoroutineContext,不承诺每次都更换物理线程。

本节小结

三个API的关键差异不是语法,而是任务关系:launch启动独立任务,async启动带结果的并发任务,withContext完成当前任务中的顺序步骤。选型前应先确定业务关系,再考虑返回值和Dispatcher。

二、Scope与Job如何建立任务边界

CoroutineScope不是装协程的容器

CoroutineScope持有CoroutineContext,其中通常包含Job和Dispatcher:

ini 复制代码
val scope = CoroutineScope(
    // SupervisorJob 管理子任务生命周期,并隔离子任务之间的失败
    SupervisorJob() + Dispatchers.Main
)

在这个Scope中启动协程时,新协程会创建自己的Job,并注册为Scope中Job的子节点:

csharp 复制代码
Scope Job
├── launch Job
└── async Deferred

这里的父子关系是运行时任务管理关系,不是类继承。

第一个任务同样拥有父Job

父Job来自Scope的Context,不需要先存在另一个业务协程。即使是Scope中的第一个launch,它仍然会成为Scope Job的子任务。

这条关系决定了任务归属。自定义Scope如果没有明确取消时机,内部所有任务就缺少可验证的生命周期终点。

Android中的常用边界

复制代码
viewModelScope  → 绑定ViewModel,onCleared时取消
lifecycleScope  → 绑定LifecycleOwner,销毁时取消
coroutineScope  → 函数内部临时结构化作用域
supervisorScope → 函数内部临时监督作用域

GlobalScope缺少局部生命周期约束,任务容易超出页面或业务对象的有效范围,因此不适合普通Android业务。

Fragment还需要区分对象生命周期和View生命周期。更新UI的任务应绑定viewLifecycleOwner,避免Fragment仍存在而View已经销毁:

scss 复制代码
viewLifecycleOwner.lifecycleScope.launch {
    // 任务归当前 Fragment View 所有,View 销毁时自动取消
    renderState()
}

任务所有权由工作有效期决定

Scope选型的核心不是在哪一层调用,而是任务在什么条件下仍然有效:

工作有效期 推荐所有者 典型任务
当前这套Fragment View存在期间 viewLifecycleOwner.lifecycleScope View动画、直接更新控件
当前页面业务存在期间 viewModelScope 页面状态加载、配置变化后继续保留的任务
当前挂起函数调用期间 coroutineScope 一次数据组合中的并发子任务
页面离开后仍需在进程内完成 注入的externalScope 本地收藏落库、短时缓存同步
App退出或设备重启后仍需可靠执行 WorkManager 持久化同步、可延迟后台任务

页面离开后仍需在进程内完成的工作,可以使用由Application级组件或导航图级ViewModel管理并注入Repository的externalScope。它仍要有明确取消时机,不能用GlobalScope代替所有权设计。需要跨进程退出或设备重启可靠执行的任务,则应交给WorkManager等持久化调度机制。

本节小结

Scope决定任务归谁管理,Job记录具体任务的生命周期。一个可靠的协程入口必须能够回答两个问题:父Job是谁,以及它在什么时机取消。

三、结构化并发如何约束任务树

父任务必须等待子任务

结构化并发要求作用域只有在全部子任务结束后才算完成。真实的数据聚合通常不是"全部并发"或"全部顺序"。例如仪表盘需要先读取本地账户配置,再并发请求用户、任务和权限:

kotlin 复制代码
// 整次刷新归 ViewModel 所有,页面业务结束时整体取消
viewModelScope.launch {
    val dashboard = repository.loadDashboard()
    uiState.value = DashboardUiState.Content(dashboard)
}

class DashboardRepository(
    private val accountStore: AccountStore,
    private val api: DashboardApi
) {
    suspend fun loadDashboard(): Dashboard = coroutineScope {
        // 账户配置是后续请求的共同前置条件,因此顺序读取
        val account = accountStore.readCurrent()

        // 三个远端请求彼此独立,先全部启动
        val user = async { api.loadUser(account.id) }
        val tasks = async { api.loadTasks(account.id) }
        val permission = async {
            api.loadPermission(account.id)
        }

        // coroutineScope 等待全部子任务,再组装一个完整结果
        Dashboard(
            user = user.await(),
            tasks = tasks.await(),
            permission = permission.await()
        )
    }
}

这段代码形成的任务树是:

csharp 复制代码
viewModelScope Job
└── refresh launch
    └── loadDashboard coroutineScope
        ├── loadUser async
        ├── loadTasks async
        └── loadPermission async

账户读取与远端请求是顺序依赖,三个远端请求之间才是并发关系。ViewModel被清理时,取消信号沿任务树向下传播;Repository函数也不会在三个请求仍然运行时提前返回。至于任一请求失败后其他任务如何处理,属于失败传播模型,将在下一篇单独展开。

取消与失败沿任务树传播

普通Job结构下,最基本的传播规则是:

复制代码
父任务取消
→ 递归取消全部子任务

单独正常取消子任务
→ 通常只影响该子任务

子任务发生未处理的非取消异常
→ 父任务失败
→ 父任务取消其他子任务

结构化并发并不保证任务永远成功,它保证失败和取消不会脱离任务树悄悄发生。Supervisor对传播规则的调整将在下一篇展开。

业务关系决定使用普通结构还是监督结构

多个步骤共同完成一个结果时,普通coroutineScope更符合"一起成功或失败"的语义。例如详情页需要用户信息和权限信息共同完成装配,任一关键数据失败都无法构成最终结果。

多个任务相互独立时,则需要监督结构。例如消息监听失败不应自动取消用户资料刷新。两类关系不能仅通过API名称区分,必须先明确业务是否要求共同成败。

本节小结

结构化并发的价值不是减少代码,而是让任务的创建、等待、取消和失败都落在同一棵树中。父任务等待子任务,生命周期能够向下管理,失败也具有明确传播路径。

结语:先确定任务关系,再选择API

协程构建器不是三种可互换的写法:

csharp 复制代码
独立启动、不返回业务结果 → launch
并发启动、需要汇总结果   → async/await
当前任务中的顺序步骤     → withContext

这三种选择必须放在明确的Scope与Job边界中。API决定局部任务关系,结构化并发决定整棵任务树能否被可靠管理。只有两层同时成立,协程代码才不会在页面销毁、任务失败或需求扩展后失去控制。

参考资料

相关推荐
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android
2501_915909061 小时前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
赵广陆2 小时前
企业实战:Markdown图片检索
android·java·开发语言
alexhilton3 小时前
千万别误用Android Skills
android·kotlin·android jetpack
淡淡的香烟4 小时前
Android视频直播播放器简单封装
android·物联网·音视频
爱笑鱼4 小时前
Android 系统启动机制导读:从 init 到 system_server,谁创建谁?
android
爱笑鱼4 小时前
Android 系统启动机制(一):init 到底是什么?为什么它不是普通 Native Service?
android
必须会一定会4 小时前
GLM-5.3 迁移实战:强制思考、1M 上下文与官方编程基准
android·开发语言·kotlin
小孔龙5 小时前
Android MessageQueue:从单锁队列到 DeliQueue
android