理解 Kotlin 的 suspend 函数
January 24, 2021
suspend
是回调(Callback)
理解 suspend
其实不需要纠结神奇的「挂起」是什么意思或者拘泥于线程是怎么切换的。实际上 suspend
的背后是大家非常熟悉的回调。
假设 postItem
由三个有依赖关系的异步子任务组成: requestToken
,createPost
和 processPost
// 三个基于回调的 API
fun requestToken(block: (String) -> Unit)
fun createPost(
token: String,
item: Item,
block: (Post) -> Unit)
)
fun processPost(post: Post)
fun postItem(item: Item) {
requestToken { token ->
createPost(token, item) { post ->
processPost(post)
}
}
}
fun requestToken(): Observable<String>
fun createPost(token: String, item: Item): Observable<Post>
fun processPost(post: Post)
fun postItem(item: Item) = requestToken()
.flatMap { createPost(it, item) }
.flatMap { processPost(it) }
但是 RxJava 这样的方案需要使用者掌握大量操作符,写复杂逻辑也很麻烦,会有一种被「困在」这个调用链里面的感觉。
Kotlin 的 suspend
关键字可以帮助我们消除回调,用同步的写法写异步:
代表挂起点(suspension point)
suspend fun requestToken(): String
suspend fun createPost(token: String, item: Item): Post
suspend fun processPost(post)
suspend fun postItem(item: Item) {
val token = 🏹 requestToken()
val post = 🏹 createPost(token, item)
🏹 processPost(post)
}
suspend
的原理
由于 createPost
suspend
具体来说,编译器看到 suspend
关键字会去掉 suspend
,给函数添加一个额外的 Continuation
参数。这个 Continuation
就代表了一个回调:
public interface Continuation<in T> {
public val context: CoroutineContext
// 用来回调的方法
public fun resumeWith(result: Result<T>)}
Kotlin 编译器会给每个 suspend 的块生成一个 Continuation
的实现类,这个实现类是一个状态机,其中的状态对应于每个挂起点,保存了需要下一步继续执行所需要的上下文(即依赖的局部变量),类似下面的伪代码:
suspend fun postItem(item: Item) {
val token = 🏹 requestToken()
val post = 🏹 createPost(token, item)
🏹 processPost(post)
}
// 编译器变换后的伪代码
// 1.脱掉了 suspend 关键字
// 2.增加了一个 Continuation 对象
fun postItem(item: Item, cont: Continuation) {
// 判断传入的是否是 postItem 的 `ContiuationImpl`
// * false: 初始化一个对应本次调用 postItem 的状态机
// * true: 对应 postItem 内其他 suspend 函数回调回来情况
// 其中 ThisSM 指的 object: ContinuationImpl 这个匿名类
val sm = (cont as? ThisSM) ?: object: ContinuationImpl {
// 实际源码中 override 的是
// kotlin.coroutine.jvm.internal.BaseContinuationImpl
// 的 invokeSuspend 方法
override fun resume(..) {
// 通过 ContinuationImpl.resume
// 重新回调回这个方法
postItem(null, this) }
}
switch (sm.label) {
case 0:
// 捕获后续步骤需要的局部变量
sm.item = item
// 设置下一步的 label
sm.label = 1
// 当 requestToken 里的耗时操作完成后会更新状态机
// 并通过 sm.resume 再次调用这个 postItem 函数
// 「我们在前面提供了 sm.resume 的实现,即再次调用 postItem」
requestToken(sm)
case 1:
val item = sm.item
// 前一个异步操作的结果
val token = sm.result as Token
sm.label = 2
createPost(token, item, sm)
case 2:
procesPost(post)
// ...
}
}
编译器将 suspend
我们可以写一段简单的 suspend
suspend
函数非常有益。可以在这里查看笔者写的一个例子。
使用 suspend
函数无须关心线程切换
suspend
提供了这样一个约定(Convention)
suspend
Activity
等具有生命周期的组件提供了开启协程所需的 CoroutineScope
,其中的 context 指定了使用 Dispatchers.Main
,即通过 lifecycleScope
开启的协程都会被调度到主线程执行。因此我们可以在调用 suspend
suspend
函数叫作「main 安全」的。
lifecycleScope.launch {
val posts = 🏹 retrofit.get<PostService>().fetchPosts();
// 由于在主线程,可以拿着 posts 更新 UI
}
suspend
不阻塞当前线程的约定,调用方其实无须关心这个函数内部是在哪个线程执行的。
lifecycleScope.launch(Dispatchers.Main) {
🏹 foo()}
比如上面这个代码块,我们指定这个协程块调度到主线程执行,里面调用了一个不知道哪里来的 suspend foo
suspend foo
里面的代码是一个潜在的耗时操作,具体在哪个线程执行是这个函数的实现细节,对于当前代码的逻辑是「透明」的。
但前提是这个 suspend
函数实现正确,真正做到了不阻塞当前线程。单纯地给函数加上 suspend
关键字并不会神奇地让函数变成非阻塞的,比如假设 suspend foo
里面的实现是这样的:
// 😖
suspend fun foo() = BigInteger.probablePrime(4096, Random())
这里这个 suspend
foo
函数的实现没有遵守 suspend
的语义,是错误的。正确的做法应该修改这个 foo
函数:
suspend fun findBigPrime(): BigInteger =
withContext(Dispatchers.Default) { BigInteger.probablePrime(4096, Random())
}
借助 withContext
BigInteger.probablePrime
suspend
enqueue
),但是底层依赖的 OkHttp 用的是阻塞的方法,最终执行请求还是调度到线程池里面去。而
随着 Android 官方将协程作为推荐的异步方案,常见的异步场景如网络请求、数据库都已经有支持协程的库,可以设想未来 Android 开发的新人其实不太需要知道线程切换的细节,只需要直接在主线程调用封装好的 suspend
函数即可,切换线程应该被当成实现细节被封装掉而几乎变成「透明」的,这是协程的厉害之处。
可以 suspend
的不仅仅是 IO
suspend
suspend
也是如此,只不过通过关键字的引入和编译器的支持,让我们可以用顺序、从上到下(sequential)的代码写出异步逻辑。不仅提升了代码可读性,还方便我们利用熟悉的条件、循环、try catch 等构造轻松地写出复杂的逻辑。
把协程和 suspend
单纯看成线程切换工具有很大的局限性。由于 suspend
suspend
函数进行封装改造。
Android View API
Suspending over viewsAnimator
结束的扩展函数:
suspend fun Animator.awaitEnd() { /* 实现见后文 */}
lifecycleScope.launch {
ObjectAnimator.ofFloat(imageView, View.ALPHA, 0f, 1f).run {
start(); 🏹 awaitEnd()
}
ObjectAnimator.ofFloat(imageView, View.TRANSLATION_Y, 0f, 100f).run {
start(); 🏹 awaitEnd()
}
ObjectAnimator.ofFloat(imageView, View.TRANSLATION_X, -100f, 0f).run {
start(); 🏹 awaitEnd()
}
}
suspend
函数后,我们可以在协程中用从上到下的顺序代码写出来,同时方便使用各种条件、循环等逻辑控制构造,提升代码表达力。
这个 Animator.awaitEnd
包装了 AnimatorListenerAdapter
这个异步回调接口。Kotlin 协程库提供了 suspendCoroutine
和 suspendCancellableCoroutine
函数(注意这两个函数本身都是 suspend
的)。我们可以在传入的 lambda 中获取到对应当前「挂起」的 Continuation
实例。在合适的回调里调用这个实例的 resume
系列方法,便能桥接 suspend
suspend fun Animator.awaitEnd() =
🏹 suspendCancellableCoroutine<Unit> { cont ->
// 如果执行这个 suspend 函数的协程被取消的话,同时取消这个 Animator。
// 注意这个 `awaitEnd` 是定义在 `Animator` 上的扩展函数,
// 因此可以直接调用 `Animator` 上的方法。
cont.invokeOnCancellation { cancel() }
addListener(object : AnimatorListenerAdapter() {
// 标记 Animator 被取消还是正常结束
private var endedSuccessfully = true
override fun onAnimationCancel(animation: Animator) {
// Animator has been cancelled, so flip the success flag
endedSuccessfully = false
}
override fun onAnimationEnd(animation: Animator) {
animation.removeListener(this)
// 如果协程仍在执行中
if (cont.isActive) {
// 并且 Animator 未被取消
if (endedSuccessfully) {
cont.resume(Unit) } else {
// 否则取消协程
cont.cancel()
}
}
}
})
}
Splitties 是一个非常地道的 Kotlin Android 辅助函数库,其中提供了一个 suspend AlertDialog.showAndAwait
方法。下面的示例代码会打开一个对话框,等待用户确认是否要删除。这是一个异步的操作,于是将协程「挂起」,等用户选择完毕后返回一个布尔值。
suspend fun shouldWeReallyDeleteFromTrash(): Boolean =
alertDialog(
message = txt(R.string.dialog_msg_confirm_delete_from_trash)
).🏹 showAndAwait( okValue = true,
cancelValue = false,
dismissValue = false
)
这里 AlertDialog.showAndAwait
使用 suspendCancellableCoroutine
包装了 DialogInterface.OnClickListener
接口。
suspendCoroutine
(和它支持取消的兄弟)包装成 suspend
函数。合理使用可以提升代码表达力。
注意上面这些例子都只涉及主线程,并不涉及线程切换的问题。
函数式异常处理
更进一步, suspend
函数的应用场景甚至都不一定局限于异步。
我们平时使用的 Kotlin 协程代码的实现在两个包里,一个是 Kotlin 的标准库 kotlin-stdlib
,另一个是协程库 kotlinx.coroutines
Continuation
和其他基础设施,kotlinx.coroutines
Λrrow (也写作 Arrow)是 Kotlin 的一个函数式编程类库,其中提供了 Either
数据类型来做异常处理:
sealed class Either<A, B>
data class Left(val value: A): Either<A, Nothing>()
data class Right(val value: B): Either<Nothing, B>()
Either
的值可能是 Left
和 Right
两种情况。习惯上用 Right
表示正常返回值(可以想成 right 在英语中还有 correct,「正确」的意思),Left
表示异常。
假设三个有相互依赖关系的子任务 takeFoodFromRefriderator
、getKnife
和 lunch
// 定义可能的异常
sealed class CookingException {
object LettuceIsRotten : CookingException()
object KnifeNeedsSharpening : CookingException()
data class InsufficientAmount(val quantityInGrams: Int) : CookingException()
}
object Lettuce; object Knife; object Salad
// 三个子任务都是返回的 Either 类型
fun takeFoodFromRefrigerator(): Either<LettuceIsRotten, Lettuce> = Lettuce.right()
fun getKnife(): Either<KnifeNeedsSharpening, Knife> = Knife.right()
fun lunch(knife: Knife, food: Lettuce): Either<InsufficientAmount, Salad> = InsufficientAmount(5).left()
我们可以使用 Either.flatMap
把三个任务组合在一起:
fun getSalad(): Either<CookingException, Salad> =
takeFoodFromRefrigerator()
.flatMap { lettuce ->
getKnife()
.flatMap { knife ->
val salad = lunch(knife, lettuce)
salad
}
suspend
suspend fun getSalad() = 🏹 either<CookingException, Salad> {
val lettuce = 🏹 takeFoodFromRefrigerator().bind()
val knife = 🏹 getKnife().bind()
val salad = 🏹 lunch(knife, lettuce).bind()
salad
}
深递归
递归应用在递归的数据结构的时候往往可以使代码简洁优雅。比如下面计算树高度的算法:
class Tree(val left: Tree?, val right: Tree?)
fun depth(tree: Tree?): Int =
if (t == null) 0 else maxOf(
depth(tree.left), depth(tree.right) ) + 1
但如果递归过深超出限制,运行时会抛出 StackOverflowException
。因此我们需要利用空间更大的堆内存。通常我们可以显式地维护一个栈数据结构。
Kotlin 标准库中有个试验性的 DeepRecursiveFunction
辅助类,帮助我们写出的代码保持递归算法的「大致形状」,但是将中间状态保存在堆内存中。其中实现的机制就是 suspend
val depth = DeepRecursiveFunction<Tree?, Int> { tree ->
// 这里是一个 suspend 的 λ
if (tree == null) 0 else maxOf(
🏹 callRecursive(tree.left), 🏹 callRecursive(tree.right) ) + 1
}
val deepTree = generateSequence(Tree(null, null)) { prev ->
Tree(prev, null)
}.take(100_000).last()
// DeepRecursiveFunction 重载了 invoke 操作符
// 可以模拟函数调用语法
println(depth(deepTree)) // 100_000
DeepRecursiveFunction
接的是一个 suspend
的块,其中的接收者(Receiver)是 DeepRecursiveScope
,可以类比成 CoroutineScope
。在这个块里面,注意我们不能像原算法那样直接递归调用 depth
(因为还是会依赖于空间有限的函数调用栈)。DeepRecursiveScope
提供了一个 suspend callRecursive
Continuation
对象在运行时存放在堆内存中,也就避开了函数调用栈的空间限制。(所以 Kotlin 的协程属于一种所谓的「无栈协程(stackless coroutine)」。)
具体实现可以参考 Deep recursion with coroutines。
suspend
suspend
这个关键字在别的语言通常叫 async
,而 Kotlin 叫 suspend
或许正暗示了 Kotlin 协程独特的设计并不限于 asynchrony,而有着更宽广的应用场景。
参考资料
在进一步深入学习协程(源码)之前,非常推荐先看一下 协程的设计文档 ,有的放矢,事半功倍。
Roman 是 Kotlin 协程的主要设计者,现在担任 Kotlin Project Lead,他在 Medium 上有一系列介绍 Kotlin 和协程的文章,可以帮助我们学习和理解 Kotlin 的一些设计思想。
欢迎阅读本文的「姊妹篇」:《谈谈 Kotlin 协程的 Context 和 Scope》
题外话:Kotlin 的异常处理
本文提到了 Λrrow Either
NullPointerException
的问题,但是在异常处理这一块却干掉了 Checked Exception,可以说是开了倒车。我们调用一个函数不去了解其实现很难确定是否会抛出异常。这在客户端使用协程的情况下尤其糟糕,异常抛出的规律不容易掌握,稍有不慎便会让应用崩溃。异常处理在其他现代编程语言比如 Swift 和 Rust 就要好得多。不过可以理解 Kotlin 这一设计或许更多是 Java 生态的包袱造成的。
Result
类型或者 Λrrow 的 Either
。如果调用方不需要获取具体错误信息的话可以直接用 T?
可空类型来表示,这样既有类型安全又有 ?.
、?:
语法糖。据说 Kotlin 有可能结合 Result
类给函数返回类型做个语法糖,非常期待。
题外话:没有用的 await
关键字
近日,Swift 语言通过了 Async/await 提案。async
相当于 Kotlin 的 suspend
。在调用 async / suspend 函数的时候,Swift 需要一个额外的 await
关键字,但是 Kotlin 不需要,调用 suspend 函数的语法和调用普通函数没有区别。这个 await
除了标记之外没有其他作用。
Kotlin 的这个设计写起来是更加方便的。比如不会有 JavaScript 中被吐槽的这种写法:
await (await fetch(url)).json()
这个说法和本文在「使用 suspend
函数无须关心线程切换」这个标题下所强调的内容是一致的。Kotlin 要求在开启协程的时候有一个 CoroutineScope
,这个是显式的,因为开启的子程序和剩下的代码块是并发的。但在协程块内部,suspend
函数从调用方的视角看确实在程序行为上和普通的函数相似,都是顺序执行,从这个意义讲似乎确实不需要有特殊的关键字作区分。
不过根据笔者的实际体会,Kotlin 的设计权衡似乎还是令「可写性」略高于「可读性」。我们阅读代码的目的可以分成两种:探索型(学习、研究)和批判型(例如 code review)。对于探索型的代码阅读,语言提供更多线索是有帮助的。比如,有时阅读 Kotlin 源码会分不清楚某个方法是调在 Receiver 上还是外层的对象。
所以作为介绍 suspend
函数的文章,本文在 Kotlin 省去的 await