这是《Kotlin 协程基础课》的第1篇文章。

正因为她觉得一切都无所谓,所以生活给她什么,她便接受什么。少年时代,她觉得选择为时过早,而现在已是青年,她又觉得改变为时过晚。

系列基础课前言

对于Kotlin学习而言,要想从“入门”走到“精通”,协程(Coroutines)是必须迈过去的一道坎。接下来一周时间,我会在之前零散学习的基础上,总结成一系列基础课文章,作为对协程的阶段性学习小结。文章目录如下:

  • 01.协程的基本概念与用法
  • 02.非阻塞式挂起(suspend)函数
  • 03.理解协程的域(Scope)和调度器(Dispatcher)
  • 04.用AAC&协程优化Android架构设计

什么是协程

但凡学一门新知识,总是离不开5w1h。说起“协程”,很多人第一反应是2号线北新泾那家做旅游的互联网公司,不过,此“协程”非彼“携程”,协程(Coroutines)并不是一个新的概念,它的年纪要比Kotlin语言大得多。“协程 Coroutines”源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被 Melvin Edward Conway 发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。目前很多现代语言都有协程的实现,比如Go、JavaScript、C#等。

协程作为一种编程思想,目的是简化并行代码编写,可以让我们以同步的方式写异步逻辑。对于Kotlin而言,“协程”一词是指实现了协程思想的一系列API的总称。Kotlin协程的底层实现是线程。

没有协程的日子里

由于Android是单一UI线程的框架,势必要进行很多UI线程以外的耗时操作,在协程之前,我们通常用这些技术来实现诸如网络请求、数据库读写等功能:

  • AsyncTask:是Android原生的异步任务写法,写过的人就知道它有多难用,业务逻辑被分散在前中后三个方法里,冗长的boilerplate代码。更有甚者,一旦发生嵌套,光是一层层回调就能把人搞疯掉。
  • Thread:直接开线程并不是一种好的设计,只有新手才这么干。
  • ThreadPool or ExecutorService:这比new Thread好一些,但同样要处理UI、工作线程切换的问题,以及无法避免的回调。
  • Handler:一个工作线程的Handler用来处理耗时任务,处理完成后丢给主线程Handler,简单、直接、朴实无华。

这时协程(Coroutines)来了,为我们推翻回调地狱、样板代码、内存泄漏、线程切换几座大山,Android开发者终于翻身农奴把歌唱,敢叫日月换新天。

第一个协程Demo

用Thread.sleep模拟耗时操作

在本文我们暂且不谈Android环境,在更通用的环境下展现协程的用法。首先我们模拟一个耗时操作与非耗时操作混合的场景,看下面一段代码,它在main函数里依次打印one two three

1
2
3
4
5
fun main(args: Array<String>) {
println("one")
println("two")
println("three")
}

同时有另一个函数,它会耗时3s后,打印出参数在控制台。我们用直白的Thread.sleep来进行延时模拟。

1
2
3
4
fun printDelayed(msg: String) {
Thread.sleep(3000)
println(msg)
}

然后将主程序的print(two)改为耗时任务。

1
2
3
4
5
fun main(args: Array<String>) {
println("one")
printDelayed("two")
println("three")
}

运行后,输出如下,非常符合预期。

1
2
3
4
one
three
// 这里等待3s
two

用delay模拟耗时操作

Kotlin的协程库提供了另一种延时的API,delay,我们把原来的Thread.sleep替换为delay

1
2
3
4
fun printDelayed(msg: String) {
delay(3000L)
println(msg)
}

此时Android Studio会在delay处提示错误:suspend function delay should be called only from a coroutine or another suspend function。翻译过来就是“delay是一个挂起函数,它只能在协程中、或者另一个挂起函数里面被调用”。我们把程序整体改写一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun printDelayed(msg: String) {
delay(3000L)
println(msg)
}

fun print123Blocking() = runBlocking {
println("one")
printDelayed("two")
println("three")
}

fun main(args: Array<String>) {
print123Blocking()
}

运行后发现程序的输出为:

1
2
3
4
one
// 等待3s
two
three

为什么不是132而是123呢,要从runBlocking的定义说起。它会“新起一个协程运行后续代码,并且在该协程的运行过程中阻塞原线程(在demo中是主线程),直至协程运行结束”。而在协程运行时是按照代码顺序逐行运行的。所以打印出来的是123而非132。

为runBlocking指定运行线程

我们在print123blocking方法里打印出当前线程名。

1
2
3
4
5
fun print123Blocking() = runBlocking {
println("one - in thread: ${Thread.currentThread().name}")
printDelayed("two - in thread: ${Thread.currentThread().name}")
println("three - in thread: ${Thread.currentThread().name}")
}

控制台输出如下:

1
2
3
one - in thread: main @coroutine#1
two - in thread: main @coroutine#1
three - in thread: main @coroutine#1

可见这个方法是在当前(main)线程里运行的,并且同属于@coroutine#1

我们可以为runBlocking指定运行的线程,在协程的语言里,使用Dispatcher来表明这一概念。我们改写一下print123Blocking方法,使用Dispatcher.Default打印1和2。样例代码与系统输出如下,可见我们为其指定了Dispatcher的代码段运行在另一个线程里,且打印顺序仍然为123,这就是Blocking的厉害之处。

1
2
3
4
5
6
7
fun print123Blocking() {
runBlocking(Dispatchers.Default) {
println("one - in thread: ${Thread.currentThread().name}")
printDelayed("two - in thread: ${Thread.currentThread().name}")
}
println("three - in thread: ${Thread.currentThread().name}")
}

控制台输出:

1
2
3
4
one - in thread: DefaultDispatcher-worker-1 @coroutine#1
two - in thread: DefaultDispatcher-worker-1 @coroutine#1
// 此处等待3s
three - in thread: main

如何利用协程打印出132

全局后台线程:GlobalScope.launch

可以在blocking域中使用GlobalScope.launch{ ... }来指定后台线程运行任务,我们基于此将原有代码改造一下。

1
2
3
4
5
6
7
fun print123Blocking() = runBlocking {
GlobalScope.launch {
println("one - in thread: ${Thread.currentThread().name}")
printDelayed("two - in thread: ${Thread.currentThread().name}")
}
println("three - in thread: ${Thread.currentThread().name}")
}

这段代码的输出是不可知的,有可能是以下两种情况,只打印3或者打印31,这是什么原因呢?因为我们是在后台全局线程中启动的“打印12任务”,后台线程是不会阻止主线程运行结束的,所以2是肯定打不出来,而1能否打印出来就看运行时线程调度情况了。

1
2
3
4
5
// case 1
three - in thread: main @coroutine#1
one - in thread: DefaultDispatcher-worker-1 @coroutine#2
// case 2
three - in thread: main @coroutine#1

等待任务完成

我们可以简单粗暴地使用delay来等待后台任务完成。

1
2
3
4
5
6
7
8
fun print123Blocking() = runBlocking {
GlobalScope.launch {
println("one - in thread: ${Thread.currentThread().name}")
printDelayed("two - in thread: ${Thread.currentThread().name}")
}
println("three - in thread: ${Thread.currentThread().name}")
delay(4000L) // 因为我们知道printDelayed会延迟3秒,故这里等待4秒
}

控制台输出为:

1
2
3
three - in thread: main @coroutine#1
one - in thread: DefaultDispatcher-worker-1 @coroutine#2
two - in thread: DefaultDispatcher-worker-1 @coroutine#2

但是这种处理方法非常丑陋,而且这个delay的时长很难设置,设置长了吧,会导致无用的等待浪费时间;设置短了吧,有可能在后台线程输出之前就结束任务,有没有更优雅的写法呢?答案是有的,job.join()为我们提供了等待任务完成的功能,代码如下所示。

1
2
3
4
5
6
7
8
fun print123Blocking() = runBlocking {
val job = GlobalScope.launch {
println("one - in thread: ${Thread.currentThread().name}")
printDelayed("two - in thread: ${Thread.currentThread().name}")
}
println("three - in thread: ${Thread.currentThread().name}")
job.join()
}

自定义Dispatcher

Dispatcher为协程的运行指定了线程,常见Dispatchers如下:

  • Dispatcher.IO:进行IO密集型操作,如数据库读写、文件读写、网络交互
  • Dispatcher.Default:进行CPU密集型操作,如列表排序、JSON解析、DiffUtils
  • Dispatcher.Main:仅存在于Android框架,调用suspend方法、进行UI操作、更新LiveData

看到这里你可能已经理解了,Dispatcher其实就是线程的另一种表现形式,我们甚至可以自定义一个Dispatcher:

1
2
3
4
5
6
7
8
9
fun print123Blocking() = runBlocking {
println("one - in thread: ${Thread.currentThread().name}")
val customDispatcher = Executors.newFixedThreadPool(2).asCoroutineDispatcher()
launch(customDispatcher) {
printDelayed("two - in thread: ${Thread.currentThread().name}")
}
println("three - in thread: ${Thread.currentThread().name}")
(customDispatcher.executor as ExecutorService).shutdown() // !主动停止,否则线程会一直运行下去
}

运行结果如下(在play.kotlin上面总是超时不知道为啥),意味着我们完全可以高度定制Dispatcher的实现,虽然大部分时间使用默认的Dispatcher就已足够。

1
2
3
one - from thread main
three - from thread main
two - from thread pool-1-thread-1

有返回值的suspend函数

最后一部分内容是跟Android开发密切相关的,大部分时间我们需要通过网络、数据库进行一些读取数据耗时操作,函数会有返回值,我们模拟一个网络操作,它读取一个startNum参数,等待1s延时后,返回startNum * 10。通过 async { ... }.await() 可以获取耗时函数的返回值,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 模拟1s延时网络操作
suspend fun calculateHardThings(startNum: Int): Int {
delay(1000)
println("result: ${result}, in thread: ${Thread.currentThread().name}")
return startNum * 10
}

// 调用延时操作,分别await,耗时共3s
fun exampleAsyncAwait() = runBlocking {
val startTime = System.currentTimeMillis()
val deferred1 = async { calculateHardThings(10) }.await()
val deferred2 = async { calculateHardThings(20) }.await()
val deferred3 = async { calculateHardThings(30) }.await()
val sum = deferred1 + deferred2 + deferred3
val endTime = System.currentTimeMillis()
println("sum = $sum, time = ${endTime - startTime}") // sum = 600, time = 3030
}

并发进行耗时操作

因为是3个耗时1s操作并发,我们自然而然希望它们同时运行,总耗时1s而不是3s,要如何实现呢?把所有的await()调用写入同一个语句,编译器会优化它们,使其同时运行。

1
2
3
4
5
6
7
8
9
10
// 调用延时操作,同时await,耗时共1s
fun exampleAsyncAwait() = runBlocking {
val startTime = System.currentTimeMillis()
val deferred1 = async { calculateHardThings(10) }
val deferred2 = async { calculateHardThings(20) }
val deferred3 = async { calculateHardThings(30) }
val sum = deferred1.await() + deferred2.await() + deferred3.await()
val endTime = System.currentTimeMillis()
println("sum = $sum, time = ${endTime - startTime}") // sum = 600, time = 1065
}

使用withContext的简化写法

async/await 会运行在当前线程中,对于网络操作一般的做饭是让其在IO线程运行,对于计算密集型操作则是在CPU(Default)线程运行,使用withContext可以同时完成async/await的操作,但缺点是这三个操作只能相继运行,无法同时运行。

1
2
3
4
5
6
7
8
9
10
// 调用延时操作
fun exampleWithContext() = runBlocking {
val startTime = System.currentTimeMillis()
val deferred1 = withContext(Dispatchers.Default) { calculateHardThings(10) }
val deferred2 = withContext(Dispatchers.Default) { calculateHardThings(20) }
val deferred3 = withContext(Dispatchers.Default) { calculateHardThings(30) }
val sum = deferred1 + deferred2 + deferred3
val endTime = System.currentTimeMillis()
println("sum = $sum, time = ${endTime - startTime}") // sum = 600, time = 3029
}

小结

本文介绍了协程的基本用法,在阅读完本文后,你应当掌握以下知识点:

  • 声明耗时函数,以及在协程代码块里调用耗时函数
  • 切换运行环境
  • 等待任务完成
  • 并行进行耗时操作,获取操作结果

参考资料