- 深入理解Kotlin协程
- 霍丙乾
- 1429字
- 2022-02-25 09:35:49
2.2.1 按调用栈分类
通常我们提及调用栈,指的就是函数调用栈,是一种用来保存函数调用时的状态信息的数据结构。
由于协程需要支持挂起、恢复,因此对于挂起点的状态保存就显得极其关键。类似地,线程会因为CPU调度权的切换而被中断,它的中断状态会保存在调用栈当中,因而协程的实现也可以按照是否开辟相应的调用栈来分类。
·有栈协程(Stackful Coroutine):每一个协程都有自己的调用栈,有点类似于线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,主要的不同体现在调度上。
·无栈协程(Stackless Coroutine):协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现。
有栈协程的优点是可以在任意函数调用层级的任意位置挂起,并转移调度权,例如Lua的协程。在这方面多数无栈协程就显得力不从心了,例如Python的Generator。通常,有栈协程总是会给协程开辟一块栈内存,因此内存开销也大大增加,而无栈协程在内存方面就比较有优势了。
当然也有反例。Go语言的go routine可以认为是有栈协程的一个实现,不过Go运行时在这里做了大量优化,它的栈内存可以根据需要进行扩容和缩容,最小一般为内存页长4KB,比内核线程的栈空间(通常是MB级别)要小得多,可见它在内存方面相对轻量。
Kotlin的协程通常被认为是一种无栈协程的实现,它的控制流转依靠对协程体本身编译生成的状态机的状态流转来实现,变量保存也是通过闭包语法来实现的。不过,Kotlin的协程可以在挂起函数范围内的任意调用层次挂起,换句话说,我们启动一个Kotlin协程,可以在其中任意嵌套suspend函数,而这又恰恰是有栈协程最重要的特性之一。
代码清单2-1 嵌套suspend函数
suspend fun level_0() { println("I'm in level 0!") level_1() //... ① } suspend fun level_1() { println("I'm in level 1!") suspendNow() //... ② } suspend fun suspendNow() = suspendCoroutine<Unit> { ... }
代码清单2-1中①处并没有真正直接挂起,②处的调用才会真正挂起,Kotlin通过suspend函数嵌套调用的方式可以实现任意挂起函数调用层次的挂起。
当然,想要在任意位置挂起,就需要对原有的函数进行增强。以Kotlin为例,这种情况下最终的协程实现就不需要挂起函数了,普通函数就相当于挂起函数。不过Kotlin的协程设计并没有采取这样的方案,其原因如下。
·实现这样的特性需要对普通函数的调用机制进行修改和增强,Kotlin所支持的所有运行环境(包括Java虚拟机、Node.js等)也都要提供相应的支持。这一点可以参考Java的协程项目Loom。
·对于普通函数的增强调度切换协程很多时候变成了隐式的行为,至少不怎么明显,例如go routine,一个API调用之后究竟会发生什么就成了运行时提供的“黑魔法”。
·如果想要避免隐式调度,可以在设计API时保留基本的yield和resume作为协程转移调度权的手段供开发者调用,但这样又显得不够实用,需要进一步封装以达到易用的效果。
Kotlin协程的实现很好地平衡了这一点,既避免了对运行环境的过分依赖,又能满足协程在任意挂起函数调用层次挂起的需求。
与开发者通过调用API显式地挂起协程相比,任意位置的挂起也可以用于运行时对协程执行的干预,这种挂起方式对于开发者不可见,因此是一种隐式的挂起操作。Go语言的go routine可以通过对channel的读写来实现挂起和恢复。除了这种显式的调度权切换之外,Go运行时还会对长期占用调度权的go routine进行隐式挂起,并将调度权转移给其他go routine,这实际上就是我们熟悉的抢占式调度了。
关于协程实现究竟属于有栈协程还是无栈协程的问题,实际上争论较多,争议点主要是调用栈本身的定义及协程实现形式上的差异。从狭义上讲,调用栈就是我们熟知的普通函数的调用栈;从广义上讲,只要是能够保存调用状态的栈都可以称为调用栈,因而有栈协程的定义也可以更加宽泛。本书中若无特别说明,调用栈均特指普通函数调用栈,并按照这个标准对协程进行分类。