(译)Suspension(挂起/暂停) 在Kotlin coroutines里面到底是如何工作的?

932 阅读7分钟

前言

挂起函数是Kotlin协程的标志。挂起功能也是其中最重要的功能,所有其他功能都建立在此基础上。这也是为什么在这篇文章中,我们目标是深入了解它的工作原理。

挂起一个协程(suspending a coroutine)意味着在其(代码块)执行过程中中断(挂起)它。和咱们停止玩电脑单机游戏很类似: 你保存并关闭了游戏,紧接着你和你的电脑又去干其他不同的事儿去了。然后,过了一段时间,你想继续玩游戏。所以你重新打开游戏,恢复之前保存的位置,继续从你之前玩的地方开始玩起了游戏。

上面所讲的场景是协程的一个形象比喻。他们(任务/一段代码)可以被中断(挂起去执行去他任务),当他们要回来(任务执行完成)的时候,他们通过返回一个Continuation(指定了我们恢复到的位置)。我们可以用它(Continuation)来继续我们的任务从之前我们中断的地方。

Resume(恢复)

那么我们来看一下它(Resume)的实际效果。首先,我们需要一个协程代码块。创建协程的最简单方式是直接写一个suspend函数,下面这段代码是我们的起始点:

suspend fun testCoroutine() {
    println("Before")

    println("After")
}
//依次输出
//Before
//After

上面代码很简单:会依次输出“Before”和“After”。这个时候如果我们在两行代码中间挂起的话会发生什么?为了到达挂起的效果,我们可以使用kotlin标准库提供的suspendCoroutine方法:

suspend fun testCoroutine() {
    println("Before")

    suspendCoroutine<Unit> {
        
    }

    println("After")
}

//依次输出
//Before

如果你调用上面的代码,你将不会看到”After“,而且这个代码将会一直运行下去(也就是说我们的testCoroutine方法不会结束)。这个协程在打印完”Before“后就被挂起了。我们的代码快被中断了,而且不会被恢复。所以?我们该怎么做呢?哪里有提到Continuation(可以主动恢复)吗?

再看一下suspendCoroutine的调用, 而且注意它是以一个lambda表达式结尾。这个方法在挂起前给我们传递了一个参数,它的类型是Continuation

uspend fun testCoroutine() {
    println("Before")

    suspendCoroutine<Unit> { continuation ->
        println("Before too")
    }

    println("After")
}

//依次输出
//Before
//Before too

上面的代码添加了: 在lambda表达式里面调用了另外一个方法, 好吧,这不是啥新鲜事儿。这个就和letapply等类似。suspendCoroutine方法需要这样子设计以便在协程挂起之前就拿到了continuation。如果suspendCoroutine执行了,那就晚了,所以lambda表达式将会在挂起前被调用。这样子设计的好处就是可以在某些时机可以恢复或者存储continuation。so 我们可以让continuation立即恢复

suspend fun testCoroutine() {
    println("Before")

    suspendCoroutine<Unit> { continuation ->
        continuation.resume(Unit)
    }

    println("After")
}

//依次输出
//Before
//After

我们也可以用它来开启一个新的线程,而且还延迟了一会儿才恢复它:

suspend fun testCoroutine() {
    println("Before")

    suspendCoroutine<Unit> { continuation ->
        thread {
            Thread.sleep(1000)
            continuation.resume(Unit)
        }
    }

    println("After")
}

//依次输出
//Before
//(1秒以后)
//After

这是一个重要的发现。注意,新启动一个线程的代码可以提到一个方法里面,而且恢复可以通过回调来触发。在这种情况下,continuation将被lambda表达式捕获:

fun invokeAfterSecond(operation: () -> Unit) {
    thread { 
        Thread.sleep(1000)
        operation.invoke()
    }
}

suspend fun testCoroutine() {
    println("Before")

    suspendCoroutine<Unit> { continuation ->
        invokeAfterSecond {
            continuation.resume(Unit)
        }
    }

    println("After")
}

//依次输出
//Before
//(1秒以后)
//After

这种机制是有效的,但是上面的代码我们没必要通过创建线程来做。线程是昂贵的,所以为啥子要浪费它们?一种更好的方式是设置一个闹钟。在JVM上面,我们可以使用ScheduledExecutorService。我们可以使用它来触发*continuation.resume(Unit)*在一定时间后:

private val executor = Executors.newSingleThreadScheduledExecutor {
    Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun testCoroutine() {
    println("Before")


    suspendCoroutine<Unit> { continuation ->
        executor.schedule({
            continuation.resume(Unit)
        }, 1000, TimeUnit.MILLISECONDS)
    }

    println("After")
}

//依次输出
//Before
//(1秒以后)
//After

“挂起一定时间后恢复” 看起来像是一个很常用的功能。那我们就把它提到一个方法内,并且我们将这个方法命名为delay

private val executor = Executors.newSingleThreadScheduledExecutor {
    Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun delay(time: Long) = suspendCoroutine<Unit> { cont ->
    executor.schedule({
       cont.resume(Unit)
    }, time, TimeUnit.MILLISECONDS)
}

suspend fun testCoroutine() {
    println("Before")

    delay(1000)

    println("After")
}

//依次输出
//Before
//(1秒以后)
//After

实际上上面的代码就是kotlin协程库delay的具体实现。我们的实现比较复杂,主要是为了支持测试,但是本质思想是一样的。

Resuming with value(带值恢复)

有件事可能一直让你感到疑惑:为啥我们调用resume方法的时候传递的是Unit?也有可能你会问为啥子我写suspendCoroutine方法的时候前面也带了Unit类型。实际上这两个是同一类型不是巧合:一个作为continuation恢复的时候入参类型,一个作为suspendCoroutine方法的返回值类型(指定我们要返回什么类型的值),这两个类型要保持一致:

val ret: Unit =
    suspendCoroutine<Unit> { continuation ->
        continuation.resume(Unit)
    }

当我们调用suspendCoroutine,我们决定了continuation恢复时候的数据类型,当然这个恢复时候返回的数据也作为了suspendCoroutine方法的返回值:

suspend fun testCoroutine() {

    val i: Int = suspendCoroutine<Int> { continuation ->
        continuation.resume(42)
    }
    println(i)//42

    val str: String = suspendCoroutine<String> { continuation ->
        continuation.resume("Some text")
    }
    println(str)//Some text

    val b: Boolean = suspendCoroutine<Boolean> { continuation ->
        continuation.resume(true)
    }
    println(b)//true
}

上面这些代码好像和咱们之前聊得游戏有点不一样,没有任何一款游戏可以在恢复进度得时候你可以携带一些东西(除非你作弊或者谷歌了下知道下一个挑战是什么)。但是上面代码有返回值的设计方式对于协程来说却意义非凡。我们经常挂起是因为我们需要等待一些数据。比如,我们需要通过API网络请求获取数据,这是一个很常见的场景。一个线程正在处理业务逻辑,处理到某个点的时候,我们需要一些数据才能继续往下执行,这个时候我们通过网络库去请求数据并返回给我们。如果没有协程,这个线程则需要停下来等待。这是一个巨大的浪费---线程资源是非常昂贵的。尤其当这个线程是很重要的线程的时候,就像Android里面的Main Thread。但是有了协程就不一样了,这个网络请求只需要挂起,然后我们给网络请求库传递一个带有自我介绍的continuation:”一旦你获取到数据了,就将他们扔到我的resume方法里面“。然后这个线程就可以去做其他事儿了。一旦数据返回了,当前或其他方法(依赖于我们设置的dispatcher)就会从之前协程挂起的地方继续执行了。

紧着我们实践一波,通过回调函数来模拟一下我们的网络库:

data class User(val name: String)

fun requestUser(callback: (User) -> Unit) {
    thread { 
        Thread.sleep(1000)
        callback.invoke(User("hyy"))
    }
}
suspend fun testCoroutine() {
    println("Before")

    val user: User =
        suspendCoroutine<User> { continuation ->
            requestUser {
                continuation.resume(it)
            }
        }

    println(user)
    println("After")
}

//依次输出
//Before
//(1秒以后)
//User(name=hyy)
//After

直接调用suspendCoroutine不是很方便,我们可以抽取一个挂起函数来替代:

suspend fun requestUser(): User {
    return suspendCoroutine<User> { continuation ->
        requestUser {
            continuation.resume(it)
        }
    }
}
suspend fun testCoroutine() {
    println("Before")

    val user = requestUser()

    println(user)
    println("After")
}

现在,你很少需要包装回调函数以使其成为挂起函数,因为很多流行库(RetrofitRoom等)都已经支持挂起函数了。但从另方面来讲,我们已经对那些函数的底层实现有了一些了解。它就和我们刚才写的类似。不一样的是,底层使用的是suspendCancellableCoroutine函数(支持取消)。后面我们会讲到。

suspend fun requestUser(): User {
    return suspendCancellableCoroutine<User> { continuation ->
        requestUser {
            continuation.resume(it)
        }
    }
}

你可能想知道如果API接口没给我们返回数据而是抛出了异常,比如服务死机或者返回一些错误。这种情况下,我们不能返回数据,相反我们需要在协程挂起的地方抛出异常。这是我们在异常情况下恢复地方。

Resume with exception(异常恢复)

我们调用的每个函数可能返回一些值也可能抛异常。就像suspendCoroutine: 当resume调用的时候返回正常值, 当resumeWithException调用的时候,则会在挂起点抛出异常:

class MyException : Throwable("Just an exception")

suspend fun testCoroutine() {

    try {
        suspendCoroutine<Unit> { continuation ->
            continuation.resumeWithException(MyException())
        }
    } catch (e: MyException) {
        println("Caught!")
    }
}

//Caught

这种机制是为了处理各种不同的问题。比如,标识网络异常:

suspend fun requestUser(): User {
    return suspendCancellableCoroutine<User> { cont ->
        requestUser { resp ->
            if (resp.isSuccessful) {
                cont.resume(resp.data)
            } else {
                val e = ApiException(
                    resp.code,
                    resp.message
                )
                cont.resumeWithException(e)
            }
        }
    }
}

翻译不动了。。。😂, 就差不多到这吧。。

结尾

我希望现在您可以从用户的角度清楚的了解挂起(暂停)是如何工作的。Best wishes!

原文地址:kt.academy/article/cc-…