第4章:suspend函数取消检查——withContext、delay、yield如何响应取消

在ViewModelScope协程中,取消并非即时发生的“中断”,而是通过协作式检查机制实现。本章深入剖析Kotlin标准库中三个核心suspend函数——withContext、delay、yield——在ViewModelScope取消上下文中的行为差异、内部原理与最佳实践。

4.1 协作式取消的核心契约

Kotlin协程的取消遵循协作式原则:取消信号仅设置协程的isActive标志为false,并不会强制中止正在执行的代码。suspend函数需要主动检查该标志并抛出CancellationException才能完成取消。下表对比了三个常用函数的取消响应特性:

suspend函数取消响应方式是否立即抛出异常典型场景
delay在挂起期间定期检查取消标志是(挂起点即响应)定时任务、轮询
yield主动让出执行权并检查取消标志是(调用点立即响应)CPU密集型计算中的公平调度
withContext在切分前后各执行一次取消检查,并在内部挂起点委托检查是(切分边界 + 内部suspend点)切换调度器、封装阻塞调用

4.2 函数内部取消机制详解

4.2.1 delay —— 定时器中的自动取消

delay内部基于协程的Continuation实现一个挂起定时器。在挂起期间,底层DefaultTimer会定期检查协程的isActive状态。一旦协程被取消(isActive == false),定时器立即触发CancellationException并恢复协程的挂起状态。因此delay是最“响应”取消的suspend函数之一,几乎与取消信号同步。

// 示例:ViewModelScope中的delay取消
viewModelScope.launch {
    try {
        delay(10_000)  // 假设3秒后协程被取消
        // 不会执行到这里
    } catch (e: CancellationException) {
        // 拿到取消异常,可自行决定是否重新抛出(通常不吞)
        Log.d("CancelDemo", "delay cancelled at ${System.currentTimeMillis()}")
        throw e  // 必须重新抛出以保证协程正确结束
    }
}
// 外部取消触发
viewModelScope.cancel()

4.2.2 yield —— 显式让出并检查

yield是一个轻量级挂起函数,它的作用是将当前协程的剩余执行机会让给同一调度器上的其他协程,并在恢复前执行一次取消检查。它不依赖外部定时器,而是直接在调用点检查isActive。适用于需要频繁让出CPU但保持响应取消的长循环。

// 示例:使用yield使计算密集型任务可取消
viewModelScope.launch(Dispatchers.Default) {
    for (i in 0 until 100_000) {
        // 每100次迭代让出一次线程
        if (i % 100 == 0) yield()
        heavyComputation(i)
    }
}
性能提示:频繁调用yield会增加上下文切换开销。建议在循环体外部根据迭代次数或时间间隔调用,而非每次迭代都调用。

4.2.3 withContext —— 调度器切换时的双重检查

withContext在进入新上下文之前会执行一次取消检查,并在离开时(恢复原始上下文)再次检查。此外,如果withContext内部调用了其他suspend函数(如delay或yield),这些函数也会执行各自的取消检查。因此withContext在响应取消方面非常可靠,但需要注意其内部的非挂起阻塞代码(如Thread.sleep)不会自动响应取消。

// 错误示例:withContext内部使用Thread.sleep会堵塞取消
viewModelScope.launch {
    withContext(Dispatchers.IO) {
        Thread.sleep(10_000)  // 即使协程被取消,此调用仍会阻塞线程10秒
        doNetworkCall()
    }
}

// 正确做法:如果必须使用线程阻塞,替换为delay或suspendable包装
viewModelScope.launch {
    withContext(Dispatchers.IO) {
        // 使用Kotlin的delay代替Thread.sleep
        delay(10_000)
        doNetworkCall()
    }
}

4.3 取消响应流程(SVG框架图)

下图展示了ViewModelScope协程从发起取消到suspend函数最终响应的完整流程:

suspend函数取消响应流程 取消信号(cancel()) isActive = false 标志设置 suspend函数挂起点检查 isActive delay(定时器轮询)| yield(显式让出)| withContext(切换边界) delay 取消 → 异常 yield 取消 → 异常 withContext 取消 → 异常 CancellationException 抛出 所有响应最终都通过抛出CancellationException完成取消传播,未响应取消的代码将继续执行直到下一个挂起点

4.4 调试与验证方法

在ViewModelScope场景下,可以使用以下技术验证suspend函数是否正确响应取消:

  • 日志标记:在关键suspend函数前后打印时间戳和isActive状态。
  • 断点检查:在withContext的lambda内部设置断点,观察取消后是否断住。
  • Thread dump:使用jstack或Android Studio的“Dump Threads”查看协程状态(挂起/阻塞)。
// 调试辅助:打印当前协程状态
viewModelScope.launch {
    Log.d("CancelCheck", "启动 isActive=${coroutineContext.isActive}")
    withContext(Dispatchers.Default) {
        Log.d("CancelCheck", "进入IO isActive=${coroutineContext.isActive}")
        // ... 业务代码
    }
    Log.d("CancelCheck", "结束 isActive=${coroutineContext.isActive}")
}

4.5 常见错误与避免

错误1:吞掉CancellationException
在catch块中捕获CancellationException但不重新抛出,会导致协程“假死”:
// 错误写法
try { delay(1000) } catch (e: CancellationException) { /* 忽略 */ }

// 正确写法
try { delay(1000) } catch (e: CancellationException) { throw e }
错误2:在withContext内部使用Thread.sleep
即使外部已将协程取消,Thread.sleep仍会阻塞线程,导致延迟取消。应替换为delay或使用suspendCancellableCoroutine包装。
错误3:在非挂起函数中调用yield
yield是挂起函数,只能在挂起函数或协程内部调用。在普通回调或runBlocking外部调用会导致编译错误。

4.6 性能与稳定性优化建议

优化目标方案说明
降低取消延迟在CPU密集型循环中每N次迭代调用yield建议N∈[50,500],避免过多上下文切换
避免不必要的取消检查在withContext(Dispatchers.Main)切换后使用ensureActive()显式检查适用于切换后需要立即检查的业务场景
减少挂起点开销将多个连续delay合并为一个较长的delay减少定时器注册/撤销次数
提升稳定性在ViewModelScope中使用viewModelScope.launch(SupervisorJob())隔离取消避免单个协程取消影响同scope内其他协程

4.7 综合示例:响应式取消实战

下面代码演示在ViewModelScope中结合withContext、delay、yield的典型场景,并验证取消响应:

class CancelViewModel : ViewModel() {
    fun startTask() {
        viewModelScope.launch {
            try {
                // 阶段1:使用delay等待初始条件
                delay(2000)

                // 阶段2:切换到IO线程进行网络请求(withContext内部响应取消)
                val result = withContext(Dispatchers.IO) {
                    // 如果被取消,withContext的边界检查会抛出异常
                    Thread.sleep(500) // 此处不是挂起函数,但withContext仍会在前后检查
                    "data"
                }

                // 阶段3:处理大量结果,每100条让出一次
                for (i in 0..1000) {
                    if (i % 100 == 0) yield()
                    processResult(result, i)
                }
            } catch (e: CancellationException) {
                // 记录取消日志并重新抛出
                Log.w("CancelDemo", "Task cancelled", e)
                throw e
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        // viewModelScope.cancel() 会自动调用,但可以额外清理
    }
}

4.8 本章小结

  • delay和yield是最佳取消响应函数,适合定时和计算密集任务。
  • withContext提供双重边界检查,但内部非挂起阻塞代码仍需手动处理。
  • 始终在catch中重新抛出CancellationException以保持取消传播。
  • 使用yield时要平衡响应速度与性能。