卷 II · CH 07 · 深度 07/18

协程不是轻量线程,是一棵责任树

真正值钱的不是能开很多任务,而是每个任务都有父母、寿命、失败传播与收尸的人。

JOB TREECANCELLATIONSUPERVISION
▷ 先答一下

并发上传三张相互独立的图片,其中一张失败。产品要求另外两张继续。最贴合语义的是:

  1. GlobalScope.launch 三次
  2. 普通 coroutineScopeasync 三次
  3. supervisorScope 内各自捕获结果
  4. 把所有异常都吞掉

父任务为孩子负责

结构化并发要求新协程挂在某个 CoroutineScope 下。父任务完成前等待孩子;父任务取消时递归取消孩子;普通父子关系里,一个孩子失败会让父任务失败,并取消兄弟。这让「页面离开后搜索请求还在跑」从手工清理问题变成结构本身的性质。

viewModelScope 把任务寿命绑到 ViewModel;lifecycleScope 绑到 LifecycleOwner;LaunchedEffect 绑到 Composition 中那次调用。选 scope 的本质是在回答:谁有权宣布这项工作不再需要?

可以带走的判断:协程作用域不是放 launch 的工具箱,而是一份取消与失败责任书。

失败传播取决于业务原子性

如果头像、姓名、执业信息必须作为一份完整资料一起加载,任何一项失败都应让整个结果失败,普通 coroutineScope 合理。如果三张附件彼此独立,一张失败不该取消另外两张,使用 supervisorScope 或由更上层分别管理结果。

SupervisorJob 不是「永不失败」。它只是阻止子任务失败自动向兄弟横向扩散;失败仍要被观察、转成状态或上报。CoroutineExceptionHandler 也不是通用 try/catch:它只处理根协程中未捕获的异常,无法让已经失败的协程恢复。

suspend fun uploadAll(items: List<Attachment>): List<UploadResult> =
    supervisorScope {
        items.map { item ->
            async {
                try {
                    UploadResult.Done(item.id, upload(item))
                } catch (cancelled: CancellationException) {
                    throw cancelled
                } catch (error: Throwable) {
                    UploadResult.Failed(item.id, error)
                }
            }
        }.awaitAll()
    }

取消是合作,不是强杀

挂起函数通常会检查取消并抛出 CancellationException。CPU 密集循环没有挂起点时,要周期性调用 ensureActive()yield()。捕获 Throwable 时若把取消异常一起吞掉,父任务以为孩子已取消,孩子却继续工作。

finally 仍会运行,适合释放资源。只有极短、确实必须完成的清理才用 withContext(NonCancellable);拿它包业务上传,相当于撕掉作用域的取消合同。

异常速答launch 的未捕获异常向父级传播;async 把结果放进 Deferred,但在结构化父子关系中失败仍会取消父级,不能靠「不 await」假装没发生。局部恢复用 try/catch,兄弟隔离用 supervisor。
⌨ 自己跑一遍

比较普通作用域与监督作用域。下面这个版本会保留第二个孩子的完成结果:

first=failed
second=completed
parent=completed with partial results
import kotlinx.coroutines.*

fun main() = runBlocking {
    supervisorScope {
        val first = async {
            try {
                delay(10)
                error("upload failed")
            } catch (error: IllegalStateException) {
                "failed"
            }
        }
        val second = async {
            delay(30)
            "completed"
        }
        println("first=${first.await()}")
        println("second=${second.await()}")
        println("parent=completed with partial results")
    }
}

在 Android Studio Kotlin/JVM scratch 运行。再让第一个 async 不捕获异常,并把 supervisorScope 改成 coroutineScope,观察失败为何会取消兄弟。

▸ 在现实里

聊天页可能同时收集消息、在线状态和正在输入提示。它们常常可以独立失败;而「先刷新令牌,再用新令牌提交消息」是严格依赖链。别按技术类型统一选择 supervisor,要按产品原子性决定失败边界。

✗ 这个直觉是错的

「GlobalScope 能避免页面取消任务。」它只是让任务失去明确所有者,测试、注销、账号切换与进程内资源清理都会变难。

若工作要比页面活得久,给它一个真实的更长寿命所有者;若要跨进程,持久化意图并交给 WorkManager,而不是制造孤儿协程。

◇ 面试收口

答案是第 3 项。三张图片独立,监督作用域能隔离兄弟失败;每个任务仍把异常变成可见结果,同时重新抛出取消。普通 coroutineScope 适合全成或全败,GlobalScope 丢失所有权,吞异常则丢失故障证据。

这一章的一句话

先定义谁跟谁共命运,再选择 coroutineScope 还是 supervisorScope。

下一章让任务开始持续产出值:同一个 Room 查询被三个界面收集时,会执行一次还是三次?答案取决于它是冷流、热流,还是被 stateIn 接上了共享电源。