协程取消传播与 Job 状态机

52 阅读4分钟

一、Job 状态机(简化但够用)

New(LAZY) ── start ─▶ Active ── 完成主体 ─▶ Completing ── 子都收尾 ─▶ Completed(正常)
                      │                          │
                      │ cancel                   └─ 子/本体异常 or cancel ─▶ Cancelling ─▶ Cancelled
  • New:只在 CoroutineStart.LAZY 出现,未启动。

  • Active:运行中,job.isActive = true。

  • Completing:主体代码结束,等待子协程/回调收尾

  • Cancelling/Cancelled:进入/完成取消流程(携带 CancellationException 原因)。

  • Completed:正常完成(无异常/取消)。

规则:父等子(父 Job 要等所有子完成/取消才进入最终 Completed/Cancelled)。

二、取消传播(结构化并发的心脏)

1) 默认模型:coroutineScope/ 普通Job

  • 子失败 ⇒ 立刻取消父与所有兄弟
  • 父取消 ⇒ 立刻取消所有子(同一个 CancellationException 原因向下传播)。
coroutineScope {
    val a = launch { workA() }
    val b = launch { error("boom") }   // 触发:取消 a + 取消父
    // 这里很快就被取消
}

2) 监督模型:supervisorScope/SupervisorJob

  • 子失败不影响父与兄弟;仍可单独感知失败。
supervisorScope {
    val a = launch { workA() }         // 继续跑
    val b = async { error("boom") }    // 失败被封在 b 里
    // 想处理 b 的失败:b.await() / b.getCompletionExceptionOrNull()
}

3) 根协程与异常处理

  • 根层 launch { … } 的未捕获异常由 CoroutineExceptionHandler(或默认)处理并上报。

  • 根层 async { … } 若不 await() ,异常会沉默(无上报)。——不建议这样用。

三、取消是“协作式”的

  • 取消并不会“强杀线程”,而是在下一个挂起点注入 CancellationException。
  • 你的代码需要协作:在循环里使用 isActive/ensureActive()/yield(),I/O/挂起函数天然可被取消。
while (isActive) {
    step()
    if (needCheck) ensureActive()
}
  • 在 finally 里做清理时,如需保证不再被取消,用:
finally { withContext(NonCancellable) { cleanup() } }

四、异常与“首错优先 + 抑制异常”

  • 多个协程几乎同时失败时:第一个到达父作用域的异常成为“主异常” ;其他异常以 suppressed 附着(Throwable.suppressed)。

  • 获取根因:

    • job.getCompletionExceptionOrNull():失败/取消原因(正常完成返回 null)。
    • job.getCancellationException():总是返回一个 CancellationException(其 cause 可能指向根因)。
  • invokeOnCompletion { cause -> … }:

    • cause == null → 正常完成;

    • 否则为取消/失败的异常。回调发生在使之完成的线程上。

五、launch vs async 在取消/异常中的差异

行为launch (Job)async (Deferred)
结果await() 拿 T
异常抛出立即冒泡到父/根(结构化)封存到 Deferred,在 await() 抛出
在 coroutineScope 中失败立刻取消父与兄弟同样立刻取消父与兄弟(即使你还没 await)
在 supervisorScope 中失败不影响兄弟/父不影响兄弟/父;需要 await() 才抛

结论:要结果用 async/await;只做事用 launch。容错并行用 supervisorScope + runCatching { … }。

六、超时/超时取消

  • withTimeout(ms) { … } 超时抛 TimeoutCancellationException(是 CancellationException 的子类),只取消其作用域内的子协程

  • 想要“超时返回空/默认而不抛异常”,用 withTimeoutOrNull { … }。

七、与 Channel/Flow 的关系

  • Channel

    • close(cause):优雅关闭,允许把缓冲里的元素读完;
    • cancel(cause):立即失败,丢弃未读元素并让挂起的 send/receive 以异常结束。
    • 经验:生产者结束时用 close()消费者不想再读用 cancel()
  • Flow

    • 收集端取消会向上游传播取消;

    • flowOn 会在边界处切换上下文,取消从下游向上游跨边界传播;

    • 用 onCompletion 感知结束/取消,用 catch 捕获上游异常(注意其只捕上游,不含下游)。

八、常用写法(模板)

// 1) 取消并等待收尾
job.cancelAndJoin()

// 2) finally 中保证清理
try {
    work()
} finally {
    withContext(NonCancellable) { closeQuietly() }
}

// 3) 并发 + 一死全死(默认结构化)
suspend fun load() = coroutineScope {
    val a = async { partA() }
    val b = async { partB() }
    a.await() to b.await()
}

// 4) 并发容错(监督)
suspend fun loadTolerant() = supervisorScope {
    val a = async { runCatching { partA() } }
    val b = async { runCatching { partB() } }
    a.await() to b.await()
}

// 5) 观察失败(即使不 await)
val d = async { work() }
d.invokeOnCompletion { e -> if (e != null) log("async failed", e) }

九、排错清单(高频坑)

  1. 只 join() 不见原始异常:join() 最多抛 CancellationException;要根因用 await() 或 getCompletionExceptionOrNull()。

  2. LAZY 导致串行:多个 async(start = LAZY) 想并发,先 start() 再 await()

  3. 在临界区挂起(synchronized/持锁期间 await/delay):易长时间持锁或死锁;把挂起点挪到锁外或用 Mutex。

  4. CPU 密集不协作:循环里不检查 isActive,取消“卡半天”;加入 ensureActive() 或 yield()。

  5. 根层 async 不 await:异常沉默;避免或至少 invokeOnCompletion 监控。

  6. 父已完成但子还在跑:父处于 Completing;检查未收尾子任务或调用 cancelChildren()。

十、速记

  • 取消是协作式的:靠挂起点 + isActive/ensureActive。

  • 默认结构化并发“一死全死” ,监督模型隔离失败。

  • 主异常 + suppressed:首错优先,其余附着。

  • cancelAndJoin 是可靠收尾的快捷方式;清理用 NonCancellable

  • Channel:生产者 close,消费者 cancel;Flow 取消向上游传播