仓颉并发机制的设计与实现

文摘   2024-07-26 16:54   广东  


陈冬杰


博士毕业于南京大学,2021 年加入华为编程语言实验室,主要参与仓颉语言并发特性的设计实现

背景与动机


并发编程是一种让程序能执行多个任务的编程技术,其主要目标是提高程序的运行效率和资源利用率(注意区分并发和并行,前者指代多个任务的执行时间有重合,如交替执行,而后者严格要求任务同时执行)。线程是最为常见的并发模型,通常被进一步区分为操作系统线程和用户态协程。


1

 操作系统线程

线程指代操作系统级别的并发单位,由操作系统进行管理。每个线程都有独立的堆栈和程序计数器,但共享进程的全局变量和资源。

2

用户态协程

协程是比线程更轻量级的并发单位,通常由程序语言或运行时库管理。协程之间的切换开销比线程更低,适用于高并发场景。相比于线程,协程具有如下的优点:


1.操作开销低:协程的创建和切换只需在用户态完成,避免了进入内核态的开销,极大降低了上下文切换的成本,开销远远小于传统的内核线程。


2.内存占用少:协程不需要分配大量的内核资源,如内核栈、线程控制块等;通常一个协程只需要几 KB 内存,因此可以创建更多的协程。


由于协程轻量化的优点,当前很多编程语言都会选用它作为并发机制的基础。


相关工作


通常,用户态协程可分为“无栈”和“有栈”两种实现方案。本文中简要对相关的几个编程语言做介绍。


1

 Golang

Go 语言提供 goroutine 实现了有栈的用户态协程,它是一种轻量级的线程机制,可以支持同时运行成千上万个 goroutine,而不会对系统资源造成严重负担。Go 语言鼓励使用“消息传递”方式来完成 goroutine 之间的通信和同步。通过使用 channel 可以安全地在多个 goroutine 之间传递数据。Go 语言还引入了 select 语句,这是一种多路复用机制,能够在多个 channel 操作中进行选择,从而简化了处理多个并发任务的逻辑。此外,Go 同时也支持共享内存的通信方式,并在 sync 包提供了一些基础的同步原语,如互斥锁(Mutex)和等待组(WaitGroup)。Mutex 用于保护临界区以防止竞态条件,而 WaitGroup 则允许等待一组 goroutine 完成。

2

Swift

在 Swift 提供了任务(Task)表示一个独立的并发执行单元,它是用户态协程的无栈实现,其核心是 Swift 5.5 中引入的 async/await 语法。这种语法将异步函数标记为 async,并使用 await 关键字允许任务暂停执行直到异步操作完成,这一编程机制改变了原来 Swift 中对于回调和嵌套闭包的需求。


Swift 不直接支持共享内存或是消息通信方式,而是通过 Actor 模型达成并发安全。Actor 通过保证内部状态的独立性和线程安全,提供了一种避免数据竞争的方法。共享状态的访问和修改必须被封装在一个 Actor 中,而 Actor 确保了所有的访问是序列化的,防止了竞态条件,从而不需要使用锁或其他同步机制。

3

Rust

Rust 在标准库中提供了操作系统级的线程模块,允许开发者可以创建和管理线程,并鼓励消息传递来实现线程间通讯。标准库中的互斥锁、(Mutex)、原子引用计数(Arc)等允许线程间共享内存的同步机制调。


此外,Rust 也提供 async/await 关键字的支持,允许开发者实现用户态协程,但它仅提供语法上的支持,并不直接提供协程的运行时。


在这两种并发模型中, Rust 都能依靠它的所有权和借用机制确保了数据在不同线程/协程之间传递时避免数据竞争的发生,达成并发安全。


两种协程模型的争论


对于两种用户态线程模型的优劣争论可谓旷日持久,网络上存在各种文章描述他们各自的优缺点。例如,无栈协程的支持者认为无栈协程能够追求极致的性能收益,而有栈协程的拥护者表示通过系统地优化,有栈协程一样有接近的性能。此外,另一个争论点在于:无栈协程要求在语言中引入新语法(如 async/await 关键字)。然而,这种新语法会增加开发者编写并发代码的复杂度。开发者需要在编程过程中手动标记(如用 async 标记异步函数并用 await 标记其调用点);并且这种标记具有“传染性”(包含 await 的函数必须标记为 async),最终导致著名的“函数染色”问题。在这一问题的争论中,无栈协程的支持者将其类比为静态的类型标注,可以帮助开发者更好地理解代码,而有栈的拥护者则表示这是对于开发者困扰,会降低编程效率。


其实,编程语言对于这两种并发模型的选择都是根据其自身的语言设计、定位和发展来综合考虑的,并没有绝对的好与坏。


仓颉并发机制


如下表格概述了仓颉语言中并发编程的相关机制。



下文我们将对仓颉并发模型和线程间的通信做介绍。


 轻量级并发模型

高效开发和简单易用始终是仓颉语言的设计定位,因而仓颉语言选择有栈协程作为其并发模型,避免了函数染色问题。因此,仓颉线程即为用户态协程。


仓颉语言中通过关键字 spawn 创建仓颉线程。下面示例代码中,(在第 10行 被创建的)每个线程独立地执行 fetch_data 函数并将结果汇总到并发哈希表(下文将对并发哈希表做说明)中,最终在主线程在第 14 行等待所有线程执行完成。



在具体实现中,仓颉线程的运行时包含了执行线程、监视器、处理器、与调度器等几个部分如下图所示。



1. 执行线程是操作系统线程,完成实际的代码执行;而在它的视角中,仓颉线程被视为可执行任务。仓颉线程和执行线程间有 M:N 的映射关系,即多个仓颉线程可在同一个执行线程上执行、而一个仓颉线程也可经历多个执行线程完成执行。一般而言,执行线程的数量和 CPU 核数相当,而仓颉线程的数量远多于执行线程。


2. 执行线程使用处理器抽象了与仓颉线程的绑定关系,每一个处理器内部都维护了一个工作队列,记录当前等待执行的仓颉线程。

执行线程通过多队列任务窃取算法在多个执行器之间平衡仓颉线程的分布以提高执行效率:当一个处理器的队列为空时,执行线程会尝试从其他处理器的队列中窃取仓颉线程以继续运行,保证了负载均衡。


3. 监视线程是具有全局视野的独立线程,用来观察各个仓颉线程的状态,避免出现调度不均衡的情况,比如某一仓颉线程占据过长时间片时需要将其进行抢占让其他线程执行。


 仓颉线程间通信

仓颉语言选择了最常见的共享内存作为线程间通信的基础,在此之上也提供了 BlockingQueue 这类数据结构来完成消息传递的通信机制。和大多数编程语言一样,仓颉提供了多样化的线程间同步机制,包括原子类型变量、互斥锁、信号量等。在本文中,我们对其中的三者(互斥锁、线程取消机制和并发数据结构)设计做介绍。


一、可重入互斥锁


仓颉语言中提供的互斥锁 ReentrantMutex 是可重入互斥锁,即一个线程能够重复多次对同一个互斥锁进行上锁操作而不会发生死锁。虽然可重入性在实现上会带来额外性能开销,但它对于开发者更加友好,能够有效地减少死锁发生。


此外,在互斥锁的具体实现中,(如相关工作的总结)我们可以根据“线程获取锁失败后的行为”将互斥锁做一个分类。


1. Spin 模式

实施自旋操作。这种模式对“小临界区”友好,在此类情况下,线程可以很快获取锁。


2. Parking 模式:

挂起到等待队列。这类互斥锁可以再次细分为两类。

1)Barging 模式:已等待线程在被唤醒后将和新到来的线程去竞争互斥锁的获取。这种模式在“细粒度锁保护且锁竞争强”的情况下有更好的吞吐量。例如一个线程在释放锁之后,很快它又需要持有锁,那么在唤醒线程真正被执行之前,它仍可以执行完新的临界区。

2)Fair 模式:严格保证线程“先到先获取”原则。这种模式对解决尾延时问题友好。


3. Adaptive 模式:

同时采用 Spin 和 Parking 两种模式。先以 Spin 方式获取,而后再被挂起。


仓颉语言中的互斥锁采用 Adaptive 模式:当仓颉线程获取互斥锁时,它先以 Spin 的方式尝试获取,而在失败多次后再挂起到等待队列中。同时,我们希望能兼顾公平性:当某一线程等待时间过长后能够尽可能保证它优先获取互斥锁,避免长时间饥饿。


最终,互斥锁的实现还需要考虑可重入性的支持。整体而言,仓颉语言中互斥锁的上锁流程如下图所示。




二、协作式线程取消


已有大量工作(如 Java 线程取消、Rust 线程取消, Windows 线程取消)都一致认为:强行终止线程是不安全行为。因为这可能会使程序执行处于不一致的状态,并导致各种难以定位的错误。对此,仓颉语言要求:线程的终止必须由程序员显式检查和处理。在这一设计下,线程终止是协作式的:一个线程发送终止请求,另一个线程应该响应该请求并完成终止。假设有两个线程(线程 T1 和线程 T2),取消操作的工作流程可以总结如下:



首先,线程 T1 发送请求给线程 T1,标记终止;其次,线程 T2 定期检查是否有终止请求;最终,线程 T2 决定响应请求;它可以忽略请求或进行终止。


在这一设计的引导下,我们在 Future 和 Thread 类型中内置了对应的 API 帮助开发者快速实现这种线程取消模式。下面代码段的第 8 行通过 cancel 方法发送终止请求,而在新线程中(第 3 行)主动检查是否需要终止线程。



并发安全的数据结构


为提升开发者在多线程访问共享内存场景下的开发体验,仓颉语言在标准库中提供了最常用的并发对象:并发哈希表(常作为数据缓存)和并发队列(常作为线程间传递消息的通道,处理“生产者-消费者”问题)。例如,在示例代码 1 中,各线程最终将结果汇入并发哈希表 resultMap 中,注意到在插入结果时线程不需要通过额外的互斥锁保证正确性,因为并发哈希表内部已确保并发访问的正确性。


这一节中介绍仓颉语言对并发对象的设计思考。在设计并发对象时,我们需要解决两个关键问题:


  • 定义并发对象中那些方法是具有并发原子性的;

  • 如何获得更高的加速比。


其中,前者是对于并发对象的 API 设计,而后者是并发对象的实现算法问题。本文中,我们以仓颉 ConcurrentHashMap 为例,说明关于这两点设计的考虑。


1. ConcurrentMap的API 设计:

仓颉提供了 Map 接口,但我们没有让 ConcurrentHashMap 实现 Map 接口,而是定义了新的 ConcurrentMap 接口,它与 Map 接口之间没有继承关系,并让ConcurrentHashMap 实现 ConcurrentMap 接口。


2.细粒度并发算法:

ConcurrentHashMap 的实现使用细粒度并发算法来提升多线程操作 ConcurrentHashMap 的性能,比如:多线程对并发哈希表中的键值对做读操作是无需加锁的,多线程修改键值不同的键值对也不需要同步,等。

ConcurrentMap 的 API 设计

并发对象提供哪些方法具有原子性保证的 API 是个重要问题,这直接影响了在并发场景下开发者如何使用并发对象。


仓颉语言在设计时,通过引入新接口 ConcurrentMap 来明确:并发表的实现中,哪些方法需要保证并发原子性。而 ConcurrentMap 接口没有实现 Map 接口的主要考虑则是:Map 接口中的很多方法在实际中是难以保证并发原子性的。例如:putAll 方法负责将一组键值对插入并发哈希表,而我们很难实现高效的方法来确保它的原子性。此外,参考其它编程语言实现和学术界提出的并发表算法,并非并发对象中的所有方法都需要保证并发原子性,保证所有方法都具有并发原子性反而会导致并发对象的实现算法变得复杂。


此外,ConcurrentMap 相比 Map 新增了一些我们认为需要保证原子性的方法,例如 replace 方法原子地替换表中的键值对,而 Map 接口没有提供。注意到,在并发场景下 replace 方法不能通过 get 和 put 的简单组合来实现。下表展示了部分 ConcurrentMap 中相比 Map 接口新增的方法。




细粒度并发算法


仓颉基于其 ConcurrentMap 接口,使用细粒度并发算法实现了并发哈希表 ConcurrentHashMap。细粒度并发算法是相对于粗粒度算法而言的,粗粒度并发往往指使用并发度较低的方法实现对并发对象的访问,例如:使用一个锁对象控制所有线程对某一并发对象的操作,这种方式简单,但性能很差,任意时刻只允许一个线程操作并发并发,即:一个线程持有锁并操作并发对象时,其它线程会因为无法持有锁而被阻塞。而细粒度并发算法则是探索更高并发度的方法来处理多线程操作并发对象的场景,例如如下图所示,



仓颉 ConcurrentHashMap 允许多线程并发读键值对,以及并发修改键不同的键值对,即:线程 T1 和 T2 可以并发读键 k3 映射到的值,线程 T4 和 T5 可以并发修改不同的键值对,但线程 T3 和 T4 修改相同的键值对则需要同步,即其中一个线程操作键 k1 时,另一个线程会被阻塞。


下表罗列了仓颉 ConcurrentHashMap 对多线程并发访问的控制:



此外,还需要指明的是:仓颉 ConcurrentHashMap 支持哈希表扩容时,并发将哈希表中的数据从旧的哈希表,迁移到新的哈希表。我们知道,为了避免键冲突,哈希表是需要进行扩容的,扩容时需要创建新的哈希表,并将原来旧表中的数据迁移到新创建的哈希表。对于仓颉 ConcurrentHashMap,当线程 T1 和 T2 并发向哈希表中新增键值对,并识别出当前哈希表需要扩容时,线程 T1 和 T2 会负责将旧哈希表中不同键的键值对,迁移到新的哈希表中。



小结


仓颉语言通过轻量级线程模型、共享内存通信和并发对象简化了并发编程的复杂性,使开发者能够编写高效的并发程序。本文介绍了并发编程的基本概念和设计实现,希望能够帮助读者更好地理解和应用仓颉语言进行并发编程。在实际开发中,合理利用并发编程技术,可以显著提升程序性能。

点击下方阅读全文,试用仓颉编程语言SDK

仓颉编程语言
仓颉编程语言官方公众号
 最新文章