并发语言实现(二):锁与原子操作

"加锁很慢"是很多开发者的刻板印象,实际性能差距,来自竞争与非竞争这两条截然不同的执行路径。以2026普通PC的典型指标来看:无竞争场景下只需要执行硬件原子指令修改锁状态,全程不会进入内核,开销大约几十个CPU周期。一旦发生锁竞争,流程就会切换为排队、内核睡眠、等待唤醒的慢路径。二者性能相差约两个数量级,快路径是几十纳秒,慢路径可达几微秒。

阅读提示 :本文面向虚拟机、并发开发相关读者。了解线程、锁的基础概念阅读体验最好;文中会对陌生名词给出简短提示,新手也可以跟随主线阅读,操作系统相关底层术语的简明释义放在文末附录,有基础可以直接跳过附录。

《并发语言实现(一):并发模型的选择》介绍过各类并发模型保存执行现场的方式。本文会拆解锁的快慢执行路径,再介绍NLang面向用户的锁、信号量、原子整数,以及运行时依靠五把域锁实现的底层机制。

一、快路径:一条不睡内核的指令

快路径依托比较交换(CAS,Compare‑And‑Swap)(硬件原子指令,用于无竞争状态下修改共享内存)原子指令。如果内存当前值与预期旧值相等,就更新为新值,整个操作由硬件保证原子性,不会被其他CPU核心打断。锁的快路径,就是借助这条原子读改写指令把锁状态从空闲置为占用,成功即可持有锁,失败才进入排队逻辑。

跨核之间的原子性由CPU缓存一致性协议提供支持。整个流程不会陷入内核,自然也就没有系统调用带来的额外开销。

二、慢路径:睡得便宜,还要防饿死

出现锁竞争就需要让执行流进入休眠。频繁发起系统调用完成休眠的成本很高,Linux借助futex(用户‑内核混合锁机制,多数逻辑跑在用户态,仅休眠唤醒时进入内核)来处理这类场景。等待队列直接挂载到锁对应的内存字上,内核只在真正需要休眠、唤醒的时候介入,绝大多数快路径逻辑都在用户态完成。这套方案省下的,正是用户态和内核态来回切换的开销。应用代码运行在用户态,操作系统内核负责硬件资源调度,进入内核必须发起系统调用,休眠的主要开销就来源于此。Windows上与之对等的实现是WaitOnAddress,设计思路大体一致。

真正进入内核休眠之前,还可以先做自旋处理,也就是不直接休眠,在原地循环重试抢锁。Go的sync.Mutex在正常模式下,等待的goroutine会先做一小段空转,寄希望于持有者很快释放锁。如果重试依旧失败,才会进入等待队列。

自旋能够提升吞吐,但会带来副作用:新到达的goroutine可以反复尝试争抢锁,那些已经排队很久的等待者反而很难拿到机会。Go为此设计了饥饿模式,当等待时长超过1毫秒,锁切换成FIFO先进先出模式,新来的协程不能插队,只有轮到队首才允许获取锁。如果拿到锁的等待者已经是队列最后一员,或是自身等待时间不足1毫秒,锁就切回正常模式。同一把锁内部维护两套调度策略,本质就是在吞吐和公平之间做权衡。

Java的synchronized走的是另一套实现思路。临界区代表同一时刻只允许单一线程进入的代码片段。synchronized把锁状态编码在对象头内部,支持多级状态跃迁:从无锁,到依靠CAS自旋的轻量级锁,再到重量级monitor;进入重量级之后,等待者会睡进monitor维护的队列。

早期JDK还实现过偏向锁,假设锁大多只被同一个线程反复获取,以此省去部分CAS操作。但现代业务场景里锁的持有者经常发生变化,撤销偏向锁的开销变得难以接受,JDK 15把它默认停用,后续版本直接移除。锁可以从轻量级升级为重量级,逆向降级却很难。一旦锁长期处于高竞争,就会停留在重量级形态,这也是业内"热点锁不建议直接使用synchronized"这一经验说法的由来。

三、关键分叉:睡的是内核线程,还是虚拟线程

M:N调度模型,也就是M条虚拟线程映射到N条载体线程,载体线程即实际执行代码的操作系统线程,在这里会遇到一个核心抉择(详见《并发语言实现(一):并发模型的选择》)。锁发生竞争的时候,我们应该让虚拟线程休眠,还是直接阻塞底层的载体线程?

直接休眠载体线程实现起来最简单,但代价很大。一旦大量虚拟线程争抢同一把锁,载体线程池会被全部堵死,整个进程失去调度能力。Go和Loom都做了接管处理:锁发生竞争时,停泊即挂起执行流(详见《并发语言实现(一):并发模型的选择》) 的对象是虚拟线程,执行上下文存入调度结构,载体线程可以转而去运行其他任务;锁释放之后,也只会唤醒对应的虚拟线程。

Loom存在载体绑定(pinning)的历史缺口。JDK 21中synchronized块与JNI原生方法调用没有被接管,虚拟线程如果在这些位置等待,载体线程会被一并占住。JDK 24发布的JEP 491完成了对synchronized路径的接管。Go的cgo调用也存在同类现象,goroutine进入C代码执行时,载体线程会随之阻塞。NLang的FFI外部函数接口也面临完全一样的问题,调度器观测不到的等待场景,只能连带载体线程一起休眠。

四、什么时候不需要锁

维护全局计数器,是不是一定需要上锁?并不一定。单个内存字的更新逻辑,可以用CAS重试循环完成:读出旧值,计算出新状态,尝试用CAS完成写入,失败就重新执行。Java的AtomicInteger、Go的sync/atomic、Rust的AtomicUsize都采用这类写法。

但这套方案适用范围有限,只能保护单次单字更新。如果是读‑改‑写这类会改动多个字段的逻辑,或是临界区内部包含复杂业务,就必须依靠锁来保护。

转账逻辑就是一个很典型的例子。需要先校验A账户余额,同时修改A、B两个账户。CAS只能保护单个字段,在校验完成到执行写入的间隙,其他执行流依旧可以修改A账户的数据,这种场景就必须使用锁。判断的核心,就看临界区逻辑是否能够收敛到单次单字CAS操作。

五、NLang的实现:从单载体到多域锁

用户手中的同步原语和锁

这里先区分两类锁对象,避免后续阅读产生混淆。一类面向应用开发者,也就是下文介绍的四类原语,由业务代码创建、调用。另一类用于保护虚拟机内部运行时存储。NLang虚拟机采用按存储域拆分锁 这一通用并发工程模式,划分出5个独立存储域;本文将该模式下的锁简称为域锁 。目前NLang的VM主要通过域锁来实现内部存储的并发保护,同时还包含少量其他辅助并发逻辑。

按存储域拆分锁属于通用并发设计模式。放弃单一全局大锁,把共享数据切分成多个互不干扰的存储域,每个域配备独立锁,获取多把锁时严格遵循固定顺序。这套模式并非虚拟机专属,数据库、操作系统内核,甚至上层业务服务都会使用。业务代码也可以借鉴这套思路,拆分子系统锁,缓解锁竞争压力。

在NLang内部,应用层Mutex和虚拟机域锁存在构造关系。业务代码使用的Mutex只是虚拟机侧表中的一条记录,这条记录的修改安全由对应域锁保障。

对比项 程序员代码里的锁 VM内部的域锁
谁创建 用户代码里创建 VM启动时自带
谁拿谁放 用户显式调用 VM自动执行
数量 想建几个建几个 固定五把
保护什么 用户自己的共享数据 VM的内部存储

落实到对外API层面,NLang一共暴露四类同步原语。

  • Mutex拥有三个核心方法。调用lock()获取锁,如果锁已经被占用,当前执行流会停泊等待;unlock()释放锁,优先把锁交给等待队列中等待时间最久的任务;tryLock()尝试获取锁,拿不到直接返回false,不会发生等待。
  • ReentrantMutex接口和普通Mutex保持一致,额外支持持有者重复加锁。如果一段已经持有锁的函数,又调用另一个需要获取同一把锁的函数,普通Mutex会造成自身死锁;可重入锁通过计数识别持有者,允许重复获取。
  • Semaphore(n)用来管理许可计数。调用acquire()申请许可,没有可用许可就进入等待;调用release()归还许可,如果存在等待者,许可会直接移交过去,不会先写入计数器;tryAcquire()尝试申请许可,失败直接返回false,不等待。
  • AtomicInt面向共享整数场景。创建时传入初始值,支持load()读取、store()写入、incrementAndGet()原子自增、compareExchange(期望值,新值)条件更新,后者就对应第一节介绍的CAS能力。
从单载体到多载体:锁状态住进侧表

NLang锁的实现,最早可以追溯到仅支持单载体运行的阶段。那个时刻整个VM同一时刻只存在一条执行流,连原子指令都不需要。依靠ownerId标记搭配等待队列,普通内存读写就可以保证正确性。获取锁只需要判断ownerId是否空闲,空闲则写入自身标识;释放锁时重置标记,或是把锁移交等待者。不存在其他执行流,也就不会出现状态被篡改的问题。

支持多载体之后,NLang面临和Go、Loom相同的选择:发生竞争时停泊虚拟线程,载体线程继续调度其他任务。实现思路也相近,等待队列、唤醒逻辑全部放在用户态调度结构中。Go、Loom把等待协程挂入运行时内部队列,NLang则使用一张专用侧表。

应用层的Mutex并不是普通内存上的锁变量,它对应侧表里的一条条目。侧表是VM为各类内建对象(List、Dict、锁、通道、流)维护的专用表格。业务脚本拿到的只是句柄,也就是VM分配的整数编号,依靠句柄才能定位到侧表内部真实状态。锁相关的全部状态,统一保存在侧表条目当中:

复制代码
MutexSlot {
    int ownerId;                     // -1表示空闲,否则是持有者的上下文id
    std::deque<SyncWaiter> waiters;  // 等待队列,FIFO(先进先出)
}

队列底层使用std::deque,等待者从队尾入队,移交操作从队首取出,两端操作效率很高,不会挪动队列内部已有元素。如果换成std::vector,每次从队首取出元素,都需要整体挪动数组内容。这两个字段的安全修改,依靠虚拟机的域锁来保障。Mutex本身只是被修改的数据记录,并不自带锁能力。保护同步原语侧表的就是五把域锁其中之一,后面会完整介绍域锁划分。

这里要区分两层休眠逻辑。业务锁发生等待时,虚拟线程只是停泊,不会直接触发操作系统休眠,任务挂在侧表等待队列,载体线程转而去调度其他任务。futex在这里的角色发生变化,前文提到它主要用于内核互斥慢路径。接管模式下锁等待全程处于用户态,futex只在载体线程池全部空闲的时候,才用来让OS线程休眠。NLang实际选用C++标准条件变量完成该逻辑。载体线程休眠与唤醒逻辑属于调度器范畴,后面的文章将会讲到这部分内容。

这里会出现一个看似矛盾的点:即便是用户锁的快路径,依然需要获取域锁。域锁本身也是普通锁,无竞争场景下同样会走到原子操作快路径,它保护的临界区只包含几次内存访问,绝大多数场景不会产生竞争。如果再额外叠加一层无锁CAS,并不会带来收益,只会增加实现复杂度。因此,实现选择优先保证简单性,此处不在添加显式的CAS。当然这套设计也存在取舍:进程内大量锁、信号量、通道、原子整数操作,都会经过这把域锁。单线程或是以计算为主的业务几乎感受不到竞争;当多载体高频操作同步原语时,这把锁会成为瓶颈。属于牺牲部分扩展性换取实现可维护性。

两阶段停泊握手:关掉竞态窗口

实现上的真正难点来自一处竞态窗口。等待者刚刚登记进入队列,还没有完成停泊,如果此时执行unlock,该如何处理?直接唤醒,任务还没有进入睡眠状态;不唤醒,任务马上就要停泊,会永久丢失唤醒信号。NLang依靠两阶段停泊握手解决这个问题。

停泊会把虚拟线程的帧栈转移到上下文对象,让出载体线程。上下文保存虚拟线程完整执行现场,本篇只需要了解该容器的存在即可。等待者登记时状态标记为kPending代表已登记,后续存在两种走向:复检时切换为kCommitted代表已经完成停泊;或是unlock操作发现它还处于kPending,打上kAcquired标记,任务不需要停泊,可以原地继续执行。

复制代码
lock(mutex):								//用户程序
    同步原语域锁内:                          // VM内部的锁, 不是用户的Mutex
        若ownerId空闲: 写上自己, 返回          // 快路径
        waiters.push_back({self, kPending})      // 登记, 本块结束后释放域锁
    调度器把本上下文标记为停泊状态(此时仍可撤销)
    再拿域锁复检自己的条目:              // 复检与提交都在同一把域锁内
        仍是kPending → 置kCommitted, 释放域锁
            随即在锁外停泊让出载体        // VM把帧栈搬进上下文, 载体去跑别的虚拟线程
        已被置kAcquired标记 → 删条目, 释放域锁, 原地继续    // unlock发生在中间, 锁已经是我的
unlock(mutex):							/用户程序
    同步原语域锁内找第一个还活着的等待者:
        kCommitted(已提交睡眠) → 出队并移交所有权, 释放域锁后再唤醒
        kPending(还没睡) → 移交所有权, 置kAcquired标记, 不唤醒
        无等待者 → ownerId置-1, 释放域锁

对于还没有完成停泊的等待者,unlock不需要发起唤醒,只需要打上kAcquired标记,任务复检时识别标记就可以继续运行。只有已经完成停泊的任务,才需要执行唤醒。打标记、提交停泊都在同一把域锁保护下执行,操作时序不会错乱。如果unlock先看到kPending就打上标记;等待者后续复检识别标记直接继续。如果等待者先提交为kCommitted,unlock就走唤醒分支。两段伪代码并不对称,lock()无法预知锁状态,需要做检查、登记;unlock()调用方一定持有锁,只需要寻找下一个接收者。

这套方案也体现出吞吐和公平之间的取舍。NLang选择永久移交模式,不会自旋,也不允许新来的任务插队,等价于Go饥饿模式永久开启。不做自旋存在现实考量:载体线程如果空转自旋,本可以调度其他虚拟线程,其中就有可能是即将释放锁的持有者。停泊唤醒全部在用户态完成,并不存在用户‑内核态切换开销,自旋带来的收益有限。代价是存在等待者的解锁操作,会额外产生一次唤醒调度开销,但队列排队顺序可以得到严格保障,依旧是优先选择简单实现。

剩下三种原语,都是在这套基础逻辑上做小幅扩展。ReentrantMutex增加计数字段,持有者重复获取时计数自增;unlock()递减计数,计数归零时才真正释放锁,锁移交新持有者之后计数重置。Semaphore(n)执行release(),如果队列存在等待者,许可直接移交,不会先存入计数器,避免新来任务插队;没有等待者才写入计数器。tryLock()、tryAcquire()不会触发停泊,获取失败直接返回结果,交由上层业务做后续处理。

AtomicInt的实现比较特殊,并没有使用std::atomic,而是普通int32_t,依靠域锁把读‑改‑写操作串行化。背后两点原因:std::atomic对象不支持拷贝移动,而侧表会扩容、复用条目,该类型无法适配;域锁已经可以完成串行,再叠加原子指令属于冗余。这套实现并不是取巧,域锁临界区很短。对外行为和标准原子类型保持一致。另外AtomicInt所有方法都不会触发停泊,所以可以安全运行在持有Mutex的临界区内部,不会发生中途停泊。站在载体线程视角,无竞争场景下依旧只有几十纳秒的开销,竞争时会短暂等待域锁。

五把域锁:把大锁拆成小队

NLang虚拟机最终拆分为五把域锁而非单一全局大锁,源于一个朴素观察:虚拟机内部绝大多数存储操作之间不存在数据依赖。

NLang虚拟机内部维护了三类存储区域。class实例与数组对象存放在堆槽位数组当中,每个槽对应一个对象,句柄就是槽下标。新建对象优先从载体本地空闲槽栈取出槽位;栈耗尽,就整块批量追加1024个新槽,这个扩容动作也叫做增长。List、Dict、锁、通道、流这类内建对象存放在侧表,表项里的C++数组自己变长时,在所属域的锁内扩容,不归增长域管。字符串对象有独立专用表,槽分配、扩容逻辑和堆槽位数组保持一致。

早期多载体版本,VM所有存储操作共用一把全局锁。很多完全互不相关的操作,比如一个载体追加List元素、另一个载体向文件流写数据,依然要排队,多核并行能力被锁竞争消耗。于是按照存储类别把对象存储切成多个互不相干的域,每个域配备独立std::mutex:

  • 容器:List/Dict结构维护以及元素读写
  • 同步原语:mutex、信号量、通道、原子整数对应的侧表与等待队列
  • 流:字节流、文件流读写状态
  • 字符串维护:字符串延迟拼接,首次访问时生成连续内容,同时维护常量字符串缓存
  • 增长:分配器批量扩充堆槽

不同域之间操作可以真正并行,只有访问同一域才会排队。有人会提出疑问,同域内部多个互不相关的List,为什么不使用读写锁?读写锁需要维护读者计数,快路径开销更高;域锁临界区大多只是几次内存访问,只有扩容场景开销偏大。权衡之后,读写锁带来的收益不足以抵消复杂度。

锁拆分之后引出新问题:部分业务操作需要同时拿到多把域锁。举个例子,调用list.add(1),开发者代码并没有写锁,但VM会自动获取容器域锁(内部内存管理需要)。有一个例外:对象单字段原生读写是原子的,不需要额外加锁。只要程序中调用过thread.spawn,就代表运行在多载体环境,VM无法静态证明对象不会被并发访问,因此默认自动加锁。理论上可以增加全局标记,顺序模式下跳过域锁,但无竞争域锁本身开销很低,为这个场景增加一套特殊逻辑收益有限。

dict.keys()就是需要多把锁的典型场景。该接口需要新建List存放返回结果。优先尝试从载体本地空闲栈拿槽;栈耗尽,就要触发分配器增长。整个操作需要先持有容器域锁,中途再获取增长域锁。

复制代码
dict.keys():                   // 用户调用它, 下面全是VM内部的动作
    进容器域锁:
        新建List实例: 对象堆空闲槽栈空时
            进增长域锁          // 第二把锁, 嵌套获取
                整块追加新槽
            出增长域锁
        把每个键登记进List元素清单
    出容器域锁                  // 后拿的先还

嵌套获取锁本身合法,但必须遵守固定顺序。如果两条线程反向拿锁,就会触发经典死锁:线程A持有容器锁等待同步原语锁,线程B持有同步原语锁等待容器锁,互相僵持。

NLang依靠位掩码记录当前线程持有的域锁,每一位对应一个域。获取锁之前做检查,如果当前已经持有排序靠后的域,又尝试获取排序靠前的域,属于反向获取,直接打印诊断信息调用std::terminate终止程序。比起死锁发生之后再排查,在错误发生当场终止更便于定位问题。字符串维护域、增长域属于叶子域,内部操作不会再去申请其他锁,天然不会参与死锁环路。

线程如果再次获取已经持有的域,会直接放行,内部维护嵌套深度计数,以此避免自身死锁。

还有一条约束严格控制域锁持有的时长。锁粒度限定在单次存储操作,List/Dict元素读写、锁状态变更、字符串处理都属于这类。字符串维护域的长字符串展开是少数耗时操作,但它属于叶子域,只会阻塞字符串相关操作。unlock()逻辑内部,域锁只保护队列查找、移交记账动作,唤醒逻辑要释放锁之后再执行,绝不带着域锁发生停泊,避免长时间占用锁。

这些约束共同支撑前面的设计:域锁临界区足够短,所以AtomicInt可以依靠域锁做串行;lock()快路径额外拿域锁,也不会带来显著开销。

可以看到锁实现跟随运行时能力不断迭代。单载体只需要记账标记;多载体引入锁;竞争压力变大之后再做域拆分。上层开发者不用感知这些底层细节,只需要调用Mutex提供的接口。无竞争时走高效快路径;发生竞争则虚拟线程停泊,不会阻塞载体线程。域锁内部记账、锁序校验等全部由运行时处理。

六、最后

锁的性能瓶颈大多集中在竞争慢路径;2026年普通PC下,无竞争快路径开销仅几十个CPU周期。NLang面向用户的锁构建在VM内部域锁之上:开发者使用熟悉的lock()/unlock()接口,等待队列、竞态窗口、锁序防死锁全部由运行时接管。载体线程何时进入条件变量休眠、上下文的完整内部结构,后面的文章将会讲到。

阅读补充:基础概念速查(附录|不影响主线阅读,有基础可直接跳过)

仅收录理解本文用到的最小释义,不作为完整教科书教程,需要深入可以查阅操作系统相关资料。

  1. CAS(Compare‑And‑Swap) :硬件原子指令。对比内存当前值和预期旧值,相等才写入新值;用来实现无竞争下的共享变量修改。
  2. futex :Linux 用户‑内核混合锁原语。大部分逻辑运行在用户态,只有线程真正休眠、唤醒的时候才发起系统调用,减少内核切换开销。
  3. 载体线程 :M:N调度模型中,操作系统提供的真实线程,负责执行字节码;虚拟线程是虚拟机层面的执行单元,可在不同载体上复用迁移。
  4. monitor(管程) :操作系统层面同步对象,Java synchronized重量级模式会依赖monitor完成线程等待与唤醒。

本文的讨论基于NLang 0.11.x版本,以后可能发生变化。

仓库地址:github.com/dliting/nlang

版本下载:github.com/dliting/nlang/releases

相关推荐
在世修行1 天前
智学文枢:题目流水线
python·多线程·状态机·pyside
我不会起名字3224 天前
Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测
redis·go·并发·延迟双删·缓存一致性
kyle~9 天前
数据结构 --- 无锁队列(Lock-Free Queue)
linux·数据结构·c++·多线程·并发
Da Da 泓11 天前
[项目] --- 基于websocket的网页五子棋(可多用户)
网络·websocket·网络协议·多线程·项目
闻缺陷则喜何志丹12 天前
【C++特性及类库】多线程
开发语言·c++·多线程·语言特性·系统库
Doker 多克16 天前
ForkJoinPool 详解
java·多线程
Pniubi17 天前
区分Java创建线程池、多线程
java·线程·线程池·多线程·并发
青春逝如流水19 天前
Java 中线程的创建方式:从基础到现代
java·开发语言·多线程·ai文章
青春逝如流水19 天前
Java 线程与操作系统、硬件的关系:状态、映射与差异
java·开发语言·多线程·ai文章