并发语言实现(六):GC与多核

写普通单线程程序,GC、多线程锁很多时候对你是透明的。但M:N虚拟线程运行时里,栈可以被挂起、存放到堆内存;同时多个OS线程(载体)并行跑代码。两件事叠加,GC回收、多线程锁会出现很多单线程不会遇到的特殊麻烦。

把调用栈搬到堆上,挂起的帧栈就要纳入垃圾回收器(GC)管理;放开多核并行,又引出锁拆分、安全点会合等问题。二者叠加,M:N运行时(详见《并发语言实现(一):并发模型的选择》)会面临两个核心问题:回收器如果漏扫挂起的栈,正在使用的对象会被错误回收;多个载体真正并行运行时,锁该如何拆分,避免性能互相拖累。本文就讲这两件事:GC如何处理挂起的执行流,锁如何逐级收缩以适配多核。安全点详见《并发语言实现(一):并发模型的选择》,上下文详见《并发语言实现(二):锁与原子操作》。

阅读提示 :阅读过程中如果遇到不熟悉的术语,可以查看文末的概念附录,不影响理解主线的话也可以直接跳过。

一、挂起的栈必须是根

GC判断对象是否存活,依靠可达性分析 。可以把GC想象成遍历图:从一批"一定还活着"的起点出发,顺着引用一路找,能访问到的对象就保留。这一批起点就叫做根集 ,包含全局变量、所有正在运行以及被挂起的调用栈。根访问不到的对象,就认定为不再使用,可以回收。

挂起的帧栈里全是活的对象引用,回收时漏扫一条,程序就会摸到被回收的对象。挂起把栈从执行语义挪进了GC的记账范围,这是《并发语言实现(一):并发模型的选择》「调用栈搬上堆」必须偿还的债。

三种运行时,三种还法。

Loom :栈块本来就是Java堆上的普通对象,GC天然扫得到,不需要额外设计,代价是冻结、解冻时的逐帧拷贝。优点是复用Java原有GC,缺点就是挂起恢复有拷贝开销。

BEAM :每个进程独立小堆,回收与全局规模解耦,一条进程回收不惊动别人;代价是进程间不能共享内存,消息传递是唯一通道。隔离做得很好,但代价是无法方便地共享对象。

NLang :挂起帧栈经调度器注册表整体进入GC根集,挂起零拷贝,代价是要自己保证扫描精度。追求挂起零拷贝,就要自己解决GC扫描挂起栈的难题。

NLang的回收器是不移动、不分代的标记-清扫:对象住在固定槽位,句柄就是4字节的槽位下标,0表示null。这套机制对用户是透明的------对象创建之后不需要手动释放,句柄当普通值传来传去就行。不移动让保守误判的代价封顶------把一个不是指针的字当指针,最多多留一个死对象,不会产生任何悬垂引用。

问题来了:GC怎么分清栈上一个数字,到底是普通数值,还是一个指向堆对象的引用?如果认错,要么误回收活着对象,要么内存泄漏。NLang靠编译期位图来解决。

精度方案分两层。第一层是编译期精确位图:函数发射时给每个帧槽位标2位类别------普通数值、字符串句柄、堆对象引用,或类别无法精确给出的双域槽,4个槽打包进1字节,随编译产物序列化进模块文件。位图覆盖返回槽、求值暂存区和局部变量区;形参不在位图里,由声明表按声明类型精确扫描。收集器扫帧时逐槽查这2位,决定怎么扫:数值槽直接跳过;字符串槽只测字符串存储;堆引用槽只测对象堆;双域槽两个域都测。第二层就是双域槽的测法:保守值测试,同一个32位字同时对照字符串句柄区间和堆下标区间,落在哪边就算哪条根。它不是兜底的后手,而是位图给不出精确类别时使用的正式分类。

这个分类有历史教训。早期扫描只认声明表,f(g(), h())这样的调用里,g()的结果对象在实参装配期间只存活于调用帧的求值暂存槽,h()的分配可能在中间触发一次回收,对象就被扫走了。先靠帧级保守扫描补上这个洞,随即被编译期位图取代------固定暂存区从此永久标成双域类别,精度层和兜底层合成一层。

下面是GC安全点会合的简化伪协议,属于内部实现细节,如果暂时看不懂,可以直接跳过伪代码阅读后续结论。

多载体并行后,回收改成停止世界会合(stop-the-world):各载体逐个走到安全点报到,最后到达的那个代为执行回收。回收由分配压力触发,在安全点执行------循环的回边和函数入口,两处顺路。每个线程进入字节码执行前登记一个「执行位」,标明自己正在执行,会合的分母就是这些执行位。协议只有三个数字------请求纪元(epoch,每轮回收加一的全局计数,等待者睡到当轮回收完成)、分母快照、到达计数:

复制代码
安全点(回边/函数入口):
    若会合在途:  退出执行位, 到达计数+1        // 最后一个到达者继续往下
    若压力超阈值:  发起会合, 分母=当前执行中的线程数, 自己算首个到达
    若未到齐:    睡到本轮回收完成, 重新登记执行位, 返回继续跑
    标记:        扫两类根+按引用字段表遍历对象图
    清扫:        未标记槽回收, 空闲槽按所在块的固定归属分发回对应载体的空闲槽栈(下文展开)
    纪元推进, 唤醒全部等待者

简单来说这套协议逻辑:当内存分配变多达到阈值,就通知所有载体跑到安全点停下来;等全部载体就位,由最后到达的载体负责执行标记-清扫回收;回收完成之后,唤醒所有载体继续执行业务代码。

发起者持锁记下当前正在执行字节码的线程数作分母;回收期间新来的线程先被挡在条件变量外,登记上执行位才能继续------不会有人带着没扫的栈溜进回收。根集中的栈类根有两处来源:全部已登记线程的求值栈,和调度器注册表里全部挂起帧;这两类根被调度器锁进同一个临界区,保证回收瞬间没有帧正从一边搬向另一边。回收阈值从1024个槽起步,清扫后至少抬到存活槽数的两倍------活得越多,收得越稀。频率是控制住了,但停顿长度没有:标记代价与存活集大小成正比,停顿随存活集增长,脚本负载下这是可接受的取舍。

对照BEAM的启示:堆的共享策略比「用哪种回收算法」更上游。

二、多核:锁的三级收缩

💡 版本历史说明 :NLang 0.10.x(阶段0)只有单载体运行模式,同一时刻只会执行一条字节码,原生不存在GIL ,互斥语义依靠调度器挂起点保证,和Node.js事件循环思路类似。下面描述的GIL三级收缩,是开发多核多载体能力时的内部迭代过渡路线 :为了从单载体过渡到真正多核并行,中间曾引入GIL作为临时方案,再一步步拆解;当前0.11.x版本已经彻底移除GIL,全部使用域锁做并发保护 。

字节码虚拟机一旦放开多核,全局解释器锁(GIL,Global Interpreter Lock)就是第一个要处理的瓶颈。NLang向多核演进的迭代思路和CPython逐步去掉GIL的思路同源:不是一步改成完全无锁,而是逐级收缩锁的保护范围。

【过渡阶段1】GIL加多载体。字节码执行仍然被GIL全局互斥保护,但声明为阻塞的原生调用(宿主(嵌入NLang的外部程序)注册给VM的C++函数,比如真正的文件IO)按协议释放GIL,别的载体照常推进。nvm(NLang命令行运行器)默认min(4, 硬件并发)个载体,库默认1个,调试器固定1个,保证单步执行的确定性。4是个工程默认值:命令行工具开箱吃到并行、按硬件并发封顶,库默认1则让嵌入宿主显式选择。它不是架构硬上限(旗标可开到64),但单队列路线有天花板:每次分派出队都要取一次调度器锁,载体再多,分派本身先串行化,锁竞争吃掉并行收益。载体要再上一个量级,就需要每处理器本地队列加工作窃取------这是单队列路线的天然下一步。

【过渡阶段2】GIL收缩到单个存储操作。GIL退居调度器域锁,只守运行队列、上下文注册表和定时器表;存储操作交给五把域锁,存储操作之间的字节码不持任何锁运行------正常路径上多数操作码根本不碰锁,纯计算段跨载体真正重叠。

【过渡阶段3】存储锁按域拆成五把。域锁是按存储域拆分锁的通用并发设计模式(详见《并发语言实现(二):锁与原子操作》),NLang的VM把它实例化为五把:容器、同步原语、流、字符串维护、分配器增长各持各的锁,同类操作串行、异类操作重叠,获取顺序固定、反向获取直接终止程序。目前VM的内部存储并发保护主要通过这套域锁实现,另外还包含少量辅助并发逻辑。对象分配则走每载体无锁空闲链表------每个载体一个空闲槽位索引栈,分配就是弹一个,栈空了才取增长域锁追加1024个槽的一整块,和mimalloc(一个以线程局部分配著称的分配器)的每线程空闲表、JVM的线程本地分配缓冲同构。

三级收缩走完,目前NLang已经完全去掉了GIL------逐级收缩的终点,就是让这把全局锁彻底消失。也就是说,现在字节码执行不再被一把全局锁互斥保护,不同载体可以真正并行执行字节码指令。

第三级有一个明确的取舍:对象字段读写不取锁。两个载体并发读写同一个可变字段,就算数据竞争------读的一方可能读到新旧混杂的过渡值,比如一个字段已更新、另一个还是旧值,但不会损坏内存。简单讲:多个线程同时读写同一个对象字段,不会直接把程序搞崩溃内存损坏,但是业务上可能读到半更新的错误数据。业务需要同步,就要主动使用Mutex、通道或者AtomicInt。与其为字段访问付一把全局锁,不如把语义写清楚交给程序员。这也说明系统设计需要匹配预期运行规模。

下面是2026普通PC上的基准测试结果,用来观察锁拆分带来的性能收益:两个任务隔着一条通道互相发送、互相接收的争用基准里,通道吞吐比锁拆分前的版本提升22%~79%------这个跨度覆盖了基准的多组配置;阻塞原生调用密集的负载,4载体做到约4倍加速。

三、边界:载体绑定与重入求值

上面讲的都是NLang字节码内部执行。一旦VM调用外部C/C++原生函数,或者VM内部发生嵌套求值,就会出现特殊边界情况。

调度器看不见的等待,根源是同一个缺口:执行流一旦脱离字节码循环,VM就看不见它的状态。《并发语言实现(二):锁与原子操作》列过的monitor绑定、cgo阻塞、FFI绑定,全是这一个缺口的三种方言;《并发语言实现(一):并发模型的选择》从模型层面讲的载体绑定,根源也是它。

执行阻塞型原生调用,整套流程分为4步:

  1. 把参数、返回值复制到C++栈上的临时空间,并且纳入GC根集,防止GC把这些临时内存回收;
  2. 将虚拟线程的帧栈保存到上下文,释放域锁,退出GC停止世界会合计数;
  3. 运行外部原生C++函数,此时其他载体可以正常运行、正常执行GC回收;
  4. 原生调用返回,重新参与GC会合,拿回对应的锁,恢复帧栈继续执行NLang代码,任务不会重新丢到调度队列尾部。

另一个边界情形是重入求值:重入求值可以简单理解成:代码跑着跑着,VM临时嵌套跑一小段别的NLang逻辑,例如打印对象时调用自定义toString。嵌套运行期间也会发生任务停泊,嵌套调度器负责处理,执行完回到原来位置继续。

还有一种形态不支持------把NLang嵌进宿主程序的C++代码,在run()之外直接调call()执行一段NLang,此刻VM里没有运行中的调度载体,挂起没人推进。

四、最后

回过头看,GC策略,尤其是堆的共享模型,会从底层限制虚拟机的并发能力。NLang出于脚本互操作的需求选择了全局共享堆,因此采用停止世界会合回收。这属于模型层面的取舍,和回收算法本身的好坏无关:堆的共享方式定下来,回收的形态和代价基本也就定了。

整体可以看出:M:N虚拟线程GC与多核并发,难点并不只是挑选某一个GC算法;栈可以挂起、多载体并行,会给GC、锁机制带来很多单线程程序不会遇到的额外约束。

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

这里整理了文中出现的部分术语简要说明,方便快速查阅。

  1. 根集 :GC扫描的起点,包括全局变量和所有未结束的栈,从根够不到的对象会被回收。
  2. 标记-清扫 :先标记所有存活对象、再清扫未标记槽位的回收算法,NLang用的是不移动、不分代的版本。
  3. 停止世界会合 :各线程在安全点集合,全部到齐后统一执行回收,回收期间程序暂停。
  4. 安全点 :回收允许暂停线程的检查位置,通常设在循环回边和函数入口。
  5. 条件变量 :线程在这里挂起等待某个条件成立、被唤醒后继续运行的同步原语。
  6. 数据竞争 :多个线程同时读写同一内存且至少一方是写,读写顺序没有同步约束。
  7. GIL(全局解释器锁) :一把传统虚拟机的全局锁,同一时刻只允许一个线程执行字节码,牺牲并行能力来简化实现;NLang通过锁拆分迭代,现已完全移除GIL。
  8. 域锁 :按功能、存储区域拆分的一组细粒度锁,代替单一的全局大锁,提升多核场景下并行度。

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

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

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

相关推荐
淡海水3 小时前
10-01-互操作-C#与C++边界的内存管理
unity·c#·游戏引擎·.netcore·gc
蒸鱼Yuzheng1 天前
游戏 GC 停顿治理:分配速率、对象存活率与增量回收
gc·unreal engine·内存优化·游戏性能·卡顿优化
tryxr2 天前
JVM 垃圾回收机制
java·jvm·gc·垃圾回收
淡海水3 天前
09-01-内存-Unity资源生命周期全景
unity·c#·游戏引擎·.netcore·gc
DLite4 天前
并发语言实现(二):锁与原子操作
多线程·并发·锁·原子操作
Huanzhi_Lin6 天前
资源回收源码阅读参考-上
游戏开发·gc·laya·layaair·资源回收·laya3.4
我不会起名字3227 天前
Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测
redis·go·并发·延迟双删·缓存一致性
淡海水7 天前
08-02-优化-LINQ与函数式编程的GC代价
unity·c#·.netcore·linq·gc
黑猫学长呀8 天前
存储芯片千问千答第11问:对于磨损类用例,waf越大的模型磨损的更快,还是waf更小的模型磨损的更快?
gc·存储·ftl·ufs·nand·存储芯片·nandflash