Jolt 的程序镜像与可移植 Scheme 后端

Lobsters Hottest 工具

摘要

Jolt 是一种 Lisp/Scheme 语言,最近新增了两项功能:类似 Common Lisp 和 Smalltalk 风格的程序镜像,用于完整状态序列化与调试;以及一个与 Chez 运行时解耦的可移植 Scheme 后端,实现与架构无关的可移植性。

<p><a href="https://lobste.rs/s/2b5zzw/program_images_portable_scheme_backends">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/08 14:34

# Jolt 的程序镜像与可移植 Scheme 后端 来源:https://yogthos.net/posts/2026-08-07-portable-jolt.html 近来,Jolt 加入了两个特性,两者都源于 Jolt 与宿主运行时之间耦合的松解。第一个是能够以 Common Lisp 和 Smalltalk 的风格序列化程序镜像;第二个是拥有一个与 Chez 运行时解耦的可移植 Scheme 层。让我们来看看这些特性在实践中为 Jolt 带来了什么。 ## 镜像:程序的“黑匣子” 如果你曾经需要维护像 Web 应用这样的生产系统,你就会知道良好日志的价值。通常的做法是,你在代码中散布日志语句,把它们发到可搜索的地方,然后在出问题时把它们当作面包屑来追踪。当系统出现错误时,你必须先重构它当时在做什么,才能理解发生了什么。如果你的日志没有捕获到关键信息,调查就沦为猜测,以及试图重建导致错误的状态。当生产环境在凌晨 3 点宕机时,这种体验尤其令人沮丧。 日志的问题在于,它迫使你在知道问题之前就猜出要问什么。每一行日志都是预先选定的程序状态投影,通常由两三个你在编写调用时觉得相关的东西组成。如果真正的原因恰好是第四样东西,日志就提供不了任何有价值的信息。你无法回头去问一个不同的问题,因为这些值已经消失了,即使它们仍在内存中,你很可能也没有办法访问。你所能依赖的,只是最初写代码时决定好的那份呈现。 结果,人们倾向于防御性地过度记录日志,这又造成了另一种问题:最终日志量巨大,增加了噪音,使追踪更加困难,却常常仍然遗漏了真正重要的字段。 但如果我告诉你,有更好的办法,而你根本不必依赖日志呢?这正是 `jolt.image` 让我们做到的事情。你不必提前选择要记录什么,只需在错误发生时把程序的整个状态转储到磁盘上。然后你可以把文件复制到本地机器,在 REPL 中加载该状态,在其中探查,看看发生了什么。 下面就是实际效果。要在错误发生时转储状态,你只需在异常处理器中调用 `image/dump!`: ``` (try (process-batch! batch) (catch Exception e (image/dump! (str "crash-" (random-uuid) ".jimg") {:error (Throwable->map e) :batch batch :pending @work-queue}) (throw e))) ``` 或者完全跳过枚举,直接保存整个程序: ``` (catch Exception e (image/dump-world! "crash.jimg") (throw e)) ``` `dump-world!` 会遍历变量表,并写出每个数据变量的根,这样你的代码中就不必预先声明其状态由什么组成。镜像是与架构无关的,因此在 arm64 服务器上写入的镜像,可以在你的 x86-64 桌面上正常恢复。一旦你将文件复制到本地机器,只需在 REPL 中打开它: ``` $ jolt repl user=> (require '[jolt.image :as image]) user=> (image/restore-world! "crash.jimg") 412 user=> (filter #(nil? (:price %)) @app.core/current-batch) ({:id 4182, :sku "B-77", ...}) ``` 恢复回来的是问题发生时内存中存在的值。映射、记录、循环和共享结构都完好无损,并附有各自的元数据,函数也可以照常调用。命名函数会解析到当前存活的函数,而匿名闭包则会以其源码形式连同捕获的值一起保存,在恢复时重新编译。因此,你可以在 REPL 中,用失败的数据去实际调用那个失败的函数,亲眼看到到底出了什么问题。你现在可以提出任何日志文件都无法回答的、严格意义上更大范围的问题,而且你无需提前知道这些问题。 你可以把程序镜像想象成一个黑匣子记录仪。正如飞机会存储仪表状态,让调查人员在事后决定要查看什么,程序镜像也提供了调试问题所需的全部信息。 此时,敏锐的读者可能会问,这种方法如何处理无法序列化的开放资源,比如 socket 或文件端口。我采用的方法是,让 `dump-world!` 默认将它们写成存根记录。镜像恢复后,你可以通过调用 `(image/stubs)` 列出它们,然后要么注册一个重新打开它们的解析器,要么在 REPL 中使用 `(image/register-stub-resolver! kind-or-pred f)` 手动替换为存活的值。 一个限制是,捕获了编译期常量的闭包会拒绝转储,因为该常量已被折叠进编译后的代码,无法恢复,而保存的源码仍然需要它。由 `partial` 和 `comp` 构建的闭包也有类似的问题,原因与此相关。镜像的头部在读取时也会被检查,这样你就不会从不兼容的构建中获得过时数据。`dump-world!` 会转储它能转储的一切,而 `dump!` 默认是严格的,它会指出违规对象的路径,而不是写出某种微妙的不完整内容。当你希望明确保证整个状态都可用时,这会很有用。 ### 把程序发送给别人 虽然事后调试是一个明显的用例,但另一个有趣的应用是促进协作。镜像是一种可以交给另一个人的程序状态,这开启了一些引人入胜的可能性。 在教学方面,这意味着你可以直接将某人放入一个运行中的系统中间,加载好真实数据,而他们无需构建或准备任何数据。想象一下,你可以说:“这是实现到一半的流水线镜像;进去看看其中的第 2 阶段吧。” 对于 bug 报告,这意味着同事可以精确地重现你的问题,因为他们只是加载了你的状态。他们可以探查、修改一些东西,再次转储,然后发回给你。 这让我们与程序的关系发生了全然不同的变化——传统上,源码被视为我们传递的工件。Jolt 将这一做法更推近于 Smalltalk 和 Lisp 机器的运作方式,以及今天 Common Lisp 中仍然有效的 `save-lisp-and-die`。程序是你保存的一个鲜活之物,可以随时间的推移而演进,而不是一份可以运行的配方。 最初,我对 Jolt 的目标是在 Chez Scheme 上构建一个 Clojure 实现。然而,[Jack Rusher](https://jackrusher.com/) 指出,完全可以分解出一个可移植的运行时和编译器,让 Chez 只是众多目标之一。Gambit 是第二个宿主显而易见的选择,因为它有一个 JavaScript 后端,可以在浏览器中运行 Jolt。如果你访问[官方网站](https://jolt-lang.github.io/),现在可以尝试在交互式 REPL 中体验,这就是运行在浏览器中的 Jolt。 这次重构将编译器的 Scheme 部分分成了三个不同的层次。核心使用可移植的 Scheme 编写,实现集合、序列、读取器、打印器、var、多方法。这是普通的 Scheme,任何严肃的实现都能原样运行。 接下来是适配器契约,宿主的能力在这里显现。每个宿主能力都通过一个 `sa-*` 入口点,契约文件列出了 72 个名字,分成几个层级:`system`(时钟、环境、退出)、`threads`、`eval`、`introspect`(用于回溯的延续帧)、`ffi`、`native-compile` 和 `image`。目标要么实现一个层级,要么诚实地降级,使得缺失的能力抛出携带消息的错误或返回空值。最后这个属性是让部分移植真正可用的关键。Gambit 版本在 `ffi`、`native-compile` 和 `image` 降级的情况下运行,并显式声明其能力。 最后,目标专属文件是两块没有人能共享的部分。其中包括适配器本身和哈希内核。例如,Chez 版本使用了不安全的 fixnum 操作,其他 Scheme 的拼写方式不同。在编译器方面,每个目标的差异通过原语表来处理,主要入口是不安全操作前缀,因此,将此前缀映射为空字符串的目标,在所有地方都会得到受检查的操作,并标明它是安全、可移植还是更慢的。 方言相关的工作比你想的要少,也比你所希望的要乏味。其中大部分涉及将记录映射到其父类型、哈希表 API、`fx` 操作的拼写、错误对象的形状,以及让哈希函数产生与 Chez 逐位相同的输出。Gambit 移植大约有 6000 行,其中很大一部分(包括种子本身)是在 Chez 上生成的,而不必从头编写。从一个已知可工作的构建中交叉生成种子,是一种让新目标无需自举的技巧。 ### 为什么拥有多个 Scheme 是值得的 立竿见影的好处是覆盖面。Gambit 编译成单个 JavaScript 文件,因此 Jolt 现在可以在浏览器中运行,网站的首页上出现了一个 REPL,使用真实的编译器和标准库直接在客户端求值。它不算特别快,但用作演示足够了。 Scheme 是一整个语言家族,有着不同的方言,各自为不同的用例进行了优化。因此,更深层的好处是打开了一个做出不同选择的实现生态系统。它们都共享核心语言语义,但每种方言都在这门语言上加入了自身的特色,提供了针对不同用例优化的运行时。Jolt 现在可以借力这整个生态系统,在其上提供一个 Clojure 层。 Chez 是一个极好的默认选择,因为它快速、相对小巧、功能丰富,拥有真正的线程、FFI 和原生编译。Gambit 让你能到达 JavaScript 和 C。与此同时,Stalin 谱系中的整程序优化编译器则完全是另一回事;它进行激进的闭包和类型分析,生成极小的输出,适合发送到受限设备上的小型二进制文件,在那里启动时间和体积是主导因素。这样的编译器通常根本没有运行时 `eval`,这听起来似乎不够格,直到你注意到种子已经在 Chez 上交叉生成了。这样,编译器可以活在一个 Scheme 上,而生成的

相似文章

Jolt:在Chez Scheme上运行Clojure

Lobsters Hottest

Jolt是一个新的Clojure实现,目标平台为Chez Scheme,旨在提供即插即用的替代方案,具有快速启动和低内存占用,利用Chez的JIT和GC来避免JVM的开销。

Scheme 是一个 Hoot

Lobsters Hottest

作者分享了学习 Scheme 并使用 Hoot 将其编译为 WebAssembly 的经验,虽然遇到了稳定性问题,但成功在浏览器中运行了物理模拟。

1jehuang/jcode

GitHub Trending (daily)

jcode 是一个开源编码代理工具,专为多会话工作流设计,资源占用低,提供CLI安装方式,并在性能上优于Claude Code和Cursor Agent等现有代理。

JavaScript 精简

Lobsters Hottest

LispE 是 NAVER 开发的一个紧凑的 Lisp 方言,它结合了函数式和数组语言特性,并支持 PyTorch 和 llama.cpp 等 AI 库。