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调度器时会抛出异常:
kotlinimplementation("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的顶部注释非常详细记录了正确的使用方法,可以在那学习。