Back
Featured image of post Kotlin Coroutine 协程 - 02 协程入门教程

Kotlin Coroutine 协程 - 02 协程入门教程

简单上手 Kotlin 协程的使用,了解协程的 Job,上下文,作用域等特性

本文是对 Kotlin Coroutines by Tutorials By Filip Babić and Nishant Srivastava 书本内容的转述和笔记书本内容是受原版权保护的内容

上一篇:Kotlin Coroutine 协程 - 01 异步开发往事书

Chapter 2: 协程入门教程

所以你已经了解了大量关于异步开发的历史,是时候让你更多地了解协程以及它是如何工作的。

在本文中,你将

  • 了解 Routine 以及程序如何控制其执行流程

  • 了解代码中的 Suspending FunctionsSuspension Points

  • 启动(Launch) 你的第一个 Kotlin 协程,并在后台创建工作(Jobs)

  • 通过创建一些经典任务来实践你所学到的知识,包括向 UI 线程发布信息。

Executing routines 执行程序

这里的语言可能会看上去有点废话,贴一部分上一篇的内容帮助没有看过的人更好的理解 「一旦你启动一个协程,或者调用一个 Suspend 函数,这个函数的返回值结果会被很好的包裹起来,立刻返回给你,这个值就像一个玉米饼一样,从外观上看,馅饼皮是准备好了,但是直到你在使用到这个变量之前,或者说,吃这个馅饼之前,里面的代码不会被执行…‘getUser’ 函数将会被标记为 Suspend 函数,这意味着系统会在后台准备调用,你会得到一个未完成的、但包装好的玉米饼。但它可能还没有执行这个函数。系统会将它移到线程池中,在那里等待进一步的命令。一旦你准备吃玉米卷,并调用结果,程序就会阻塞,直到你得到一个准备好的玉米饼…知道了这一点,程序就可以跳过函数代码的其他部分,直到它到达使用这个函数返回结果的第一行代码,这被称为 Awaiting。然后到了使用返回结果的那个点,协程开始执行 getUser 函数,如果 getUser 还没有执行完的话,协程就会 Suspend 程序,等到执行完了再从这里恢复,再继续执行。」

每当你启动一个程序,你的计算机就会创建一个叫做 主程序(Main Routine) 的东西。这是每个程序的核心部分,因为它是你设置和运行代码中所有其他组件的地方。

作为最基本的学习样本,你通常有一个 主函数(Main Function),它打印出 Hello World。这个主函数是你程序的入口点,是主程序的一部分。

但随着你的程序越来越大,函数的数量和对其他函数的调用也越来越多。

每当你在主程序块中调用一些其他函数时,你就会启动一个叫做 子程序(Subroutine) 的东西。一个子程序只是嵌套在另一个程序中的程序。计算机将所有这些所有的程序放在 调用堆栈(Call Stack) 中,这个结构可以跟踪当前正在运行的程序以及当前程序被调用的情况。当一个子程序运行结束后,它就会从堆栈中 弹出(Popped off),控制权将被重新传回给调用它的子程序。最后,如果堆栈是空的,也没有其他东西可以运行,程序就结束了。

调用子程序就像做阻塞式调用。而协程可以认为是某种类型的子程序,但是你可以通过非阻塞式的方法调用子程序。正因为如此,标准的一个子程序和协程的主要区别在于,后者可以与其他代码并行运行。你可以启动并忘记它们,继续进行程序的其他部分。

Launch a Coroutine 启动协程

编写一个 Demo 如下,

fun main() {
  (1..10000).forEach {
    GlobalScope.launch {
      val threadName = Thread.currentThread().name
      println("$it printed on thread ${threadName}") 
    }
  }
  Thread.sleep(1000)
}

这段代码会启动一万个协程,你可以看到许多的输出,每一行都在具体的写出在打印什么数字,在什么线程上。但是如果启动一万个线程,大概率你将会得到的是 OutOfMemoryException

但是注意,因为协程的本质是一套线程框架,协程需要在线程的基础上运行,这个对比其实不够严谨,因为协程对应的应该是线程池 API,而不是原生线程,如果你用线程池来做这个实验得到的性能效果会和协程几乎一模一样。参考:扔物线 - 到底什么是「非阻塞式」挂起?协程真的比线程更轻量级吗?
协程本质上是提供了一套更好用,写起来,看上去比原有线程池,RxJava 等线程框架更易于理解和学习的线程框架。
协程并不直接提升异步的性能,而是简化异步开发的步骤,使得异步开发变得简单清晰,从而间接的使得你写出更有利于程序性能的代码。

在上面的代码中,launch 函数包裹了协程的主体,并传递之后的 lambda 代码块,这被称之为 协程构造器(Coroutine Builder)

其次,在启动协程时,你必须提供一个 作用域(Scope),因为协程是一种后台机制,它们并不真正关心自己的起点和整体的生命周期。设想一下,如果在协程完成之前,程序就结束了,这会发生什么?在这种情况下,你会使用一个叫做 GlobalScope 的东西,他表明了,在这个全局作用域 GlobalScope 下,协程的生命周期是和程序的生命周期绑定在一起的。正因为如此,你还需要将当前线程通过 Thread.sleep(1000) 进行暂停和阻塞,等待协程运行结束。

Building a Coroutine 构建一个协程

在 Kotlin 协程库中,有许多的协程生成方法供你使用,用以 launch 一个新的协程。而在前面的例子中,你调用了 launch 方法来使用协程构建器。

public fun CoroutineScope.launch(
  context: CoroutineContext = EmptyCoroutineContext, 
  start: CoroutineStart = CoroutineStart.DEFAULT, 
  block: suspend CoroutineScope.() -> Unit ): Job

正如你所看到的,启动函数有几个参数可以传入:CoroutineContextCoroutineStartlambda 函数。其中 lambda 函数决定了当你启动协程时将会发生什么,而前两个参数是可选的。

CoroutineContext 是一个持久性的数据集,它包含了关于当前协程的上下文信息。它还可以包含一些对象,例如该程序的 JobDispatcher,这两个对象我们将在后面提到。由于你在上面的代码中没有指定任何上下文,它将自动使用 EmptyCoroutineContext,这意味着你不关心 CoroutineScope 所处的上下文。如果你愿意,你可以创建自定义的上下文,但在大多数情况下,现有的上下文就足够了。

CoroutineStart 是你可以启动一个协程的模式,选项包括:

  • DEFAULT:根据上下文,立即安排开始执行这个协程

  • LAZY:懒启动协程,必须通过 job.start(), job.join 等方式手动触发协程

  • ATOMIC:与 DEFAULT 相同,但是无视过程中的任何取消操作

  • UNDISPATCHED:先立刻在当前线程下执行,直到运行到该协程内部的第一个 Suspension Point 才开始调度执行(调度的属性和上下文由那个 Suspension Point 决定)

我们后面仍会提及这四种启动方式,一时半会看不明白完全不用担心,因为这牵扯了后面需要理解的一些知识
对于这四种模式的详细解释可参考 Benny Huo - 破解 Kotlin 协程 - 2 协程启动篇

最后,你需要指定一个 lambda 块,其中包含协程需要执行的代码。如果你检查一下前面对启动函数的定义,你会发现这个 lambda 块的签名与标准lambda 块有些不同。它的签名是 block: suspend CoroutineScope.() -> Unit。这是一个带有 CoroutineScope 接收器的 lambda。也就是说,你的代码块实际运行的空间是在 CoroutineScope 下,因此你可以由此嵌套的启动更多的协,并且这个 lambda 块是被标记为 suspend 的。

Scoping Coroutines 让协程工作在作用域里

正如你所学到的那样,协程的执行可以与主程序平行启动。然而这并不意味着,如果主程序结束或停止了,协程也会随之停止。至少在 Kotlin 协程 API 的早期几个版本中是这样的。这种无法控制的行为导致了一些奇奇怪怪的错误,即使你关闭了应用程序,应用程序也会把协程执行完毕。

为了解决像这样的生命周期管理问题,协程 API 团队实现了一个 CoroutineScopes。每个 作用域(Scope) 都知道它与哪个上下文有关,而且每个作用域都有自己的生命周期。如果你的协程所使用作用域的生命周期结束了,所有的 Job,即使是正在进行的,也会停止。这就是为什么,如果你尝试在没有 Thread.sleep 的情况下运行上述代码,可能会没有任何输出,或者只有一部分输出。

由于你必须在任意一种 CoroutineScope 上调用 launch 函数,有两种方法可以做到。你可以使用 GlobalScope,就像刚刚那段代码,你并不关心协程到底在哪里被启动。或者你可以自己实现 CoroutineScope 接口,并提供一个 CoroutineContext 的实例,你可以在这其中运行协程。显然,前者更简单,当你不关心协程的结果,也不关心 Job 的完成情况时,这是一个很好的选择。但是如果你想指定,使用协程结果的地方(比如UI线程),以及当你想把 Job 绑定到某个对象实例的生命周期,比如 Android 中的 Activity 实例时,后者将会更好。

有些情况下,生命周期或手动取消不一定会取消协程。你不仅要提供取消机制,而且还要写出合作的代码(Cooperative Code)。这意味着你的函数要检查其包装的 Job 是否正在运行。在本文的后面,你会看到如何做到这一点。

Explaining Jobs 理解 Jobs

如果你已经注意到了,协程中的大多数东西都会指向 Job,你会创建并运行它。这个 Job 也是你之前使用 launch 函数所返回的。但什么是 Job 对象,你能用它做什么呢?

当你启动一个协程时,你基本上要求在要求系统执行你的 lambda 代码块,而这段代码实际上并不会被立即执行,而是被插入到一个队列中。

一个 Job 基本上是队列中协程的一个处理者(Handle)。它只有几个字段和函数,但它提供了很多可扩展性。例如,可以使用 join 函数在不同的 Job 实例之间引入一个依赖关系。例如 Job AJob B 调用了 join 函数,这意味着在 B 完成之前, A 不会被执行。也可以使用特定的协程构造器在 Job 实例之间建立 父子(Parent-child) 关系。一个 Job 中的任何的一个子 Job 没有完成,那么这个父 Job 就不能完成。所有的子 Job 必须完成,以使其父 Job 完成。

Job 的抽象结构,使得状态控制成为可能,这些状态的转换遵循下图描述的流程。

协程 Job 流程

当你启动一个协程时,你创建一个 Job,它会先是处于 NEW 状态,然后直接进入 ACTIVE 状态,不过这取决于你使用的协程构建器的 CoroutineStart 参数(DEFAULT 就会直接被调度且直接被运行,就会直接进入 ACTIVE 状态,而 LAZY 便只会处于 NEW 状态)。当协程处于 NEW 状态,而没有进入 ACTIVE 状态时,你也可以使用 startjoin 函数,将一个 JobNEW 状态移动到 ACTIVE 状态。一个正在运行的协程总是处于 ACTIVE 状态。正如你在状态图中所看到的,Job 可以完成也可以被取消。

完成和取消对依赖型 Job 实例的作用是非常重要的。特别是,你可以看到一个 Job 一直处于 COMPLETING 状态,直到它的所有子 Job 完成。这种完成状态是内部的,如果从外部查询,会导致该 Job 变回 ACTIVE 状态。

状态是非常基本的属性,因为它们给你提供了关于协程正在进行的信息,以及你可以对它们做什么。你也可以查询一个 Job 的状态并采取相应的行动,或者简单地迭代这些子 Job 并对它们做一些事情。

创建一个 Job 是非常容易的,嵌套起来也不难。你已经看到了它们是如何完成工作的,但在被取消或出现异常的情况下,Job 会发生什么?

Canceling Jobs 取消 Jobs

当你启动一个协程并创建一个 Job 时,可能会发生很多奇奇怪怪的事情。异常可能发生,或者你可能需要取消这个 Job,因为可能你的程序中出现了一些新的情况。例如,假设你需要从网络上下载一个图片列表,每当你需要将一张图片显示在一个列表中的一行时,你就启动一个下载的协程。这个下载可能因为没有连接而失败,你必须处理相关的异常。或者下载可能被取消,因为用户在滚动列表时,图像在下载好之前就离开了屏幕。在使用协程时,理解如何管理这种情况是非常重要的。

通常情况下,一个 未捕获的异常(Uncaught Exception) 会导致整个程序崩溃。然而,由于协程有 Suspend 行为,如果发生异常,也可以 Suspend 并在以后管理。

更简单的是,你可以处理 Job 取消的方式。你可以通过调用相关 Job 实例上的 cancel 函数来实现。然后,系统就会很聪明地理解 Job 对象之间的依赖关系。如果你取消一个 Job,你会自动取消它的所有子 Job。如果它还有一个父 Job,那么这个父 Job 也会被取消。所以这是一个连锁反应,嵌套 Job 中的任何一个 Job 被取消,所有的 Job 都会被取消。

当然你也可以使用一个特殊的父 Job,它不要求所有的子 Job 都顺利完成。它们可以被取消,也可以独立失败。这种 Job 被称为 SupervisorJob

如前所述,即使你取消了一个 Job,你的代码也可能不关心取消事件。但是如果你想的话,你可以通过使用其 isActive 属性来检查这一点,使用 isActive 标志运行 while 循环比使用你自己的条件更安全。或者你至少应该在你的条件之上,尝试依赖 isActive

Digging Deeper into Coroutines 深入了解协程

到目前为止,你已经启动了大量的协程,而且你已经知道了多个协程 Job 之间可以建立起他们的依赖关系。但是,在启动一个协程时,你还可以做其他事情。例如,你有一些工作必须要在运行前延迟一段时间,你可以用 delay 函数来做这件事。

fun main() {
  GlobalScope.launch { 
    println("Hello coroutine!")
    delay(500)
    println("Right back at ya!") 
  }
  Thread.sleep(1000)
}

如果你运行上面的代码,你应该在控制台中看到 Hello coroutine,然后是,Right back at yadelay 函数可以高效地等待给定的时间,然后在一切准备就绪后运行代码。

相较于 Thread.sleep()delay 函数更加高效,因为 Thread.sleep() 会直接阻塞当前线程(它真的开始睡大觉了),直到时间停止,线程恢复。而 delay 函数则是会将当前线程调度到别的需要的地方,线程会持续的工作。而当 delay 时间结束时,则会使用其他线程恢复协程。

Dependent Jobs in Action 让 Job 之间依赖起来

到目前为止,你已经了解到,每当你启动一个协程时,你可以得到一个 Job 引用。你还可以在不同的 Job 实例之间建立依赖关系──但如何建立呢?只要把前面的代码换成这样就行了。

fun main(args: Array<String>) {
  val job1 = GlobalScope.launch(start = CoroutineStart.LAZY) { 
    delay(200) 
    println("Pong") 
    delay(200) 
  }

  GlobalScope.launch { 
    delay(200) 
    println("Ping") 
    job1.join() 
    println("Ping") 
    delay(200) 
  } 
  
  Thread.sleep(1000)
}

通过上面的代码。

  • 你首先启动了一个包含一些 delay 并打印 Pong 字的协程,将创建的 Job 保存到 job1 的引用中。

  • 然后,你又启动了包含几个 println 的协程,同时也调用了 job1join 函数。

那么预期的输出会是什么?

如果你直接跟着代码走,你会期望看到 Pong,然后是两次 Ping,但事实并非如此。

正如你所看到的,你用 CoroutineStart.LAZY 的值作为 CoroutineStart,这意味着相关的代码将只会在你真正需要时被执行。

而在这里,「真正被需要」发生在第二个协程调用 job1join 函数时。这就是为什么前面的代码的结果应该是 PingPongPing

Managing Jobs Hierarchy 管理 Job 的层级关系

在前面的代码中,你在不同的 Job 实例之间创建了一个依赖关系,但这不是那种父-子的关系。同样,用下面的代码来代替前面的代码,此时你可以使用with 函数,以避免重复出现 GlobalScope 接收器。

fun main(args: Array<String>) {
  with(GlobalScope) { 
    val parentJob = launch { 
      delay(200)
      println("I'm the parent")
      delay(200)
    }
    launch(context = parentJob) { 
      delay(200) 
      println("I'm a child")
      delay(200)
    }
    if (parentJob.children.iterator().hasNext()) {
      println("The Job has children ${parentJob.children}")
    } else { 
      println("The Job has NO children") 
    }
    Thread.sleep(1000)
  }
}

依次翻阅上述代码。

  • 在这里,你启动了一个协程,并将其 Job 分配给 parentJob 引用。

  • 然后,你使用前一个 Job 作为 CoroutineContext 启动另一个协程。能这么做的原因是 Job 也实现了 CoroutineContext 接口。在此之下,你在这里传递的由 Job 充当的 CoroutineContext 与当前激活的那个 CoroutineContext 会由此 合并(Merged),而由于你使用的是 GlobalScope 所以先前激活的上下文是 EmptyCoroutineContext

如果你运行前面的代码,你可以看到 parentJob 是如何拥有子 Job 的。如果你运行同样的代码,去掉第二个协程构造器的上下文,你可以看到父子关系没有建立,子 Job 也不存在。

Using Standard Functions with Coroutines 与标准函数一起使用协程

另一件你可以用协程做的事情是建立一种重试机制(类似于断线重连)。 使用标准库中的 repeat 函数,再配合上面学到的 delay 协程函数,你可以使得协程实现一种「重试」的逻辑。

fun main(args: Array<String>) {
  var isDoorOpen = false
  println("Unlocking the door... please wait.\n")
  GlobalScope.launch { 
    delay(3000)
    isDoorOpen = true
  }
  GlobalScope.launch {
    repeat(4) { 
      println("Trying to open the door...\n")
      delay(800)
      if (isDoorOpen) { 
        println("Opened the door!\n")
      } else {
        println("The door is still locked\n")
      }
    }
  }
  Thread.sleep(5000)
}

试着运行这段代码。你应该可以看到,有人在尝试开门几次后,最终成功了。

因此,使用 delay 函数,以及 Kotlin 标准库中的 repeat,你成功地建立了一个重试机制。你可以使用同样的流程来构建网络化的回退和重试逻辑。一旦你在稍后学习了如何从协程中返回值,你就会发现这可以有多么强大。

Posting to the UI Thread 推送到 UI 线程

目前为止,你所看到的协程的绝大部分功能都是内置于语言本身的,这些都相对简单。向 UI 线程推送信息也并不复杂,简而言之便是,启动一个新的协程,以 UI 调度器作为其线程的上下文即可。

由于我们讨论的是具有可见用户界面的应用程序,你可以在 Android、Swing 和 JavaFx 应用程序中向主线程推送信息。你可以用 Dispatchers.Main 作为上下文,以如下方式实现。

GlobalScope.launch(Dispatchers.Main) { ... }

不过,你需要小心,因为这还不够。你可能还需要设置以下依赖关系之一。

implementation ’org.jetbrains.kotlinx:kotlinx-coroutines-android:...
implementation ’org.jetbrains.kotlinx:kotlinx-coroutines-swing:...
implementation ’org.jetbrains.kotlinx:kotlinx-coroutines-javafx:...

否则将会发生…

Exception in thread "DefaultDispatcher-worker-3"
java.lang.IllegalStateException: Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. ’kotlinxcoroutines-android’

你可以通过 Swing 来尝试这个例子,将下面这段代码加入 build.gradle

implementation ’org.jetbrains.kotlinx:kotlinx-coroutines-swing:$kotlin_coroutines_version'

然后你可以写入如下代码

fun main() {
  GlobalScope.launch {
    val bgThreadName = Thread.currentThread().name
    println("I’m Job 1 in thread $bgThreadName")
    delay(200)
    GlobalScope.launch(Dispatchers.Main) {
      val uiThreadName = Thread.currentThread().name
      println("I’m Job 2 in thread $uiThreadName")
    }
  }
  Thread.sleep(1000)
}

在最外部的第一个协程会打印出它所执行的线程的名称。而在短暂的延迟之后,你使用 Dispatchers.Main 作为 CoroutineContext 启动另一个协程,这意味着这段代码将运行在主线程。

所以如果你运行这段代码,你会得到这样的结果:

I’m Job 1 in thread DefaultDispatcher-worker-1
I’m Job 2 in thread AWT-EventQueue-0

第一个 Job 已经由一个 worker 线程在后台执行了,而第二个是在 Swing 中的主线程。

下一篇:Kotlin Coroutine 协程 - 03 挂起函数

comments powered by Disqus