本节摘要:结构化并发是协程体系最重要的设计:每个协程必须属于一个作用域,作用域组成父子树,父取消则子全部取消、子失败则父感知。这让"并发任务的生命周期"与"业务归属"绑定,忘记取消从常见事故变成语法不可能。本节讲 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 是它在主流语言里最完整的实现。
两个核心概念。CoroutineScope(作用域):协程的出生地与监护人,提供 CoroutineContext(含调度器、Job 等)。Job(作业):一个协程的句柄,组成父子树,承载取消与状态。
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) val parentJob = scope.launch { // 父协程 launch { fetchBanner() } // 子一 launch { fetchFeed() } // 子二 // 父协程会等到两个子协程都完成才算完成 }
树的四条传播规则,是本章乃至全章的心脏:
对照"孤儿制造机":提交即忘的任务没有 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 构建器:
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)"与"分叉(构建器)",三者就永远不会混。
树立好了,下一节解决"每个节点跑在哪个线程":调度器的分工律令与 withContext 的切换纪律。