深入理解 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 : 只有协程被需要时,才会开始调度,需要 包括了主动调用 startjoin 或者 await 函数。如果调度前被取消,协程将进入异常结束状态。

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

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

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

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

虽然我们之前实现的协程没有启动模式,但效果等同于 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

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

更多关于 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()
    }
}

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

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

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

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 的顶部注释非常详细记录了正确的使用方法,可以在那学习。

相关推荐
海天鹰3 小时前
PHP上传文件
android·开发语言·php
2501_915918414 小时前
详解iOS App上架至App Store的全流程步骤与注意事项
android·macos·ios·小程序·uni-app·cocoa·iphone
码农coding5 小时前
android12 SystemUI之StatusBar(二)
android
GitLqr5 小时前
别被“Flutter 传感器延迟 150ms”带偏了:这可能只是你的实现方式错了
flutter·架构·kotlin
Lesile7 小时前
Interview#1 历史演进:MVC · MVP · MVVM · MVI架构详解
android·android jetpack
杉氧7 小时前
Flutter 像素级还原实战:用 CustomPaint 与 Bezier 曲线手绘精致图针
android·前端·flutter
我命由我1234510 小时前
Android 在构建过程中,发现有两个依赖库都包含了相同路径的资源文件
android·java·开发语言·java-ee·kotlin·android-studio·android runtime
Coffeeee10 小时前
谷歌的一个优化建议,让我重新学了一遍Android里面如何正确处理位图
android·google·kotlin
冰暮流星11 小时前
mysql之新建表及对表的查询
android·数据库·mysql