挂起与非挂起
前言
到目前为止,您已经了解到协程依赖于挂起代码和挂起函数的概念。挂起代码基于与常规代码相同的概念,除了系统能够挂起其执行并在以后继续执行。但是当您使用一个可挂起的函数和一个普通的函数时,它们的调用方式看起来几乎一样。
如果您更进一步并复制您使用的函数,但是在开始时添加 suspend 修饰符关键字,您将拥有两个具有相同参数的函数,但您必须将 suspendable 函数包装在一个launch 块中。这就是 Kotlin 协程 API 的构建方式,但实际的函数调用并没有发生改变。
系统在编译时通过 suspend 修饰符来区分这两个函数,但是这两个函数的区别在哪里呢?以及工作方法又有何不同呢?这两个函数在 Kotlin 协程的挂起机制中又是如何工作的?
通过分析为每个函数生成的字节码,并解释调用堆栈在这两种情况下的工作原理,可以找到答案。下面我们将首先分析不可挂起的函数。
分析常规函数
在 IntelliJ 中点击 Open 按钮,导航至 suspending-functions/projects/starter 文件夹并选择 suspending_functions 项目打开本章节的代码。
如果您打开 Main.kt,在本项目中,您会注意到一个小的 main 函数。它调用了一个简单的、常规的、不可挂起的函数 getUserStandard()
,它没有回调或协程。此功能将有四种不同的变体。这个变种是最简单的,我们先来分析一下:
fun getUserStandard(userId: String): User {
Thread.sleep(1000)
return User(userId, "Filip")
}
该函数接受一个参数:userId。它使当前线程休眠一秒钟,以模拟长时间运行的操作。 之后,它返回一个 User
对象。
实际上,该干啥功能很简单,也没有隐藏的机制。您可以点击 Tools ▶ Kotlin ▶︎ Show Kotlin Bytecode 分析生成的字节码。之后您应该会看到 Kotlin Bytecode 窗口,并点击 Decompile 按钮,您可以看到生成的代码,看起来应该是这样的:
@NotNull
public static final User getUserStandard(@NotNull String userId) {
Intrinsics.checkParameterIsNotNull(userId, "userId");
Thread.sleep(1000L);
return new User(userId, "Filip");
}
分析反编译后的 Java 代码,您可以看到它与实际代码没有太大区别。它非常简单,并且与 Kotlin 代码类似。
对代码的唯一补充是编译器添加的空检查和注释,以确保遵循非空类型系统。 一旦程序启动这个函数,它会检查参数不为空,并在一秒钟后返回一个 User
对象。
这个函数比较简单,但它的问题在于 Thread.sleep(1000)
调用。如果您在主线程上调用该函数,您将冻结您的 UI 界面一秒钟。如果您使用回调并为长时间运行的操作创建一个新线程来实现这一点,那就更好了。看看您将如何在第二个例子中使用回调来实现它。
使用回调
一个更好的解决这个问题的方法为这个函数传入一个回调参数。此回调将用作通知程序有关 User
已准备好并可以使用了。此外,它还需要创建一个脱离主线程并单独的执行线程。
为此,请将 getUserStandard()
替换为以下代码:
fun getUserFromNetworkCallback(
userId: String,
onUserReady: (User) -> Unit) {
thread {
Thread.sleep(1000)
val user = User(userId, "Filip")
onUserReady(user)
}
println("end")
}
确保您导入了 thread
,就像下面这样:
import kotlin.concurrent.thread
修改 main
函数中的调用代码:
fun main() {
getUserFromNetworkCallback("101") { user ->
println(user)
}
println("main end")
}
再次执行字节码分析,您将看到如下所示的输出:
public static final void getUserFromNetworkCallback(
@NotNull final String userId,
@NotNull final Function1 onUserReady) {
Intrinsics.checkParameterIsNotNull(userId, "userId");
Intrinsics.checkParameterIsNotNull(onUserReady, "onUserReady");
ThreadsKt.thread$default(
false,
false,
(ClassLoader)null,
(String)null,
0,
(Function0)(new Function0 () {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
public final void invoke () {
Thread.sleep(1000L);
User user = new User(userId, "Filip");
onUserReady.invoke(user);
}
}), 31, (Object)null);
String var2 = "end";
System.out.println(var2);
}
与前面示例生成的 Java 代码相比,这次是一个相当大的改变。系统再次执行一系列空值检查以强制执行类型系统。之后,它创建一个新线程,并在线程的 public final void invoke
中调用包装的代码。代码本身与上一个示例相比没有太大变化,但现在它依赖于一个线程和一个回调。
一旦系统运行 getUserFromNetworkCallback
,它就会创建一个线程。当线程就绪执行时,它就会运行 invoke
代码块,并使用回调将结果传递回来。如果您运行上面的代码,您会得到以下结果:
end
main end
User(id=101, name=Filip)
这意味着 main
函数确实可以在 getUserFromNetworkCallback
之前完成。它启动的线程以及线程中的代码在主线程运行完成之后存在。
这个函数比上一个例子好一点,因为它从脱离了主线程独自工作,使用回调来最终消费数据。但是这里的问题是您用来构建 User
对象的代码可能会抛出一个异常。这意味着您必须将其包装在 try/catch 块中。但最好是 try/catch 块位于创建值的实际位置。但是,如果您在此处捕获异常,如何将其传播回调用处呢?
这通常是通过使用稍微不同的回调签名来完成的,该签名传递给您希望运行的函数,它允许您传递值或传递异常。接下来,看看函数结束时如何处理这两条路线。
处理 Happy and Unhappy Paths
编程时,当程序运行一切顺利,此时程序走的路线称为 happy path。与此相反,当事情出错的时候,程序走的路线就是 unhappy path。
在上面的示例中,如果程序发生错误,通过那个回调无法处理错误情况。您要么必须将整个函数调用包装在 try/catch 块中,要么从 thread
函数中捕获异常。前者的实现不够优雅,因此您真的希望在同一个地方处理所有可能的路线吗?后者也好不到哪里去,因为您可以传递给回调的只是一个值,所以您必须要么传递一个可为空的值,要么传递一个空对象。
为了这个功能可用并简洁,程序员定义有两个参数的 lambda 回调,第一个参数是我们期望的值,第二个参数在错误发生时传入。
下面是函数的签名及其回调,以替换 Main.kt 中的 getUserFromNetworkCallback
:
fun getUserFromNetworkCallback(
userId: String,
onUserResponse: (User?, Throwable?) -> Unit) {
thread {
try {
Thread.sleep(1000)
val user = User(userId, "Filip")
onUserResponse(user, null)
} catch (error: Throwable) {
onUserResponse(null, error)
}
}
}
回调现在可以接受一个值或一个错误。无论采用哪个参数或路线,它都应该有效且非空,而其余参数将为空,表明它所管理的路线没有发生。
修改 main
函数中的调用处:
fun main() {
getUserFromNetworkCallback("101") { user, error ->
user?.run(::println)
error?.printStackTrace()
}
}
如何 user 不为空,您可以打印并输出它,或者用它做一些其他的事情。
点击字节码反编译器窗口中的 Decompile 按钮查看字节码时,您应该看到以下内容:
public static final void getUserFromNetworkCallback(
@NotNull final String userId,
@NotNull final Function2 onUserResponse) {
Intrinsics.checkParameterIsNotNull(userId, "userId");
Intrinsics.checkParameterIsNotNull(onUserResponse, "onUserResponse");
ThreadsKt.thread$default(
false,
false,
(ClassLoader)null,
(String)null,
0,
(Function0)(new Function0 () {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
public final void invoke () {
try {
Thread.sleep(1000L);
User user = new User(userId, "Filip");
onUserResponse.invoke(user, (Object)null);
} catch (Throwable var2) {
onUserResponse.invoke((Object)null, var2);
}
}
}), 31, (Object)null);
}
代码没有太大变化,它只是将所有内容包装在 try/catch 中,并将 (value, null)
或 (null, error)
对传递给用户。另一方面,如果出现错误,您可以打印其堆栈跟踪或检查错误类型等。这种方法比上一个示例中的方法要好得多,但仍然存在问题。它依赖于回调,所以如果您需要三个或四个不同的请求和值,您就必须构建那个可怕的 “回调地狱” 层级。此外,每次调用这样的函数时,都会产生分配新线程的开销。
分析可挂起函数
在带有回调的示例中发现的问题是可以通过使用协程来补救的。修改前面发现的问题,需要您对上面的示例进行进一步改进:
- 去除回调并使用协程实现上面的示例。
- 提供高效的错误处理方式。
- 优化线程创建的开销问题。
为了实现这些改进,您将从 Coroutines API 中学习另一个函数—— suspendCoroutine
。此函数允许您手动创建协程并管理其控制状态和流程。它与 launch
块不同,它只是定义了一种构建协程的方式,却在幕后处理了所有事情。
但是,在我们尝试 suspendCoroutine
之前,应该分析下当您为已存在的函数添加 suspend
修饰符时会发生什么。在 Main.kt 文件中添加另一个函数,函数签名如下:
suspend fun getUserSuspend(userId: String): User {
delay(1000)
return User(userId, "Filip")
}
同时确保导入 delay
,像下面这样:
import kotlinx.coroutines.delay
该函数与第一个示例中的函数一样简单,除了您添加了 suspend
修饰符且不用睡眠当前线程,取而代之的是调用 delay
函数,它是一个可挂起的函数并且可以在给定的时间内挂起协程。基于这些变化,您可能会认为字节码的差异不是很大,对吧?
好吧,您可以使用 Kotlin 字节码反编译器中的 Decompile 按钮获得的字节码如下:
@Nullable
public static final Object getUserSuspend(@NotNull String userId, @NotNull Continuation var1) {
Object $continuation;
label28: {
if (var1 instanceof < undefinedtype >) {
$continuation = (<undefinedtype>)var1;
if ((((<undefinedtype>)$continuation).label &
Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -=
Integer.MIN_VALUE;
break label28;
}
}
$continuation = new ContinuationImpl(var1) {
// $FF: synthetic field
Object result;
int label;
Object L $0;
@Nullable
public final Object invokeSuspend (@NotNull Object result) {
this.result = result;
this.label | = Integer.MIN_VALUE;
return MainKt.getUserSuspend((String)null, this);
}
};
}
Object var2 =((<undefinedtype>)$continuation).result;
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(((<undefinedtype>)$continuation).label) {
case 0:
if (var2 instanceof Failure) {
throw ((Failure) var2).exception;
}
((<undefinedtype>)$continuation).L$0 = userId;
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) ==
var4) {
return var4;
}
break;
case 1:
userId = (String)((<undefinedtype>)$continuation).L$0;
if (var2 instanceof Failure) {
throw ((Failure) var2).exception;
}
break;
default:
throw new IllegalStateException ("call to ’resume’ before ’invoke’ with coroutine");
}
return new User (userId, "Filip");
}
这个庞大的代码块与前面的示例有很大的不同,与您看到的第一个示例相比,它可以称得上是一个庞然大物。依次分析这些代码,以了解正在发生的事情:
- 您会注意到的第一件事是函数的额外参数 — Continuation。它构成了协程的整个基础,也是可挂起函数与常规函数的区别中最重要的一点。Continuations 允许函数在挂起模式下工作。它们允许系统在挂起它们后返回到函数的原始调用栈点。您可以说 Continuations 只是系统或当前正在运行的程序的回调,并且通过使用 Continuations,系统知道如何导航函数的执行和调用堆栈。
- 实话实说,所有函数实际上都有一个隐藏的、内部的、与它们相关的 Continuation。这个 Continuation 与 Kotlin Coroutines API 无关,它实际上与您正在使用的操作系统相关联,并且基于此具有不同的内部实现。系统通常使用它来导航调用堆栈和代码。但是,可挂起的函数有一个它们可以使用的额外实例,以便它们可以被挂起并且程序可以继续执行,最后使用第二个 Continuation,导航回可挂起的函数调用栈点或接收其结果。
- 我们在这里讨论的第二个 Continuation 是作为函数参数可见的。如前所述,系统实现了一个隐藏的内部 Continuation,它在反编译代码中不可见,因为它是在非常低的级别中实现的。
- 其余代码首先检查我们所在的 Continuation 实例,因为每个可挂起函数都可以创建多个 Continuation。每个 Continuation 都将描述该函数执行的一个流程。例如,如果您在一个可挂起的函数上调用
delay(1000)
,您实际上是在创建另一个执行实例,它在一秒钟内完成并返回到起始点 — 调用delay
的那一行。 - 该代码使用按位与 & (AND) 运算符组合 var1 和 Int.MIN_VALUE,如果返回 true,则跳转至 label28。这意味着发生了初始挂起调用,代码可以继续进行其余操作。否则,它从内部调用
getUserSuspend
并使用按位或 | (OR) 运算符,将 label 和 Int.MIN_VALUE 的结果作为标记。 - 完成后,它会在 label 上检查当前活跃的 Continuation。如果 label 为零,则意味着它还没有完成第一次挂起调用 —
delay
。 在这种情况下,它只返回该执行的结果,即延迟的函数调用。最后,它还将 label 置为 1,以通知它过去的延迟应该继续使用代码。在同一个代码块中,系统使用之前的 Continuation 创建一个新的、已包装的实例,该实例将返回到此函数,并带有不同的 label。 - 最后,如果 label 等于 1,它就是 continuation-stack 中最大的索引值,可以这么说,这意味着函数在
delay
后恢复,并且它已准备好为您提供值 - User。如果在这之前出现任何问题,系统会抛出异常。 - 最后,调用
return new User(userId, "Filip")
;将 User 对象一直传回 Main.kt 的原始调用处。
还有另一种默认情况,如果系统尝试继续或执行恢复流程,但实际上并没有执行函数调用,它只会引发异常。当子 Job 在其父级之后完成时,有时会发生这种情况。对于极为罕见的情况,这是一种默认的保护机制。如果您小心并按规则使用协程,那么父 Job 应该总是等待它们的子 Job 完成,所以上述情况不应该发生。
简而言之,系统将 Continuation 用于状态机和内部回调,以便它知道如何在代码中导航、存在哪些执行流程以及应该在哪些点挂起并稍后恢复。使用 label 描述状态,它可以具有与函数中的挂起点一样多的状态。
要调用新创建的函数,您可以使用下一段代码:
fun main() {
GlobalScope.launch {
val user = getUserSuspend("101")
println(user)
}
Thread.sleep(1500)
}
同时确保导入 GlobalScope
,如下所示:
import kotlinx.coroutines.GlobalScope
上面的函数调用就像第一个例子一样。不同之处在于它是可挂起的,因此您可以将其在协程中运行,从而脱离主线程。您还依赖了 Coroutine API 的内部线程,因此没有额外的线程开销。代码书写上是顺序的,即使它可能是异步执行的。并且您可以在调用栈点使用 try/catch 块,即使该值可以异步生成。上一个示例中的所有痛点都已解决!
将代码改为可挂起的
另一个问题是何时应该将现有代码迁移到可挂起的函数和协程?这是一个比较偏颇的问题,但是您仍然可以遵循一些客观的指导方针来确定您是使用协程还是标准机制更好。
一般来说,如果您的代码充满了复杂的线程,并且经常分配新线程来完成您需要的工作,但是您没有能力使用固定的线程池,反而随手创建新线程,这时您应该迁移到协程。
性能上的优势立竿见影,因为 Coroutines API 已经具有预定义的线程机制,使您可以轻松地在线程之间切换并在线程之间分配多个工作。
如果您正在构建新线程,由于异步或长时间运行的操作,您经常会大量滥用回调,因为线程之间最简单的通信方式是通过回调。这与迁移到协程的第一个原因不谋而合。如果您使用回调,您可能会遇到代码样式、可读性和理解函数背后的业务逻辑所需的认知负荷方面的问题。在这种情况下,您也应该尝试将代码迁移到程。
当有一些您无法更改的 API 的时候,问题就来了。在这些情况下,您无法更改源代码。假设您有以下代码,但它来自外部库:
fun readFile(path: String, onReady: (File) -> Unit) {
Thread.sleep(1000)
// some heavy operation
onReady(File(path))
}
此函数强制您使用回调,即使您可能有更好的方法来处理长时间运行或异步操作。 但是您可以很容易地用 suspendCoroutine() 包装这个函数:
suspend fun readFileSuspend(path: String): File =
suspendCoroutine {
readFile(path) { file ->
it.resume(file)
}
}
这段代码非常好,因为如果它设法读取一个文件,它会将它传递给协程作为结果。如果出现问题,它会抛出异常,您可以在调用栈点捕获它。使用协程完全包装异步操作是非常强大的。但是如果您的函数依赖回调来不断产生值 — 比如订阅套接字,那么像这样的协程就没有意义了。您最好用 Flow API 来实现这样的机制,您将在第 11 章 “开始使用 Coroutine Flow” 中学习相关知识。