写解释器、做虚拟机的人,迟早都会撞上同一个硬核问题:函数跑到一半卡住等待时,它的现场该存放在哪里。
Go随手开百万goroutine、Python里混进一个阻塞requests.get直接拖垮整个事件循环、C#的async一写就层层传染,这些大家天天碰到的现象,根源都落在这件事上。
一门语言要支持并发,第一个要回答的问题是:挂起的执行现场放在哪里——内核栈、用户栈,还是编译期生成的状态机里。这篇先把线程模型的几种配比摆清楚,再梳理Go、Java、C#、Rust、Python、Node这些主流方案在机器层面是怎么处理「挂起现场」的,最后交代NLang的选型取舍——它把调用栈从C++栈搬到堆上,将递归解释器重构为帧栈机。锁、通道、Future这些具体原语,留到本系列后续文章展开。
一、挂起一个函数,机器上到底要存什么
先铺垫两块基础概念。程序内存大致分成栈和堆:栈就是调用栈,每次函数调用自动压入帧,函数返回就弹出,帧的生命周期和函数绑定;堆是动态分配内存,生命周期不受单个函数控制,C++由开发者手动管理,托管语言交给垃圾回收器(GC)回收。文中反复提到的「把调用栈搬到堆上」,意思就是把原本函数返回就自动销毁的栈帧,变成一块可以随意保存、正常被GC回收的普通堆数据。
另一块关键是寄存器:CPU里高速存储单元,这里重点关注两个:程序计数器PC,记录下一条要执行的指令地址;栈指针SP,标记当前栈顶位置。
普通函数调用在机器层面的流程很清晰:调用时,CPU压入栈帧,里面放局部变量、返回地址,PC跳转到函数内部;函数返回时弹出栈帧,PC切回调用点。整条调用链就是栈上一串相连的帧,SP指向最顶层:
main() ← 帧:局部变量+保存的返回地址 └─ f() ← 帧 └─ g() ← 帧;SP指着这里,PC正在执行g里的指令想要挂起一个执行到一半的函数,只需要保存三样信息:PC指令位置、SP栈顶位置,外加少量正在使用的寄存器。整条调用链和各层局部变量可以维持原样。这种把执行流中途挂起、之后原地恢复的操作,本系列统一称为停泊。
所有协程实现,存的都是这三份现场信息,区别只在存储方案;其上再叠加两组独立的设计抉择:协程要不要自带一整条完整栈(有栈/无栈);调度切换是代码主动让出(协作式),还是运行时随时打断(抢占式)。
切换耗时都是2026年普通PC的典型数字:操作系统线程切换需要陷入内核,保存恢复寄存器、走内核调度,开销是微秒级;纯用户态协程的现场、寄存器都放在用户态内存,切换只是几条赋值指令,百纳秒以内。很多人说协程“轻”,真正的根源就是切换发生在用户态,不需要陷入内核。
二、线程模型分类:1:1、N:1、M:N
挂起现场怎么存放,回答的是「现场能不能搬」;还有一个正交的问题:语言层的执行流和内核调度实体按什么比例对应。这个映射关系直接决定并发规模上限,以及阻塞行为如何扩散,线程模型因此分成三类。
1:1:一个语言线程配一个内核线程。传统多线程程序开几千个线程就压力很大,原因就在映射本身:语言执行流直接对应操作系统线程,创建、切换都要陷入内核,每个内核线程都占实打实的内核资源,几千上万个线程基本就到头了。「线程很贵」的常识就是1:1模型给的。好处同样来自内核:任何线程阻塞在系统调用里都不拖累别人,多核天然能用。Pythonthreading、C++std::thread、虚拟线程之前的Java线程都是这类。
N:1:所有执行流挤在一个内核线程上。最疼的两件事:一个执行流陷入阻塞系统调用,全进程跟着停摆;只有一个内核线程,多核用不上。换来的是切换全在用户态,便宜到极致。事件循环就是N:1的当代形态——Node、asyncio用「全部非阻塞IO、绝不让循环阻塞」的纪律绕开第一个硬伤,多核靠多开进程绕开第二个。
M:N:语言层执行流复用一个小型内核线程池。Go随手开百万goroutine、Loom轻松创建百万虚拟线程,都是这一格的产物:执行流海量、切换便宜、多核并行,两头的好处都要。代价是运行时必须自带调度器,而调度器要插手,前提是「执行到一半」的状态能被整体搬走——问题于是落回第一节那三样东西。怎么搬,就是后面两节的分野:有栈路线把整条栈搬走,无栈路线干脆不留栈。
这条分界线也解释了后面反复出现的载体绑定(pinning)问题,载体就是承载虚拟线程的内核线程:1:1的调度权在内核,任何阻塞都有内核兜底;M:N的调度权在用户态运行时,运行时必须看得见每一次挂起——看不见的那次(原生调用、没被插桩的阻塞),虚拟线程连同载体一起被占住,无法迁移。Loom的synchronized、Go的cgo、NLang的FFI,是同一个缺口的三个版本。
三、有栈路线:切换就是换一根栈指针
最原始的有栈协程就是纤程(fiber):Windows Fiber、POSIXucontext、Boost.Fiber都属于这类。给每个执行流分配一段连续定长栈内存,切换的时候,把当前的SP、PC、寄存器存起来,再把新执行流的现场加载进CPU,就完成切换。好处很直观:整条调用链完整保留,任意深度的函数都可以随时挂起。
代价也来自连续裸栈的设计:栈长度固定,没法超配;GC识别不了这块栈内存,分不清哪些字是指针;挂起之后很难可移植地回溯调用栈;外部取消挂起的纤程也很麻烦——栈上的C++对象,必须恢复到对应栈帧才能执行析构。
Go的有栈协程实现,有三项值得关注的核心机制。
栈会搬家。goroutine初始栈只有大约8KB,快要占满时,运行时分配一块更大的新栈(小栈翻倍,大栈增幅收窄),把旧栈内容复制过去,同时修正栈里所有指向旧栈的指针——只要代码里取了局部变量地址&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(JDK Enhancement Proposal,JDK增强提案)也明确不做时间片抢占,调度让位只依赖阻塞点。
Erlang BEAM是古老的按步数抢占方案。它的进程就是可抢占的有栈协程,每个进程自带一小块独立堆,依靠消息传递通信、默认不共享内存。调度按归约数(reduction,字节码执行步数)计数,每次函数或内置函数调用累加一次,攒满配额就被强制让出,配额从OTP 19.2起是4000归约。不需要信号、不需要中断,就在解释循环的安全点计数,这套源自电话交换时代的设计至今没换过思路,依然简洁。
四、无栈路线:编译器把函数拆成状态机
无栈协程没有独立调用栈。挂起靠的是编译期转换:每个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回调,就绪后再唤醒。状态机本身可以住在栈上、没有虚表开销(spawn到多线程执行器时会被装箱上堆);代价是没有抢占,长计算必须主动让位;而且状态机存在自引用,需要Pin机制来保障安全。
Pythonasyncio是这套机制的另一个实例。yield原生就是手写挂起点:CPython的生成器帧本身就在堆上,yield保存状态返回,send()恢复;async/await只是语法封装。事件循环单线程轮询IO多路复用器,IO就绪之后再推进协程。开头那个Python的坑也就解释清楚了:单线程事件循环没有抢占能力,一旦调用阻塞同步接口,整个循环卡死。
顺带一提:解释器天然适合堆帧这条路。它的调用栈本来就是程序侧的数据结构。Lua协程每个lua_State自带堆上调用信息,创建成本极低;CPython 3.11也把帧对象挪到堆上。这个结论后面会用到。
还有一种经常被混在一起的模型:事件循环+非阻塞IO——第二节分类里的N:1路线,说的就是它。Python asyncio、Node.js、Nginx都属于这类。它甚至没有“挂起函数”这个概念:文件描述符设为非阻塞,读不到数据立刻返回,把监听事件注册进多路复用器,事件循环阻塞在等待事件上;就绪之后,执行注册的回调。函数逻辑被拆成注册前、回调后两段,这就是回调地狱的来源;await就是抹平回调嵌套的语法糖。
把四条路线汇总对比(切换开销同样是2026年普通PC参考值):
| 维度 | 1:1内核线程 | 有栈协程 | 无栈协程 | 事件循环回调 |
|---|---|---|---|---|
| 多路复用比例 | 1:1 | M:N | M:N | N:1 |
| 栈 | 内核管理,MB级 | 每个协程独立完整栈 | 无栈,状态机对象 | 没有挂起栈 |
| 挂起位置 | 任意阻塞调用 | 任意调用深度 | 仅await标记点 | 手动拆分前后逻辑 |
| 函数着色 | 无 | 无 | 有 | 有(回调边界) |
| 抢占 | 内核抢占 | 安全点/信号抢占 | 一般不支持抢占 | 无抢占 |
| 切换成本 | 微秒级(陷入内核) | 百纳秒级 | 纳秒级(return返回) | 函数回调开销 |
| 代表实现 | Python threading | Go、Loom、BEAM | C#、Rust async | Node、asyncio、Nginx |
五、NLang的选型:为什么最后没有await
帧栈机是最终落地的形态,但选型过程本身更值得看:否决项往往比采纳项更能体现设计思路。立项调研阶段,原本倾向Java路线:虚拟线程+async无栈协程双模型共存,用Future互通。最后落地成单一机制,明确放弃await关键字,四条核心判断:
否决纤程路线。定长连续栈要么按峰值配内存浪费,要么得引入Go式栈搬家加指针修正;百万任务每个分配64KB就要几十GB内存。更关键的是NLang的帧本身就在堆上,只保存活跃帧,内存开销和无栈状态机接近;纤程带来的GC盲区、不可移植栈回溯、外部取消、异常处理的麻烦,NLang全都可以避开,收益却重合。
否决编译期状态机。状态机节省内存的前提是:帧很大、跨挂起点不需要保留的局部变量很多。NLang的帧全部是统一8字节槽,本身很小,两者内存差距被抹平。但代价一点不少:任意位置都可能触发挂起(sleep、recv、IO可以写在任意调用深度),状态机改写、临时值跨挂起点、finally双路径语义这些麻烦,一个都躲不掉,却拿不到数量级的性能收益。
否决await关键字,两层理由。第一是模型归属:NLang选择阻塞式虚拟线程路线,和Go、Loom、BEAM同属一类,它们都没有await。await f和f.get()语义等价,新增关键字反而误导用户,以为存在另一套异步体系。第二是文法代价:前期语法原型测试,基线文法本身就有不少移进归约冲突,新增await会大幅增加语法冲突;靠优先级强行抹平冲突,只是语法糖,不值得承担解析器复杂度。
否决信号抢占。Go的信号抢占是给编译出的原生代码设计的,原生代码没有天然安全点。字节码解释器天然自带安全点:函数入口、循环回边(每次循环末尾跳回循环头的那条跳转)。在回边位置计数配额,就是BEAM归约计数的思路,不需要信号、不需要中断。
核心结论:字节码解释循环本身就是状态机,递归解释器只是把调用栈藏在了C++栈上。把调用栈搬到堆上,Lua、CPython堆帧、Loom堆栈块,都是同一个方向。于是架构确定:统一帧栈基底。虚拟线程 = 堆帧上下文+配额抢占调度;协程能力由阻塞调用自然挂起实现。对照第二节的分类,这就是M:N——而且NLang没有另发一套「协程」概念:M:N复用本身就是协程效果,每个函数天然可挂起。原本双模型要承担的隔离规则、两套同步原语、跨模型死锁风险,随着单模型直接消失。
六、语言面:阻塞式直书,没有着色
用户侧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底层就是「状态+等待者列表」,内部结构后面的文章将会讲到。Mutex、ReentrantMutex、Semaphore、AtomicInt是内建原语;WaitGroup这类工具,直接在标准库concurrent包用基础原语实现,不侵入VM内核。
这套模型天然覆盖单线程异步场景:默认单载体,多个任务轮流跑定时器、通道、join这类等待,和JS事件循环解决的问题一致,只是写法是顺序阻塞式,不用写回调。
| JavaScript | NLang |
|---|---|
| 事件循环 | 调度器运行队列(默认单载体) |
setTimeout(f, 100) | thread.spawn(f)+thread.sleep(100) |
await promise/Promise.all | f.get()/thread.join(t) |
| 回调嵌套 | 顺序直接书写 |
结语
NLang的选型是顺着解释器本身的形态走的:帧栈化之后,虚拟线程和协程合并成一套概念,挂起只是移动帧,抢占就是配额检查,await成了不必要的语法糖。阻塞直写的开发体验、单载体下可复现的调度、嵌入侧零额外线程负担,都是这套机制顺带带来的。
不少并发问题,根源来自执行现场的存储与迁移方式。不同实现会带来各自约束;底层机制相近的系统,也常常碰到同类问题。
本文的讨论基于NLang 0.11.x版本,以后可能发生变化。
仓库地址:github.com/dliting/nlang
版本下载:github.com/dliting/nlang/releases