Kotlin coroutine 一勺烩

615 阅读8分钟

Kotlin提供了协程来更加方便的实现异步代码。主要的部分就是suspend方法以及配套的丰富的API和库。本文尽可能的用简单的语言来解释协程的基础概念。

什么是协程

Kotlin团队把它定义为“轻量级的线程”,它们是实际的线程可以执行的某种任务。

线程可以在某个“挂起点”停止执行协程,而去处理其他的任务。然后可以在之后继续执行这个协程,也有可能是在另外一个线程上执行

所以,一个协程不是一个任务,而是一系列的“子任务”。这些“子任务”会按照特定的顺序执行。即使代码看起来是在一个顺序的代码块里的,协程里对“挂起函数”的调用时间也是顺序执行的。这就需要我们一探挂起函数的究竟了。

看这段代码:

suspend fun printSomething(something: String) {
    println(something ?: "something launched")
}

Button(
    modifier = Modifier.padding(start = 20.dp),
    onClick = {
    println("Start")

    scope.launch {
        launch {
            printSomething("launched 1")
        }

        println("launched 2")

        launch {
            printSomething("launched 3")
            printSomething("launched 4")
        }
    }

    println("End")
}) {
    Text(text = "Try Coroutine", color = Color.White)
}

挂起函数printSomething只干了一件事,打印输入的文字。还有就是在coroutine里跑一个挂起函数,也可以不是,如图:

image.png

在图里,suspend关键字是灰色的,也就是可有可无。

重点在打印出来的文字:

image.png

这里我们只需要注意launched 3launched 4它们是在一个launch里执行的,顺序也是调用的顺序

挂起函数

在kotlinx的delay或者是Ktor的HttpClient.post函数的定义都带有关键字suspend

suspend fun delay(timeMillis: Long) {...}
suspend fun someNetworkCallReturningValue(): SomeType {
 ...
}

这些函数就叫做挂起函数。挂起函数可以挂起当前协程的执行而不会阻塞所在的线程

也就是说挂起函数可能在某个点停止了执行,而在之后的某个时间点又继续执行。然而这里没有说到当前线程会干什么。

挂起函数都是顺序的

挂起函数并没有什么特别的返回类型。除了多了一个suspend关键字并没有其他特别的地方。也不需要类似于Java的Future或者JavaScript的Promise之类的包装器。这也就更加确定了挂起函数本身并不是异步的(至少从调用者的角度看是这样),也不像JavaScript的async方法需要返回一个promise。

在挂起函数里调用其他的挂起函数和平常的函数调用没什么区别:被调用的函数执行完之后才会继续执行剩下的代码。

suspend fun someNetworkCallReturningSomething(): Something {
    // some networking operations making use of the suspending mechanism
}

suspend fun someBusyFunction(): Unit {
    delay(1000L)
    println("Printed after 1 second")
    val something: Something = someNetworkCallReturningSomething()
    println("Received $something from network")
}

如此一来复杂的异步代码写起来也就相当容易了。

挂起和非挂起怎么连接到一起

直接在非挂起函数里调用挂起函数是无法编译的。这是因为只有协程里才可以调用挂起函数,所以我们要新建一个协程先。这就需要用到协程构造器:

协程构造器

协程构造器就是新建了一个挂起函数,然后调用其他的挂起函数。他们可以在非挂起函数内被调用,是因为他们本身不是挂起函数,也就可以扮演一个普通函数和挂起函数的桥梁。

Kotlin提供了很多种不同的协程构造器,我们来认识几种:

阻塞当前线程的runBlocking

这是最简单的协程构造器了。它会阻塞当前线程一直等到里面的挂起函数都执行完毕:

fun main() { 
    println("Hello,")
    
    // we create a coroutine running the provided suspending lambda
    // and block the main thread while waiting for the coroutine to finish its execution
    runBlocking {
        // now we are inside a coroutine
        delay(2000L) // suspends the current coroutine for 2 seconds
    }
    
    // will be executed after 2 seconds
    println("World!")
}

可以看到runBlocking的定义,需要传入的最后一个参数是一个挂起函数,但是它本身不是(阻塞线程):

fun <T> runBlocking(
   ..., 
   block: suspend CoroutineScope.() -> T
): T {
  ...
}

runBlocking经常用在讲解协程时候的hello world例子里阻塞main方法显示挂起函数执行的结果。

“launch”发射后不管

一般协程是不阻塞所在的线程的,而是开始一个异步任务。协程构造器launch就是用来在后台开始一个异步任务的。比如:

fun main() { 
    GlobalScope.launch { // launch new coroutine in background and continue
        delay(1000L)
        println("World!")
    }
    println("Hello,") // main thread continues here immediately
    runBlocking {     // but this expression blocks the main thread
        delay(2000L)  // ... while we delay for 2 seconds to keep JVM alive
    } 
}

这个会打印出“Hello”, 随后打印出“World”。

GlobalScope不用急,后面会详细的讲到。

本例中为了可以看到输出的结果所以在最后还是阻塞的了线程。

使用“async”获得异步任务的结果

这是另外一个协程构造器async。这个构造器可以得到异步任务执行的返回值。

fun main() {
    val deferredResult: Deferred<String> = GlobalScope.async {
        delay(1000L)
        "World!"
    }
    
    runBlocking {
        println("Hello, ${deferredResult.await()}")
    }
}

async构造器会返回一个Deferred类型的对象,这和Future或者Promise类似。之后通过await调用可以得到异步任务的返回结果。

await不是一个简单的阻塞方法,它是一个挂起函数。也就是说这个不能直接在mian方法里调用await,所以上例中是在runBlocking里调用的。

这里再次出现了GlobalScope。协程的scope是用来创建结构化的并发的。

但是上面的只能是作为一个例子使用的,反复强调的就是runBlocking一般是不用的。各位还记不记得一段非常有名的代码,24小时后发出一个alert:

Thread.sleep(/* 24小时 */)

Android开发的面试官如果遇到这样的回答,面试的人可以直接回去等消息了🐶。一般不用runBlocking

所以,实际上调用挂起函数是如何的,这里通过一个Button的点击事件来演示:

Button(
    modifier = Modifier.padding(start = 20.dp),
    onClick = {
        println("Start")

        scope.launch {
            val firstUserDeferred: Deferred<UserInfo> = async {
                fetchUser(1)
            }

            val secondUserDeferred: Deferred<UserInfo> = async {
                fetchUser(2)
            }

            val firstUser = firstUserDeferred.await()
            val secondUser = secondUserDeferred.await()

            println("Fetch user completed: first: ${firstUser.userId}, second: ${secondUser.userId}")
        }

        println("End")
    }) {
    Text(text = "Coroutine Async", color = Color.White)
}

delay用来模拟网络请求的延迟。最后打印出用户的userId

运行结果:

image.png

让多个async请求

用上面的例子来说,如果需要两个用户的数据就两个请求,那么如果要一个数组很多个用户的数据要怎么发这个请求呢。同时还要保证一点,这些个用户的请求必须得是并行的。

要实现这个目的可以像上面的例子一样写,请求一个用户对应一个async:

val firstUserDeferred: Deferred<UserInfo> = async {
    fetchUser(1)
}

val secondUserDeferred: Deferred<UserInfo> = async {
    fetchUser(2)
}

val firstUser = firstUserDeferred.await()
val secondUser = secondUserDeferred.await()

还可以这样写:

val (firstUser, secondUser) = listOf(1,2).map {
    async(Dispatchers.IO) {
        fetchUser(it)
    }
}.awaitAll()

把需要获取的用户id直接按照一个数组处理,map到async协程上。最后使用awaitAll获得结果值数组。本例中是按照tuple处理的。

结构化并发

从上面的例子里,你会发现他们有个共同点:阻塞并等待协程执行完成。

kotlin可以席间结构化的协程,这样父协程可以管理子协程的生命周期。他可以等待所有的子协程完成,或者一个子协程发生异常的时候取消所有的子协程。

创建结构化的协程

除了runBlocking,一般不在协程里调用,所有的协程构造器都定义在CoroutineScope的扩展里面:

fun <T> runBlocking(...): T {...}
fun <T> CoroutineScope.async(...): Deferred<T> {...}
fun <T> CoroutineScope.launch(...): Job {...}
fun <E> CoroutineScope.produce(...): ReceiveChannel<E> {...}
...

要新建一个协程,你要么用GlobalScope(新建一个顶层协程),要么用一个已经存在的协程scope的扩展方法。有一个可以说是某种约定,最好是写一个CoroutineScope的扩展方法来新建协程。

async的定义如下:

fun <T> CoroutineScope.async(
    ...
    block: suspend CoroutineScope.() -> T
): Deferred<T> {
    ...
}

看上面的代码,async传入的参数也是一个CoroutineScope的扩展。也就是说你可以在里面调用协程构造器而不用指定调用对象。

上面的例子的可以这样修改:

fun main() = runBlocking {
    val deferredResult = async {
        delay(1000L)
        "World!"
    }
    println("Hello, ${deferredResult.await()}")
}
fun main() = runBlocking { 
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}
fun main() = runBlocking {
    delay(1000L)
    println("Hello, World!")
}

我们不再需要GlobalScope了,因为runBlocking已经提供了一个scope了。

coroutineScope构造器

上面说过runBlocking不鼓励使用。因为Kotlin的协程的初衷就是不阻塞线程。不过runBlocking就是一个coroutineScope构造器。

coroutineScope会挂起了当前的协程,一直到所有的子协程都执行完毕。例如:

fun main() = runBlocking { // this: CoroutineScope
    launch { 
        delay(200L)
        println("Task from runBlocking")
    }
    
    coroutineScope { // Creates a new coroutine scope
        launch {
            delay(500L) 
            println("Task from nested launch")
        }
    
        delay(100L)
        println("Task from coroutine scope") // This line will be printed before nested launch
    }
    
    println("Coroutine scope is over") // This line is not printed until nested launch completes
}

调度器

前面代码中的协程构造器在调用的时候其实都包含了一个可选参数,那就是CoroutineContextCoroutineContext就包含了Dispatchers。默认的调度器就是Dispatchers.Unconfined

Dispatchers主要是用来决定协程在哪个线程或者哪一批线程上执行。Dispatchers主要包括:

  • Default
  • Unconfined
  • Main
  • IO

Dispatchers.Default

用来处理CPU密集型任务

比如:

  • 矩阵计算
  • 对内存的数据较多的数组之类的进行排序,过滤或者查找
  • 对内存里的bitmap做处理,比如加滤镜等
  • 在内存里处理JSON,不是从文件读取这种事

所以现在知道在哪里可以使用Dispatchers.Default了。这个和RxJava的Schedulers.computation()非常相似。

launch(Dispatchers.Default) {
    // 处理CPU密集任务 
}

Dispatchers.IO

这个是专门用来处理磁盘、网络操作的。

比如:

  • 网络请求
  • 下载文件
  • 磁盘移动文件、读写文件
  • 数据库查询
  • 记载SharedPreferences数据

如:

launch(Dispatchers.IO) {
    // 处理CPU密集任务 
}

Dispatchers.Main

在android开发中,我们可以使用Dispatchers.Main来让协程在主线程中运行。只有在主线程里才可以更新UI。

比如:

  • 更新UI
  • 对一些数据量不大的数据结合做排序、过滤或者查找等
launch(Dispatchers.Main) {
    // 处理CPU密集任务 
}

Dispatchers.Unconfined

我们来先看看官方文档的说法:如果一个协程没有指定任何的线程。它会在调用它的当前帧上执行,并在任何挂起函数被唤起的线程继续执行,并且不特别指定线程策略

在Android开发中遇到使用这个调度器的情况真不多见。

异常处理

默认情况下,launchawait对于异常的处理是不同的。launch会让异常向上冒泡传递,async会给用户一个捕捉异常的机会。

比如:

val job = scope.launch {
    throw IndexOutOfBoundsException()
}

这种情况,app直接crash。

val deferred = scope.async {
    println("Throw exception in async")
    throw ArithmeticException()
}

这段只要不调用await是不会crash的。在调用await的时候可以使用try-catch处理异常。就是这样:

val deferred = scope.async {
    println("Throw exception in async")
    throw ArithmeticException()
}

try {
    deferred.await()
} catch (e: ArithmeticException) {
    println("Caught ArithmeticException")
}

在调用await的时候使用try-catch可以处理异常。

CoroutineExceptionHandler

所以,在处理异常的时候,对于launch来说可以在内部把异常吞掉,不让launch捕获异常。或者,就可以使用ExceptionHandler来处理。换句话说这个机制是专门给launch启动的协程使用的。在async启动的协程里这个handler不会被用到:

val handler =
    CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }
GlobalScope.launch(handler) {

    val job = scope.launch(handler) {
        throw IndexOutOfBoundsException()
    }
    job.join()

    println("Joined failed job")

    val deferred = scope.async(handler) {
        println("Throw exception in async")
        throw ArithmeticException()
    }

    try {
        deferred.await()
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}
  1. GlobalScope里使用handler没有效果,异常还是会冒泡并最终导致app崩掉
  2. 只有launch启动的协程会打印出CoroutineExceptionHandler caught java.lang.IndexOutOfBoundsExceptionasync启动的协程并不会打印handler的异常内容。

还有一种情况,如果同时有多个协程在运行,如:

suspend fun getUsers() {
    delay(200L)
    println("fetching users")
}

suspend fun getTodoList() {
    throw NetworkErrorException()
}

try {
    val usersDeferred = async { getUsers() }
    val todoListDeferred = async { getTodoList() }

    val users = usersDeferred.await()
    val todoList = todoListDeferred.await()
} catch (e: Exception) {
    println("Get users & todo list caught exception: $e")
}

getUsersgetTodoList是两个挂起函数。

如果他们两个之一发生异常会发生什么呢?app会直接crash,虽然异常会被catch到,会打印异常语句。

image.png

这个时候就需要coroutineScope出场了。

try {
    coroutineScope {
        val usersDeferred = async { getUsers() }
        val todoListDeferred = async { getTodoList() }

        val users = usersDeferred.await()
        val todoList = todoListDeferred.await()
    }
} catch (e: Exception) {
    println("Get users & todo list caught exception: $e")
}

这次发生异常之后会被catch到,并且app不会崩溃。

但是使用了coroutineScope还是有问题。如果一个请求出现问题,可以返回空值,并且可以接续执行其他的协程如何处理呢。这就需要supervisorScope出场了:

supervisorScope {
    val usersDeferred = async { getUsers() }
    val todoListDeferred = async { getTodoList() }

    val users = try {
        usersDeferred.await()
    } catch (e: Exception) {
        println("get users error")
        null
    }

    val todoList = try {
        todoListDeferred.await()
    } catch (e: Exception) {
        println("get todo list error")
        emptyList<Int>()
    }
}

同时每个await都加一个try-catch语句块。这样就可以 保证在任意一个网络请求出问题的时候单独处理。

coroutineScopesupervisorScope最大的不同是coroutineScope在子协程出问题的时候会中断执行。supervisorScope在一个子协程出问题的时候不会取消其他的。

协程的取消

协程的取消是合作式的。千言万语汇成一句话就是:你让它取消,它会收到一个取消的信号。但是实际取消还是不取消取决于你。kotlinx.coroutines里的函数都是可以取消的,他们会在取消的时候抛出CancellationException异常。

看这段代码:

Button(onClick = {
    val handler =
        CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }

    scope.launch {
        val job = launch {
            repeat(1_000) {
                println("Coroutine running... $it")
                delay(500L)
            }
        }

        delay(1200L)

        println("MAIN: I'm no waiting")

        job.cancelAndJoin()

        println("main: quited")
    }
}) {
    Text("Cancel coroutine", color = Color.White)
}

运行结果:

image.png

如果把while循环里的delay删掉呢,运行结果:

image.png

上下两个运行结果分析,如果没有delay的话想要取消协程是取消不掉的。dalay就是kotlinx.coroutine库里的函数,所以协程才可以取消。

那么,如果自己开发的协程里就不用delay这样的函数就没法取消了么?还是可以的,有两个办法:

  1. 检查isActive
  2. 调用kotlinx.coroutine库的函数,比如:delay或者yield

使用isActive

直接看官方代码:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (isActive) { // 取消循环执行
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

在循环里不断检查isActive的值,如果被取消则停掉循环退出协程。运行结果:

image.png

也可以使用另外一个函数来实现这个功能:ensureActive()

while (true) { // 取消循环执行
    ensureActive()
    // print a message twice a second
    if (System.currentTimeMillis() >= nextPrintTime) {
        println("job: I'm sleeping ${i++} ...")
        nextPrintTime += 500L
    }
}

image.png

看下ensureActive()的源码:

/**
 * Ensures that current job is [active][Job.isActive].
 * If the job is no longer active, throws [CancellationException].
 * If the job was cancelled, thrown exception contains the original cancellation cause.
 *
 * This method is a drop-in replacement for the following code, but with more precise exception:
 * ```
 * if (!job.isActive) {
 *     throw CancellationException()
 * }
 * ```
 */
public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

使用yield

主要是用来让同一个调度器(dispatcher)下的其他协程先执行。对于本协程来说,调用它会挂起当前的执行,然后立刻恢复。可以用来处理重CPU、或者可能耗尽线程池的任务。

suspend fun doHeavyWork() {
    withContext(Dispatchers.Default) {
        repeat(1000) {
            yield()
            // do heavy work
        }
    }
}

当然在这里提到就是说它可以用来处理协程的取消:

Button(onClick = {
    val handler =
        CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }

    scope.launch {
        val startTime = System.currentTimeMillis()
        val job = launch(Dispatchers.Default) {
            var nextPrintTime = startTime
            var i = 0
            while (true) {
                yield() //* 使用yield取消协程
                // print a message twice a second
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("job: I'm sleeping ${i++} ...")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1300L) // delay a bit
        println("main: I'm tired of waiting!")
        job.cancelAndJoin() // cancels the job and waits for its completion
        println("main: Now I can quit.")

    }
}) {
    Text("Cancel coroutine", color = Color.White)
}

Android开发相关

在Android开发使用协程的时候,会用到专门定制的scope和挂起函数

To be continued。。。