协程的基本概念
本章我们介绍协程的基本概念.
你的第一个协程
协程 是一段可挂起的计算代码的一个实例. 概念上类似于线程, 它包含一段需要运行的代码, 与其他代码并行工作. 但是, 协程并没有绑定到任何特定的线程上. 它的运行可以在一个线程上挂起, 然后在另一个线程中恢复运行.
协程可以看作是轻量的线程, 但有很多重要的区别, 使得协程的使用与线程非常不同.
下面请运行以下代码, 看看你的第一个协程:
你将看到以下运行结果:
我们来分析一下这段代码做了什么.
launch 是一个 协程构建器. 它启动一个新的协程, 协程会与其他代码并行执行, 其他代码则会继续自己的工作. 所以 Hello
会先输出.
delay 是一个特殊的 挂起函数. 它 挂起 协程一段指定的时间. 挂起一个协程不会 阻塞 底层的线程, 而是允许其他协程运行, 并使用底层的线程执行它们的代码.
runBlocking 也是一个协程构建器, 它负责联通通常的 fun main()
内的非协程的世界 与 runBlocking { ... }
括号之内使用协程的代码. IDE 会在 runBlocking
的开括号之后会提示 this: CoroutineScope
.
如果你在这段代码中删除或者忘记了 runBlocking
, 那么会在 launch 调用处发生错误, 因为 launch
声明在 CoroutineScope 上:
runBlocking
的名称表示, 运行它的线程 (在这个示例中 — 是主线程) 在调用期间之内会被 阻塞, 直到 runBlocking { ... }
之内的所有协程执行完毕. 你会经常在应用程序的最顶层看到这样使用 runBlocking
, 而在真正的代码之内则很少如此, 因为线程是代价高昂的资源, 阻塞线程是比较低效的, 我们通常并不希望阻塞线程.
结构化的并发
协程遵循 结构化的并发 原则, 意思就是说新的协程只能在一个指定的 CoroutineScope 之内启动, CoroutineScope 界定了协程的生命周期. 上面的示例代码中 runBlocking 建立了相应的作用范围(Scope), 所以前面的示例程序会等待, 直到延迟 1 秒后 World!
打印完毕, 然后才会退出.
在真实的应用程序中, 你会启动很多协程. 结构化的并发保证协程不会丢失或泄露. 直到所有子协程结束之前, 外层的作用范围不会结束. 结构化的并发还保证代码中的任何错误都会正确的向外报告, 不会丢失.
代码重构, 抽取函数
下面我们把 launch { ... }
之内的代码抽取成一个独立的函数. 如果在 IDE 中对这段代码进行一个 "Extract function" 重构操作, 你会得到一个带 suspend
修饰符的新函数. 这就是你的第一个 挂起函数. 在协程内部可以象使用普通函数那样使用挂起函数, 但挂起函数与普通函数的不同在于, 它们又可以使用其他挂起函数(比如下面的例子中使用的 delay
函数)来 挂起 当前协程的运行.
作用范围(Scope)构建器
除了各种构建器提供的协程作用范围之外, 还可以使用 coroutineScope 构建器来自行声明作用范围. 这个构建器可以创建一个新的协程作用范围, 并等待在这个范围内启动的所有子协程运行结束.
runBlocking 和 coroutineScope 构建器看起来很类似, 因为它们都会等待自己的代码段以及所有的子任务执行完毕. 主要区别是, runBlocking 方法为了等待任务结束, 会 阻塞 当前线程, 而 coroutineScope 只会挂起协程, 而低层的线程可以被用作其他用途. 由于这种区别, runBlocking 是一个通常的函数, 而 coroutineScope 是一个挂起函数.
你可以在任何挂起函数中使用 coroutineScope
. 比如, 你可以将打印 Hello
和 World
并发代码移动到一个 suspend fun doWorld()
函数之内:
这段代码的输出同样是:
作用范围构建器与并发
在任何挂起函数之内, 可以使用 coroutineScope 构建器来执行多个并发的操作. 我们在 doWorld
挂起函数内启动 2 个并发的协程:
launch { ... }
之内的 2 段代码会 并发 执行, 启动之后 1 秒会先输出 World 1
, 启动之后 2 秒会输出 World 2
. 直到 2 段代码都结束之后, doWorld
之内的 coroutineScope 才会结束, 然后 doWorld
函数会返回, 直到这时才会输出 Done
:
明确控制的 job
launch 协程构建器会返回一个 Job 对象, 它是被启动的协程的管理器, 可以用来明确的等待协程结束. 比如, 你可以等待子协程结束, 然后再输出 "Done":
这段代码的输出是:
协程是非常轻量的
与 JVM 线程相比, 协程消耗更少的资源. 有些代码使用线程时会耗尽 JVM 的可用内存, 如果用协程来表达, 则不会达到资源上限. 比如, 以下代码启动 50,000 个不同的协程, 每个协程等待 5 秒, 然后打印一个点号('.'), 但只消耗非常少的内存:
如果你使用线程来实现同样的功能 (删除 runBlocking
, 将 launch
替换为 thread
, 将 delay
替换为 Thread.sleep
). 那么会消耗非常多的内存. 根据你的操作系统, JDK 版本, 以及程序的设定, 这段程序要么会抛出内存不足(out-of-memory)的错误, 要么会缓慢的启动线程, 以免出现太多并发运行的线程.