协程任务的组织方式:launch、async、withContext与结构化并发
协程代码中的任务关系,不能只通过"有没有返回值"判断。launch、async和withContext分别表达启动任务、并发获取结果和顺序执行步骤;CoroutineScope与Job则把这些任务组织成具有生命周期边界的树。
如果只记住API差异,代码很容易出现两个问题:本应并发的任务被顺序执行,或者已经离开页面的任务仍然没有明确归属。完整判断需要同时看任务是否独立、调用方是否等待、结果如何返回以及任务由谁取消。
内容摘要
launch创建新协程并返回Job,适合不返回业务结果的独立任务。async创建带结果的并发任务并返回Deferred;并发不等于一定并行。withContext在当前协程内执行一个顺序步骤,不建立独立业务任务。- CoroutineScope提供管理边界,Job建立父子任务树。
- 结构化并发保证父任务等待子任务,并让取消和失败沿可预测的方向传播。

一、三个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决定局部任务关系,结构化并发决定整棵任务树能否被可靠管理。只有两层同时成立,协程代码才不会在页面销毁、任务失败或需求扩展后失去控制。