Kotlin 系列文章详细计划-07-视频脚本

这是码上开学 Kotlin 系列第 7 集的视频脚本。

视频脚本不是文章结构要求,所以不属于「必读」;但建议参与文章编写的作者看一下视频脚本再去看「文章结构要求」和写文章,这样写出来的文章和视频会更容易打配合,更容易比较「搭」。

如果你想用视频脚本直接作为基准来扩展成文章,也没问题,写得好、容易读是唯一标准。

标题:Kotlin 的协程用力瞥一眼

脚本:

开场白

大家好,我是扔物线朱凯。今天是我们的码上开学 Kotlin 系列的协程素质三连之最后一连。

上期回顾

在上上期里,我介绍了 Kotlin 的协程到底是什么——它就是个线程框架。上期我又对 Kotlin 协程的「挂起」扒了皮:它其实就是个可以被自动切回来的切线程。两个概念一个比一个玄乎,但其实本质都非常简单,是吧?

这一期,我们就对协程进行最后一击:讲一讲它的「非阻塞式」,以及对一些常见的疑问做一下解答,在解答过程中难免会 diss 到一些网络大 V 以及 JetBrains 的官方文档和官方演讲,如有冒犯,我就提前(拱手)……(抬头笑)通知你们一声了。

协程的「非阻塞式」挂起

首先,我来说一下那个很多人都听过,但基本上没几个人搞明白是什么的「非阻塞式」挂起。全世界到处都在宣传,协程的挂起是非阻塞式的,好像很厉害的样子,但它到底是个什么意思?搞不明白。而且就算听我在上一期讲了这么多关于挂起的本质,好像还是不明白什么叫「非阻塞式」挂起,对吧?接下来我就讲一讲。

  • 这个所谓的「非阻塞式」,它本质上其实指的是「不卡线程」这件事,不卡线程,就叫做非阻塞式。协程的挂起卡线程吗?当然不卡了,线程都切走了,对吧。比如你本来在主线程执行,忽然一个挂起函数,切到后台去处理图片了。那你的主线程会卡吗?肯定不卡呀,对吧。

    但是我们要知道:这是你用协程。那你如果在主线程手动用 Java 自带的 Thread 或者线程池去切线程,主线程会卡吗?依然也是不卡的。是吧?那么用 Java 的 Thread 来切线程,这个是非阻塞式吗?

    认真听的人脑子肯定在转了:到底是不是啊?其实它也是的,是非阻塞式。

    「啊?非阻塞式不是协程的挂起独有的特性吗?怎么线程也有了?」我现在就给你解释。

  • 在网上有一种说法是「协程的挂起是非阻塞式的,而线程是阻塞式的」,这种说法是有严重误导性的,显得好像协程的异步比线程的异步更高级一样。但其实这里所谓「线程的阻塞式」,指的是「单线程」是阻塞式的,因此单线程中的耗时代码会卡线程。而协程呢?单协程也可以是非阻塞式的,因为它可以利用挂起函数来切线程。但实际上 Kotlin 协程的挂起就是切线程而已,他跟 Java 的切线程是完全一样的。只是在写法上,上下两行连续的代码,协程可以悄悄地把线程切走再切回来,不会卡住当前线程,这也就是所谓的「非阻塞式」挂起;而不用协程的话,上下两行连续的代码就只能是单线程的,那当然会卡线程了。 所以,协程的挂起函数和 Java 原始的线程切换,其实都是非阻塞式的,只是协程是一种「看起来阻塞,实际上却非阻塞」的写法而已。明白了吧?

  • 另外,还有人说协程的这个「非阻塞式」比线程更高效,甚至有人给出了一种详细的解释——我忘了在哪看的了:说,如果用线程来处理网络请求,在网络请求返回之前,线程一直在等着它,是处于阻塞状态不做事的,他说这就导致了线程利用率不高;而如果用协程,由于协程在等待网络请求的过程中会被挂起,线程没有被阻塞,这就提高了线程的利用率。

    听着好有道理呀!但是这种说法是错误的。 首先,所有的代码本质上都是阻塞式的,而只有比较耗时的代码才会导人类可感知的等待,比如在主线程上做一个耗时 50 ms 的操作会导致界面卡掉几帧,这种是我们人眼能观察出来的,而这就是我们通常意义所说的「阻塞」。而耗时操作呢,上期我就说过了,一般分为两种:CPU 计算的耗时和 I/O 的耗时,而网络请求属于 I/O 操作,它的性能瓶颈是 I/O ——也就是和网络的数据交互,而不是 CPU 的计算速度,所以线程会被网络交互所阻塞。但这个阻塞是不可避免的,因为你必须做这个 I/O,那它比较慢怎么办?不怎么办,没办法,你只能让线程在那里慢慢处理。但是要注意,它是在「慢慢处理」,而不是单纯地等待,它等待只是因为网络传输的性能低于 CPU 的性能,但它本质上是在做工作的。所以我再说一遍,这种阻塞:不,可,避,免。 那……协程不是就可以挂起吗?这不就是不用傻等了? 同志们,协程的挂起本质上是什么?上期我花了一整个视频来讲,这期的开头也提到了,协程的挂起的本质是——切线程。那网络请求时的挂起是什么?还是切线程啊!它是把主线程空置出来,然后在后台线程去做网络交互,而不是先切到后台去做网络请求,然后在这个网络请求到了那个所谓的「等待阶段」的时候再挂起一次,通过这种方式把后台的这个网络交互线程给空出来,让这个网络交互线程能立即去做别的网络请求,就不用傻等了。没这种好事的,你把网络交互线程空出来了,它就能去做下一个请求了,好爽啊是吧?那你刚才那个正在等待的网络请求怎么办?它还是会有另一个线程来承载啊!不然你觉得,这个网络交互会凭空地自己完成?不可能的兄台。一定要记住,挂起的本质是切线程,只是它能够在完成之后自动切回来而已,没有别的神奇之处了。

所以明白了吗?所谓协程的「非阻塞式挂起」,只是用看似同步的方式写了异步代码而已,并没有任何相比于线程更加高效的地方。

协程与线程

那协程和线程到底是什么关系呢?

别的语言我不说,在 Kotlin 里,协程就是基于线程来实现的一种更上层的工具 API,类似于 Java 自带的 Executor 系列 API 或者 Android 的 Handler 系列 API。那它和线程到底什么关系呢?就像 Handler API 一样,协程它就是一个基于线程的上层框架。

至于还有人说「协程是「用户态」的,它的切换不需要和操作系统交互,因此协程切换的成本比线程低」,还有「协程由于是「协作式」的,所以不需要线程的同步操作」,这些描述对于有些语言来说是对的,但对 Kotlin 来说,完全是无稽之谈。为什么有些介绍 Kotlin 协程的文章里这么写了呢?因为这些文章的作者不~懂~装~懂~。一定要记住,在 Kotlin 里,协程的本质还是线程。尤其是面试官们,协程这种新概念,你一定要自己了解清楚了再去面别人,不然人家来你这面试一趟,不光没学到东西,还被你灌输一堆错误知识,那就太惨了,是不是(笑)。

说到这里,Kotlin 协程的三大疑问:协程是什么、挂起是什么、挂起的非阻塞式是怎么回事,我就已经全部讲完了。非常简单:协程就是切线程;挂起就是可以自动切回来的切线程;挂起的非阻塞式指的是它能用看起来阻塞的代码写出非阻塞的操作,就这么简单。当然了,这几句是总结,它们背后的原理你是一定要掌握住的。如果忘了,再去把我之前的视频看一遍就好。

协程是「轻量级」线程?

最后……我今天要给 Kotlin 的官方纠个错。

Kotlin 官方文档以及某些官方演讲都说过「Kotlin 的协程相当于轻量级线程」,并且举了真实的代码来作为证据:同时执行 10 万个延时任务,用协程没问题,但用线程做的话多半要内存溢出,所以协程比线程要「轻量级」:

image-20190922152347617

有理有据,哈?我(呸!)

但这种说法不仅是有误导性的,而且完全是错误的。因为它这这个例子里的协程代码,本质上是把所有内容都放进了一个线程池来做事,它要读者拿来和 100_000 个 Thread 比较性能,那么别人尝试过后一定会出现内存不足。

但首先,它比较的对象是 Thread,而其实协程更接近的比较对象应该是 Java 的线程池 API,也就是 ExecutorService 那几个类。如果和它们做比较,使用和不使用协程的性能就不相上下了;

另外具体到我说的这个官方例子,它狡猾的地方除了把对比对象直接定为了 Thread 而不是线程池 API 之外,还有一点是它的「延时」这个操作,协程的对比对象里使用的是 Thread.sleep() 方法。这样的话,如果用普通的线程池和协程比较,依然会出现协程性能更高的结果;但其实协程这里的「延时」操作对应的应该是 Java 里的 Executors.newSingleThreadScheduledExecutor() 。如果换成这个 Executor 来对比,那用不用协程的性能就真的彻底没有区别了。

Thread 是最底层的控件,Executor 和 Coroutine 都是基于它所造出来的工具包,Kotlin 官方偷换了概念,把直接使用 Thread 说成是比协程重,显得好像协程有性能上的优势一样,真的有点太鸡贼了。

尾声

呼,不会被封杀吧。到这个视频为止,码上开学 Kotlin 系列的协程部分就告一段落了。以后可能还会讲更多协程,也可能不会再讲,但就算再讲也不会是下期了。如果你喜欢我的视频,欢迎关注我的账号不错过我的任何新内容,以及长按点赞按钮支持一下。我们下期见!(盖章:禁!)

以下内容不想录了。甚至连写不写在文章里也没想好。


协程与 RxJava

协程和 RxJava 在切换线程方面功能是一样的,都能让你写出避免嵌套回调的复杂并发代码,不过协程写法比 RxJava 更简单一点,因此连 RxJava 的操作符都省了。

CoroutineScope、CoroutineContext