5.2 结构化并发与作用域树


5.2 结构化并发与作用域树

本节摘要:结构化并发是协程体系最重要的设计:每个协程必须属于一个作用域,作用域组成父子树,父取消则子全部取消、子失败则父感知。这让"并发任务的生命周期"与"业务归属"绑定,忘记取消从常见事故变成语法不可能。本节讲 CoroutineScope 与 Job 的树形规则、安卓生命周期作用域、coroutineScope 构建器的并发事务语义,以及逃逸出结构的代价。

并发里的"孤儿进程"问题

操作系统课讲过一个经典问题:父进程先退出,子进程成为孤儿,没人回收资源、没人接收退出状态。Java 的并发原语全是"孤儿制造机":

executor.submit(() -> heavyTask()); // 提交即忘 无人负责它的下落 new Thread(() -> syncData()).start(); // 页面销毁了它还在跑 CompletableFuture.supplyAsync(() -> fetch()) .thenApply(...) // 异常没人接 吞进 Future 里

ExecutorService 里的任务与"提交它的业务"之间没有任何结构关系——业务结束了任务还在,任务失败了业务不知道。线程池时代的最佳实践是"记得 shutdown、记得 Future.get、记得异常处理",全部是道德义务,而道德义务挡不住事故(第 1 章的判空问题在这里换个形态重现)。

协程的答案不是更好的线程池,而是结构:launch 不是"提交任务",是"在某个作用域里生出子协程"。父与子的关系进入运行时模型,取消与失败沿树传播。这个思想叫结构化并发(structured concurrency),Kotlin 是它在主流语言里最完整的实现。

作用域与 Job:树的两块积木

两个核心概念。CoroutineScope(作用域):协程的出生地与监护人,提供 CoroutineContext(含调度器、Job 等)。Job(作业):一个协程的句柄,组成父子树,承载取消与状态。

val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) val parentJob = scope.launch { // 父协程 launch { fetchBanner() } // 子一 launch { fetchFeed() } // 子二 // 父协程会等到两个子协程都完成才算完成 }

树的四条传播规则,是本章乃至全章的心脏:

  1. 父取消,全体取消:取消 parentJob,两个子协程连带取消,孙辈同理——一句 cancel 整树拔起;
  2. 子失败,父取消其余子:子一抛异常,父进入取消流程,子二收到取消——同一次业务里的并发任务"要么一起成功,要么一起收场";
  3. 父等子:父协程的完成定义是"自己的代码跑完且全部子协程完成"——launch 块结束不等于父完成;
  4. 结构内异常向上传:异常沿 Job 树向上冒,直到遇到 SupervisorJob 或被 CoroutineExceptionHandler 接住(5.4 节展开)。

对照"孤儿制造机":提交即忘的任务没有 1、2、3 中的任何一条。结构化并发把生命周期的责任从"记得"变成"结构"。

安卓页面的作用域树

下图是一个典型 Activity 页面的协程树:根是生命周期作用域,ViewModel 的作用域挂在自己的树根上,两棵树随各自的生命周期整树取消。

安卓页面的作用域树

安卓的两个现成作用域

Jetpack 把树根种进了生命周期组件,开发者几乎不需要手工建作用域:

class OrderHistoryActivity : AppCompatActivity() { fun refresh() { lifecycleScope.launch { // Activity 作用域 val badges = repo.refreshBadges() binding.tvCount.text = "共 ${badges.size} 枚" } } } class OrderHistoryViewModel : ViewModel() { val orders: StateFlow<List<Order>> = flow { emitAll(repo.observeOrders()) // ViewModel 作用域 }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) }

两个作用域的分工从图里能读出来:跟随界面的短命任务进 lifecycleScope(刷新角标、预加载当前页图片、轮播动画),跨界面配置变更的业务任务进 viewModelScope(数据拉取、支付提交、同步)。屏幕旋转时 Activity 重建、lifecycleScope 整树取消重长,而 viewModelScope 里的数据任务照常进行——这是ViewModel 存在的核心理由之一:给业务任务一个比界面更长的家。

反面模式是 GlobalScope。它的树根是全局单例 Job,等于放弃了全部结构保障:

GlobalScope.launch { syncCart() } // 反面教材 // 页面销毁照跑 异常无人接 生命周期无限延长

GlobalScope 上的协程就是新时代的孤儿进程。真实需要"比页面活得久"的任务应该放进 ViewModel、Application 级的自定义作用域,或 WorkManager(后台任务的正解),而不是逃进全局。IDE 对 GlobalScope 有显式警告(需要 DelicateCoroutinesApi 注解才能压制),这个设计本身就在劝退。

coroutineScope:并发的事务语义

函数内部要并发跑几个子任务并等它们全部完成,用 coroutineScope 构建器:

suspend fun loadPage(): PageData = coroutineScope { val banner = async { repo.banner() } val feed = async { repo.feed() } val notice = async { repo.notice() } PageData(banner.await(), feed.await(), notice.await()) }

coroutineScope 的语义是一笔并发事务:三个 async 是它的子协程,全部完成它才返回;任何一个失败,其余立刻取消,异常向调用者抛出——不会出现"banner 成功、feed 失败、页面拿了一半数据"的中间态。第 5.1 节支付案例的 coroutineScope { } 包裹就是这层语义。对比裸 launch 的"发出去就不管",事务性是结构化并发给并发组合上的保险。

async 与 launch 的选择也在这节定型:要结果用 async(await 拿值),只要执行用 launch。以及一条安卓纪律:async 只在 coroutineScope 内部用于并行拆分,不要把它当"后台任务启动器"用——后者属于作用域的职责。

逃逸出结构的代价

结构再好也拦不住刻意的逃逸。三种常见逃逸与代价:

// 逃逸一 GlobalScope 见上 孤儿重现 // 逃逸二 把协程存进字段手动管理 private var syncJob: Job? = null fun start() { syncJob = scope.launch { syncCart() } } fun stop() { syncJob?.cancel(); syncJob = null } // 手动管理的每个环节都是事故点 // 逃逸三 在挂起函数里另起作用域 suspend fun syncAll() { CoroutineScope(Dispatchers.IO).launch { ... } // 这个作用域不随调用者取消 }

逃逸二在老代码里大量存在(kotlinx 老版本的 Job 存字段模式),它的每个手动环节——启动、取消、置空——都依赖"记得"。逃逸三更隐蔽:挂起函数内部新建作用域,调用者取消时这个内嵌作用域毫无反应。**判断一个协程写法是否结构化,只问一句:它的父是谁?父取消它会不会跟着取消?**答不上来的,就是逃逸。

自定义作用域:需要比页面更长的生命周期时

虽然日常开发用 lifecycleScope 与 viewModelScope 就够了,但有两个场景需要自己种树根:Application 级的常驻任务(全局配置同步、心跳、索引预热)与业务会话级任务(一次登录流程、一笔交易的多个步骤)。自定义作用域的标准姿势:

class AppSyncScope(app: Application) { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> log("同步任务异常", e) }) fun startPeriodic() = scope.launch(CoroutineName("config-sync")) { while (isActive) { runSuspendCatching { syncOnce() } delay(15 * 60 * 1000) } } fun shutdown() { scope.cancel() } // Application 终止或测试收尾时 }

四个组成部分一个都不能省:SupervisorJob(兄弟任务互不牵连)、明确调度器(别依赖默认 Main,应用级任务通常 IO 或 Default)、异常处理器(否则未处理异常直接打到全局崩溃)、以及有计划的取消点。CoroutineName 给堆栈与线程名留排查线索。测试里可以注入 TestDispatcher 替换调度器,让定时循环在虚拟时间里秒级跑完(第 8 章测试节展开)。

需要"既不随页面、也不随应用"的生命周期时(如登录会话),同样模式挂在业务对象上,会话结束时 cancel——作用域树的本质是把生命周期的边界画在业务对象上,画在哪里取决于"这批任务失败或取消时,什么应该跟着收场"。

常见问题

问:怎么判断一个任务该进哪个作用域?有没有简单判据?
一个判据两个问题。问题一:任务的结果给谁用?给当前界面用(刷新、预加载)进 lifecycleScope;给"这个界面的数据"用(拉取、计算、写入)进 viewModelScope;给全局用(同步、上报、缓存预热)进应用或会话级自定义作用域。问题二:任务失败要不要连累兄弟——要(一次业务的并行拆分)用 coroutineScope 包一层;不要(互不相干的独立任务)依赖 SupervisorJob 的作用域。两个问题答完,归属基本就定了。剩下拿不准的场景,默认往"更长的作用域"放——任务活得比需要的长只是浪费,活得比需要的短是事故。

问:结构化并发下还需要线程池管理吗(shutdown、拒绝策略那些)?
调度器内部当然有线程池,但应用代码对它的管理责任大幅缩水:Dispatchers 的池由库管理生命周期,不需要 shutdown;任务的取消由作用域树负责,不需要 Future.cancel 的手动编排;拒绝与背压靠 limitedParallelism 与 Channel(容量与满时策略)表达。仍在的应用层职责只剩两件:为特定资源限制并发(数据库连接、文件句柄)时用 limitedParallelism 或信号量;Application 退出时取消自定义作用域。从"管理线程"到"管理作用域",是协程时代并发代码最重要的视角转移。

问:coroutineScope、supervisorScope、viewModelScope 三者什么关系?
三个层次各管一段。coroutineScope 是构建器,把一段并发代码变成事务(全部成功才返回);supervisorScope 同为构建器但改传播规则(子失败不连坐),适合"尽力而为"的并行;viewModelScope 是 Jetpack 预制的树根,等价于 SupervisorJob 加 Main 调度器挂在 ViewModel 生命周期上。常见组合:viewModelScope 里 launch,launch 内用 coroutineScope 做事务性拆分,需要容错并行时换 supervisorScope。分清"树根(scope)"与"分叉(构建器)",三者就永远不会混。

本节要点回顾

  • 结构化并发:每个协程必属于一个作用域,父子组成 Job 树,生命周期责任从"记得"变成"结构";
  • 四条传播规则:父取消全体取消、子失败父收场、父等全部子、异常沿树上冒;
  • 两个现成树根:界面任务进 lifecycleScope,业务任务进 viewModelScope,旋转屏幕时两者命运不同;
  • coroutineScope 是并发事务:全部成功才返回,一个失败全部收场;
  • GlobalScope 与手动 Job 管理是逃逸:判断标准只有一句——它的父是谁,父取消它跟不跟;
  • 与第 4 章的衔接:作用域树管任务的生死,不可变快管数据的生死,两者合起来才是完整的并发防线。

树立好了,下一节解决"每个节点跑在哪个线程":调度器的分工律令与 withContext 的切换纪律。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U