破解 Kotlin 协程(12):协程为什么被称为『轻量级线程』?

接触新概念,最好的办法就是先整体看个大概,再回过头来细细品味。

文中如果没有特别说明,协程指编程语言级别的协程,线程则特指操作系统内核线程。

1. 协程到底是啥?

Kotlin 的协程从 v1.1 开始公测(Experimental) 到现在,已经算是非常成熟了,但大家对它的看法却一直存在各种疑问,为什么呢?因为即便我们把 Kotlin 丢掉,单纯协程这个东西本身就已经长时间让大家感到疑惑了,不信的话可以单独搜一下协程或者 Coroutine,甚至连 Lua 之父在提到为什么协程鲜见于早期语言实现,就是因为这概念没有一个清晰的界定。

更有意思的是,在查阅资料的过程中,你会经常会陷入一种一会儿『啊,我懂了』,一会儿『啊,我懂个屁』的循环当中,不瞒各位说,我从七八年前刚开始学 Lua 的时候面对 Lua 的协程也是这个破感觉,后来接触 goroutine 又来了一遍,接触 Kotlin 的协程又来了一遍,习惯就好。

那么问题的关键在于,协程的概念是不是真的混乱呢?其实不是的,协程的概念最核心的点其实就是函数或者一段程序能够被挂起(说暂停其实也没啥问题),待会儿再恢复,挂起和恢复是开发者的程序逻辑自己控制的,协程是通过主动挂起出让运行权来实现协作的,就没了,一句话就能说明白的事儿是不是特简单?它跟线程最大的区别在于线程一旦开始执行,从任务的角度来看,就不会被暂停,直到任务结束这个过程都是连续的,线程之间是抢占式的调度,因此也不存在协作问题。

那么我们再来理一理协程的概念:

  • 挂起恢复
  • 程序自己处理挂起恢复
  • 程序自己处理挂起恢复来实现协程的协作运行

关键核心就是协程是一个能挂起并且待会儿恢复执行的东西。任何时候自己产生疑惑的时候都回过来再想想这几句话,就算协程最终呈现给我们的样子可能『花里胡哨』,但万变不离其宗。

有的朋友不理解什么叫挂起,挂起这个词其实还真是源于操作系统的叫法,直观的理解上,你就当做暂停理解吧。

2. 为什么协程的概念会有混乱的感觉?

我们前面提到,协程的概念其实并不混乱,那么混乱的是什么?是各家对它的实现。这就好像牛顿第二定律一样,看似很简单,F = ma,用起来就五花八门了,衍生的各种公式更是层出不穷。

协程不就是要挂起、恢复么,请问挂起恢复具体要怎么做?没有定义呀。既然没有定义是不是就可以随便?是的,抓住老鼠就是好猫~

协程这一点儿跟线程真的是没法比啊,主流操作系统都有成熟的线程模型,应用层经常提到的线程的概念大多就是映射方式的差异,所以不同的编程语言一旦引入了线程,那么基本上就是照搬了系统线程的概念,线程本身也不是他们实现的——这很好理解,因为线程调度是操作系统做的嘛。

Java 对线程做了很好的支持,这也是 Java 在高并发场景风生水起的一个关键支柱,不过如果你有兴趣去看下虚拟机底层对线程的支持,例如 Android 虚拟机,其实就是 pthread。Java 的 Object 还有一个 wait 方法,这个方法几乎支撑了各种锁的实现,它底层是 condition。

绝大多数协程都是语言层面自己的实现,不同的编程语言有不同的使用场景,自然在实现上也看似有很大的差异,甚至还有的语言自己没有实现协程,但开发者通过第三方框架的方式提供了协程的能力,例如 Java 的框架 Quasar,加上协程实现本身在操作系统层面就有过一系列演进,因此出现了虽然理论上看起来很简单,但实现上却多样化的局面。

3. 协程有哪些主流的实现?

我们在前面讲各个语言的实现有差异,说的是看似有很大的差异,主要是各自的关键字、类型命名不一样,但总结下来大家对于协程的分类更倾向于按照有没有栈来分,即:

  • 有栈协程 Stackful Coroutine:每一个协程都会有自己的调用栈,有点儿类似于线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,主要不同体现在调度上。
  • 无栈协程 Stackless Coroutine:协程没有自己的调用栈。

栈这个东西大家应该都很熟悉了,我们递归调用函数的层次太多就会导致 StackOverflowException,因为栈内存是有限的;我们的程序出现了异常我们总是希望看到异常点的调用关系,这样方便定位问题,这也需要栈。

有栈协程有什么好处呢?因为有栈,所以在任何一个调用的地方运行时都可以选择把栈保存起来,暂停这个协程,听起来就跟线程一样了,只不过挂起和恢复执行的权限在程序自己,而不是操作系统。缺点也是非常明显的,每创建一个协程不管有没有在运行都要为它开辟一个栈,这也是目前无栈协程流行的原因。

goroutine 看上去似乎不像协程,因为开发者自己无法决定一个协程的挂起和恢复,这个工作是 go 运行时自己处理的。为了支持 goroutine 在任意位置能挂起,goroutine 其实是一个有栈协程,go 运行时在这里做了大量的优化,它的栈内存可以根据需要进行扩容和缩容,最小一般为内存页长 4KB。

JavaScript、C# 还有 Python 的协程,或者干脆就说 async/await,相比之下就轻量多了,它们看起来更像是针对回调加了个语法糖的支持——它们其实就是无栈协程的实现了。无栈,顾名思义,每一个协程都不会单独开辟调用栈,那么问题来了,它的上下文是如何保存的?

这就要提到传说中的 CPS 了,即 continuation-passing-style。我们来想象一下,程序被挂起,或者说中断,最关键的是什么?是保存挂起点,或者中断点,对于线程被操作系统中断,中断点就是被保存在调用栈当中的,而我们的无栈协程要保存到哪儿呢?保存到 Continuation 对象当中,这个东西可能在不同的语言当中叫法不一样,但本质上都是一个 Continuation,它就是一个普通的对象,占用内存非常小,还是很抽象是吧,想想你常见的 Callback,它其实就是一个 Continuation 的实现。

Kotlin 的协程的根基就是一个叫做 Continuation 的类。我在前面的文章不止一次提到,这家伙长得横看竖看就是一个回调,resume 就是 onSuccess,resumeWithException 就是 onFailure。

Continuation 携带了协程继续执行所需要的上下文,同时它自己又是挂起点,因为待会儿恢复执行的时候只需要执行它回调的函数体就可以了。对于 Kotlin 来讲,每一个 suspend 函数都是一个挂起点,意味着对于当前协程来说,每遇到一个 suspend 函数的调用,它都有可能会被挂起。每一个 suspend 函数都被编译器插入了一个 Continuation 类型的参数用来保存当前的调用点:

1
2
3
4
suspend fun hello() = suspendCoroutine<Int>{ continuation ->
println("Hello")
continuation.resumeWith(Result.success(10086))
}

我们定义了一个 suspend 函数 hello,它看起来没有接收任何参数,如果真是这样,请问我们在后面调用 resumeWithcontinuation 是哪里来的?

都说挂起函数必须在协程内部调用,其实也不是,我们在前面讲挂起原理的时候就用 Java 代码直接去调用 suspend 函数,大家也会发现这些 suspend 函数都需要传入一个额外的 Continuation,就是这个意思。

当然,Java 也不是必须的,我们只需要用点儿 Kotlin 反射,一样可以直接让 suspend 函数现出原形:

1
2
3
4
5
6
7
8
val helloRef = ::hello
val result = helloRef.call(object: Continuation<Int>{
override val context = EmptyCoroutineContext

override fun resumeWith(result: Result<Int>) {
println("resumeWith: ${result.getOrNull()}")
}
})

这与我们在协程挂起原理那篇的做法如出一辙,我们虽然没有办法直接调用 hello(),但我们可以拿到它的函数引用,用发射调用它(这个做法后续可能也会被禁掉,但 1.3.50 目前仍然是可用的),调用的时候如果你什么参数都不传,编译器就会提示你它需要一个参数,呃,你看,它这么快就投降了——需要的这个参数正是 Continuation

再强调一下,这段代码不需要运行在协程体内,或者其他的 suspend 函数中。现在请大家仔细想想,为什么官方要求 suspend 函数一定要运行在协程体内或者其他 suspend 函数中呢?

答案自然就是任何一个协程体或者 suspend 函数中都有一个隐含的 Continuation 实例,编译器能够对这个实例进行正确传递,并将这个细节隐藏在协程的背后,让我们的异步代码看起来像同步代码一样。

说到这里,我们已经接近 Kotlin 协程的本质了,它是一种无栈协程实现,它的本质就是一段代码 + Continuation 实例。

4. Kotlin 协程真的只是一个线程框架吗?

这个说法其实是很奇怪的。我如果问你线程其实是一个 CPU 框架吗,你肯定会觉得这俩,啥啊???

Kotlin 协程确实在实现的过程中提供了切线程的能力,这是它的能力,不是它的身份,就好比拿着学位证非说这是身份证一样,学位证描述的是这人能干啥,不能描述这人是谁。

杠精们可能会说学位证有照片有名字啊。你拿着学位证去买飞机票你看人家认不认呗。

协程的世界可以没有线程,如果操作系统的 CPU 调度模型是协程的话;反过来也成立——这个应该不会有人反对吧。Kotlin 协程是不是可以没有线程呢?至少从 Java 虚拟机的实现上来看,好像。。。。不太行啊。没错,是不太行,不过这不是 Kotlin 协程的问题,是 Java 虚拟机的问题,谁让 Java 虚拟机的线程用起来没有那么难用呢,在它刚出来的时候简直吊打了当时其他语言对并发的支持(就像 goroutine 出来的时候吊打它一样)。

我们知道 Kotlin 除了支持 Java 虚拟机之外,还支持 JavaScript,还支持 Native。JavaScript 无论是跑在 Web 还是 Node.js 当中,都是单线程玩耍的;Kotlin Native 虽然可以调用 pthread,但官方表示我们有自己的并发模型(Worker),不建议直接使用线程。在这两个平台上跑,Kotlin 的协程其实都是单线程的,又怎么讲是个线程框架呢?

说到这儿可能又有人有疑问了,单线程要协程能做什么呢?这个前端同学可能会比较有感触,谁跟你们说的异步一定要多线程。。Android 开发的同学其实可以想想你在 Activity 刚创建的时候想要拿到一个 View 的大小一般返回都是 0,因为 Activity 的布局是在 onResume 方法调用之后完成的,所以 handler.post 一下就好了:

1
2
3
4
5
6
7
override fun onResume(){
super.onResume()
handler.post {
val width = myView.width
...
}
}

这就是异步代码嘛,但这代码其实都运行在主线程的,我们当然可以用协程改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override fun onResume() {
super.onResume()
GlobalScope.launch(Dispatchers.Main) {
val width = handler.postSuspend {
myView.width
}
Log.d("MyView", width.toString())
}
}

suspend fun <T> Handler.postSuspend(block: () -> T) = suspendCoroutine<T> {
post {
it.resume(block())
}
}

其实我个人觉得如果 Kotlin 协程的默认的调度器是 Main,并且这个 Main 会根据各自平台选择一个合适的事件循环,这样更能体现 Kotlin 协程在不同平台的一致性,例如对于 Android 来说 Main 就是 UI 线程上的事件循环,对于 Swing 同样是 Swing 的 UI 事件循环,只要是有事件循环的平台就默认基于这个循环来一个调度器,没有默认事件循环的也好办,Kotlin 协程本身就有 runBlocking 嘛,对于普通 Java 程序来说没有事件循环就给它构造一个就行了。

Kotlin 协程的设计者没有这样做,他们当然也有他们的道理,毕竟他们不愿意强迫开发者一定要用协程,甚至立刻马上就得对原有的代码进行改造,他们希望 Kotlin 只是一门编程语言,一门提供足够安全保障和灵活语法的编程语言,剩下的交给开发者去选择。

5. 协程真的比线程有优势吗?

这可不是一个很容易回答的问题。

Kotlin 协程刚出来的时候,有人就做过性能对比,觉得协程没有任何性能优势。我们完全可以认为他的测试方法是专业的,在一些场景确实用协程不会有任何性能上的优势,这就好比我们需要在一个单核 CPU 上跑一个计算密集型的程序还要开多个线程跑一样,任何特性都有适合它的场景和不适合它的领域。

想必大家看各类讲解协程的文章都会提到协程比线程轻量,这个其实我们前面也解释过了,编程语言级别实现的协程就是程序内部的逻辑,不会涉及操作系统的资源之间的切换,操作系统的内核线程自然会重一些,且不说每创建一个线程就会开辟的栈带来的内存开销,线程在上下文切换的时候需要 CPU 把高速缓存清掉并从内存中替换下一个线程的内存数据,并且处理上一个内存的中断点保存就是一个开销很大的事儿。如果没有直观的感受的话,就尽情想象一下你正要拿五杀的时候公司领导在微信群里发消息问你今天的活跃怎么跌了的场景。

线程除了包含内核线程本身执行代码能力的含义以外,通常也被赋予了逻辑任务的概念,所以协程是一种轻量级的『线程』的说法,更多描述的是它的使用场景,这句话也许这样说更贴切一些:

协程更像一种轻量级的『线程』。

线程自然可以享受到并行计算的优待,协程则只能依赖程序内部的线程来实现并行计算。协程的优势其实更多是体现在 IO 密集型程序上,这对于 Java 开发者来说可能又是一个很迷惑的事情,因为大家写 Java 这么多年,很少有人用上 NIO,绝大多数都是用 BIO 来读写 IO,因此不管开线程还是开协程,读写 IO 的时候总是要有一个线程在等待 IO,所以看上去似乎也没有什么区别。但用 NIO 就不一样了,IO 不阻塞,通过开一个或很少的几个线程来 select IO 的事件,有 IO 事件到达时再分配相应的线程去读写 IO,比起传统的 IO 就已经有了很大的提升。

欸?没有写错吗?你写的可是线程啊?

对啊,用了 NIO 以后,本身就可以减少线程的使用,没错的。可是协程呢?协程可以基于这个思路进一步简化代码的组织,虽然线程就能解决问题,但写起来其实是很累的,协程可以让你更轻松,特别是遇到多个任务需要访问公共资源时,如果每个任务都分配一个线程去处理,那么少不了就有线程会花费大量的时间在等待获取锁上,但如果我们用协程来承载任务,用极少量的线程来承载协程,那么锁优化就变得简单了:协程如果无法获取到锁,那么协程挂起,对应的线程就可以让出去运行其他协程了。

我更愿意把协程作为更贴近业务逻辑甚至人类思考层面的一种抽象,这个抽象层次其实已经比线程更高了。线程可以让我们的程序并发的跑,协程可以让并发程序跑得看起来更美好。

线程本身就可以,为什么要用协程呢?这就像我们经常被人问起 Java 就可以解决问题,我为什么要用 Kotlin 呢?为什么你说呢?

6. 小结

总的来说,不管是异步代码同步化,还是并发代码简洁化,协程的出现其实是为代码从计算机向人类思维的贴近提供了可能。


关于作者

霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导