深入理解 Kotlin 协程 (八):拾遗补阙,探秘官方框架的调度细节与取消闭环

Kotlin 协程的官方框架 kotlinx.coroutines 主要由以下几个部分构成:

  • core: 框架的核心逻辑,包括了复合协程以及 Channel、Flow 等特性。

  • ui: 包括 android、javafx、swing 库,用于提供各个平台的 UI 调度器以及特有的逻辑。

  • reactive 相关: 提供了对各种响应式编程框架的协程支持,如 rx2 提供了对 RxJava 2.x 的协程支持。

  • integration 相关: 提供了其他框架中异步回调的集成,如 jdk8 集成中新增了 CompletableFuture 协程 API。

  • test: 提供了测试模块,用于控制协程的虚拟时间、测试挂起函数等,对于编写单元测试来说必不可少。

接着我们来看看在之前未涉及到的官方细节。

协程的启动模式(CoroutineStart)

在官方的协程构建器中,还会传入一个启动模式(start: CoroutineStart):

kotlin 复制代码
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT, // 启动模式
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

启动模式有四种:

  • DEFAULT: 创建协程后,立即开始调度,如果在调度前协程被取消,将进入取消响应状态。

  • ATOMIC: 也是创建后立即调度,不过在执行到第一个挂起点之前不响应取消。

  • LAZY : 只有协程被需要时,才会开始调度,需要 包括了主动调用 start、join 或者 await 函数。如果调度前被取消,协程将进入异常结束状态。

  • UNDISPATCHED: 协程创建后会立即在当前函数调用栈中执行,直到遇到第一个真正挂起点。

注意:这里的立即调度不等于立即执行,立即调度表示调度器会立即执行调度,但协程的具体执行时机,由调度器决定。这就好像老妈安排你去扫地,任务已经下发了,但什么时候扫,由你决定。

其中 UNDISPATCHED 和 ATOMIC,协程都一定会执行,但有一点区别:到第一个挂起点之前,前者少了一次线程调度,直接在创建协程所在的调用者线程上立即执行;后者则依然会被调度器分发到指定线程上执行,只是在遇到第一个挂起点之前对取消状态"免疫"。

总之,这些启动模式更多是为了应对特殊场景,在业务开发中,通常使用 DEFAULT 和 LAZY 就够了。

虽然我们之前实现的协程没有启动模式,但效果等同于 ATOMIC 模式。

官方提供的调度器(Dispatchers)

官方框架中提前准备好了四个调度器,我们可以通过 Dispatchers 单例来访问:

  • Default: 默认的调度器,适合后台计算任务。

  • IO: IO 调度器,适合执行 IO 操作

  • Main: UI 调度器,平台不同,UI 线程的调度器也不同。在 Android 上,会将协程调度到 UI 事件循环(主线程)中执行。

注意:除了引入核心依赖外,还要引入 Android 平台的依赖,否则在使用 Dispatchers.Main 调度器时会抛出异常:

kotlin 复制代码
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") // 核心库
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") // Android 平台依赖
  • Unconfined: 不受限制的调度器,也就是说协程执行在哪个线程上无所谓。本质上来说,这个相当于调度器为空,在哪恢复,就在恢复的线程上执行。但嵌套 Unconfined 协程的话,会进行特殊处理:放到协程框架内部的事件循环上,防止栈溢出。

举一个例子来说明 Unconfined 调度器:

kotlin 复制代码
runBlocking {
    // runBlocking 在主线程中执行
    launch(Dispatchers.Unconfined) {
        println(Thread.currentThread().name) // 启动时,协程处于主线程
        
        delay(100) // 挂起
        
        // 等外部恢复执行时,协程会在唤醒当前协程的线程上执行(不是主线程)
        println(Thread.currentThread().name) 
    }
}

接着我们来说说使用场景,如果涉及到了 UI 操作,就必须要使用 Main;如果只是普通的后台任务,应该使用 Default;如果涉及到了 IO 操作,比如网络或是文件,就使用 IO。

其实 Default 和 IO 调度器的背后都是线程池,只是配置不同。

更多关于 Default 和 IO 的区别和使用场景,可以看这篇博客:Kotlin 协程中的 IO 与 Default 调度器详解 | Baeldung中文网

如果需要自定义调度器,可以通过继承 CoroutineDispatcher 抽象类来完成。但我们更多的是将一个已经存在的线程池转换成调度器:

kotlin 复制代码
Executors
    .newSingleThreadExecutor()
    .asCoroutineDispatcher()
    .use { dispatcher ->
        val result = GlobalScope.async(dispatcher) {
            delay(timeMillis = 100)
            123456
        }.await()
    } 

注意这个转换而来的调度器必须要主动关闭,以免造成线程泄漏。

另外,使用 withContext 也可以很方便地切换调度器,作用等价于 async{ ... }.await(),如果调用 async 后立即调用了 await,可以使用 withContext 替代,因其内存开销更低。

协程的取消响应点

我们知道自定义的挂起函数可以通过 suspendCancellableCoroutine 来响应取消,就像这样:

kotlin 复制代码
// 异步定时任务
suspend fun delayWithCallback(delayMs: Long): String = suspendCancellableCoroutine { cont ->
    // 创建定时器
    val timer = Timer()
    timer.schedule(object : TimerTask() {
        override fun run() {
            // 回调执行,先判断协程是否已取消
            if (cont.isActive) {
                cont.resume("定时 $delayMs ms 执行完成") { cause, value, context -> }
            }
            timer.cancel()
        }
    }, delayMs)

    // 监听协程取消
    cont.invokeOnCancellation {
        // 销毁定时器
        println("协程取消,关闭定时器")
        timer.cancel()
    }
}

另外,自带的挂起函数(如 delay、withContext 等)也会响应取消,所以协程通常默认只会在这些挂起点检查取消并退出。

如果没有任何的挂起点,例如一个文件复制的函数:

这是标准库提供的扩展函数。

kotlin 复制代码
public fun InputStream.copyTo(out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE): Long {
    var bytesCopied: Long = 0
    val buffer = ByteArray(bufferSize)
    var bytes = read(buffer)
    while (bytes >= 0) {
        out.write(buffer, 0, bytes)
        bytesCopied += bytes
        bytes = read(buffer)
    }
    return bytesCopied
}

我们可以使用 suspendCancellableCoroutine 来包装这段逻辑,也可以手动轮询判断 isActive 标志:

kotlin 复制代码
// 可取消的同步流复制函数
@OptIn(InternalCoroutinesApi::class)
suspend fun InputStream.copyToSuspend(
    out: OutputStream,
    bufferSize: Int = DEFAULT_BUFFER_SIZE
): Long {
    var bytesCopied: Long = 0
    val buffer = ByteArray(bufferSize)
    var bytes = read(buffer)
    val job = currentCoroutineContext()[Job]
    while (bytes >= 0) {
        // 检查协程是否取消
        job?.let {
            if (!it.isActive) {
                throw job.getCancellationException() // 内部 API
            }
        }
        out.write(buffer, 0, bytes)
        bytesCopied += bytes
        bytes = read(buffer)
    }
    return bytesCopied
}

如果 Job 为空,说明当前所在的是一个简单协程,没有实现取消逻辑。

这一段判断逻辑可以使用 ensureActive 扩展函数代替:

kotlin 复制代码
public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

如果为了不影响内部逻辑,可以使用 yield 挂起函数,它内部就有取消响应点,而且就在函数开头。

kotlin 复制代码
val context = uCont.context
context.ensureActive()
...

此外,yield 还会尝试让出线程的执行权,让其他协程有执行的机会。

不过要注意性能差异,yield 的主要职责是让出线程,这会触发底层的上下文切换和重新排队,性能开销较大。如果我们只是为了在密集计算或循环中响应取消,使用 ensureActive() 才是最优解。

异步任务的超时控制

对于异步任务的超时取消,我们可以同步发射一个协程,到时间后取消异步任务。不过这样有些冗余,我们可以使用官方提供的一个可以设置超时的函数 withTimeout:

kotlin 复制代码
runBlocking {
    val time = withTimeout(timeMillis = 5000L) {
        val delayTime = (4000L..6000L).random()
        delay(timeMillis = delayTime)
        val formatter =
            DateTimeFormatter.ofPattern("HH:mm:ss")
        LocalDateTime.now().format(formatter)
    }
    println(time)
}

它在超时后,会取消 block 代码块的执行,并且抛出一个取消异常。如果我们不希望在超时的情况下抛出取消异常,可以使用 withTimeoutOrNull 函数,它在超时后会返回 null。

禁止取消(NonCancellable 上下文)

最后我们来看一下禁止取消。举个例子,我们希望使用 delay 模拟耗时任务,同时我们又不希望它能响应外部的取消,例如我们想要观察其他挂起函数的取消效果:

kotlin 复制代码
runBlocking {
    val job = launch {
        listOf(1, 2, 3, 4).forEach {
            yield()
            delay(timeMillis = it * 100L)
        }
    }
    delay(timeMillis = 200L)
    job.cancelAndJoin()
}

这时,可以使用 NonCancellable 上下文,它能够禁止作用范围内的取消响应。

kotlin 复制代码
GlobalScope.launch {
    val job = launch {
        listOf(1, 2, 3, 4).forEach {
            yield()
            withContext(NonCancellable) {
                delay(timeMillis = it * 100L)
            }
        }
    }
    delay(timeMillis = 200L)
    job.cancelAndJoin()
}

我们先来说说它的原理,其实它就是一个实现了 Job 接口的 Job 上下文,不过 Job 的一些能力都是废弃、阉割的,比如:

kotlin 复制代码
@Deprecated(level = DeprecationLevel.WARNING, message = message)
override val parent: Job?
    get() = null

@Deprecated(level = DeprecationLevel.WARNING, message = message)
override fun cancel(cause: CancellationException?) {}

@Deprecated(level = DeprecationLevel.HIDDEN, message = "Since 1.2.0, binary compatibility with versions <= 1.1.x")
override fun cancel(cause: Throwable?): Boolean = false // never handles exceptions

@Deprecated(level = DeprecationLevel.WARNING, message = message)
override val children: Sequence<Job>
    get() = emptySequence()

协程内部调用 ensureActive() 检查取消状态时,实际上是在检查 Job.isActive。由于在 withContext 得到的新的协程上下文中,获取到的 Job 是 NonCancellable 对象,它的 isActive 被写死为了永远返回 true,因此异常永远不会被抛出。

换言之 withContext 内部不知道当前协程取消了,故内部能够不响应取消。

kotlin 复制代码
public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
        val oldContext = uCont.context
        val newContext = oldContext.newCoroutineContext(context)
        newContext.ensureActive() // 内部获取(get(Job))到的 Job 对象是 NonCancellable
        // ...
    }
}

NonCancellable 需要与 withContext 配合使用,不应该 作为 launch 等协程构建器的上下文传入,因为这会破坏协程的结构化并发,协程不会随着父协程的取消而取消,父协程也不会等待子协程,更不会在子协程崩溃时被取消,NonCancellable 完全切断了协程的父子关系。

NonCancellable 的顶部注释非常详细记录了正确的使用方法,可以在那学习。

相关推荐
千里马学框架5 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台5 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone5 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc5 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo5 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077006 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work6 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼6 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work6 天前
ch21 签名、校验与发版
kotlin
AFinalStone6 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui