12.5.2 协程的复苏

12.5.2 协程的复苏

经过前面对不同线程实现方式的铺垫介绍,我们已经明白了各种线程实现方式的优缺点,所以多数读者看到笔者写“因为映射到了系统的内核线程中,所以切换调度成本会比较高昂”时并不会觉得有什么问题,但相信还是有一部分治学特别严谨的读者会提问:为什么内核线程调度切换起来成本就要更高?

经过前面对不同线程实现方式的铺垫介绍,我们已经明白了各种线程实现方式的优缺点,所以多数读者看到笔者写“因为映射到了系统的内核线程中,所以切换调度成本会比较高昂”时并不会觉得有什么问题,但相信还是有一部分治学特别严谨的读者会提问:为什么内核线程调度切换起来成本就要更高?

1
线程A -> 系统中断 -> 线程B

处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码的组合体,代码执行时还必须要有上下文数据的支撑。而这里说的“上下文”,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源;以线程的角度来看,是方法的调用栈中存储的各类信息; 而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B 挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。

如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销就能够省略掉吗?答案是“不能”。但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。

有一些古老的操作系统(譬如DOS)是单人单工作业形式的,天生就不支持多线程,自然也不会有多个调用栈这样的基础设施。而早在那样的蛮荒时代,就已经出现了今天被称为栈纠缠(Stack Twine)的、由用户自己模拟多线程、自己保护恢复现场的工作模式。其大致的原理是通过在内存里划出一片额外空间来模拟调用栈,只要其他“线程”中方法压栈、退栈时遵守规则,不破坏这片空间即可,这样多段代码执行时就会像相互缠绕着一样,非常形象。

到后来,操作系统开始提供多线程的支持,靠应用自己模拟多线程的做法自然是变少了许多,但也并没有完全消失,而是演化为用户线程继续存在。由于最初多数的用户线程是被设计成协同式调度 (Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine)。又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine),起这样的名字是为了便于跟后来的“无栈协程”(Stackless Coroutine)区分开。无栈协程不是本节的主角,不过还是可以简单提一下它的典型应用,即各种语言中的await、async、yield这类关键字。无栈协程本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限。

协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。与之相对的,一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。

协程当然也有它的局限,需要在应用层面实现的内容(调用栈、调度器这些)特别多,这个缺点就不赘述了。除此之外,协程在最初,甚至在今天很多语言和框架中会被设计成协同式调度,这样在语言运行平台或者框架上的调度器就可以做得非常简单。不过有不少资料上显示,既然取了“协程”这样的名字,它们之间就一定以协同调度的方式工作。笔者并没有查证到这种“规定”的出处,只能说这种提法在今天太过狭隘了,非协同式、可自定义调度的协程的例子并不少见,而协同调度的优点与不足在12.4.2节已经介绍过。

具体到Java语言,还会有一些别的限制,譬如HotSpot这样的虚拟机,Java调用栈跟本地调用栈是做在一起的。如果在协程中调用了本地方法,还能否正常切换协程而不影响整个线程?另外,如果协程中遇传统的线程同步措施会怎样?譬如Kotlin提供的协程实现,一旦遭遇synchronize关键字,那挂起来的仍将是整个线程。