kotlin协程之官方框架

316 阅读6分钟

前面介绍了kotlin标准库提供的基础api,也通过基础api实现了一个生产者消费者的例子,可以看出,基础api对用户不是非常友好,要实现很多功能都得要自行封装,为了解决这个问题,kotlin提供了官方的协程框架kotlinx.coroutines

框架构成

要了解kotlinx.coroutines,必须先了解其构成,它的构成主要如下:

  • core :协程的核心逻辑,主要包含如下功能:

  • ui:包括android、javafx、swing三个库,用于提供各个平台的UI调度器

  • reactive:提供对各种响应式编程框架的支持,包括如下选项:

    • reactive:提供对Reactive Stream的协程支持
    • Flow (JDK 9):提供对jdk9 Flow的支持
    • 提供对RxJava 2.x 的支持
    • 提供对RxJava 3.x 的支持
  • integration 提供与其他框架的异步回调的集成:

    • jdk8:CompletionStage.await
    • Guava :ListenableFuture.await
    • slf4j:提供对MDCContext作为协程上下文的元素,以使协程中使用slf4j打印日志时能够读取对应的MDC中的键值对
    • play-services: 提供对GooglePlay服务中的Task的协程api支持

要在工程中使用官方框架,需要引入依赖到gradle中:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}

核心概念

协程启动

官方提供了CoroutineScope.launch函数启动协程,先看看它的定义:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job
​
public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

launch函数有三个参数:

  • context是协程上下文,默认使用EmptyCoroutineContext
  • start参数表示协程启动模式,默认使用CoroutineStart.DEFAULT,具体稍后介绍。
  • block是协程体,是一个suspend函数。

launch返回一个Job实例,Job接口中有start、cancel、join等方法,以实现协程的控制。

官方框架提供GlobalScope实现CoroutineScope接口,因此我们可以用GlobalScope创建协程:

GlobalScope.launch { 
    println("run in coroutine")
}

需要注意的是,GlobalScope.launch创建的是顶级协程,在实际使用协程时不应该直接利用这种方式创建协程。以android为例,如果我们使用GlobalScope.launch创建,协程,那么当Activity销毁时,极大的增加了协程销毁的工作,很容易导致泄露。

启动模式

我们可以设置协程的启动模式,一共有四种:

  • DEFAULT:协程创建后,立即开始调度,在调度前去过协程被取消,其将直接进入取消相应的状态
  • ATOMIC:协程创建后,立即开始调度,协程执行到第一个挂起点之前不响应取消。
  • LAZY:只有协程被需要时,包括主动调用协程的start、join或者await等函数的时候才会开始调度,如果调度前就被取消,那么将直接进入异常结束状态。
  • UNDISPATCHED:协程创建后立即在当前函数调用栈执行,直到遇到第一个真正的挂起点。

需要注意立即调度立即执行的区别,立即调度表示协程的调度器会立即接收调度指令,但是具体执行的时机以及在哪个线程上执行需要根据具体情况而定,也就是说立即调度到立即执行之间通常会有一段时间。

协程调度器

kotlin协程-基础设施一文中介绍了kotlin协程基础api的使用,其中启动协程都是通过resumestartCoroutine方法实现,并没有进行线程切换。因此官方框架预制了四个调度器,用于线程切换,可以通过Dispatchers对象访问它们:

  • Default:默认调度器,适合处理后台计算,其实是一个CPU密集型任务调度器。
  • IO:IO调度器,适合执行IO相关操作,起始是一个IO密集型任务调度器。
  • Main:UI调度器,根据平台不同会被初始化为对应的UI线程的调度器,例如在Android平台上它会将协程调度到UI事件循环中执行,即在主线程中运行。
  • Unconfined:“无所谓”调度器,不要求协程执行在特定线程上,协程的调度器如果是Unconfined,那么它在挂起点恢复执行时会在恢复所在的线程上直接执行,当然,如果嵌套创建以它为调度器的协程,那么这些协程这些协程会在启动时被调度到协程框架内部的事件循环上,避免出现StackOverflow。

在使用时,我们可以根据自身的需要进行需要选择合适的调度器,那具体该如何使用呢,那就要先看看调度器的定义了:

public actual object Dispatchers {
    @JvmStatic
    public actual val Default: CoroutineDispatcher = DefaultScheduler
​
    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
​
    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
​
    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultIoScheduler
}
​
public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
        ...
}

可以看出,这四种调度器都是CoroutineDispatcher类型变量,而CoroutineDispatcher实现了ContinuationInterceptor接口,从kotlin协程-基础设施一文中可以知道,ContinuationInterceptor就是拦截器,也是一种协程的上下文,所以说我们可以将协程调度器直接传递给launch函数的context参数即可。

GlobalScope.launch(Dispatchers.IO) {
    println("run in Dispatchers.IO ${Thread.currentThread().id}")
}

执行结果如下:

run in Dispatchers.IO DefaultDispatcher-worker-1: 14Process finished with exit code 0

当然我们也可以实现自己的CoroutineDispatcher,只需要继承它,并重写dispatch方法即可。

class MuDispatcher : CoroutineDispatcher(){
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        ...
    }
}

还可以将已有的线程池转换为调度器,不过这个调度器在使用完毕后需要主动关闭,避免造成线程泄露:

Executors.newCachedThreadPool()
    .asCoroutineDispatcher()
    .use {
        ...
    }

协程控制

delay

在不阻塞线程的情况下将协程挂起,并在指定时间后恢复执行,其定义如下:

suspend fun delay(timeMillis: Long)

观察以下demo:

GlobalScope.launch(Dispatchers.IO) {
    println(Date())
    delay(1000)
    println(Date())
}

以上demo将协程延时1秒后恢复执行,其输出如下:

Mon Sep 12 10:24:23 CST 2022
Mon Sep 12 10:24:24 CST 2022
​
Process finished with exit code 0

Job

上一节说到CoroutineScope.launch返回Job实例,此时Job与当前的协程关联起来了,所以可以通过Job对协程进行控制,下面介绍下它的主要接口:

  • cancel:取消当前Job,即取消协程,其定义如下:

    abstract fun cancel(cause: CancellationException? = null)
    
  • invokeOnCompletion:注册完成回调,当Job执行完成时,会调用CompleteHandler,其定义如下:

    abstract fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
    
  • join:将当前协程挂起,直到job执行完成

    abstract suspend fun join()
    
  • start:启动与当前Job关联的协程,如果协程已启动或者已经执行完成,返回false,否则返回true,其定义如下:

    abstract fun start(): Boolean
    

Job有如下状态:

StateisActiveisCompletedisCancelled
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse

通常,Job被创建后状态都为Active。但如果以CoroutineStart.LAZY模式启动协程时,它的初始状态为New,此时调用start或join方法会将状态置为Active

其状态转换图如下:

image.png

处于Active状态的Job依照其所关联的协程更新状态,如果协程正常执行完成,那么Job最后状态就会成为Completed,而如果协程执行失败了,则job状态则会编程Cancelled。

当Job执行过程中发生异常,则其状态立即转换为Canceling,等待其所有子协程都执行完成,状态会转换为Cancelled。

当协程体执行完成,其状态先转换为Completing,等待所有子协程执行完成,其状态转换为Completed。

作用域构建器

runBlocking

从前面的介绍可以知道lunch函数必须在CoroutineScope中才能调用,就是说只能在协程的作用域下面才能创建新的协程,然后日常开发中并不是所有地方都能拿到协程作用域的,考虑如下例子:

fun main() {
    GlobalScope.launch {
        println("1")
        delay(1000)
        println("2")
    }
​
    println("3")
}

在普通的main函数中直接创建一个协程,协程内部通过delay模拟耗时操作,本意是要等协程执行完成后再退出main函数的,但由于协程的异步性质,并不会等到协程执行完才退出main函数:

3
​
Process finished with exit code 0

可以看出只输出了3,而协程内部的日志都没有输出。为了等待协程执行完成,我们可以用join等待协程执行完成,但这样不得不把main函数编程suspend函数:

suspend fun main() {
    GlobalScope.launch {
        println("1")
        delay(1000)
        println("2")
    }.join()
​
    println("3")
}

输出如下:

1
2
3
​
Process finished with exit code 0

从输出可以看出确实达到了预期的效果,那么有没有不把main改为suspend函数就能实现的呢?

可以使用runBlocking函数,runBlocking函数会创建一个协程作用域,他可以保证在协程作用域内的所有代码和子协程没有全部执行完之前一直阻塞当前线程。需要注意的是,runBlocking函数通常尽在测试环境中使用,正式环境上使用容易产生一些性能上的问题。

下面用runBlocking函数重写上面的例子:

fun main() {
    runBlocking{
        println("1")
        delay(1000)
        println("2")
    }
​
    println("3")
}

输出如下:

1
2
3
​
Process finished with exit code 0

coroutineScope

如果我们想要在suspend函数中调用launch创建新的协程,既不能轻易通过GlobalScope.launch创建协程,也不能在正式环境中使用runBlocking,那还有其他方式创建协程作用域吗?当然有,那就是coroutineScopecoroutineScope是一个挂起函数,其与runBlocking类似,它可以保证作用域内的所有代码和子协程在全部执行完成之前,会一直阻塞当前协程:

suspend fun coroutineScopeTest() = coroutineScope {
    launch {
        println("coroutineScopeTest 1")
        delay(1000)
        println("coroutineScopeTest 2")
    }
    println("coroutineScopeTest 3")
}

其输出结果如下:

coroutineScopeTest 3
coroutineScopeTest 1
coroutineScopeTest 2
​
Process finished with exit code 0

可以看出,确实等到子协程执行完成才退出。

async

在android中,UI线程不能做耗时操作,否则会造成卡顿问题。现在考虑这样的情形,假设一个协程运行的UI线程上,这时候想要发起网络请求,此时就需要切换到子线程中执行。如果直接使用launch创建运行与子线程的协程,由于无法直接获取执行结果,就必须要通过回调方式获取结果。众所周知,一旦用上了回调,那必然无法避免Callback hell问题。这种情况可以使用async来实现。

async函数必须在协程作用域当中才能调用,它会创建一个新的子协程并返回一个Deferred对象,如果我们想要获取async函数代码块的执行结果,只需要调用Deferred对象的await()方法即可。当调用await方法时,如果代码块还没执行完,那么await()方法会将当前协程阻塞住,知道async内部代码执行完成

下面是它的函数定义:

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

可以看出,与launch的参数基本一致。下面来看个demo:

suspend fun asyncTest(): Int = coroutineScope {
    val def = async {
        delay(1000)
        5 + 5
    }
    val result = def.await()
​
    println("async result $result")
​
    return@coroutineScope result
}

运行结果为:

async result 10
​
Process finished with exit code 0

withContext

withContext是一个挂起函数,调用withContext之后,会立即执行代码块中的代码,并将当前协程阻塞住,其定义如下:

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

它接收context及代码块所谓参数,context可以使用默认提供的Dispatchers的调度器切换线程。

考虑上一节的例子,在UI线程中发起网络请求需要切换到子线程中:

suspend fun withContextTest()  {
    println("mock UI thread")
    val result = withContext(Dispatchers.IO) {
        println("mock req")
        delay(1000)
        5 + 5
    }
    println("mock UI thread result $result")
}

输出结果为:

mock UI thread
mock req
mock UI thread result 10Process finished with exit code 0

可以认为withContext是async的一种简化版本,但它要求必须提供一个context对象。它的主要目的就是切换协程上下文,并阻塞当前协程直到内部代码块完成。而async功能更加丰富,可以由调用者决定何时阻塞,并且可以主动取消等等,使用async可以实现多个并发任务的调度工作。

协程取消

调用job.cancel()可以取消协程,但需要协程内部支持取消,下面介绍一下。

suspenCalcellableCoroutine

kotlin协程-基础设施一文中我们知道suspend函数通过suspendCoroutine函数实现,但是suspendCoroutine不能响应取消状态,而suspenCalcellableCoroutine挂起时如果协程已被取消或者已经完成,会抛出CancellationException。如果suspenCalcellableCoroutine`已挂起,然后其关联的协程被取消了,此时该挂起函数不能正常恢复。

总之,可以使用此函数在挂起点响应取消事件。

取消检查

如果协程中没有挂起点,那么也就无法响应取消了,比如:

suspend fun readData() {
    val file = File("testData")
    println(file.absolutePath)
​
    file.bufferedReader().use {
        var content = ""
​
        var read = it.readLine()
        while (read != null) {
            content += read
            content += "\n";
            read = it.readLine()
        }
​
        println("read data $content")
    }
}

此函数连续去读文件,但是对coroutine的cancel毫无响应,为了添加对cancel的响应,我们可以使用yield函数:

suspend fun readData() {
    val file = File("testData")
    println(file.absolutePath)
​
    file.bufferedReader().use {
        var content = ""
​
        var read = it.readLine()
        while (read != null) {
            yield()
            content += read
            content += "\n";
            read = it.readLine()
        }
​
        println("read data $content")
    }
}

yield函数的作用主要是检查所在协程的状态,如果已经取消,则抛出异常语义响应。此外它还会尝试让出线程的执行权,给其他协程提供执行机会。

超时取消

一般情况下,发送网络请求都需要设置超时来应对网络异常的情况,一般情况下,网络请求框架都会提供统一的超时设置,但如果某个请求想要设置较短的超时就比较麻烦,kotlin协程提供了withTimeout函数设置超时:

suspend fun testTimeout() {
    GlobalScope.launch {
        withTimeout(1000) {
            mockTask()
        }
    }.join()
}
​
suspend fun mockTask() {
    println("task start")
    delay(5000)
    println("task end")
}

其输出如下:

task start
​
Process finished with exit code 0

可以看出,超时设置确实生效了。

禁止取消

考虑如下demo:

suspend fun yieldTest() = coroutineScope {
    val job = launch {
        for (i in 0 until 4) {
            println("coroutine 1 exec $i")
            yield()
            delay(1000)
        }
    }
​
    delay(400)
    job.cancelAndJoin()
}

在外层要是400毫秒后将协程取消了,但由于yield和delay都能响应取消事件,如果我们不希望delay响应取消事件,可以用NonCancellable上下文来执行,如下:

suspend fun yieldTest() = coroutineScope {
    val job = launch {
        for (i in 0 until 4) {
            println("coroutine 1 exec $i")
            yield()
            withContext(NonCancellable) {
                delay(1000)
            }
        }
    }
​
    delay(400)
    job.cancelAndJoin()
}

NonCancellable需要与withContext搭配使用,不应当作为launch这样的协程构造器的上下文。

参考

参考链接