Harmony OS Next之仓颉:「仓颉线程」、「Kotlin协程」线程? or 协程?

816 阅读14分钟

作为国产编程语言,同时又背靠「华为鸿蒙」这棵大树,仓颉这门语言一时间风头无两。作为一门现代语言,并发的支持显然是重中之重。最近学习仓颉语言的时候,在官网看到了一段似曾相识的描述:

仓颉提供仓颉线程的概念,开发者在大多数情况下只需面向仓颉线程编写并发代码。

仓颉线程本质上是用户态轻量级线程,每个仓颉线程都受到底层 native 线程的调度执行,并且多个仓颉线程可以由一个 native 线程执行。每个 native 线程会不断地选择一个就绪的仓颉线程完成执行,如果仓颉线程在执行过程中发生阻塞(例如等待互斥锁的释放),那么 native 线程会将当前的仓颉线程挂起,并继续选择下一个就绪的仓颉线程。发生阻塞的仓颉线程在重新就绪后会继续被 native 线程调度执行。

我勒个骚刚...

用户态轻量级线程?我再确认下打开的不是Kotlin协程的官网吧,嗯...打开的确实是仓颉的官网。这就很有嚼头了...

Kotlin官网:Kotlin协程是可挂起计算的实例。它在概念上类似于线程,因为它需要与其余代码同时运行的代码块。但是,协程不绑定到任何特定线程。它可以在一个线程中挂起其执行并在另一线程中恢复。协程可以被认为是轻量级线程...

同样的用户态轻量级线程,同样的支持挂起,仓颉所谓的仓颉线程在描述上显然和Kotlin协程十分相似。Kotlin的协程刚推出时,在开发者群体中引起了不小的争论,大致有以下两个观点:

  • Kotlin的协程根本不是协程,只是个线程池框架
  • Kotlin的协程本质上还是依赖线程,没有任何性能上的优势

相信不少Kotlin开发者对上面两个问题也都捉摸不定。类似的,仓颉线程是不是也算是「协程」呢?性能上有没有优势呢?本篇文章就阐述一下个人对协程的认知以及尝试回答一下上面两个问题。

一切为了性能

计算机发展的早期阶段,每隔大约18到24个月,集成电路上的晶体管数量就会翻一番,从而使计算机的计算性能显著提高,同时成本降低。这就是有名的摩尔定律

然而近年来,随着工艺制程接近物理极限,CPU性能的增长速度不断放缓。于是人们尝试通过让计算机尽可能的多处理一些计算任务来压榨计算机性能。我们都知道,计算机的运算速度相比于存储速度以及网络通信速度差异太大了,如果计算机只能执行一个计算任务,就会导致计算机大量时间都花费在磁盘I/O、网络通信上,CPU大部分时间都处于等待其他资源的状态,造成性能的浪费,所以操作系统就需要有一个手段来实现计算机多任务的处理,这就是线程。在同一时间内处理多个任务的能力就是我们常说的并发

线程的概念

线程是操作系统能够独立调度和执行的最小单位。它是进程中的一个实体,负责执行进程中的任务。一个进程可以包含多个线程,它们共享进程的资源(如内存、文件句柄)。

操作系统通过引入线程实现了不同任务的独立调度,提升了并发效率。主流的操作系统都提供了线程的实现,同时不同的语言对线程也有一套自己的抽象实现,根据和操作系统内核线程的对应关系不同,大致上有三种方式:

  • 使用操作系统内核线程实现(1:1实现)
  • 使用用户线程(1:N实现)
  • 使用用户线程加内核线程混合实现(N:M实现)

内核线程实现

以Java中的线程举例,Java语言针对不同操作系统提供了统一的抽象实现Thread,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程,对应着操作系统的内核线程。所以Java的线程直接使用了操作系统内核线程实现(1:1实现),不同操作系统和不同硬件上的实现差异由JVM抹平。

内核线程就是由操作系统内核管理和调度的线程。每个内核线程都有自己的上下文,包括寄存器、堆栈等,由操作系统在不同线程之间切换时保存和恢复。内核通过调度器完成对线程的调度,并负责将线程的任务映射到处理器上。

需要注意的是,程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP)

轻量级进程是介于用户线程和内核线程之间的一种线程实现方式。它通常由操作系统内核支持,提供用户线程和内核线程之间的桥梁。

我们通常意义上所讲的线程指的就是轻量级进程,轻量级进程和内核线程的数量关系为1:1,即每个轻量级进程都由一个内核线程支持。

画板

正是由于每个轻量级进程的背后都是基于内核线程的支持,所以轻量级进程的各种操作(如创建、同步等)都需要进行对应的内核线程的操作调用,这部分的工作对应的系统调用代价相对较高,同时内核线程需要消耗一定的内核资源(如内核线程的栈空间),因此一个操作系统对于轻量级线程的支持是有数量限制的,创建过多的轻量级线程可能会导致程序卡顿甚至发生OOM,比如记一次「线程优化」导致的业务异常

用户线程实现

用户线程是由用户级线程库管理的线程,操作系统内核通常对其不可见。它们在用户空间中实现,提供了一种轻量级的多线程机制。也就是说,用户线程的创建、调度、同步等操作完全由语言自己实现,无需借助内核线程。这种进程和用户线程之间1:N的实现我们称之为一对多实现。因此相比于内核线程实现,用户线程会相对快速,且内核资源消耗较低,所以支持的线程数量的规模也会更高。

Java早期版本实现的绿色线程(Green Threads)就是一种用户线程的实现。这种实现缺点也很明显:

  • 无法利用多核:由于所有线程在单个内核线程上运行,绿色线程无法利用多核处理器的并行性。
  • 阻塞问题:如果一个绿色线程阻塞(例如进行I/O操作),整个JVM可能被阻塞。

由于这些限制,绿色线程很快就被Java给抛弃了,现代主流的JVM的线程实现都被替换为了内核线程1:1的实现。

画板

内核线程和用户线程的混合实现

除了上面两种独立实现的线程模型外,还有一种结合了内核线程和用户线程一起使用的实现方式,这种混合实现我们称之为N:M实现。即在这种实现下,既存在内核线程(轻量级进程),同时也存在用户线程。这种实现结合了以上两种的特点:

  • 用户线程的创建、同步、调度等操作仍然发生在用户空间中,所以依然快速、资源消耗小
  • 操作系统支持的轻量级进程作为内核线程和用户线程的桥梁,用户线程可以使用内核线程的线程调度以及映射CPU执行操作。

我们所熟悉的Kotlin协程就是基于此实现的,与之实现类似的还有我们今天提到的主角仓颉线程

线程?协程?

这里我们先明确一点概念,这里的线程,指的就是上面所说轻量级进程(或者说内核线程),我们上面一直在讲基于基于内核线程实现的线程模型使用成本高,具体是怎么个高呢?

这部分的成本,主要源自于操作系统调度切换内核线程的执行成本。想象这么一个场景,操作系统将执行的线程从A线程切换到B线程,大概要经历以下几个过程:

线程调度器在线程切换时,最主要的工作就是保存和恢复线程的上下文,这里的上下文,指的就是存储在内存、缓存以及寄存器中的一个个数值。在这个过程中,会频繁涉及到数据在各种存储介质中的来回拷贝,而I/O操作本身就不是一种轻量操作。

既然内核线程的切换开销这么大,我们能不能绕开内核线程的切换,将多任务的处理、任务切换交由开发者自己处理以缩减任务切换的开销?当然可以,上面讲的用户线程就是这种做法:程序自己模拟多线程效果、自己实现任务的挂起、恢复。由于最初大部分用户线程都被设计成协同式调度,所以用户线程也有了一个别名,叫做协程(Coroutine)。 这也是为什么大部分语言的协程实现都会提到所谓的用户态

早期的协程实现确实是协同式调度,然而这并不意味着协程就一定以协同调度的方式工作。事实上,非协同式、可自由调度的协程的例子也不少见。

协程概念最早出现在1963年,由美国计算机科学家Melvin E. Conway提出:协程是一种程序组件,它允许程序中的某些部分在执行过程中被挂起,并在稍后的时间从挂起点恢复执行。 协程与线程的区别在于,协程是由程序员显式控制的,不依赖于操作系统调度,因此具有更低的上下文切换开销。

基于以上的描述,我们可以大致总结协程的特点:

  • 相比于传统的线程,协程更加轻量:轻量级“线程”
  • 协程可以被挂起并在特定的时间节点从挂起点恢复执行:用户态
  • 协程和内核线程不是一一对应的。

当然,任何事物都有两面性,协程也有其不足的地方:

  • 协程的任务执行时间是由程序自己控制的,如果一个协程的代码实现有问题,一直不进行协程切换动作,那么程序会一直阻塞等待协程执行完毕;
  • 协程依赖语言的具体实现,相对于操作系统本身的内核线程,需要自己实现调度器、挂起、恢复等逻辑,相对复杂且抽象。

协程的不同实现

不同语言对于协程的实现五花八门,各不相同,不过也是有迹可循的。

调用栈进行分类,协程的实现分为有栈协程&无栈协程

  • 有栈协程: 其大致的原理是通过在内存里划出一片额外空间来模拟调用栈,有栈协程是每个协程拥有自己的独立调用栈。协程可以在任何时候暂停,并在以后恢复时继续执行。典型的例子就是Go语言实现的协程,仓颉线程也是有栈协程。
  • 无栈协程:无栈协程不维护独立的调用栈,通常通过状态机或闭包实现。协程的每个暂停点被转换为状态,以实现程序的挂起/恢复。无栈协程本质上是一种有限状态机,状态保存在闭包里。它的典型应用,即各种语言中的await、async、yield这类关键字。

由于实现方式的不同,有栈协程和无栈协程的区别也很明显:

内存管理:有栈协程需要独立的栈,无栈协程则不需要,这意味着有栈协程往往意味着更多的内存占用。

  • 灵活性:有栈协程更灵活,适合复杂的控制流;无栈协程适合简单的、状态明确的任务。
  • 实现复杂度:无栈协程的实现可能需要更多的状态管理和转换逻辑。

调度方式进行分类,协程的实现分为对称式协程非对称式协程

  • 对称式协程:对称式协程允许协程之间相互切换,任何协程都可以将控制权转移给其他协程。
  • 非对称式协程:非对称式协程只能将控制权交还给调用者或调度器,通常是通过 yieldawait关键字实现。

「仓颉线程」是不是协程?

我的回答:是,当然是。

协程的核心概念其实就是程序能够自己挂起、自己恢复,挂起和恢复由程序自己控制。从这个定义上来讲,「仓颉线程」当然是协程。那为什么仓颉自己非要将其称之为“仓颉线程”而不是协程呢?

这个问题我也请教了仓颉的开发人员,他们认为协程应该是区别于传统线程的抢占式,而是协同式,但是仓颉线程是支持抢占式的,所以仓颉的语言专家认为「仓颉线程」有别于协程,所以定义时和协程做了区分。

仓颉线程本质上是一种用户态的轻量级线程,支持抢占且相比操作系统线程更轻量化。

其实不少资料上都显示协程的工作方式一定是协同式的,但是我并没有找到这种说法的根源,早期的协程确实都是基于协同式实现,但是如今非协同式、可自由调度的协程的例子也不少见,比如Go语言实现的协程:

Go 运行时还会对长期占用调度权的 go routine 进行隐式挂起,并将调度权转移给其他 go routine,这其实就是我们熟悉的抢占式调度了。

所以协程并不意味着就一定以协同调度的方式工作, 基于此我仍然认为仓颉线程就是协程。

当然一千个人心里就有一千个哈姆雷特,作为开发者的我们,需要做的就是破开认知的迷障,了解技术背后的本质。至于它叫协程还是仓颉线程,不重要。

kotlin协程究竟称不称得上协程?

我得回答仍然是:是,当然是

从协程的核心概念出发,Kotlin的协程确实实现了程序的挂起、恢复,挂起恢复由程序自己控制,所以毫无疑问kotlin协程确实是协程。至于为什么有 Kotlin协程就是一个线程池框架 这种说法,我理解最大的原因就是Kotlin协程在Java虚拟机上的实现确实要依赖线程,但这是受限于Java虚拟机对线程的实现问题。毕竟Kotlin的协程在Js上也有对应的支持,你总不能还说他就是一个线程池框架吧?

相比于线程,协程究竟有没有性能上的优势?

这个问题如果你仔细阅读了文章,相信一定会有一个清晰且坚定的回答:一定有

在讲解协程的概念时,我们就提到了内核态的线程切换需要经历的过程以及对应的操作系统资源之间的切换,相比于编程语言层面实现的用户态多任务切换,内核态的线程切换一定是会带来更多的性能开销的。