写解释器、做虚拟机的人,迟早都会撞上同一个硬核问题:函数跑到一半卡住等待时,它的现场该存放在哪里。
Go 随手开百万 goroutine、Python 里混进一个阻塞 requests.get 直接拖垮整个事件循环、C# 的 async 一写就层层传染,这些大家天天碰到的现象,根源都落在这件事上。

上一篇我们聊了 NLang 0.10.0 把调用栈从 C++ 栈搬到堆上、将递归解释器重构为帧栈机。这篇我们把视野拉开:先跳出 NLang,梳理 Go、Java、C#、Rust、Python、Node 这些主流方案在机器层面是怎么处理「挂起现场」的;拆解锁、通道、Future、定时器这些并发原语的内部结构。搞懂底层,前面那些并发相关的“为什么”自然就能解释。之后再回到 NLang 0.10.x 整条迭代线,看这套思路在一台小型字节码虚拟机里怎么落地,包括后续的多核扩展方案。

一、挂起一个函数,机器上到底要存什么

先铺垫两块基础概念。程序内存大致分成栈和堆:栈就是调用栈,每次函数调用自动压入帧,函数返回就弹出,帧的生命周期和函数绑定;堆是动态分配内存,生命周期不受单个函数控制,C++ 由开发者手动管理,托管语言交给 GC 回收。文中反复提到的「把调用栈搬到堆上」,意思就是把原本函数返回就自动销毁的栈帧,变成一块可以随意保存、正常被 GC 回收的普通堆数据。

另一块关键是寄存器:CPU 里高速存储单元,这里重点关注两个:程序计数器 PC,记录下一条要执行的指令地址;栈指针 SP,标记当前栈顶位置。

普通函数调用在机器层面的流程很清晰:调用时,CPU 压入栈帧,里面放局部变量、返回地址,PC 跳转到函数内部;函数返回时弹出栈帧,PC 切回调用点。整条调用链就是栈上一串相连的帧,SP 指向最顶层:

main()             ← 帧:局部变量 + 保存的返回地址
 └─ f()            ← 帧
    └─ g()         ← 帧;SP 指着这里,PC 正在执行 g 里的指令

想要挂起一个执行到一半的函数,本质只需要保存三样东西:PC 停在哪、SP 指向哪,外加少量正在使用的寄存器。整条调用链、每层的局部变量都留在栈上不用改动。所有协程实现,本质就是对这三样现场信息的不同存储方案,再叠加两个独立选择:
协程要不要自带一整条完整栈(有栈 / 无栈);调度切换是代码主动让出(协作式),还是运行时随时打断(抢占式)。

还有一个很关键的数字,解释了协程为什么轻:下面的耗时,都是 2026 年普通 PC 的典型表现。操作系统线程切换需要陷入内核,保存恢复寄存器、走内核调度,开销是微秒级;而协程的现场、寄存器都放在用户态内存,切换只是几条赋值指令,百纳秒级别。所谓协程快,核心不是“轻量”这个形容词,而是切换全程不用切内核态。

二、有栈路线:切换就是换一根栈指针

最原始的有栈协程就是纤程(fiber):Windows Fiber、POSIX ucontext、Boost.Fiber 都属于这类。给每个执行流分配一段连续定长栈内存,切换的时候,把当前的 SP、PC、寄存器存起来,再把新执行流的现场加载进 CPU,就完成切换。好处很直观:整条调用链完整保留,任意深度的函数都可以随时挂起。

代价也来自连续裸栈的设计:栈长度固定,没法超配;GC 识别不了这块栈内存,分不清哪些字是指针;挂起之后很难可移植地回溯调用栈;外部取消挂起的纤程也很麻烦——栈上的 C++ 对象,必须恢复到对应栈帧才能执行析构。

Go 把有栈路线做到了工业级成熟,三个机制值得吃透。
栈会搬家。goroutine 初始栈只有大约 2KB,快要占满时,运行时分配一块两倍大的新栈,把旧栈内容复制过去,同时修正栈里所有指向旧栈的指针。这就要求编译器给栈布局生成精确类型映射,标记每一个位置是不是指针。只要你取了局部变量地址 &x,栈扩容搬家时,指针就要同步修正。

阻塞调用被改写为挂起。核心是 netpoller:conn.Read() 没有数据的时候,goroutine 不会卡在阻塞系统调用里。运行时把文件描述符注册到 epoll(Linux)/kqueue(FreeBSD)/IOCP(Windows),然后把 goroutine 现场存入调度结构、放进等待队列,调度器切去跑别的 goroutine。等数据就绪,poller 再把它标记为可运行。这就是单机支撑上万并发连接 C10K 问题的经典解法:上百万连接看似阻塞在读操作,底层只需要少量 OS 线程。

抢占机制的迭代。早期 Go 只在函数入口检查抢占标记,没有函数调用的死循环会直接饿死调度器;Go 1.14 引入异步信号抢占,定时器发信号,把 goroutine 停在安全点(运行时可以安全暂停、状态一致的位置)。调度架构是经典 GMP:G 代表任务、M 是内核载体线程、P 是带本地队列的逻辑处理器;M 阻塞时,P 可以脱离,绑定其他 M 继续跑任务,P 之间互相偷任务做负载均衡。

Java 虚拟线程(Project Loom)思路更激进:栈不再是连续内存块。虚拟线程的调用栈拆成多块独立栈块,全部放在 Java 堆上,由 GC 统一管理。挂载到载体线程运行时,栈块展开成真实栈;碰到阻塞点,比如 LockSupport.park、socket IO,再把栈块冻结回堆,载体线程就可以去跑别的虚拟线程。这也是为什么可以轻松创建百万虚拟线程,不会耗尽 OS 线程资源——睡着的只是堆上的一堆数据。代价是冻结、解冻存在拷贝开销;还有两个经典限制:synchronized 块、原生 JNI 帧没法冻结,会把载体线程一起占住,JDK 21 还有这个问题,后续版本持续优化。JEP 444 也明确不做时间片抢占,调度让位只依赖阻塞点。

Erlang BEAM 是古老的按步数抢占方案。它的进程本质是可抢占有栈协程,每个进程自带一小块独立堆,依靠消息传递通信、默认不共享内存。调度按归约数(reduction,字节码执行步数)计数,每个进程分配大约 1000 归约的配额,用完就让出。不需要信号、不需要中断,就在解释循环的安全点计数,这套源自电话交换时代的设计,至今依然简洁。

三、无栈路线:编译器把函数拆成状态机

无栈协程没有独立调用栈。挂起的本质是编译期转换:每个 await 位置,把函数切分成多段,函数变成一个显式状态机;跨挂起点需要保留的局部变量,提升成状态机对象的字段。C# 的 async 是最好理解的例子:

// 编译器生成的状态机(示意)
struct FetchStateMachine {
    int state;                     // 0=开始,1=第一个await之后
    int len;                       // 跨await存活的局部变量,提升为字段
    int result;                    // 完成时交回调用方的值
    Task<int>.Awaiter awaiter;
    void MoveNext() {
        switch (state) {
        case 0:
            awaiter = CountAsync().GetAwaiter();
            if (!awaiter.IsCompleted) {
                state = 1;
                return;            // 「挂起」= 一次普通的return
            }
            goto resume;
        case 1: resume:
            len = awaiter.GetResult();
            result = len + 1;
            state = -1;            // 完成
        }
    }
}

所谓挂起,就是一次普通 return;恢复执行,就是调度器再次调用 MoveNext()。“执行到一半”这个抽象,简化成一个状态标记加若干字段的堆对象。无栈协程切换快、内存占用低的根源在这里,同时所有限制也都来自这套转换逻辑。

第一个限制就是大家常吐槽的函数着色:await 只能写在 async 函数里面,async 函数的调用者也必须处理异步结果,于是跟着变成 async。这不是语言设计者的选择偏好,是状态机改写带来的必然结果。C#、Rust、Python async 全都绕不开这个约束。

第二个限制:挂起点只能是编译器识别的 await。表达式临时值、析构、finally 在正常返回和挂起恢复两条路径上的语义,编译器都要单独处理,这也是异步编译器 bug 的高发位置。

Rust 的 async 是无栈零成本版本:async 块会生成实现 Future::poll(cx) 的状态机,执行器(Tokio)反复 poll 推进执行;挂起时保存 Waker 回调,就绪后再唤醒。没有堆分配、没有虚表开销,代价是没有抢占,长计算必须主动让位;而且状态机存在自引用,需要 Pin 机制来保障安全。

Python asyncio 是这套机制的另一个实例:yield 原生就是手写挂起点,CPython 的生成器帧本身就在堆上,yield 保存状态返回,send() 恢复;async/await 只是语法封装。事件循环单线程轮询 IO 多路复用器,IO 就绪之后再推进协程。最开始 Python 的那个坑也就解释清楚了:单线程事件循环没有抢占能力,一旦调用阻塞同步接口,整个循环卡死。

顺带一提:解释器天然适合堆帧这条路。它的调用栈本来就是程序侧的数据结构。Lua 协程每个 lua_State 自带堆上调用信息,创建成本极低;CPython 3.11 也把帧对象挪到堆上。这个结论后面会用到。

还有一种经常被混在一起的模型:事件循环 + 非阻塞 IO。Python asyncio、Node.js、Nginx 都属于这类。它甚至没有“挂起函数”这个概念:文件描述符设为非阻塞,读不到数据立刻返回,把监听事件注册进多路复用器,事件循环阻塞在等待事件上;就绪之后,执行注册的回调。函数逻辑被拆成注册前、回调后两段,这就是回调地狱的来源;await 本质就是语法糖,用来抹平回调嵌套。

把四条路线汇总对比(切换开销同样是 2026 普通 PC 参考值):

维度1:1内核线程有栈协程无栈协程事件循环回调
栈内核管理,MB级每个协程独立完整栈无栈,状态机对象没有挂起栈
挂起位置任意阻塞调用任意调用深度仅 await 标记点手动拆分前后逻辑
函数着色无无有有(回调边界)
抢占内核抢占安全点/信号抢占一般不支持抢占无抢占
切换成本微秒级(陷入内核)百纳秒级纳秒级(return 返回)函数回调开销
代表实现Python threadingGo、Loom、BEAMC#、Rust asyncNode、asyncio、Nginx

四、锁、通道、Future、定时器:你天天用的东西内部长什么样

前面反复提到“挂起等待”:goroutine 等 IO、虚拟线程等锁、协程等 Future。我们写的 mu.Lock()、<-ch、await,底层到底在等待什么?这里拆开四个最常用的并发原语。

**锁的内部:一条原子指令,加上可选的睡眠。**无竞争场景,加锁就是一条 CAS 原子指令,把锁标记从空闲改成持有,全程用户态,不碰内核。出现竞争才走慢路径,Linux 靠 futex、Windows 靠 WaitOnAddress,把 OS 线程真正休眠。虚拟线程运行时在这里有一个关键分支:锁竞争时,休眠的是虚拟线程还是载体线程?Go、Loom 都会优先挂起虚拟线程,载体线程继续跑别的任务;不然大量虚拟线程抢锁,会把载体池全部堵死。Loom 的 synchronized 钉住问题,就是这套接管逻辑没有覆盖原生 monitor 锁。NLang 早期单载体版本更特殊:同一时刻只有一个执行流,锁完全不需要原子指令,只需要一个标记 + 等待队列;开启多核之后,同样要面对这个分支,第六节细说。

**通道的内部:环形缓冲 + 两条等待队列。**Go channel 结构体包含一把锁、环形缓冲区、发送队列、接收队列。空通道执行 recv,当前 goroutine 进入接收队列挂起;send 发现有接收者在等待,直接把值交给接收方,跳过缓冲区,也就是大家常说的直接交接快路径。理解这个快路径,就能明白为什么 Go channel 传结构体不一定堆分配,NLang 的 Channel 同样实现了这个逻辑。

**Future/Promise 的内部:状态 + 等待者列表。**三种状态:未完成、完成、失败,附带一串等待者,可以是回调或者挂起的执行流。结果就绪后写入状态,逐个唤醒等待方。JS Promise、Java CompletableFuture、Rust oneshot channel,骨架都是这个。它是跨并发模型的通用桥梁:调用方可以轮询、注册回调,也可以直接挂起等待。

定时器和调度器也有成熟范式。setTimeout、time.After 底层两种结构:最小堆,最近到期任务在堆顶,增删 O(log n),libuv、Go 运行时在用,会给过期定时器打僵尸标记,批量清理;另一种是哈希时间轮,类似时钟表盘,每个格子存放同一时段的任务,插入 O(1),Linux 内核、Netty、Kafka 偏好它,适合海量高频定时器场景。

调度器主流三种架构:全局单队列,实现最简单,但锁竞争大;每个处理器本地队列 + 工作窃取,Go GMP、BEAM、Tokio、ForkJoinPool 都是这套,兼顾缓存局部性和负载均衡,是工业首选;单线程事件循环,没有锁竞争,但一个死循环会卡死整个调度。

五、NLang的选型:为什么最后没有await

上一篇讲了帧栈机的实现,没说选型取舍。否决项往往比采纳项更能体现设计思路。立项调研阶段,原本倾向 Java 路线:虚拟线程 + async 无栈协程双模型共存,用 Future 互通。最后落地成单一机制,明确放弃 await 关键字,四条核心判断:

**否决纤程路线。**定长连续栈撑不起大规模任务,百万任务每个分配 64KB 就要几十 GB 内存。更关键的是 NLang 的帧本身就在堆上,只保存活跃帧,内存开销和无栈状态机接近;纤程带来的 GC 盲区、不可移植栈回溯、外部取消、异常处理的麻烦,NLang 全都可以避开,收益却重合。

**否决编译期状态机。**状态机节省内存的前提是:帧很大、跨挂起点不需要保留的局部变量很多。NLang 的帧全部是统一 8 字节槽,本身很小,两者内存差距被抹平。但代价一点不少:任意位置都可能触发挂起(sleep、recv、IO 可以写在任意调用深度),状态机改写、临时值跨挂起点、finally 双路径语义这些麻烦,一个都躲不掉,却拿不到数量级的性能收益。

**否决 await 关键字,两层理由。**第一是模型归属:NLang 选择阻塞式虚拟线程路线,和 Go、Loom、BEAM 同属一类,它们都没有 await。await f 和 f.get() 语义等价,新增关键字反而误导用户,以为存在另一套异步体系。第二是文法代价:前期语法原型测试,基线文法本身就有不少移进归约冲突,新增 await 会大幅增加语法冲突;靠优先级强行抹平冲突,只是语法糖,不值得承担解析器复杂度。

**否决信号抢占。**Go 的信号抢占是给 JIT 原生代码设计的,原生代码没有天然安全点。字节码解释器天然自带安全点:函数入口、循环回边。在回边位置计数配额,就是 BEAM 归约计数的思路,不需要信号、不需要中断。

核心结论,就是第三节末尾那句:字节码解释循环本身就是状态机,递归解释器只是把调用栈藏在了 C++ 栈上。把调用栈搬到堆上,Lua、CPython 堆帧、Loom 堆栈块,都是同一个方向。于是架构确定:统一帧栈基底。虚拟线程 = 堆帧上下文 + 配额抢占调度;协程能力由阻塞调用自然挂起实现。原本双模型要承担的隔离规则、两套同步原语、跨模型死锁风险,随着单模型直接消失。

六、0.10.x整条线:机制逐一落地

0.10.0 到 0.10.22 分多期交付完整这套并发能力。上篇只覆盖 0.10.0 的单载体:帧栈机、挂起信号、FIFO 配额调度、记账式锁与通道。这一节重点讲剩下三块:多核、GC、语言侧 API,顺带展开两处关键实现。

**帧栈机再看一眼。**单个调用帧结构(精简版,去掉调试行号等次要字段;宿主指嵌入虚拟机的外部 C++ 程序):

struct CallFrame {
    std::vector<uint8_t> localsBuf; // 局部变量缓冲(接收者、形参槽)
    uint8_t* externalResult;         // 结果写入宿主侧C++单元(嵌入桥接)
    int32_t resultFrameIdx;          // 结果槽所在帧下标
    uint16_t resultDstOff;           // 结果槽在缓冲内偏移
    const CompiledFunction* func;
    uint16_t pc;                     // 恢复点:下一条指令偏移
    uint16_t opPc;                   // 在飞指令偏移,用于try块范围判定
    uint32_t outMask;                // out参数回写掩码
    std::vector<int32_t> handlerExcStack; // 本帧catch处理器栈
};

重点看最后一项:异常处理器状态是按帧保存,不是全局。所以「在 catch 块里 sleep,唤醒之后裸 throw; 依然可以重抛」这个跨挂起异常语义,就是靠这个字段保证。对比 Loom:Loom 需要反复冻结解冻拷贝栈,因为它的栈是载体原生栈;NLang 帧本来就在堆,挂起只是把帧序列 move 到等待队列,零拷贝。

**抢占就是回边数步数。**在循环回边安全点检查配额,每 1000 次回边触发一次调度判断;配额耗尽,并且存在其他可运行上下文,才触发让位。这里有一层嵌套求值守卫:VM 在执行过程中,可能递归进入解释循环,比如调用 toString 重写、执行宿主回调,这个窗口内调度权在外层,直接跳过抢占检查。

void VmExecutor::CheckSchedulerQuota() {
    if (m_spScheduler->CarrierNestDepthRef() > 0)
        return;  // 嵌套求值窗口内,不触发抢占
    int& budget = m_spScheduler->CarrierTripBudgetRef();
    if (--budget > 0)
        return;
    budget = m_schedTripLimit;
    m_spScheduler->ServiceDueTimers();
    if (!m_spScheduler->HasOtherRunnable()) {
        // 没有其他任务:重置预算继续跑
        return;
    }
    RequestPark(ParkSignal::Reason::Preempted);
}

思路和 BEAM 归约计数同源,额外增加“有其他任务才让位”的门槛。这个门槛带来一个很重要的特性:没有并发的纯顺序程序,执行行为完全不变,配额永远不会触发。0.10.x 之前所有存量测试,天然就是帧栈改造语义等价的回归验证。FIFO + 配额带来确定性:默认单载体下,同一输入每次运行的执行交错完全一致,并发测试可以直接断言交错顺序;多载体环境下顺序不做保证,正确性只依赖 join、通道、锁建立的 happens-before 先行发生关系。

**调度器:够用,但不简陋。**调度器记录每个上下文的状态:可运行、等待定时器 / join / 锁 / 通道、已结束;同时保存它的帧栈和唤醒时间。运行队列 FIFO,出队带一点缓存亲和:载体优先尝试取前8个里自己上次跑过的上下文,提升缓存命中率,没有就取队首。实现细节:上下文注册表用 std::deque,push_back 不会移动已有元素,挂起帧的地址稳定。定时器交给一条懒启动的后台线程,sleep 到最近到期时间,哪怕所有载体都阻塞,定时器也能准时触发。两条退出规则:所有载体阻塞、没有可运行任务、定时器空、main 未完成,判定死锁,直接报错而不是静默卡死;main 返回之后,还在挂起的上下文直接丢弃,避免一个后台 sleep 任务阻止脚本退出,Go 也是同样取舍。

为什么不做 GMP、工作窃取?这套架构是为大量载体、频繁阻塞的多核场景设计的。NLang 默认载体数量为1,上限很小,全局调度锁 + 亲和窗口已经足够。架构跟着规模走,不是盲目追逐流行方案。

**GC:挂起的栈必须纳入根集。**GC 判断对象存活依靠可达性:从根集(全局变量、所有正在运行的栈)遍历,可达对象存活,其余回收。挂起的帧栈里面全是活跃引用,回收时漏掉任意一条,就会出现野引用。每个帧槽位配套编译期精确位图:编译器标注槽是数值、字符串、堆引用,还是无法识别;无法识别的槽走保守扫描,宁可误标记,保证对象不被提前回收。保守扫描也修复了之前提到的 bug:f(g(), h()) 里,g() 的返回值临时放在求值暂存槽,位图没有覆盖这个临时位置,对象存在被误回收的风险,帧级保守扫描补上了这个漏洞。

多载体并行之后,GC 采用 stop-the-world 会合回收:每个执行流跑到下一个安全点停下,最后到达的执行流负责回收;回收时,所有载体求值栈、全部挂起帧都纳入根集。对比 Loom:Loom 栈块是普通堆对象,天然被 GC 扫描,代价是拷贝;NLang 帧栈是 GC 特殊根注册表,零拷贝,但需要位图 + 保守扫描兜底。对比 BEAM:BEAM 每个进程独立堆,回收互相隔离;NLang 选择共享堆,方便脚本互操作,代价就是回收必须全局会合。堆的共享策略,比选择哪一种回收算法更前置。

**多核:锁的三级收缩。**上篇说并行是后续目标,0.10.15、0.10.16 落地,路线是逐步收缩全局解释器锁 GIL,和 CPython 去 GIL 思路相似。

  1. 0.10.1:GIL + 多载体,字节码执行互斥;阻塞型原生调用(IO 等)执行时释放 GIL,别的载体可以推进。nvm 默认载体数 min(4,硬件并发数),库默认单载体,调试器固定单载体保证单步确定。
  2. 0.10.15:GIL 从整个执行窗口收缩到单条存储操作,纯计算类操作码无锁,真正并行执行。
  3. 0.10.16:存储锁按领域拆成五把:容器、同步原语、流、字符串、分配器增长,同类操作串行、异类操作并行;对象分配使用每载体本地空闲链表,和 mimalloc、JVM TLAB 思路一致。对象字段读写不加锁,跨载体共享可变字段会触发数据竞争,可能读到中间状态,不会内存损坏,同步需要主动使用 Mutex。
    实测(2026 普通 PC):通道收发压测,吞吐提升区间 22%~79%;阻塞IO密集负载,4载体可以做到约4.1倍加速。

**语言面:阻塞式直书,没有着色。**用户侧 API 看不到任何异步标记:

import thread;
import io;

class Producer {
    public Channel<int> feed;
    public void run() {
        for (int i = 1; i <= 10; i = i + 1) {
            feed.send(i);
        }
        feed.close();
    }
}

int main() {
    Producer p = Producer();
    Channel<int> c = {};
    p.feed = c;
    Thread producer = thread.spawn(p.run);
    int sum = 0;
    while (true) {
        try {
            sum = sum + c.recv();     // 空通道自动挂起,不占用载体
        } catch (ChannelClosedException e) {
            break;                    // close 作为广播停止信号
        }
    }
    thread.join(producer);
    io.print("consumer: summed 1..10 to ${sum}");
    return 0;
}

thread.spawn 根据委托返回类型分派:func<void> 返回 Thread,func<T> 返回 Future<T>;get() 是阻塞取值,挂起当前虚拟线程,就绪后继续执行,多个等待者同时唤醒,异常原样向上抛出。Future 底层就是第四节的状态+等待列表;后续补充了 Promise、Future.selectAny 多路等待。Mutex、ReentrantMutex、Semaphore、AtomicInt 是内建原语;WaitGroup 这类工具,直接在标准库 concurrent 包用基础原语实现,不侵入 VM 内核。

这套模型天然覆盖单线程异步场景:默认单载体,多个任务轮流跑定时器、通道、等待汇合,和 JS 事件循环解决的问题一致,只是写法是顺序阻塞式,不用写回调。

JavaScriptNLang
事件循环调度器运行队列(默认单载体)
setTimeout(f, 100)thread.spawn(f) + thread.sleep(100)
await promise / Promise.allf.get() / thread.join(t)
回调嵌套顺序直接书写

**调试:并发最难排查的是看不见。**0.10.4 开始加入调试钩子:任意上下文命中断点,全部上下文暂停;暂停期间定时器计时继续,但不触发执行,恢复之后统一补发,相当于暂停冻结时间;ndb 调试器 threads 列出全部上下文状态、PC 位置,t <id> 切换单步焦点;IDE 调试面板可以点开上下文树,检查任意挂起帧和局部变量。0.10.20 补充未捕获异常上报:派生任务异常没有被 join 观测,程序退出时打印到 stderr,不再静默吞掉错误,解决 Python 里经典的 Task exception was never retrieved 问题。

七、边界与下一步

这套方案同样有行业共性短板:FFI 原生调用执行时,字节码解释循环被卡住,VM 看不到内部状态,阻塞原生调用会钉住载体线程。Loom native 帧卡住载体、Go cgo 阻塞 M,本质是同一个问题。NLang 的处理方案:阻塞原生函数需要声明登记,执行时主动释放 GIL。

嵌套求值窗口的挂起也有迭代:0.10.x 版本直接报错;0.11.x 会新增嵌套调度器,在求值窗口内推进等待任务,完成之后恢复;只有嵌入宿主直接调用 call() 的窗口,依然不支持挂起。

已立项的优化方向,面向百万并发:定时器从线性表改成四叉最小堆(对齐 Go 运行时)、帧槽缓冲池化,把帧创建开销压到 10 微秒以内、GC 定向优化。验收目标(2026 普通 PC):稳定跑完 100 万挂起上下文,稳态内存控制在 0.5~1.5GB。

结语

NLang 协程选型的核心,是顺着解释器本身的形态去设计:字节码循环本来就是状态机,调用栈本来就适合做成堆上数据。帧栈化之后,虚拟线程和协程两套概念合并成一套。挂起只是移动帧,抢占就是配额检查,await 变成不必要的语法糖。阻塞直写的开发体验、单载体下可复现的调度、嵌入侧零额外线程负担,都是这套机制一起带来的。

给大家一个可以迁移到其他语言的思路:以后碰到 await 报错、事件循环阻塞、synchronized 钉住虚拟线程这类问题,先问自己一句:**这个执行到一半的现场,保存在哪里,谁负责迁移它?**存储方案不一样,限制就不一样;存储方案相同,踩的坑也会相通。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐