「最后一次,彻底搞懂kotlin协程」(四) |挂起与恢复:异步变同步的秘密

2,995 阅读11分钟


引言

  当我发现我不停的看到关于Kotlin协程的文章的时候,我突然意识到:可能现有的文章并没有很好的解决大家的一些问题。在看了一些比较热门的协程文章之后,我确认了这个想法。此时我想起了一个古老的笑话:当一个程序员看到市面上有50种框可用架的时候,决心开发一种框架把这个框架统一起来,于是市面上有了51种框架我最终还是决定:干,因为异步编程实在是一个过于重要的部分。


  我总结了现存资料所存在的一些问题:

  1. 官方文档侧重于描述用法,涉及原理部分较少。如果不掌握原理,很难融会贯通,使用时容易踩坑
  2. 部分博客文章基本是把官方文档复述一遍,再辅之少量的示例,增量信息不多
  3. 不同的博客文章之间内容质量参差不齐,理解角度和描述方式各不相同,部分未经验证的概念反而混淆了认知,导致更加难以理解
  4. 部分博客文章涉及大量源码相关内容,但描述线索不太容易理解,缺乏循序渐进的讲述和一些关键概念的铺垫和澄清
地心说与日心说.gif


  而为什么 coroutine 如此难以描述清楚呢?我总结了几个原因:

  1. 协程的结构化并发写法(异步变同步的写法)很爽,但与之前的经验相比会过于颠覆,难以理解
  2. 协程引入了不少之前少见的概念,CoroutineScope,CoroutineContext... 新概念增加了理解的难度
  3. 协程引入了一些魔法,比如 suspend,不仅是一个关键字,更在编译时加了料,而这个料恰好又是搞懂协程的关键
  4. 协程的恢复也是非常核心的概念,是协程之所以为协程而不单单只是另一个线程框架的关键,而其很容易被一笔带过
  5. 因为协程的“新”概念较多,技术实现也较为隐蔽,所以其主线也轻易的被掩埋在了魔法之中


  那么在意识到了理解协程的一些难点之后,本文又将如何努力化解这些难题呢?我打算尝试以下一些方法:

  1. 从基础的线程开始,澄清一些易错而又对理解协程至关重要的概念,尽量不引入对于理解协程核心无关的细节
  2. 循序渐进,以异步编程的发展脉络为主线,梳理引入协程后解决了哪些问题,也破除协程的高效率迷信
  3. 物理学家费曼有句话:“What I cannot create, I do not understand”,我会通过一些简陋的模拟实现,来降低协程陡峭的学习曲线
  4. 介绍一些我自己的独特理解,并引入一些日常场景来加强对于协程理解
  5. 加入一些练习。如看不练理解会很浅,浅显的理解会随着时间被你的大脑垃圾回收掉,陷入重复学习的陷阱(这也是本系列标题夸口最后一次的原因之一)

在上一篇结构化并发中我们已经弄清楚了 CoroutineScope,CoroutineContext,Job 等关键概念,以及它们形成的结构化并发概念。在这一篇我们就进入 kotlin 协程最为神奇的也最让人困惑的特性,异步变同步的写法,相信学习完本篇之后能解开大家的困惑。

同步 / 异步 / 并发

概念

为了便于后面的讲述,我们有必要先澄清一下在编程中的同步/异步/并发的概念,

  1. 同步 Synchronous

    同步指程序逐行按照顺序执行(多线程之间也可以顺序执行),每一行代码的执行都要等待前一行代码执行完成。因为语言特性,同步在单线程中是天然的。

  2. 异步 Asynchronous

    异步指程序执行不依赖于主程序流,而是通过回调、事件通知或其他机制来处理程序中的某些任务。在异步编程中,程序可以在执行某个操作时同时继续执行其他操作,而无需等待当前操作完成(可以参考线程篇中对于 EventLoop模型的介绍)。

  3. 并发 Concurrency

    并发侧重于多个任务同时执行的能力。在并发编程中,多个任务可以同时进行,与异步侧重点的的区别是并不要求执行完任务后后续回到主程序流中(并发也可以,只是侧重点不是)。

下面用几幅图表示其侧重点,并不完全准确

Sync.drawio.png

1. 同步


Async.drawio.png

2. 异步


Concurrency.drawio.png

3. 并发


在类似于 Android 这样的平台中,我们有一个主程序流,即主线程,多数场景一般也不要求很高的并发,所以异步编程就成了主角。从原始的线程池,到 Android 官方的 AsyncTask,再到专用于网络的 Volley,OkHttp,一路发展到通用的 Rxjava,以及本文的主角 Kotlin 协程,异步编程都是其中的重点

异步变同步

  厘清了同步/异步的侧重点,那什么又是异步变同步呢。下面我们用一个对比的例子来说明。 下面先准备一些用于异步执行相关的参数和函数:

// Coroutine1.kt
// threadPool related
val handler: ExecutorService = Executors.newSingleThreadExecutor { Thread(it, "main") }
val threadPool: ExecutorService = Executors.newCachedThreadPool()
// coroutine related
val mainDispatcher: CoroutineDispatcher = handler.asCoroutineDispatcher()
val coroutineScope = CoroutineScope(mainDispatcher)

fun ExecutorService.post(runnable: Runnable) = handler.execute(runnable) // 模拟 Handler.post

fun getUserProfile(): Pair<String, String> {
    printlnWithThread("get User Profile")
    return "userName" to "avatarUrl"
}

fun getImageBy(url: String): String {
    printlnWithThread("get Image")
    return "avatar"
}

fun renderImage(image: String): String {
    printlnWithThread("render Image")
    return "$image with border and shadow"
}

首先我们用 SingleThreadExecutor 来模拟 Handler线程池篇详细讲述了 Handler,SingleThreadExecutor 的相似性,忘记了可以复习一下。然后我们把由 SingleThreadExecutor 模拟的 Handler 通过协程的扩展方法 ExecutorService.asCoroutineDispatcher() 转换成 CoroutineDispatcher,用于替代 Dispatchers.Main。如果不明白这里为什么不能使用 Dispatchers.Main,以及为什么不能在直接使用 main 线程,需要复习一下线程篇中的关于 EventLoop 的内容。

  做好准备工作之后,下面我们用一个用例来做演示,用例为从后端获取一个用户数据并展示,详细描述如下:

  1. 从后端获取 UserProfile 信息,包含名字和头像地址
  2. 同时设置一个头像占位图
  3. 拿到 UserProfile 后,设置用户名字
  4. 用 UserProfile 中的头像地址去后台获取图片数据
  5. 获取到用户头像数据后展示头像
  6. 再把用户头像渲染出阴影和边框效果
  7. 把渲染出来的结果再次展示出来

异步代码

  看起来流程比较繁琐,但总体并不复杂,我们知道耗时任务不能在主线程执行,这里我们先用 ThreadPool 来实现这个需求,看看下面的示例:

// Coroutine2.kt
// threadPoolAsync
fun main() {
    handler.post {
        // 1
        printlnWithThread("set place holder")
        threadPool.execute {
            // 2
            val userProfile = getUserProfile()
            handler.post {
                // 3
                printlnWithThread("set user name: ${userProfile.first}")
                threadPool.execute {
                    // 4
                    val avatar = getImageBy(userProfile.second)
                    handler.post {
                        // 5
                        printlnWithThread("set avatar: $avatar")
                    }
                    // 6
                    val finalAvatar = renderImage(avatar)
                    // 7
                    handler.post {
                        printlnWithThread("set avatar: $finalAvatar")
                    }
                }
            }
        }
    }
}

// log
main: set place holder
pool-1-thread-1: get User Profile
main: set user name: userName
pool-1-thread-1: get Image
pool-1-thread-1: render Image
main: set avatar: avatar
main: set avatar: avatar with border and shadow

从日志可以看出我们按照预期实现了上述需求。但代价是代码难看,代码层级太深,已经形成了箭头型代码。当业务逻辑加入了更多的判断分支,将会变得更加难以维护(关于箭头型代码,可以参考这篇文章如何重构“箭头型”代码)。我们的思维更适合线性的结构,而非嵌套的结构。经验比较丰富的同学会想到一个词:回调地狱。但其实回调本身并不可怕,可怕的是嵌套,所以我愿称其为嵌套地狱。下面我们来看看协程的表现。

同步代码

// Coroutine3.kt
// CoroutineAsync
fun main() {
    coroutineScope.launch {
        // 1
        printlnWithThread("set place holder")
        // 2
        val userProfile = withContext(Dispatchers.IO) { getUserProfile() }
        // 3
        printlnWithThread("set user name: ${userProfile.first}")
        // 4
        val avatar = withContext(Dispatchers.IO) { getImageBy(userProfile.second) }
        // 5
        printlnWithThread("set avatar: $avatar")
        // 6
        val finalAvatar = withContext(Dispatchers.Default) { renderImage(avatar) }
        // 7
        printlnWithThread("set avatar: $finalAvatar")
    }
}

// log
main: set place holder
DefaultDispatcher-worker-1: get User Profile
main: set user name: userName
DefaultDispatcher-worker-1: get Image
main: set avatar: avatar
DefaultDispatcher-worker-1: render Image
main: set avatar: avatar with border and shadow

我们实现了同样的效果,但是从上面的代码中我们可以看出一个字:“平”,所有的逻辑,我们的目光可以沿着 part1 垂直落下就可以看完整个逻辑,有一种飞流直下三千尺的感觉,我们不必像 ThreadPool 的实现一样,需要目光随着代码参差不齐的结构闪转腾挪,在良好的实践下(后续在最佳实践篇介绍),协程的使用者甚至不用关心具体的线程

  其实上述的业务逻辑都是异步的,我之所以把线程池的实现称之为异步,是因为最直接的异步代码就是这样层层嵌套。相应的,我把协程的实现称之为同步是因为这种 “平” 的写法就是我们在写普通的同步代码的写法,所以所谓的异步变同步其实在于写法的改变,两种实现最终的执行方式和流程是等价的

  确实很 Magic !不过魔法的威力固然强大,但如果不了解其内部的原理,也很容易失控,导致在实际使用时容易踩坑。下面我们就先来看看协程的一些细节,看看跟你的设想是否相同,后面再讲解其原理。

时间线

  对于协程执行的时间线的理解容易出错,我们以上面的协程实现为例,按照标注的执行步骤,梳理一下执行的时间线和具体的线程。

  1. 主线程 -> 2. IO 线程 -> 3. 主线程 -> 4. IO 线程 -> 5. 主线程 -> 6. 计算线程 -> 7. 主线程

我们的 7 个子任务在不同的线程间来回流转,这就是异步的体现。我把这个过程绘制成一个图,图中包含执行的时间线和任务在执行线程之间流转和交互的细节,图中的序号对应上面的每一个具体步骤,以主线程为线索:

Coroutine-Async.drawio.png

Coroutine-Async

先在主线程执行 part1,然后把 part2 流转到 IO 线程,主线程去做其他的事,在 part2 执行完毕后再把 part3 流转回主线程,然后再次流转,就在各个流转之间完成了整个 Coroutine,也串出了整条时间线。

执行顺序

  看了上面的之后整个工作流在不同线程间的流转、交互过程就清楚了。但画图着实是一个麻烦的过程,我们在编码的时候不太可能去画一幅图,所以我们需要在代码中去读懂这个过程,我们还是用图示在代码中来表示,我在代码中标注了关键的节点和说明:

Coroutine-execute.drawio.png

Coroutine Execeute


上面的图示,我们从左往右看大概可以分为三列,这三列恰好构成了三组。第一组: 1,4,7,10,都是主线程执行,1是初始执行,4,7,10 是恢复到主线程执行。第二组:2,5,8 ,这一组是挂起点,挂起意味着当前的协程执行结束,并启动了一个新的协程。第三组:3,6,9,与第二组一一对应,3 就是 2 启动的协程,6 对 5,9 对 8。 每一个协程执行完成之后都会恢复之前的状态,分别恢复到 4,7,10 的执行处,整个执行顺序即标号从 1 到10 的顺序。相信这张图示清晰的展示了整个协程执行的过程,但,挂起点是什么,恢复又是什么,恢复的状态又是什么?要说明这个问题,我们需要了解 Kotlin 协程的状态机制,这个机制被隐藏在 suspend 关键字里。

suspend

  如果大家看过一些 kotlin 协程的文章,很可能看到过这么一个说法:suspend 就是一个提醒,或者警告,告诉你这里要发生耗时操作了。但我不得不说,这个说法是错误的,下面我用一个示例来说明:

// Coroutine4.kt
fun main() = runBlocking {
    val nonTimeCost = measureTimeMillis { nonTimeCost() }
    println("nonTimeCost: $nonTimeCost")

    val timeCost = measureTimeMillis { println(sqrt(3, 1000)) }
    println("timeCost: $timeCost")
}

// 开根号计算,不用关心实现
fun sqrt(number: Long, precision: Int): BigDecimal {
    var x0 = BigDecimal.valueOf(1.0)
    var x1: BigDecimal
    val x2 = BigDecimal.valueOf(number)

    do {
        x1 = x0
        x0 = x1 - (x1.pow(2) - x2).divide(x1 * x2, precision, RoundingMode.HALF_UP)
    } while ((x0 - x1).abs() > BigDecimal.ONE.divide(BigDecimal.TEN.pow(precision)))

    return x0.setScale(precision, RoundingMode.HALF_UP)
}

suspend fun nonTimeCost() = delay(0)

// log
nonTimeCost: 0
1.732050807568...
timeCost: 6606

我们的耗时操作 sqrt 函数并不需要 suspend 修饰,虽然其耗时长达 6606ms,我们也不会得到任何的编译器的警告,事实上如果加上 suspend 之后反而会得到一个编译器警告:Redundant 'suspend' modifier 。而我们的 nonTimeCost 耗费了 0 ms,但却必须加上 suspend,如果我们把 suspend 去掉,就会编译失败:“Suspend function 'delay' should be called only from a coroutine or another suspend function”

  所以 suspend 函数不一定意味着那是一个耗时的操作,而意味着这个函数里应该发生挂起,挂起的原因可能是因为有耗时的操作,也可能就是因为现在还不适合继续做后续的工作,也不能干等,所以要先去做其他的工作。同理,即使有耗时的操作也不意味着这个函数应该由 suspend 修饰,使用 suspend 的原因只是因为要在函数里执行挂起操作(调用更底层的挂起函数),在函数里直接或间接调用底层 suspend 函数是使用 suspend 修饰函数的充要条件,耗时操作则既不充分,也非必要,但确是一个非常重要的原因。常用的底层的 suspend 函数并不多,比如 delay,withContext,join,await,yield,suspendCoroutine/suspendCancellableCoroutine。挂起概念如何重要,下面我们就讲讲挂起。

挂起

  我个人觉得挂起是一个很不直观的翻译(suspend的翻译中暂停可能更直观),挂起的概念本身也不是专用于 kotlin 协程的,所以直接用将其用于理解协程的挂起可能反而导致我们错误的理解,不过我们还是有必要先看一下 wiki 中是如何描述挂起的

挂起(英语:suspend)是指在操作系统行程管理将前台的行程暂停转入后台的动作。将进程挂起可以让用户在前台执行其他的行程。挂起的行程通常释放除CPU以外已经占有的系统资源,如内存等。

wiki 中的挂起概念主要用于进程,会暂停这个进程,并释放相应的资源。暂停这个词本身隐含了恢复,这是挂起的一个重要概念。

  在 kotlin 协程中,上述与进程对应的概念变成了 Coroutine,Kotlin 协程在挂起时不一定会暂停 Coroutine 的执行(只有 delay,join,await 可能),也不一定会释放资源。kotlin 协程中的挂起意味着当前执行 Corotuine 阶段性结束了,执行当前Coroutine 的线程会继续去做其他的事(参考上图:Async),kotlin 协程会在合适的时机使用合适的 CoroutineContext 恢复执行这个 Coroutine 的下一个阶段,依次类推,直到整个 Coroutine 执行完所有的阶段。

  整个外层 Coroutine 被这些挂起操作切分成多个阶段(参考上图:Coroutine Execeute),协程就在不断的挂起和恢复之间完成了这些阶段。阶段的控制由则是由 kotlin 协程的编译魔法来完成,避免每次恢复都从头开始执行,这个魔法的本质就是一个状态机,学习下面状态机一节后会对挂起的本质有更深入的理解。

状态机

  要深入了解 kotlin 协程状态机的秘密,可以借助由字节码反编译出来的代码,Android Studio中的步骤是 Tools -> Kotlin -> Show Kotlin ByteCode -> Decompile。相信很多人已经看过反编译后的代码了,但却不是很容易理解,其中主要有以下几点原因:

  1. 生成的代码命名不适合阅读
  2. 生成的代码量大,其中加入了看似跟主线不太相关的代码导致整体更加难以理解(上面协程例子中 11 行的逻辑代码反编译后有 210 行)
  3. 不能直接对反编译的代码 debug

  笔者会以一个相对简单的例子,对生成的代码做一些适合人类阅读的重构,并对关键节点进行讲解。下面就跟随笔者一起,从协程代码本身开始,逐步揭开这个魔法背后的秘密:

// Coroutine5.kt
fun main() {
    // 1
    val coroutineScope = CoroutineScope(CoroutineName("my"))

    // 2
    coroutineScope.launch {
        println("before delay suspend")
        delay(100)
        println("after delay suspend")
    }

    // 3
    println("before sleep")
    Thread.sleep(200)
    println("after sleep")
}

// log
before sleep
before delay suspend
after delay suspend
after sleep

这是一个非常简单的例子,launch 启动一个 Coroutine,里面三行逻辑,中间是一个挂起操作。 下面我们来看看反编译之后的代码,我会对其做适当的重构和裁剪。如果想要想更好的理解我做了什么重构,在看过下面的讲解后,可以按照上面的步骤得到完整的反编译代码,回头来仔细对照下面的代码:

// Decompiled Coroutine4.kt
public static final void main() {
  // 1
	CoroutineScope coroutineScope = CoroutineScope(new CoroutineName("my")); 
  
  // 2
  BuildersKt.launch(coroutineScope, ...) {
    // 2.1 增加 label
    int label;
    
    public final Object invokeSuspend(@NotNull Object $result) {
      CoroutineSingletons suspendFlag = CoroutineSingletons.COROUTINE_SUSPENDED;
      switch (this.label) {
        case 0:
          // 2.2 delay 挂起点之前
          System.out.println("before delay suspend");
          // 2.3 设置 label 为 1
          this.label = 1;
          // 2.4 挂起点,这里会返回 CoroutineSingletons.COROUTINE_SUSPENDED
          // ** 注意这里的 this 参数 **
          if (DelayKt.delay(100L, this) == suspendFlag) {
            // 2.5 return 出 invokeSuspend 方法
            return suspendFlag;
          }
          break;
        case 1:
          // 2.6 主动检查是否在前面的步骤出现异常,或者取消
          ResultKt.throwOnFailure($result); 
          break;
        }
      
      // 2.7
      System.out.println("after delay suspend");
      return Unit.INSTANCE;
    }
  }
  // 3
  System.out.println("before sleep");
  Thread.sleep(200L);
  System.out.println("after sleep");
}

  上面反编译的代码,按照注释,我们依然分为三个部分,其中 part1,3 基本与原代码相同,我们主要看看 part2。先总览一下,新增了一个 invokeSuspend 函数,其逻辑占了 part2 的大多数,也是我们关注的重点。接下来我们按照顺序来过一遍 part2:
  1. 2.1 在 invokeSuspend 之前,增加了一个 label 变量,这是 launch 后面的 lambda 的全局变量
  2. 2.2 在 invokeSuspend 方法的 switch(lable) 的 case 0 分支,打印了 "before delay suspend"
  3. 2.3 在 delay 挂起点之前,把 label 的状态推进到了 1
  4. 2.4 这里是关键,这里调用了 delay 挂起函数,其参数不仅有我们的 delay时间:100L,另外还传入了 this,即 launch 后面的 lambda,调用结果会返回 COROUTINE_SUSPENDED(在 withContext 不变更 Dispatcher 或者在内部遇到其他挂起点时不会返回 COROUTINE_SUSPENDED)
  5. 2.5 直接从 invokeSuspend 方法中返回,第一阶段结束,就是说当前线程第一次执行这个 Coroutine 就结束了,也就是挂起了,接着去做其他事了
  6. 协程里的线程池中的线程会在 delay 的时间完成后,恢复执行这个 lambda,因为在 2.4 把 lambda 作为 this 传入了,要办到这一点很容易
  7. 2.6 例行检查,看前面的步骤是否出错,或者整个 Coroutine 已经取消,如果没问题就通过 break,进入 2.7
  8. 2.7 打印 "after delay suspend",并返回 Unit(意味着当前这个 Coroutine 全部执行结束)

  相信过完整个流程后什么是挂起也讲明白了,挂起意味着:当前阶段已经干完并从当前 Coroutine 的执行返回了,当前线程可以先去做其他事,等协程框架在合适的时间再恢复执行下一阶段。阶段的控制则由 kotlin 协程框架在函数编译时插入的插入的 label 变量控制,这个 label 在挂起点后插入。

  恢复到当前线程的秘密在 lambda 的 this 里面的 coroutineContext 保存了相关信息。因为状态控制和 EventLoop 机制的原因,其实 Coroutine 是被执行了多次,而不是执行了一次,不同的阶段是不连续的,只是因为写法看起来是连续的,所谓的 “切线程” 也是从连续的角度去看待而产生的误解(关于"切线程"这个概念的澄清,请参考线程篇)。

  我用一个玩家的术语来特别非正式地描述一下 Coroutine 和普通函数的区别,有玩家经验的请谨慎参考:普通函数就像一个普通的单机游戏,执行完就等于通关了,而 Coroutine 像是“良心的”单机游戏,有多周目,每一个挂起点就是一个周目的结束点,开启下一个周目的时候会继承前一个周目的经验和装备(恢复到挂起点),不用再从新手开始,新的周目可能有的任务和敌人会发生变化,我们就是在继承前一周目装备和经验的基础上继续新的任务。

总结

  一般意义上同步、异步的概念来源于任务和结果在不同线程的流转,而代码中的同步和异步则指的是 “平” 的写法和嵌套写法的区别。kotlin 协程中的异步变同步指的是代码写法的变化,其实现有以下几个基础:

  1. 挂起和恢复机制
  2. EventLoop(我们无法把一个挂起恢复到一个没有实现 EventLoop 模型的线程上)
  3. 自动生成的状态机,确保恢复到正确的位置 。

  本文以异步变同步为线索讲述了 suspend,Coroutine 背后的机制,加上结构化并发篇中已经介绍的 CoroutineScope, CoroutineContext,Job 等,我们已经涵盖了 kotlin 协程中大多数重要概念,大家可以在评论区中回复没有涵盖到的点,笔者尽量会在后面的文章中覆盖到。下一篇我们来看看与异步编程紧密相关的 Dispatchers 以及 kotlin 中的 Handler,线程池,下一篇见。

示例源码github.com/chdhy/kotli…
练习:把本文 Coroutine3.kt 中的例子反编译,参考本文的方式,精简一下反编译的代码,更加深入的认识一下协程的挂起(withContext 生成的必要代码比 dealy 更加复杂)

点赞👍文章,关注❤️ 笔者,获取其他文章更新

  1. 「最后一次,彻底搞懂kotlin协程」(一) | 先回到线程

  2. 「最后一次,彻底搞懂kotlin协程」(二) | 线程池,Handler,Coroutine

  3. 「最后一次,彻底搞懂kotlin协程」(三) | CoroutineScope,CoroutineContext,Job: 结构化并发

  4. 「最后一次,彻底搞懂kotlin协程」(四) | suspend挂起,EventLoop恢复:异步变同步的秘密

  5. 「最后一次,彻底搞懂kotlin协程」(五) | Dispatcher 与 Kotlin 协程中的线程池

  6. 「最后一次,彻底搞懂kotlin协程」(六) | 全网唯一,手撸协程!

  7. 「最后一次,彻底搞懂kotlin Flow」(一) | 五项全能 🤺 🏊 🔫 🏃🏇:Flow

  8. 「最后一次,彻底搞懂kotlin Flow」(二) | 深入理解 Flow

  9. 「最后一次,彻底搞懂kotlin Flow」(三) | 冷暖自知:Flow 与 SharedFlow 的冷和热