深入理解 Kotlin 协程 (六):进退有度,解密协程取消响应与异常分发机制

44 阅读11分钟

协程的取消机制

取消协程需要协程内部配合,这点和线程一样,本质上也是协作式的取消,就是将状态设置为取消,协程内部根据状态的变化来响应。

完善 Job 的状态流转与取消通知

我们基于上一篇博客中的代码,来完善协程的取消逻辑。

首先支持协程取消回调的注册:

// [AbstractCoroutine.kt]
override fun invokeOnCancel(onCancel: OnCancel): Disposable {
    // 1. 创建回调包装对象,以便后续可以手动解绑
    val disposable = CancellationHandleDisposable(job = this@AbstractCoroutine, onCancel = onCancel)
    // 2. 原子更新协程状态
    val newState = state.updateAndFetch { prev ->
        when (prev) {
            is CoroutineState.Incomplete -> {
                // 如果协程还在运行中,就追加回调
                CoroutineState.Incomplete().from(state = prev).with(disposable = disposable)
            }

            is CoroutineState.Cancelling,
            is CoroutineState.Complete<*> -> {
                // 已取消或已完成,无需注册(无意义),保持当前状态
                prev
            }
        }
    }

    // 3. 如果注册时正在取消,立即同步触发回调
    (newState as? CoroutineState.Cancelling)?.let {
        onCancel()
    }

    return disposable
}

// [CancellationHandleDisposable.kt]
/**
 * 可移除的取消回调
 */
class CancellationHandleDisposable(
    val job: Job,
    val onCancel: OnCancel
) : Disposable {
    override fun dispose() {
        // 从当前绑定的协程实例中移除自身
        job.remove(disposable = this@CancellationHandleDisposable)
    }
}

接着是 cancel 函数的实现:

// [AbstractCoroutine.kt]
override fun cancel() {
    // 1. 原子流转状态
    val prevState = state.fetchAndUpdate { prev ->
        when (prev) {
            is CoroutineState.Cancelling,
            is CoroutineState.Complete<*> -> {
                // 保持不变,意味着重复调用 cancel 无任何副作用
                prev
            }

            is CoroutineState.Incomplete -> {
                // 流转为取消状态
                CoroutineState.Cancelling().from(prev)
            }
        }
    }

    // 2. 只有在取消之前是未完成状态,才去通知注册的取消回调
    if (prevState is CoroutineState.Incomplete) {
        prevState.notifyCancellation()
    }
}

// [CoroutineState.kt]
/**
 * 遍历并触发所有已注册的取消回调
 */
fun notifyCancellation() {
    this@CoroutineState.disposableList.loopOn<CancellationHandleDisposable> {
        it.onCancel()
    }
}

注意,这里并不能这样实现:

// 先更新后获取新状态
val newState = state.updateAndFetch {
    // ...
}

if (newState is CoroutineState.Cancelling) {
    // 旧状态可能是 Incomplete 或 Cancelling
    // 但我们只要从 Incomplete 转变为 Cancelling 的情况
    prevState.notifyCancellation()
}

因为 cancel 允许多次调用,在协程第一次调用 cancel 到结束之前,每次调用 cancel 得到的新状态都会是 Cancelling,这就会导致后续的 if 语句一定为 true,存在取消回调被多次重复通知的情况。

就算你在通知后将回调列表清空,也可能会出现这种情况:线程 A 通知后、清空前,线程 B 又进行了并发通知,还是无法避免,因为通知回调和清空回调就不是原子性的。

挂起函数响应取消的底层支撑:CancellableContinuation

怎么让挂起函数支持取消呢?其实我们之前提到过了,就是让它能够监听当前协程的取消状态,并在取消时停止耗时任务。

参考标准库中的 suspendCoroutine 函数来实现 suspendCancellableCoroutine 函数:

import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
import kotlin.coroutines.intrinsics.intercepted

// [CancellableContinuation.kt]
suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
) = suspendCoroutineUninterceptedOrReturn { continuation: Continuation<T> ->
    // 拦截原始的 continuation,并用 CancellableContinuation 包装以接管挂起与取消逻辑
    val cancellable = CancellableContinuation(continuation.intercepted())
    block(cancellable)
    cancellable.getResult()
}

suspendCoroutineUninterceptedOrReturn 我们早已见过,调用它能够获取到一个未被拦截(Unintercepted)的原始 Continuation 实例,在其 Lambda 中我们需要返回(OrReturn)一个值:如果结果已经准备好了(快路径),我们直接返回结果,否则(慢路径)返回一个特殊的编译器标记常量 COROUTINE_SUSPENDED

首先来定义状态:

// [CancellableContinuation.kt]
/**
 * 挂起点内部状态
 */
sealed class CancelState {
    object Incomplete : CancelState()
    class CancelHandler(val onCancel: OnCancel) : CancelState() // 只允许注册一个取消回调
    class Complete<T>(
        val value: T? = null,
        val exception: Throwable? = null
    ) : CancelState()

    object Cancelled : CancelState()
}

// 挂起决策
enum class CancelDecision {
    UNDECIDED, // 初始状态:还未决定
    SUSPENDED, // 确定挂起 
    RESUMED // 确定已拿到结果
}

CancellableContinuation 的实现只需静态代理 Continuation 即可:在完成(resumeWith)时流转状态,支持取消回调的注册。

import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED

// [CancellableContinuation.kt]
@OptIn(ExperimentalAtomicApi::class)
class CancellableContinuation<T>(
    private val continuation: Continuation<T>
) : Continuation<T> by continuation {
    private val state = AtomicReference<CancelState>(CancelState.Incomplete)
    private val decision = AtomicReference(CancelDecision.UNDECIDED)

    // 是否完成
    val isCompleted: Boolean
        get() = when (state.load()) {
            CancelState.Incomplete,
            is CancelState.CancelHandler -> false

            is CancelState.Complete<*>,
            CancelState.Cancelled -> true
        }

    /**
     * 注册取消回调
     */
    fun invokeOnCancellation(onCancel: OnCancel) {
        val newState = state.updateAndFetch { prev ->
            when (prev) {
                CancelState.Incomplete -> CancelState.CancelHandler(onCancel)
                is CancelState.CancelHandler ->
                    throw IllegalStateException("Prohibited.")

                is CancelState.Complete<*>,
                CancelState.Cancelled -> prev
            }
        }
        if (newState is CancelState.Cancelled) {
            onCancel()
        }
    }

    /**
     * 将当前挂起点绑定到所在的协程上
     */
    private fun installCancelHandler() {
        if (isCompleted) return
        // 获取当前所在协程
        val currentJob = continuation.context[Job] ?: return
        currentJob.invokeOnCancel {
            doCancel()
        }
    }

    private fun doCancel() {
        val prevState = state.fetchAndUpdate { prev ->
            when (prev) {
                is CancelState.CancelHandler,
                CancelState.Incomplete -> {
                    // 流转为取消状态
                    CancelState.Cancelled
                }

                CancelState.Cancelled,
                is CancelState.Complete<*> -> {
                    prev
                }
            }
        }
        if (prevState is CancelState.CancelHandler) {
            // 执行取消回调
            prevState.onCancel()
            resumeWith(Result.failure(exception = CancellationException("Cancelled."))) // 抛出异常响应取消
        }
    }

    /**
     * 决定是真正挂起,还是同步返回结果
     */
    @Suppress("UNCHECKED_CAST")
    fun getResult(): Any? {
        // 此时才绑定,为的是在真正的挂起点注册取消回调,提升性能
        installCancelHandler()
        if (decision.compareAndSet(CancelDecision.UNDECIDED, CancelDecision.SUSPENDED))
            // 结果尚未就绪,返回挂起标志
            return COROUTINE_SUSPENDED

        // 此时没有真正挂起(decision 为 RESUMED),同步完成,可以获取结果
        return when (val currentState = state.load()) {
            is CancelState.CancelHandler,
            CancelState.Incomplete -> COROUTINE_SUSPENDED

            CancelState.Cancelled ->
                throw CancellationException("Continuation is cancelled.")

            is CancelState.Complete<*> -> {
                (currentState as CancelState.Complete<T>).let {
                    it.exception?.let { e -> throw e } ?: it.value
                }
            }
        }
    }

    /**
     * 完成回调
     */
    override fun resumeWith(result: Result<T>) {
        when {
            // 同步完成:抢先修改决策,将结果存入状态
            decision.compareAndSet(CancelDecision.UNDECIDED, CancelDecision.RESUMED) -> {
                state.store(
                    CancelState.Complete(
                        result.getOrNull(),
                        result.exceptionOrNull()
                    )
                )
            }
            
            // 异步恢复:当前已挂起,直接唤醒底层的 continuation
            decision.compareAndSet(CancelDecision.SUSPENDED, CancelDecision.RESUMED) -> {
                state.updateAndFetch { prev ->
                    when (prev) {
                        is CancelState.Complete<*> -> {
                            throw IllegalStateException("Already completed.")
                        }

                        else -> {
                            CancelState.Complete(
                                result.getOrNull(),
                                result.exceptionOrNull()
                            )
                        }
                    }
                }
                continuation.resumeWith(result)
            }
        }
    }
}

getResult()resumeWith() 处理了协程底层的并发竞态问题,尝试挂起(getResult) 与任务完成尝试唤醒 (resumeWith),需要进行“赛跑”:

  • 最常见的情况是慢路径(真正挂起),此时异步任务耗时较长,getResult() 会先执行,将 decision 置为 SUSPEND,协程会交出线程的执行权进行挂起。当异步任务完成后,触发 resumeWith,发现协程挂起,就会调用底层的 continuation.resumeWith 将其唤醒。

  • 如果异步任务很快就完成,此时 getResult() 还没来得及执行,resumeWith 会将状态改为 RESUME 并将结果放在状态中,此时协程还没有挂起。接着 getResult() 会尝试挂起,发现此时已经得到了结果,就会取出结果同步返回,不会挂起和切线程。

为什么需要两种取消回调?

这两种取消回调的代码是不是重复了?其实没有,因为它们的作用对象和职责完全不同。

Job 的取消回调是全局的,它代表的是一整个协程的生命周期。当它取消时,会向外通知这个协程将要灭亡。它的关心对象包括子协程、调用了 join 等待它的其他协程,关心对象很多,所以使用的是回调列表。

而 CancellableContinuation 更为局部,它代表的仅是协程内部的某一个挂起点,当整个协程要“完蛋”时,它会完成资源的清理操作。关心的对象只有挂起函数的底层实现,因为一个挂起点同一时刻只能完成一件事,所以只允许注册一个回调。

改造具体挂起函数以支持取消

有了 CancellableContinuation 后,我们就可以让挂起函数感知到取消了。

改造 delay

首先是 delay,添加取消响应:

suspend fun delay(time: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) {
    if (time <= 0) {
        return
    }

    suspendCancellableCoroutine { continuation ->
        val future = executor.schedule({ continuation.resume(Unit) }, time, unit)
        continuation.invokeOnCancellation {
            // 当所在协程被取消时,取消底层的 future 定时任务
            future.cancel(true)
        }
    }
}

改造 join

接着改造 join,响应取消时不再等待。

private suspend fun joinSuspend() = suspendCancellableCoroutine { continuation ->
    val disposable = doOnCompleted { _ ->
        continuation.resume(value = Unit)
    }
    continuation.invokeOnCancellation {
        // 所在协程被取消时,移除需要等待的目标协程注册的完成回调,防止在完成状态下,恢复执行抛出移除
        disposable.dispose()
    }
}

如果 join 函数对应的协程已经完成,我们可以直接返回;但此时,我们可以检查一下当前所在协程的状态,如果已经取消,则抛出取消异常予以响应。

override suspend fun join() {
    when (state.load()) {
        is CoroutineState.Incomplete,
        is CoroutineState.Cancelling -> {
            // 被等待的协程尚未完成,挂起等待
            return joinSuspend()
        }

        is CoroutineState.Complete<*> -> {
            // 目标协程已完成,但需主动检查当前所在协程的活跃状态以响应取消
            val isActive = currentCoroutineContext()[Job]?.isActive ?: return
            if (!isActive) {
                // 此时一定是取消状态(而非完成)
                throw CancellationException("Coroutine is already cancelled")
            }
            return
        }
    }
}

为什么要在目标协程完成时,检查当前协程是否被取消?

因为如果同一时间外部取消了当前协程,而我们不做任何检查,那么当前协程会直接返回,接着执行后续的代码,无法响应取消。

所以即使当前协程没有挂起,但是在挂起函数中,就需要主动看看自己有没有被取消,来保证取消机制,官方也是这么做的,封装了一个扩展函数 Job.ensureActive()

示例代码

private suspend fun testLaunch() {
    // launch 示例代码
    val job = launch {
        try {
            println("1. 协程开始执行")
            delay(1000)
            println("3. 我不应该被打印,因为 delay 被取消了")
        } catch (e: CancellationException) {
            println("3. 捕获到取消异常:delay 过程被中断了!")
        }
    }

    delay(500)
    println("2. 外部触发取消")
    job.cancel()
}

可以看到:结果中并不会打印 1s 后的日志,在 500ms 取消协程时,delay 会先将线程回调解除,并抛出取消异常,结束当前协程。

private suspend fun testJoin() {
    // join 示例代码
    // 协程 B:目标协程,执行一个耗时任务
    val jobB = launch(CoroutineName("JobB")) {
        println("JobB: 开始执行长任务...")
        delay(5000)
        println("JobB: 任务执行完毕!")
    }

    // 协程 A:所在协程,等待 B 完成
    val jobA = launch(CoroutineName("JobA")) {
        try {
            println("JobA: 我正在等待 JobB 完成...")
            jobB.join()
            println("JobA: 终于等到 JobB 跑完了!")
        } catch (e: CancellationException) {
            println("JobA: 我被取消了,不再等 JobB 了,我要退出!")
        }
    }

    delay(1000)
    println("Main: 取消 JobA")
    jobA.cancel()

    delay(6000)
}

JobA 在取消时,会先移除 JobB 的完成回调(无需等待),再抛出取消异常来退出。而 JobB 并不会受到任何影响,还是会打印“任务执行完毕”。

取消异常究竟是如何被抛出的?

在前面的代码中,我们看到了当外部调用 cancel 时,底层的 CancellableContinuation 会执行下面这行代码来响应取消:

resumeWith(Result.failure(exception = CancellationException("Cancelled.")))

它仅仅是给协程的底层传递一个包含了异常的 Result 对象,为什么最终这个异常会在挂起函数中抛出,并被外层的 try...catch 捕获到呢?

关键就在于 Kotlin 编译器为我们生成的协程状态机(底层是 BaseContinuationImpl)。

当调用了原始 ContinuationresumeWith,并传入失败的 Result 后,协程会被唤醒。此时,状态机会接管这个结果,并将其交给挂起函数恢复执行的逻辑点,伪代码如下:

// 协程状态机恢复执行时的处理逻辑
val value = result.getOrThrow()
  • 如果 resumeWith 传递的是成功的结果,getOrThrow() 会返回结果,代码接着向下执行;

  • 但如果传递的是失败的结果,在这个地方 getOrThrow() 会直接将取消异常(CancellationException)抛出。

所以,这个异常是由 Kotlin 编译器生成的状态机抛出的,会在当前挂起函数被调用的那一行精准抛出。

协程的异常处理机制

我们接着来讲清楚协程中的异常应该怎么处理:

  • 在协程体内,无论是挂起函数还是普通函数抛出的异常,都可以通过 try...catch 来捕获。
  • 对于未捕获的异常,则在获取结果时处理。

定义与简化异常处理器

为了统一拦截和处理第二种未捕获异常,我们来定义一个异常处理器。

interface CoroutineExceptionHandler : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>

    /**
     * 处理异常
     */
    fun handleException(context: CoroutineContext, exception: Throwable)
}

然后创建一个函数,来简化它的创建:

inline fun CoroutineExceptionHandler(
    crossinline handler: (CoroutineContext, Throwable) -> Unit
): CoroutineExceptionHandler {
    return object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
        override fun handleException(context: CoroutineContext, exception: Throwable) {
            handler.invoke(context, exception)
        }
    }
}

未捕获异常的分发与处理

我们在 AbstractCoroutine 定义一个 handleJobException 函数,返回 true 表示异常已处理。它的子类可以根据自身需要来实现特有的异常处理逻辑。

/**
 * 处理未捕获异常
 */
protected open fun handleJobException(e: Throwable) = false

它的子类有两个,异常处理逻辑有些不同:

  • StandaloneCoroutine:launch 启动,自身无返回结果。我们希望它在遇到未捕获的异常时,优先调用自身的异常处理器进行处理,如果没有进行配置,则将异常抛给 completion 调用时所在线程的 uncaughtExceptionHandler 来兜底。

  • DeferredCoroutine:async 启动,通常有返回结果。由于它需要将结果返回给调用者,所以我们无需覆写该方法去主动处理异常,而是将未捕获的异常存放在最终状态中,等外部调用 await 获取结果时,才将异常抛出。

StandaloneCoroutine 的具体实现如下:

// [StandaloneCoroutine.kt]
override fun handleJobException(e: Throwable): Boolean {
    super.handleJobException(e)
    // 优先由自身的异常处理器处理,无配置则由线程的 uncaughtExceptionHandler 兜底
    context[CoroutineExceptionHandler]?.handleException(context, e)
        ?: Thread.currentThread().let {
            it.uncaughtExceptionHandler.uncaughtException(it, e)
        }
    return true
}

处理的逻辑完成后,在协程执行完成处进行异常的尝试分发:

override fun resumeWith(result: Result<T>) {
    // ...
        
    // 若最终状态携带异常,进入异常分发流程
    (newState as? CoroutineState.Complete<T>)?.exception?.let { e ->
        tryHandleException(e = e)
    }

    newState.notifyCompletion(result)
    newState.clear()
}

/**
 * 尝试处理异常,分发前先做静默过滤
 */
private fun tryHandleException(e: Throwable): Boolean {
    return when (e) {
        is CancellationException -> {
            // 忽略正常的取消控制流,避免将其视为程序崩溃
            false
        }

        else -> {
            // 将常规未捕获异常交由子类处理
            handleJobException(e)
        }
    }
}

为什么要过滤掉 CancellationException

挂起函数取消时,会通过抛出取消异常来实现取消响应,它是正常的流程,类似于线程的中断异常。所以需要忽略,不能让它触发崩溃处理逻辑。而未捕获异常为什么要向上传递,这就涉及到了协程的作用域了,我将会在下一篇博客中详细讲解。

异常处理器的实战演示

最后我们来演示一下异常处理器是如何接受崩溃的:

suspend fun main() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        println("CoroutineExceptionHandler got ${throwable.message}, from ${coroutineContext[CoroutineName]}")
    }

    launch(context = exceptionHandler + CoroutineName("main")) {
        println("Started main coroutine")
        throw ArithmeticException("Divide by zero")
        println("Ended main coroutine")
    }.join()

    delay(time = 200L)
}

因为我们之前有注入默认的调度器,协程完成时会在守护线程中执行,此时主线程可能会提前退出,所以加一个延迟保证异常处理器的执行。

结果输出为:

Started main coroutine
CoroutineExceptionHandler got Divide by zero, from main