Kotlin 协程入门
本文主要介绍协程长什么样子, 协程是什么东西, 协程挂起的实现原理以及整理了协程学习的资料.
协程 HelloWorld
协程在官方指南中被称为一种轻量级的线程, 所以在介绍协程是什么东西之前, 这里通过几个与线程对比的小例子初步认识协程.
启动线程与启动协程
/* Kotlin code - Example 1.1 */
// 创建一条新线程并输出 Hello World.
thread {
println("使用线程输出 Hello World! Run in ${Thread.currentThread()}")
}
// 创建一个协程并使用协程输出 Hello World.
GlobalScope.launch {
println("使用协程输出 Hello World! Run in ${Thread.currentThread()}")
}
/* output */
使用线程输出 Hello World! Run in Thread[Thread-0,5,main]
使用协程输出 Hello World! Run in Thread[DefaultDispatcher-worker-1,5,main]
上面的例子是一个简单的输出 Hello World 的程序. 在这个例子中, 我们可以看到创建并启动一条协程和创建并启动一条线程的代码几乎一致, 唯一不同的就是创建线程调用的是 #thread
方法, 而创建协程调用的是 GlobalScope#launch
方法.
暂停线程与暂停协程
/* Kotlin code - Example 1.2 */
fun demoSleep() {
// 创建并运行一条线程, 在线程中使用 Thread#sleep 暂停线程运行 100ms.
thread {
val useTime = measureTimeMillis {
println("线程启动")
println("线程 sleep 开始")
Thread.sleep(100L)
println("线程结束")
}
println("线程用时为 $useTime ms")
}
}
fun demoDelay() {
// 创建并运行一条协程, 在协程中使用 #delay 暂停协程运行 100 ms.
GlobalScope.launch {
val useTime = measureTimeMillis {
println("协程启动")
println("协程 delay 开始")
delay(100L)
println("协程结束")
}
println("协程用时为 $useTime ms")
}
}
/* output */
线程启动
线程 sleep 开始
线程结束
线程用时为 102 ms
协程启动
协程 delay 开始
协程结束
协程用时为 106 ms
上面例子展示了暂停线程和暂停协程的方法. 我们可以使用 Thread#sleep
方法暂停一条线程, 而暂停一条协程, 只需要把 Thread#sleep
直接替换成 #delay
就可以了.
等待线程执行结束与等待协程执行结束
/* Kotlin code - Example 1.3 */
/**
* 线程等待另外一个线程任务完成的方法
*/
private fun waitOtherJobThread() {
// 启动线程 A
thread {
println("线程 A: 启动")
// 随便定义一个变量用于阻塞线程 A
val waitThreadB = Object()
// 启动线程 B
val threadB = thread {
println("线程 B: 启动")
println("线程 B: 开始执行任务")
for (i in 0..99) {
Math.E * Math.PI
}
println("线程 B: 结束")
}
// 线程 A 等待线程 B 完成任务
println("线程 A: 等待线程 B 完成")
threadB.join()
println("线程 A: 等待结束")
println("线程 A: 结束")
}
}
/**
* 协程等待另外一个协程任务完成的方法
*/
private fun waitOtherJobCoroutine() {
// 启动协程 A
GlobalScope.launch {
println("协程 A: 启动")
// 启动协程 B
val coroutineB = GlobalScope.launch {
println("协程 B: 启动")
println("协程 B: 开始执行任务")
for (i in 0..99) {
Math.E * Math.PI
}
println("协程 B: 结束")
}
// 协程 A 等待协程 B 完成
println("协程 A: 等待协程 B 完成")
coroutineB.join()
println("协程 A: 等待结束")
println("协程 A: 结束")
}
}
/* output */
线程 A: 启动
线程 A: 等待线程 B 完成
线程 B: 启动
线程 B: 开始执行任务
线程 B: 结束
线程 A: 等待结束
线程 A: 结束
协程 A: 启动
协程 A: 等待协程 B 完成
协程 B: 启动
协程 B: 开始执行任务
协程 B: 结束
协程 A: 等待结束
协程 A: 结束
在上面的例子中, 创建了一条线程 A(协程 A), 然后在线程 A(协程 A)中再创建一条线程 B(协程 B), 接着使用 #join
方法使线程 A(协程 A)等待线程 B(协程 B)执行结束. 我们可以清楚的看到等待线程和等待协程的代码几乎是一致的, 甚至连等待的方法都是 #join
.
中断线程与中断协程
/* Kotlin code - Example 1.4 线程的中断与协程的中断. */
private fun cancelThread() {
val job1 = thread {
println("线程: 启动")
// 循环执行 100 个耗时任务.
for (i in 0..99) {
try {
Thread.sleep(50L)
println("线程: 正在执行任务 $i...")
} catch (e: InterruptedException) {
println("线程: 被中断了")
break
}
}
println("线程: 结束")
}
// 延时 200ms 后中断线程.
Thread.sleep(200L)
println("中断线程!!!")
job1.interrupt()
}
private fun cancelCoroutine() = runBlocking {
val job1 = GlobalScope.launch {
println("协程: 启动")
// 循环执行 100 个耗时任务.
for (i in 0..99) {
try {
delay(50L)
println("协程: 正在执行任务 $i...")
} catch (cancelException: CancellationException) {
println("协程: 被中断了")
break
}
}
println("协程: 结束")
}
// 延时 200ms 后中断协程.
delay(200L)
println("中断协程!!!")
job1.cancel()
}
/* output */
线程: 启动
线程: 正在执行任务 0...
线程: 正在执行任务 1...
线程: 正在执行任务 2...
中断线程!!!
线程: 被中断了
线程: 结束
协程: 启动
协程: 正在执行任务 0...
协程: 正在执行任务 1...
协程: 正在执行任务 2...
中断协程!!!
协程: 被中断了
协程: 结束
在上面例子中, 可以看到中断线程调用的方法是 #interrupt
, 当线程被中断后会抛出 InterruptedException
. 中断协程的方法为 #cancel
. 协程被中断后会抛出 CancellationException
.
通过上面的几个小例子, 我们可以看到几乎每一个线程的方法在协程中都有一个方法与之对应. 除了调用的方法名称不一样, 协程在使用上可以说几乎和线程没有特别大的区别.
协程是什么?
可挂起的计算实例。 它在概念上类似于线程,在这个意义上,它需要一个代码块运行,并具有类似的生命周期 —— 它可以被创建与启动,但它不绑定到任何特定的线程。它可以在一个线程中挂起其执行,并在另一个线程中恢复。而且,像 future 或 promise 那样,它在完结时可能伴随着某种结果(值或异常)。
上面这段话引用自 Kotlin 官方协程设计文档中对协程的描述. 那么这段话应该怎么理解呢? 首先, 协程需要一个计算实例. 类比与线程, 创建和启动线程同样需要一个计算实例. 对于线程来说, 线程的计算实例是 Runnable
, 我们需要把 Runnable
扔给线程才能在线程中完成计算任务. 对于协程来说, 这个计算实例是 suspend
关键字修饰的方法或 lambda 表达式, 我们需要把这个 suspend
关键字修饰的方法或 lambda 表达式扔给协程才能在协程中完成计算任务. 接着, 除了需要一个计算实例之外, 协程中的这个计算实例还必须是可挂起的, 这也是协程和线程的区别. 那么可挂起是什么意思呢? 比如在上面暂停线程与暂停协程的例子中, 线程和协程同样是等待 100ms, 在线程的实现方式中, 是通过调用 Thread#sleep
方法阻塞线程来实现的, 而在协程的实现中, 调用 #delay
实现的等待是不会阻塞任何线程的(协程也是运行在某一条线程上的). 同样是等待, 线程等待的实现方式会阻塞线程, 而协程等待的实现方式不会阻塞线程, 所以就把线程的等待称之为阻塞, 把协程的等待称之为挂起. 同样的, 在上面等待线程执行结束与等待协程执行结束例子中, 线程调用 threadB#join
势必会造成线程 A 的阻塞, 而在协程中, 调用 coroutineB#join
也能实现同样的功能却不会造成任何线程的阻塞.
协程挂起的实现原理
经过上文的简单介绍, 我们知道了协程是什么, 协程和线程的区别是什么. 这里做个总结, 协程是一个可挂起的计算实例, 和线程的区别就是协程的计算实例在执行某些需要等待的任务时是可挂起的, 不阻塞线程的. 那么下面就开始介绍 Kotlin 协程是怎么实现等待某些任务而不阻塞线程的.
/* Kotlin code - Example 2.1*/
/**
* 自定义一个 delay 挂起函数. 功能和协程库中的 [delay] 函数是一样的.
* 这里使用的是标准库中定义挂起函数的方法.
*/
private suspend fun customDelay(delayInMillis: Long) = suspendCoroutine { complete: Continuation<Unit> ->
// 创建一个可延时执行任务的 Thread Executor.
val executorService = Executors.newSingleThreadScheduledExecutor()
// 延时 delayInMillis ms 后调用 complete#resume 方法通知该任务已经执行完成了.
executorService.schedule({
complete.resume(Unit)
executorService.shutdown()
}, delayInMillis, TimeUnit.MILLISECONDS)
}
/**
* suspend 函数可以类比于 Thread 中的 Runnable.
* 同时, suspend 函数还被看作挂起点, 也就是说运行到这个函数的时候
* 可能会被切换到其他线程当中运行.
*/
private suspend fun doSomething() {
println("A")
customDelay(10L) // 挂起点
println("B")
customDelay(10L) // 挂起点
println("C")
}
/**
* Example 2.1 使用标准库启动一个协程.
*/
fun main() {
// 位于标准库中的协程启动函数
::doSomething.startCoroutine(Continuation(EmptyCoroutineContext) {
println(">>> doSomething Completed <<<")
})
// 防止进程退出.
Thread.sleep(1000L)
}
/* output */
A
B
C
>>> doSomething Completed <<<
在正式介绍协程挂起原理之前, 需要先简单介绍一下协程的几个基本知识点.
- 所有
suspend
修饰的函数或 lambda 表达式可以直接通过public fun <T> (suspend () -> T).startCoroutine(completion: Continuation<T>)
这个拓展方法创建并启动协程, 该方法在suspend
修饰的计算实例(也就是前文提到的suspend
修饰的函数或 lambda 表达式)完成计算后会回调参数的#resumeWith
方法. 这个函数是最底层创建并启动协程的函数, 所有封装的协程构建器最终都要通过这个方法来创建并启动一条协程. 在上面与线程对比的几个小例子中使用到的GlobalScope#launch
协程构建器最终也会调用该方法来创建并启动协程. 这也是在协程是什么这一节提到协程需要的计算实例是suspend
关键字修饰的方法或 lambda 表达式的原因. -
suspend
修饰的函数被称为挂起函数. 调用挂起函数可能会挂起计算实例, 所以调用挂起函数的地方也被称为挂起点. 在上面代码示例中,#customDelay
和#doSomething
都是挂起函数. 在#doSomething
中调用#customDelay
的地方被称为挂起点. -
public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T
这个函数的作用是把被编译器隐藏的Continuation
参数暴露出来. 我们可以通过这个函数自定义自己的挂起函数, 实现等待却不阻塞线程的任务.
在弄懂这几个知识点之后, 上面的代码就很容易知道是干什么的了. 上面的代码实际上就是使用 #doSomething
创建并启动一条协程, 在 #doSomething
中依次输出 "A", "B", "C". 执行完 #doSomething
之后输出 "doSomething Completed". 在执行 #doSomething
的过程中会调用自定义的 #customDelay
方法挂起等待 10ms.
说了这么多, 那么协程到底是如何实现等待而不阻塞线程的呢? 这里面的原理其实十分简单. 协程实现等待而不阻塞线程的方法就是通过回调, 只不过这个回调是 Kotlin 编译器实现的. 既然是编译器实现的, 那么我们就需要反编译一下这段代码看看 Kotlin 编译器到底做了什么黑科技的东西. 在 idea 中, Kotlin 编译后的代码可以通过 Tools -> Kotlin -> show kotlin bytecode 这几个步骤查看. 为了更加清晰的展示编译器干了什么东西, 这里我就直接贴我整理过后的反编译 Java 代码了. 下面这段整理过的 Java 代码和反编译的代码是等效的.
/* Java code - Example 2.2 */
public class StartCoroutineSimulation {
/**
* 一个可挂起的计算实例. 根据协程的定义, 这个接口的对象就是协程.
*/
interface Continuation {
/**
* 唤醒被挂起的计算任务, 继续运行.
*/
void resume();
}
/**
* 自定义一个 delay 挂起函数. 功能和协程库中的 [delay] 函数是一样的.
* 这里使用的是标准库中定义挂起函数的方法.
*/
private static void customDelay(Continuation complete, long delayInMillis) {
Continuation continuation = new Continuation() {
@Override
public void resume() {
// 创建一个可延时执行任务的 Thread Executor.
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
// 延时 delayInMillis ms 后调用 complete#resume 方法通知该任务已经执行完成了.
executorService.schedule(() -> {
complete.resume();
executorService.shutdown();
}, delayInMillis, TimeUnit.MILLISECONDS);
}
};
continuation.resume();
}
private static void doSomething(Continuation complete) {
Continuation continuation = new Continuation() {
int label = 0;
@Override
public void resume() {
switch (label) {
case 0:
// 片段任务 A
label = 1;
System.out.println("A");
customDelay(this, 10L);
return;
case 1:
// 片段任务 B
label = 2;
System.out.println("B");
customDelay(this, 10L);
return;
case 2:
// 片段任务 C
label = 3;
System.out.println("C");
break;
}
complete.resume();
}
};
continuation.resume();
}
/**
* Example 2.2 模拟 kotlin 协程标准库启动一个协程.
*/
public static void main(String[] args) {
doSomething(new Continuation() {
@Override
public void resume() {
System.out.println(">>> doSomething Completed <<<");
}
});
}
}
/* output */
A
B
C
>>> doSomething Completed <<<
通过反编译的代码, 我们可以看出 Kotlin 编译器做了以下几个点.
- 每一个挂起函数都被编译成了一个
Continuation
. - 每一个挂起函数都被编译器添加了一个
Continuation
参数. 在完成该函数的任务之后, 会回调该参数的#resume
方法. 该参数在 Kotlin 的源码中是被隐藏的, 所以自定义挂起函数的时候需要使用public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T
函数把隐藏的Continuation
参数暴露出来, 以便通知调用者任务已经完成了. - 如果挂起函数中有挂起点, 被编译成的
Continuation
中的#resume
方法会被实现成状态机模式. 两两挂起点之间组成一种状态. 在上面例子中, 我们可以清晰的看到println("A")
到第一个#costomDelay
方法之间组成了第一种状态,println("B")
到第二个#customDelay
方法之间组成了第二种状态, 最后的println("C")
组成最后一种状态. - 在一个挂起函数调用另外一个挂起函数时, 需要把自身作为参数传入另外一个挂起函数中. 当另外一个挂起函数完成时, 会回调参数的
#resume
方法通知调用者继续完成任务. 例如在上面的例子中, 从#doSomething
函数调用#customDelay
函数对应代码customDelay(this, 10L);
中可以看出#doSomething
把自身this
作为参数传给了#customDelay
方法, 而最终自定义的#customDelay
方法在等待任务结束后通过complete.resume();
这句代码让#doSomething
函数继续运行. 顺带一提, Kotlin 规定suspend
修饰的函数或 lambda 表达式只能在suspend
修饰的函数或表达式中调用. 就是因为suspend
修饰的函数或 lambda 表达式会编译成需要Continuation
参数的Continuation
, 调用另外一个suspend
修饰的函数或 lambda 表达式需要传入一个Continuation
作为参数, 而只有在suspend
修饰的函数或 lambda 表达式中才有Continuation
(自身) 对象传入另外一个suspend
函数中.
至此, 我们已经清晰的了解到了协程是怎么实现等待而不阻塞线程的了. 总结成一句话就是 suspend
关键字修饰的方法或 lambda 表达式会编译成一个带 Continuation
参数的 Continuation
对象, 当一个 Continuation
调用另外一个 Continuation
时需要把自身作为参数传入到另外一个 Continuation
中, 另外一个 Continuation
完成任务后会传入的 Continuation
参数的 #resume
方法让调用者继续运行. 更简单的说, 协程的挂起就是通过 Continuation
这个回调对象实现的.
协程的使用指导
本片文章的初衷就是让大家初步认识一下协程长什么样子, 协程是什么东西, 协程的挂起原理是什么. 搞明白了这个三个问题, 那么本片文章的目的也就达到了. 如果有兴趣继续学习 Kotlin 协程相关内容, 这里整理了一些 Kotlin 协程相关的官方文档资料. 资料按易难程度顺序排列.
- 协程的基本使用 - 来自 Kotlin 官方文档
- 把 Callback 回调转换成协程 - 来自 Kotlin 官方协程设计文档 (理解该文章需要读懂上文协程挂起的实现原理)
- 使用协程的方式实现生产者消费者模式 - 来自 Kotlin 官方文档
- 使用协程进行 UI 编程指南 - 来自 Kotlin 官方协程 UI 编程指导
- 像 RxJava 一样使用协程 - 来自 Kotlin 官方文档
最后的最后, 如果觉得本文对你有帮助, 请帮我点个👍. 谢谢大家 ^ _ ^. 本文欢迎分享和转载, 转载请补上链接并注明出处.