NLang 0.10.0发布:并发基础设施

写解释器的人大多清楚,协程/虚拟线程最麻烦的从来不是包装一个 yield,而是能不能在任意执行点安全冻结、恢复整条调用栈。这次 NLang 0.10.0 直接换掉底层递归解释器,改成堆上帧栈机,补上了虚拟线程、Mutex、Channel 一整套并发原语。

这一版的主线是并发:语言层面加了虚拟线程、Mutex 和 Channel<T>。为了撑住这些能力,执行引擎从递归解释器重写成了帧栈机。前面的 0.9.1 到 0.9.9 算是在铺路:脚本运行器 nlang 上线,嵌入入口补全,一批原本要跑到运行期才炸的语义问题,被提前到编译期拦住了。

这篇先讲清楚并发为什么值得动到执行引擎这一层,再看新的语言能力,然后拆解并发原语内部实现,最后过一遍 0.9.x 各版本。

并发为什么值得重写执行引擎

多数脚本语言的解释器,是拿宿主语言的递归来实现被解释语言的函数调用的:解释器每执行一次函数调用,就递归调用自己一次。前面版本的 NLang 也这样,调用栈直接叠在 C++ 的调用栈上。这么写简单、快,但代价是执行状态被锁死在 C++ 栈里------C++ 栈不能整体搬动、复制或者序列化,解释器只能在函数调用的边界上做文章,没法在一条指令中间停下来。

而并发恰恰要求在任意点停:sleep 可能在三层嵌套调用的深处被触发,调用它的那个上下文要交出执行权、原地冻结,将来再从同一条指令精确恢复。要做到这一点,调用栈本身必须是可搬动的数据。

成熟方案有三类:给协程各自一条独立的 C 栈(Lua 协程思路);把栈做成可复制、可增长的结构,由运行时搬移(Go goroutine);把每个函数编译成可恢复的状态机,彻底去掉调用栈(Python 生成器)。三条路都能走,但各有各的复杂度:栈边界处理、栈复制开销,或是每次调用带来的状态机开销。

NLang 走了第四条更贴近教科书模型的路线:解释器不再递归,靠一条派发循环驱动显式帧链。每个调用帧保存局部变量、指令指针,还有 try/catch/finally 的处理器链,全部放在堆上。挂起一个上下文,就是把它的帧链从运行队列挪到等待队列,整条调用栈原样冻结,不需要拷贝栈,也不用生成状态机;恢复的时候,派发循环从冻结帧的指令指针接着跑。异常状态也能保留:在 catch 块里 sleep,唤醒之后裸 throw; 依旧可以正常重抛异常。

这就是"虚拟线程"里"虚拟"的含义:一个 NLang 上下文不绑定操作系统线程,多个上下文共用一条 OS 线程(载体)运行。CPython 3.11 把帧对象挪到堆上、减少 C 栈递归,思路和这里相通------帧上堆已经慢慢变成解释器圈子里的共识。

调度器:确定性优先

有了可以冻结的上下文,还需要配套调度器。0.10.0 的调度器做了很明确的取舍。

调度是先进先出轮转:上下文排队执行,每个上下文跑到循环回边(跳回循环头的指令)时才检查配额;配额用完,并且还有其他上下文在等待,才主动让出执行权。如果只有一个上下文在跑,执行路径和旧版本完全一致,调度器相当于不介入。

为什么不用时间片抢占?定时抢占会让上下文交错顺序随机,今天能复现的并发 bug,换一次运行就可能消失。配额抢占把调度点固定在循环回边和显式挂起点,同一份输入每次跑出来的执行交错完全一样,并发缺陷可以稳定复现。对定位教学和研究的语言来说,这个特性比吞吐更重要。

另外几条调度规则:多个上下文同时 sleep,按唤醒时间先后排队;main 阻塞,并且没有其他上下文可以继续推进时,虚拟机直接报死锁错误,而不是卡死不动;main 返回之后,还在阻塞等待的上下文会被直接丢弃。

写并发:thread、Mutex、Channel

thread 命名空间不需要 import(thread 是保留包名)。thread.spawn(f) 接收一个无参 func<void> 委托,返回 Thread 对象;thread.join(t) 阻塞当前上下文直到目标线程结束,如果目标抛出异常,join 会把原始异常重新抛给调用者,所有等待这个线程的上下文都会被唤醒。配套接口还有 thread.yield、thread.id(main 的 id 是 0)和 thread.sleep(ms),sleep(0) 等价于主动让位。

复制代码
import io;

class Worker {
    public int n;
    public int total;
    public void run() {
        for (int i = 0; i < n; i = i + 1) {
            total = total + i;
        }
    }
}

int main() {
    Worker w = Worker();
    w.n = 1000;
    Thread t = thread.spawn(w.run);
    thread.sleep(5);
    thread.join(t);
    io.print("total: ${w.total}");
    return 0;
}

Mutex 不可重入,等待者先进先出排队,tryLock 不会阻塞;解锁不属于自己的锁,会抛出明确的运行时错误,而不是未定义行为。

Channel<T> 使用无界缓冲:send 永远不会阻塞;空信道调用 recv 会挂起,直到读到值或者信道关闭;close 是幂等操作,会先把缓冲区里剩余的值全部读完才生效。缓冲区清空之后,接收引用类型元素会拿到 null;基础值类型没有 null,这时 recv 会报运行时错误。

复制代码
import io;

int main() {
    Channel<string> c = {};
    c.send("hello");
    c.close();
    io.print(c.recv());       // hello
    io.print("${c.recv()}");  // null:已关闭且已排空
    return 0;
}

close 先排空缓冲这个规则很实用:它让 close 本身就可以作为广播停止信号,所有卡在 recv 上的接收者同时唤醒收尾,不需要额外加停止标记,这也是 CSP 模型里信道的经典用法。

并发原语技术内幕

挂起是怎么发生的。

sleep、join、lock、recv 这类接口要阻塞时,虚拟机内部抛出一个信号,执行循环捕获这个信号,把当前上下文完整的调用栈存起来,切换去跑下一个就绪上下文。指令位置、局部变量、异常上下文全都保存在帧栈里,这套挂起机制不需要额外扩展指令集。调用执行完阻塞逻辑之后,唤醒时直接从调用的下一条指令继续,上层代码感知不到中间发生过切换。Java 21 Loom 虚拟线程也是把调用栈搬到堆上做挂载卸载,属于同一类思路。

Mutex:锁本质上只是简单的数据结构。

NLang 的 Mutex 内部就两个字段:持有者标记、等待队列。lock() 遇到空闲锁就标记持有者;锁已经被占用(包括当前上下文自己持有,也就是不可重入的来源),就进入队列挂起。unlock() 如果有等待者,会直接把锁交给队首的上下文再唤醒它,移交和唤醒一步完成,不存在锁空闲但等待线程还在排队的间隙。

和其他语言的锁不一样,pthread、std::mutex、JVM synchronized 要处理多核并行竞争,依赖 CAS 原子指令、内核等待队列,开销和复杂度都很高。但 NLang 的调度器同一时刻只跑一个上下文,切换只会发生在显式挂起点,互斥就退化成简单记账,不需要原子指令,也不用下沉到内核。这点和 Node.js 单线程事件循环很像:锁保护的不是并行执行,而是挂起点之间的状态交错。

Channel:发送优先直接交接给接收方。

Channel<T> 由缓冲区和等待队列组成。执行 send 的时候,会先看有没有接收者已经阻塞在 recv:有,就直接把值交给接收者,不走缓冲区;没有,才放进缓冲里。被挂起的接收者唤醒后直接拿到值,省去一次入缓冲再取出的拷贝,Go channel 同样实现了这个快路径。close 会唤醒全部等待者,先消费完缓冲里剩下的数据,之后才返回 null,语义和 Go 保持一致。

sleep 与 join。

sleep 只把到期时间写入定时器表。载体线程不会一碰到 sleep 就直接休眠,只有所有上下文都没法继续运行时,载体才会 sleep 到最近的到期时间;定时器主要用来决定唤醒顺序。Thread 对象只存一个编号,join 如果目标已经结束,直接返回;如果目标执行抛出异常,join 原样向上抛出。被阻塞的调用栈在 GC 里始终标记为存活,所以不管目标上下文阻塞多久,这套机制都稳定可用。

0.9.x:把契约挪到编译期

0.9.1 到 0.9.9 没有重量级新功能,但方向很统一:尽量把原本运行期才暴露的错误,提前在编译阶段拦住。

虚分派按签名检查(0.9.4、0.9.7)。

虚方法、接口分派原本只按名字匹配方法。旧版本里子类可以写同名但签名不一样的方法,编译运行都不会报错,比如 f(5) 会错误路由到 f(string),参数读取直接乱掉。0.9.4 之后,签名不匹配直接编译报错,同时在类定义阶段校验接口实现完整性。0.9.7 继续收紧接口成员规则:接口成员默认就是 public,接口是对外契约,不存在私有成员;显式写 private、static、const 都会编译失败。一个通用结论:只要分派是按名字查找,签名就属于契约,应该交给编译器强制校验。

默认参数补全(0.9.6、0.9.8)。

构造器和 super() 调用开始支持默认参数。默认表达式在调用点求值,所以构造器默认值不能访问 this------对象还没初始化完成。0.9.8 修复了一个隐蔽 bug:默认表达式内部嵌套调用时,会在调用方帧分配临时参数槽,但帧大小计算没有计入这部分临时空间,临时值会覆盖外层实参。帧尺寸必须覆盖表达式最坏情况下的临时槽深度,这是编译器很容易漏掉的细节。

跨模块对等(0.9.5)。

具名实参、函数值、复杂默认参数现在可以跨 .ncu 模块传递。默认参数以表达式子集序列化进模块文件,仍然在每次调用点求值,语义和源码直接内联完全一致。.ncu 版本升到 2.3,旧模块需要重新编译。

移位运算符(0.9.9)。

<< 和 >> 支持全部整数类型。结果类型跟随左操作数,遵循 Java/C# 的规则;>> 对有符号数做算术移位、无符号数做逻辑移位。移位范围超出 [0, 位宽) 会抛出明确的运行时错误------这在 C 里属于未定义行为,不同编译器跑出来结果不一样,NLang 把行为固定了。

脚本运行器与嵌入入口(0.9.1--0.9.3)。

nlang script.n、nlang -c "..." 可以直接跑源码,顶层语句自动包进 main(),进程退出码对应顶层 return 值,不需要输出中间产物,用法贴近 Python。嵌入 API 新增 runSource/runFile,宿主程序可以直接编译执行动态脚本;0.9.3 修复了同一个进程反复编译脚本时,语法节点内存泄漏的问题。

获取与更新

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

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

0.9.1 到 0.9.9 的各版本安装包,同样放在 releases 页面。

最后

0.10.0 的并发模型是刻意保守的:单载体、确定性调度。换来的好处是并发程序和顺序代码一样,可复现、好调试;代价是目前还没法用上多核。并行是接下来要做的事,而这套确定性语义,正是后续加入并行之后,还能稳定调试的基础。

相关推荐
余槐i10 小时前
虚拟线程上线后吞吐反而掉了:Spring Boot 3 里 synchronized 锁住载体线程的定位与修复
java·spring boot·性能优化·虚拟线程·jdk 21
Zwawa1 天前
编译器工具链:GCC、Clang、MSVC、ICC、armclang
c语言·嵌入式·编译器
小哈里2 天前
【编译器】从计算机系统原理到 LLM 编译器与算子优化
大模型·编译器·1024程序员节·计算机系统原理·算子优化·推理平台
程序喵大人13 天前
【C++入门】编译链接模型 - 07 链接器怎样把符号对上号
开发语言·c++·编译器·编译链接·链接器
DLite14 天前
NLang 0.9.0发布:成为可嵌入语言
开发语言·编程语言·编译技术·nlang
Erishen16 天前
Lume C11编译器内部架构:从树形解释器到双LLVM后端的设计之旅
架构·开源·编译器
~木雨20 天前
Java 线程从生到死:创建三式、六态流转、中断协作与死锁活锁饥饿,一篇讲透
java·java并发·虚拟线程·死锁·线程状态·中断机制
智嵌研习社22 天前
从 `.c` 到 `.elf`:GHS 编译器下 RH850 C 文件完整编译流程深度拆解
编译器·rh850·链接器·编译流程·汇编器·ghs
EatFan23 天前
从云原生到AI原生:2026后端架构“三驾马车”(事件驱动、虚拟线程、AI Agent内嵌)演进解析
spring boot·云原生·架构·虚拟线程·ai-native·ai agent·spring ai