摘要

协成写起来方便,能异步代码同步去写,协程重要的是切换到别的线程后还可以自动切换回来。

每个语言对协程的实现有所不同

协程存在于用户态,大量的协程实际上只占用了内核态的一个线程。
当协程数量和内核线程的数量不一致时,需要有调度器来维护所有的协程,尽可能让它们公平地使用CPU。

协程是什么

「协程 Coroutines」源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被 Melvin Edward Conway 发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。

  • 我们所有的代码都是跑在线程中的,而线程是跑在进程中的。
  • 协程没有直接和操作系统关联,但它不是空中楼阁,它也是跑在线程中的,可以是单线程,也可以是多线程。
  • 单线程中的协程总的执行时间并不会比不用协程少。
  • Android 系统上,如果在主线程进行网络请求,会抛出 NetworkOnMainThreadException,对于在主线程上的协程也不例外,这种场景使用协程还是要切线程的。

协程好在哪里

在 Java 中要实现并发操作通常需要开启一个 Thread

1
2
3
4
5
6
new Thread(new Runnable() {
@Override
public void run() {
...
}
}).start();

直接使用 Thead 的那些困难和不方便:

  • 线程什么时候执行结束
  • 线程间的相互通信
  • 多个线程的管理

可以用 Java 的 Executor 线程池来进行线程管理:

1
2
3
4
val executor = Executors.newCachedThreadPool()
executor.execute({
...
})

但它的缺点也很明显:

  • 需要处理很多回调,如果业务多则容易陷入「回调地狱」。
  • 硬是把业务拆分成了前台、中间更新、后台三个函数。

解决回调地狱的方法一般是使用RxJava,RxJava,准确来讲是 ReactiveX 在 Java 上的实现,是一种响应式程序框架,我们通过它提供的「Observable」的编程范式进行链式调用,可以很好地消除回调。

下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
coroutineScope.launch(Dispatchers.Main) {   // 在主线程开启协程
val user = api.getUser() // IO 线程执行网络请求
nameTv.text = user.name // 主线程更新 UI
}
这个 launch 函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch 的那些代码,这一段连续代码叫做一个「协程」。

而通过 Java 实现以上逻辑,我们通常需要这样写:
api.getUser(new Callback<User>() {
@Override
public void success(User user) {
runOnUiThread(new Runnable() {
@Override
public void run() {
nameTv.setText(user.name);
}
})
}

@Override
public void failure(Exception e) {
...
}
});

这里只是展示了一个代码片段,launch 并不是一个顶层函数,它必须在一个对象中使用,这里只关心它内部业务逻辑的写法。

launch 函数加上实现在 {} 中具体的逻辑,就构成了一个协程。

通常做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。

协程的「1 到 0」

对于回调式的写法,如果并发场景再复杂一些,代码的嵌套可能会更多,这样的话维护起来就非常麻烦。但如果你使用了 Kotlin 协程,多层网络请求只需要这么写:如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。比如以下两个请求:

1
2
3
4
🏝️
api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)

如果使用回调式的写法,那么代码可能写起来既困难又别扭。于是我们可能会选择妥协,通过先后请求代替同时请求:

1
2
3
4
5
6
7
🏝️
api.getAvatar(user) { avatar ->
api.getCompanyLogo(user) { logo ->
show(merge(avatar, logo))
}
}

在实际开发中如果这样写,本来能够并行处理的请求被强制通过串行的方式去实现,可能会导致等待时间长了一倍,也就是性能差了一倍。

而如果使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合并即可:

1
2
3
4
5
6
7
8
9
10
🏝️
coroutineScope.launch(Dispatchers.Main) {
// 👇 async 函数之后再讲
val avatar = async { api.getAvatar(user) } // 获取用户头像
val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
val merged = suspendingMerge(avatar, logo) // 合并结果
// 👆
show(merged) // 更新 UI
}

可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是 suspendingMerge 并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。至于挂起具体是什么,可以看下一篇文章。

让复杂的并发代码,写起来变得简单且清晰,是协程的优势。

这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。

本来需要回调,现在直接没有回调了,这种从 1 到 0 的设计思想真的妙哉。

这里的 api.getUser 是一个挂起函数,所以能够保证 nameTv.text 的正确赋值,这就涉及到了协程中最著名的**「非阻塞式挂起」**

什么时候用协程?

什么时候用协程?当你需要切线程或者指定线程的时候。

你要在后台执行任务?切!

1
2
3
4
5
🏝️
launch(Dispatchers.IO) {
val image = getImage(imageId)
}

然后需要在前台更新界面?再切!

1
2
3
4
5
6
7
8
🏝️
coroutineScope.launch(Dispatchers.IO) {
val image = getImage(imageId)
launch(Dispatchers.Main) {
avatarIv.setImageBitmap(image)
}
}

好像有点不对劲?这不还是有嵌套嘛。

如果只是使用 launch 函数,协程并不能比线程做更多的事。不过协程中却有一个很实用的函数:withContext 。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。那么可以将上面的代码写成这样:

1
2
3
4
5
6
7
8
🏝️
coroutineScope.launch(Dispatchers.Main) { // 👈 在 UI 线程开始
val image = withContext(Dispatchers.IO) { // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
getImage(imageId) // 👈 将会运行在 IO 线程
}
avatarIv.setImageBitmap(image) // 👈 回到 UI 线程更新 UI
}

这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
🏝️
// 第一种写法
coroutineScope.launch(Dispatchers.IO) {
...
launch(Dispatchers.Main){
...
launch(Dispatchers.IO) {
...
launch(Dispatchers.Main) {
...
}
}
}
}

// 通过第二种写法来实现相同的逻辑
coroutineScope.launch(Dispatchers.Main) {
...
withContext(Dispatchers.IO) {
...
}
...
withContext(Dispatchers.IO) {
...
}
...
}

由于可以"自动切回来",消除了并发代码在协作时的嵌套。由于消除了嵌套关系,我们甚至可以把 withContext 放进一个单独的函数里面:

1
2
3
4
5
6
7
8
9
10
🏝️
launch(Dispatchers.Main) { // 👈 在 UI 线程开始
val image = getImage(imageId)
avatarIv.setImageBitmap(image) // 👈 执行结束后,自动切换回 UI 线程
}
// 👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}

这就是之前说的「用同步的方式写异步的代码」了。

不过如果只是这样写,编译器是会报错的:

1
2
3
4
5
🏝️
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
// IDE 报错 Suspend function'withContext' should be called only from a coroutine or another suspend funcion
}

意思是说,withContext 是一个 suspend 函数,它需要在协程或者是另一个 suspend 函数中调用。

suspend 挂起是什么

launchasync 或者其他函数创建的协程,在执行到某一个 suspend 函数的时候,这个协程会被「suspend」,也就是被挂起。

那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。

注意,不是这个协程停下来了!是脱离,当前线程不再管这个协程要去做什么了。

suspend 是有暂停的意思,但我们在协程中应该理解为:当线程执行到协程的 suspend 函数的时候,暂时不继续执行协程代码了。

我们先让时间静止,然后兵分两路,分别看看这两个互相脱离的线程和协程接下来将会发生什么事情:

线程:

前面我们提到,挂起会让协程从正在执行它的线程上脱离,具体到代码其实是:

协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。

那线程接下来会做什么呢?

如果它是一个后台线程:

  • 要么无事可做,被系统回收
  • 要么继续执行别的后台任务

跟 Java 线程池里的线程在工作结束之后是完全一样的:回收或者再利用。

如果这个线程它是 Android 的主线程,那它接下来就会继续回去工作:也就是一秒钟 60 次的界面刷新任务。

协程:

线程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程

谁指定的?是 suspend 函数指定的,比如我们这个例子中,函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程。

Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行,关于 Dispatchers 这里先不展开了。

那常用到的调度器有哪些?

常用的 Dispatchers ,有以下三种:

  • Dispatchers.Main:Android 中的主线程
  • Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
  • Dispatchers.Default:适合 CPU 密集型的任务,比如计算

回到我们的协程,它从 suspend 函数开始脱离启动它的线程,继续执行在 Dispatchers 所指定的 IO 线程。

紧接着在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来(resume)

这个「切回来」是什么意思?

我们的协程原本是运行在主线程的,当代码遇到 suspend 函数的时候,发生线程切换,根据 Dispatchers 切换到了 IO 线程;

当这个函数执行完毕后,线程又切了回来,「切回来」也就是协程会帮我再 post 一个 Runnable,让我剩下的代码继续回到主线程去执行。

我们从线程和协程的两个角度都分析完成后,终于可以对协程的「挂起」suspend 做一个解释:

协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,就是切个线程;

不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程

再简单来讲,就是一个稍后会被自动切回来的线程调度操作

suspend 关键字的意义?

这个 suspend 关键字,既然它并不是真正实现挂起,那它的作用是什么?

它其实是一个提醒。

函数的创建者对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。

什么是非阻塞式挂起

线程阻塞很好理解,现实中的例子就是交通堵塞,它的核心有 3 点:

  • 前面有障碍物,你过不去(线程卡了)
  • 需要等障碍物清除后才能过去(耗时任务结束)
  • 除非你绕道而行(切到别的线程

阻塞不阻塞,都是针对单线程讲的,一旦切了线程,肯定是非阻塞的,你都跑到别的线程了,之前的线程就自由了,可以继续做别的事情了。

协程的挂起,就是非阻塞式的,协程是不讲「阻塞式的挂起」的概念的,因为挂起这件事,本来就是涉及到多线程。

所以「非阻塞式挂起」,其实就是在讲协程在挂起的同时切线程这件事情。

下面的例子重点是说线程虽然会切,但写法上和普通的单线程差不多。

1
2
3
4
5
6
7
8
9
10
11
12
main {
GlobalScope.launch(Dispatchers.Main) {
// 👇 耗时操作
val user = suspendingRequestUser()
updateView(user)
}

private suspend fun suspendingRequestUser() : User = withContext(Dispatchers.IO) {
api.requestUser()
}
}

从上面的例子可以看到,耗时操作和更新 UI 的逻辑像写单线程一样放在了一起,只是在外面包了一层协程。

而正是这个协程解决了原来我们单线程写法会卡线程这件事。

阻塞的本质

首先,所有的代码本质上都是阻塞式的,而只有比较耗时的代码才会导致人类可感知的等待,比如在主线程上做一个耗时 50 ms 的操作会导致界面卡掉几帧,这种是我们人眼能观察出来的,而这就是我们通常意义所说的「阻塞」。

举个例子,当你开发的 app 在性能好的手机上很流畅,在性能差的老手机上会卡顿,就是在说同一行代码执行的时间不一样。

视频中讲了一个网络 IO 的例子,IO 阻塞更多是反映在「等」这件事情上,它的性能瓶颈是和网络的数据交换,你切多少个线程都没用,该花的时间一点都少不了。

而这跟协程半毛钱关系没有,切线程解决不了的事情,协程也解决不了。

summary

协程是什么、挂起是什么、挂起的非阻塞式是怎么回事,非常简单:

  • 协程就是切线程;
  • 挂起就是可以自动切回来的切线程;
  • 挂起的非阻塞式指的是它能用看起来阻塞的代码写出非阻塞的操作,就这么简单。