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

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

协程代码中的任务关系,不能只通过"有没有返回值"判断。launch、async和withContext分别表达启动任务、并发获取结果和顺序执行步骤;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决定局部任务关系,结构化并发决定整棵任务树能否被可靠管理。只有两层同时成立,协程代码才不会在页面销毁、任务失败或需求扩展后失去控制。

参考资料

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui