# 执行模型 Deft 是基于栈的字节码虚拟机,每个运行时运行一个 mutator 线程。并发分为两个层级:共享堆的协作式协程,以及通过复制交换值的隔离工作运行时。本文介绍解释器、事件循环和调度模型,并将其与 Smalltalk/Pharo、Tcl、Lua、Node 和 BEAM 进行对比。日常使用的 API 参见并发;分配与 GC 内部机制参见内存管理。 ## 每个运行时的四要素 一个运行时是一个自包含的执行世界:一个 VM、一个堆、一个事件循环、一个 OS 线程。TUI 面板应用、`defworker` 子运行时、`deftask` 子运行时以及 cluster 工作进程各自都是一个运行时。 值绝不会以引用方式跨越运行时边界。任务与结果通过有界邮箱进行深拷贝,因此运行时之间不共享可变状态,也无需加锁。这与 Java 或 Go 的取舍相反(所有线程共享一个堆),而与 BEAM 接近(每个进程拥有自己的堆;消息通过复制传递)。Node 的 `worker_threads` 介于两者之间:相互隔离的 isolate,采用结构化克隆消息传递,但可以共享 `ArrayBuffer`——Deft 没有共享内存的后门。 ## 解释器 - 基于栈的字节码,线程化分派(threaded dispatch)。 源码编译为一组紧凑的操作码:字面量、算术、分支、调用、集合操作、协议与方法定义以及协程控制。分派采用 Zig 计算跳转(computed-goto)惯用法的带标签 switch:每个处理程序都通过 `continue :main_dispatch` 进行尾跳转,因此没有逐指令的循环头部测试,且该 switch 会下沉为跳转表。一个合成的 `gate` 操作码在武装后会将执行路由到慢路径(错误、燃料、调试器)。目前没有 JIT。 - NaN 装箱(NaN-boxed)值。 每个值都是一个 64 位字——double 不加标签直接传递,其他所有值都是带标签的指针。数字为 IEEE double:没有 fixnum 或 bignum。 - 晚绑定方法。 函数即闭包。`call_method` 通过带内联缓存的类型注册表解析接收者类型加方法名,而非类虚表(vtable)。`defprotocol` 规范会被注册用于校验和 `satisfies?`;路由仍基于名称。 - 非移动式标记清除 GC。 停止世界(stop-the-world)、无写屏障、按类型区分的空闲链表、对象永不移动——参见内存管理。 Pharo 的 Cog VM 恰好相反:JIT 编译的消息发送通过类方法字典分派,镜像是部署单元。Deft 放弃了消息发送的纯粹性和 JIT 吞吐量,换取一个可从源码启动、可按应用嵌入的小型解释器。 ## 事件循环 每个运行时拥有一个由 `epoll` 支撑的 Tcl 风格事件循环,其中单次 `epoll_wait` 统一处理 I/O 就绪与定时器到期。每次迭代按优先级顺序处理事件源: 1. 文件事件——fd 就绪 2. 定时器事件——`after` 3. 协程——就绪队列 4. 空闲处理程序——仅当没有其他待处理工作时 `vwait` 驱动循环直到没有剩余工作;服务器和 TUI 宿主会持续泵动该循环。这与 Tcl 的事件循环和 Node 的 libuv 循环属于同一层级;Tcl 是其直接祖先(`vwait`、`after` 和文件事件均以其为蓝本)。 ## 协程:协作式与浅层 协程是一个显式状态机——`ready`、`running`、`suspended`、`done`、`failed`——停放在事件循环的就绪队列上。它通过 `yield`、`yield --wait ms`、`await`、邮箱接收或直接移交给另一个协程来显式挂起。 挂起只有一帧深。 协程只保存自身主体帧的指令指针、局部变量和操作数栈切片。从主体调用的函数内部挂起不被保存格式支持: - 嵌套的 `await` 或 `sleep` 不会停放协程——它在原地阻塞该调用并同时泵动事件循环,因此其他协程、定时器和 I/O 仍能推进; - 嵌套的裸 `yield` 是空操作。 回报是无需 async coloring(异步着色):辅助函数可以在内部 `await`,并可从普通代码中调用(异步库代码就是这样从同步形态的调用方工作的)。代价是浅层挂起——你无法在辅助函数中途抢占或恢复协程。 操作预算防饥饿机制需要说明一点。每个协程时间片存在 100,000 次操作的预算,但强制让出目前被禁用:真正的工作发生在嵌套调用中,而保存格式无法恢复嵌套帧,因此预算会重置,协程继续运行。失控协程由调用深度限制和调试器中断(见下文)约束;完整的续延保存/恢复是已知的 TODO。唯一保留的预算让出是分配预算,它也只在协程的基础帧上触发。 这使 Deft 处于两个众所周知的设计点之间: | 模型 | 挂起源 | 示例 | |---|---|---| | 有栈协程 | 任意调用深度 | Lua、Go、Erlang 进程、Java 虚拟线程 | | 无栈 + 着色 | 仅异步函数自身的帧 | JavaScript、Python `async`/`await` | | Deft | 协程主体帧;嵌套等待阻塞并泵动 | — | Tcl 8.6 的 `coroutine`/`yield` 也是有栈的,实现于非递归求值器之上:“栈”是堆分配的调用记录,而非 C 帧。 ## 抢占与调试器 VM 也可以从外部停止。一旦调试会话进入武装状态,就会在指令边界检查中断标志;当调试服务器连接时,解释器还会每 4,096 条指令轮询一次,因此即使繁忙的计算也能被夺取。这是真正的指令级抢占,但它不是调度器:暂停会就地冻结整个运行时(其自身的协程、定时器和 I/O 全部停顿),继续时恢复同一执行流。它无法将某个计算停放到队列上并改运行另一个计算。 Pharo 的绿色 `Process` 恰好形成对比:可抢占、按优先级排序,并具体化为续延,因此调度器可以在检查点将任意进程切换为另一个进程。该策略层带来的是:在代码行为异常时的响应性(用户代码循环时 UI 进程继续运行)、无需协作的公平性、无需 `await` 纪律的更简单应用代码,以及对任意进程的实时调试。其代价是 VM 复杂度和故障模式(优先级反转、低优先级饥饿)。Deft 用可预测性换取这些:协作式协程加上作为紧急刹车的调试器。参见调试服务器。 ## 工作进程与并行 真正的并行意味着不同线程上的独立运行时: - `deftask`——每次调用一个一次性子运行时;主体被快照,结果以 future 或 stream 形式返回。 - `defworker`——一个持久化、从文件加载的运行时,带有其模块图、`@worker` 处理程序和 worker 局部状态;`worker/send` 分派任务,`worker/stream` 流式传输结果。 - cluster——N 个 `SO_REUSEPORT` 工作运行时,位于内核连接分发之后,由父进程编排并热重载。 值通过 `CrossHeapRing` 邮箱跨越边界:发送方预留一个槽位并写入值;接收方将其深拷贝到自己的堆中。由于 GC 是非移动式的,接收线程可以在属主回收期间安全地从属主堆读取有根值,但分配只发生在属主线程上。 这类似于 Node 的 `worker_threads`/`cluster` 形态(减去共享内存)和 BEAM 的复制消息形态(减去逐进程抢占)。参见 defworker、运行时 和 并发。 ## 家族树 | 运行时 | 并发单元 | 堆 | 调度 | 并行? | |---|---|---|---|---| | Deft | 协程 + 工作运行时 | 每个运行时一个 | 协作式;无抢占 | 仅工作进程 | | Pharo | `Process` | 一个共享镜像 | 抢占式,带优先级 | 否 | | Tcl | 协程 + 线程/解释器 | 一个分配器,引用计数 | 事件循环 + NRE 协程 | 线程,各自独立的解释器 | | Lua | 协程 | 一个堆 | 协作式 | 否 | | Node | promise / worker | 每线程一个 isolate | 事件循环 | 工作线程 | | BEAM | 进程 | 每进程一个 | 抢占式 | 是 | | Go / Loom | goroutine / 虚拟线程 | 一个共享堆 | 抢占式 | 是 | Tcl 是最接近的单一祖先:事件循环、`vwait`、有栈协程、按线程解释器和安全解释器。Lua 贡献了协程语法;Node 提供了 future/worker/cluster 词汇。按运行时划分的 GC 堆和内核沙箱是 Deft 自己的增补——沙箱见沙箱。 ## 设计说明 - 浅层协程使挂起状态保持小巧。 只保存一帧使 `ObjCoroutine` 保持紧凑、GC 根保持有界,并让挂起开销低廉。让嵌套等待阻塞并泵动而非失败,正是消除着色要求的原因。 - 两个层级解决不同的问题。 协程共享堆,使并发应用逻辑无需繁文缛节即可访问同一数据;工作进程获得独立堆,使失控或易崩溃的计算无法破坏宿主。 - 有意省略的功能: 优先级与时间片、有栈挂起、共享内存线程以及镜像。每一项都是自洽的设计,但每一项都会增加 Deft 目前不需要的 VM 机制;调试器中断和分配预算已覆盖失控代码的情形。 ## 快速参考 | 问题 | 答案 | |---|---| | Deft 是单线程的吗? | 每个运行时一个 mutator 线程;并行来自多个独立运行时 | | 协程共享内存吗? | 是——同一个堆,无需加锁 | | 工作进程共享内存吗? | 否——值通过 `CrossHeapRing` 邮箱深拷贝 | | 辅助函数可以 `await` 吗? | 可以——嵌套等待阻塞并泵动;无需异步着色 | | 协程可以在辅助函数中挂起吗? | 否——挂起只有一帧深,位于协程主体 | | 什么能阻止一个协程独占事件循环? | 目前:几乎没有——操作预算让出被禁用(TODO);调用深度和调试器约束失控协程 | | 调试器可以暂停任何应用吗? | 可以——武装后通过指令边界中断,但它会就地冻结该运行时 | | 有优先级吗? | 没有——协程是 FIFO 且协作式 |