注册

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

前言


挂起函数是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-…


作者:老炮儿丶狗二
链接:https://juejin.cn/post/6999461797140889614
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册