Jolt:在Chez Scheme上运行Clojure
摘要
Jolt是一个新的Clojure实现,目标平台为Chez Scheme,旨在提供即插即用的替代方案,具有快速启动和低内存占用,利用Chez的JIT和GC来避免JVM的开销。
<p><a href="https://lobste.rs/s/btplc7/jolt_running_clojure_on_chez_scheme">评论</a></p>
查看缓存全文
缓存时间: 2026/07/24 04:58
# 在 Chez Scheme 上运行 Clojure
来源:https://yogthos.net/posts/2026-07-02-jolt.html
我一直在开发一个面向 Chez Scheme 的 Clojure 新实现(https://jolt-lang.github.io/),现在它已经发展到值得与更多人分享、希望对他人的阶段。这篇文章将解释这个项目的动机、当前状态以及它为何可能引起兴趣。
我的主要目标是打造一个 JVM Clojure 的即插即用替代品,拥有快速启动和轻量内存占用。如果你深入了解 Jolt 的内部,很快会发现 Chez Scheme 是托管 Clojure 的绝佳选择。由于两种语言共享相同的 Lisp 根源,Clojure 的核心语义可以无缝映射到 Scheme,避免了将函数式范式强加于像 JVM 这样的平台所产生的不匹配问题。Chez 带来了一个成熟的 JIT 编译器,能够激进地优化生成的代码,同时保持较小的运行时占用。此外,你还能得到一个高度优化的分代垃圾收集器,它几乎是为处理 Clojure 工作流中不可变数据结构的快速分配和释放而生的。最后,Chez 能在多种操作系统上原生运行,因此你最终得到的是精简的原生二进制文件,启动时间极短,无需拖带庞大的 Java 环境。
虽然 JVM 是一个极其强大的工程产物,但它也是开发者放弃 Clojure 时提到最多的原因之一。Java 运行时诞生于企业架构时代,专为 Tomcat 或 WebSphere 这类本质上自成操作系统的巨型应用服务器设计。在这种模式下,应用服务器会贪婪地占用所有可用主机内存,并连续运行数月,因此漫长的 JVM 启动阶段和沉重的初始资源占用被视为完全可以接受的权衡。但这种单体设计与现代 Web 应用的构建方式格格不入,尤其是在快节奏的初创环境中,工程文化已转向微服务。如今,你通常希望部署能即时启动的隔离容器,并能够动态水平扩展以应对突发流量高峰。为了让一个轻量级独立服务运行而拖带一个庞大的已预热 Java 环境,直接违背了这一原则。
我一直想知道,如果要创建一个符合现代开发实践的 Clojure 运行时,需要付出怎样的努力,而 Jolt 正是我解决这个问题的尝试。当然,目前已经存在许多 Clojure 方言。我特别期待 Jank 的发展,它已经朝着完整的 Clojure 实现迈出了很大步伐,但它侧重于与核心语言的兼容性。不幸的是,大多数现有的 Clojure 库最终都依赖与 JVM 宿主的一些交互,这意味着它们无法不经修改就在这些方言上运行。
能够利用现有的成熟且经过实战检验的库生态系统将是非常好的,我很想看看在 Chez 运行时之上提供 Java 特定的垫片层可行性如何。我的关键发现是:大多数库实际上并没有使用太多 Java 标准库的接口进行互操作。一旦你映射出`java.io`、`java.time`等几个包,就可以运行当前 Clojure 栈的很大一部分,而无需重新实现或将其移植到新平台。再次值得提及的是,其他一些努力,例如面向 Go 的 let-go(https://nooga.github.io/let-go/)方言,也遵循着类似的计划(https://github.com/nooga/let-go/blob/main/docs/jvm-compat-plan.md)。关键区别在于我们各自针对的平台以及这些平台能开放访问的生态系统。
## 目前已实现的功能
目前,Jolt 支持许多流行的 Clojure 库。已经可以使用 Ring、Reitit、Selmer 和 HoneySQL 构建一个完整的 Ring 应用(https://github.com/jolt-lang/examples/tree/main/ring-app)。完整测试套件通过的库列表可在官方文档网站(https://jolt-lang.github.io/docs/libraries.html)上查看。有些库,比如 Reitit,会降级到 Java,但这不成问题,因为 Jolt 允许使用 Jolt 库提供垫片。例如,路由器(https://github.com/jolt-lang/router)库实现了 Reitit 所需的`reitit.Trie`(https://github.com/jolt-lang/router/blob/main/src/reitit/trie_jolt.clj)Java 类。因此,如果你需要某个特定的 Clojure 库,而核心语言中又没有现成的垫片,你可以自己添加它们以使其工作。在许多情况下,这也不需要从零开始,你可以利用成熟的 Scheme 或 C 库来实现底层功能,只需编写少量胶水代码将功能暴露为 Java API。
Jolt 使用`deps.edn`来管理依赖和别名,与常规 Clojure 基本相同。额外的好处是,你还可以像这里(https://github.com/jolt-lang/http-client/blob/main/deps.edn)所示那样指定原生 C 依赖。这些库必须存在于系统中才能被识别。
使用 Clojure 的主要乐趣一直在于其交互式开发周期,因此熟悉的 nREPL 工作流得到了完全支持。你可以启动 Jolt 应用,从你最喜欢的编辑器中接入,然后像在 JVM 上运行 Clojure 一样开发它。此外,nREPL 也可以嵌入到编译后的发布二进制文件中。
作为项目的一部分,我还想做的一件事是映射出 Clojure 作为规范实际上由什么构成。因此,随着我添加对现有库的支持并在此过程中发现各种细微差别,我同时构建了一套一致性规范(https://github.com/jolt-lang/jolt/tree/main/test/conformance)、EBNF(https://jolt-lang.github.io/docs/spec/02-reader.html)和 RFC(https://jolt-lang.github.io/docs/rfc/README.html)。我想特别感谢 Jank 团队的 clojure-test-suite(https://github.com/jank-lang/clojure-test-suite),它对于启动项目并确保核心语言语义(如读取器、特殊形式以及`clojure.core`的大部分内容)正确无误来说非常宝贵。然而,它并未涉及许多库所依赖的 JVM 宿主契约,因此构建语料库的大部分工作都用于弥补这一差距。它从 JVM 参考 Clojure 中提取每个期望值,目前包含约 3500 个案例,每个案例都被标记为可移植(`:common`,任何忠实的 Clojure 方言都必须满足)或宿主相关(`:jvm`,涉及互操作)。认证步骤会针对真正的 Clojure 重新评估整个语料库,以捕捉未分类的差异,确保契约不会悄然偏离。
## 内部机制
让库能够工作暴露出了一组反复出现的问题。在宿主端,Jolt 镜像了流行 Clojure 库常用的九个`java.*`包。这些包括`java.io`、`java.lang`、`java.util`、`java.time`、`java.math`、`java.net`、`java.nio`、`java.sql`和`java.text`,这些包中总共约有一千种方法和字段的实现。值得指出的是,Jolt 并没有提供 JVM 标准库的完整重新实现。虽然大多数类携带了完整的方法集,但有一些类只解析到足以使`instance?`和类型检查正确落地,而少数几个类(如`bean`和`proxy`)是文档明确的部分实现。在很多情况下,只要像`Math/sqrt`、`(StringBuilder.)`和`instance?`这样的互操作形式能解析到真实行为就足够了,而无需构建一个完整的类层次结构。
由于底层没有 JVM,类身份必须使用层次结构图来合成,以支持`instance?`和`(class x)`,并且原始的 Chez 运行时错误需要映射到忠实的 JVM 异常层次结构上,以便`catch`分发能够正确工作。那些普通的值相等性无法捕捉的情况是最棘手的。例如,整个序列生产者系列必须在构建时是惰性的,从左到右实现,进行记忆化,并以 32 个元素为一组进行分块,以匹配 Clojure 的行为;而数字塔则需要完整的 JVM 传播规则,加上无符号检查数学的 64 位包装。真正的考验,是运行各个库的真实测试套件,以发现微妙的实现细节和 Java 宿主交互。移植 spec.alpha、core.logic、core.async、test.check、tools.reader、rewrite-clj 以及数十个其他库,揭示了大部分边界情况,比如数据读取器返回代码形式、带命名空间的映射字面量、`*print-length*`和`*print-level*`、通过 deftype 和 reify 合并的协议方法,甚至是通过 Chez 的弱对和守护者连接的真实 GC 驱逐的软引用和弱引用。
就体积而言,一个最小的二进制文件在未优化时编译到约 13 兆字节,直接链接后仅为 8 兆字节。在性能方面,Jolt 目前在多个基准测试中与 JVM Clojure 相当(https://github.com/jolt-lang/jolt/tree/main/bench),多数情况下持平或慢约 2 倍,仅在少数情况下慢约 7 倍。这完全归功于成熟的 Chez 运行时。当然,就像我们可以从常规 Clojure 降级到 Java 一样,Jolt 允许你在需要峰值性能的情况下同样使用 Scheme 甚至 C FFI。
## 为何不直接等待 Leyden?
一旦 Project Leyden(https://openjdk.org/projects/leyden/)落地,它将解决与 JVM 相关的臭名昭著的启动时间和内存占用问题,因此你可能会怀疑是否有必要拥有一个专门的方言来解决这些问题。我认为有必要,因为我们可以利用像 Stalin Scheme(https://github.com/barak/stalin)中看到的完全程序优化技巧,将优化推得更远。虽然 Leyden 和 Stalin 都依赖于形式化的封闭世界约束,但它们有着完全不同的最终目标。Leyden 主要侧重于将计算从运行时转移到构建时,以保证不会动态加载新的类。这种方法允许 AOT 编译器进行积极的死代码消除,以及去虚拟化和单态化,因为了解完整的类层次结构允许虚方法调用变成直接跳转或内联代码。但 Leyden 无法实现 Stalin 那种结构消除,因为它必须尊重 Java 语义。Leyden 避免进行激进的流程分析,仍然受制于关于 Java 对象布局和内存模型的严格规则。另一方面,Stalin 风格的优化器可以完全自由地将对象标量化,或者在证明对象生命周期很短时绕过标准垃圾回收路径。与 Leyden 不同,它不受制于需要生成一个遵守 JVM 语义并与 Java 垃圾收集器集成的程序。
在 Chez Scheme 之上构建也避免了 Java 无法摆脱的架构包袱,最明显的例子是适当的尾调用优化。Scheme 按定义强制要求 TCO,因此将 Clojure 编译到 Scheme 可以免费获得无限相互递归函数,并拥抱 JVM 积极对抗的函数式编程范式。内存模型和原生互操作提供了另一个重要分歧,使得 C 生态系统如此吸引人。即使 Leyden 缩小了 JVM 的占用空间,每个值仍然携带 Java 对象头的重量。Chez Scheme 以更低的开销运行,并在微秒内启动。从 Scheme 中,你可以直接操作原始的 C 指针和结构体,封装高性能库,而无需承担 JNI 和 Panama 在托管边界上带来的序列化和上下文切换代价。
还有数值计算的现实:JVM 强迫动态类型进入装箱对象。因此,在紧致的数学循环中会产生巨大的垃圾收集压力,除非严重依赖原始类型提示。Scheme 实现利用像 NaN 装箱或标记指针这样的技术直接在 CPU 寄存器中表示动态类型,使得动态数学运算更接近硬件运行。重要的是,在 JVM 上实现 Leyden 级别的性能依赖于使用提前编译阶段,这破坏了使 Clojure 如此强大的交互式 REPL 体验。Jolt 可以利用 Chez Scheme 生成高度优化的原生机器代码,同时保留动态绑定。你既得到了紧密编译二进制文件的执行特性,又保持了 Clojure 的交互灵魂。当然,当不需要交互功能时,你仍然可以进行 AOT 以获得更好的性能优势。
Jolt 还支持基于 ClojureScript 编译器模型的树摇。当包含库时,可以追踪哪些部分实际被使用。通过追踪调用图并消除任何地方都未引用的命名空间和函数,可以生成精简的发布二进制文件。
## 桌面与原生互操作
除了原始性能和内存占用之外,更大的长期利益在于直接访问 Scheme 和 C 生态系统。一个明显的例子是构建桌面应用。目前,最常见的方法是使用 Java UI 框架的包装器,并接受 Java 运行时带来的臃肿。将 Clojure 带到 Chez Scheme 绕过了这个问题,允许你通过外部函数接口直接绑定到像 GTK 这样的原生 C 库。即使存在 Java 原生接口(JNI)甚至更新的 Project Panama,当从托管内存跨越到原生指针时,它们不可避免地会引入摩擦和开销。从 Scheme 来看,C 边界几乎变得不可见,使得与基础原生库交互成为可能,而无需支付运行时代价或编写大量样板 C 代码来弥合鸿沟。
为了证明这一概念,我制作了 glimmer(https://github.com/jolt-lang/glimmer),它使用 Reagent 风格的反应式原子来驱动 GTK 组件。一个经典的 TodoMVC(https://github.com/jolt-lang/examples/tree/main/glimmer-app)应用展示了从 Jolt 使用原生 UI 工具包是多么无缝。反应式数据模型已被证明在 ClojureScript 中是一种极其有效的工具,而使用 Reagent 风格的 API 包装 GTK 将相同的人体工程学带到了原生桌面环境,给予了我们两全其美的体验。你可以使用反应式原子和声明式组件结构,同时使用快速的原生工具包进行渲染。最终应用的内存占用远小于任何在 Java 虚拟机中运行的程序。当然,我们都熟知并喜爱的 nREPL 驱动开发在这里也同样有效。
TodoMVC 截图
我们甚至可以更进一步,使用 glimmer-gl(https://github.com/jolt-lang/glimmer-gl)在 GTK 窗口内创建 OpenGL 上下文。你可以在这里(https://github.com/jolt-lang/examples/tree/main/glimmer-gl-app)看到反应式方法在这个上下文中如何无缝工作。
OpenGL 截图
我甚至成功让一个完整的类似 Quake 的 FPS(https://github.com/jolt-lang/examples/tree/main/fps-demo)在 Jolt 上运行。原生构建反应式 3D 场景通常涉及在低级语言中手动管理状态变更。直接从 Clojure 驱动 OpenGL 管道使得动态建模复杂的视觉状态变得容易,而无需牺牲原始性能。你可以构建本地图形集成...
相似文章
Clojure 速度几乎媲美 C(需借助一些优化)
本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。
基于Go的Clojure
Glojure是一个开源的、基于Go的Clojure语言解释器,能够无缝访问Go库,并允许嵌入到Go应用程序中。它目前处于早期开发阶段,但已用于业余项目。
Chez Scheme 在 Debian 中的现状
Chez Scheme 10.4.0 在长时间间隔后已上传到 Debian unstable;该软件包已移交至 Debian Scheme Dream Team,交叉编译问题已修复,且未来版本现在要求可重现构建。
jank 现已拥有自己的自定义 IR
jank 是一种 Clojure 方言,现已引入一种在 Clojure 语义层面设计的自定义中间表示,以实现更好的优化并与 JVM 竞争。
使用Clojure约一个月后的感想
作者分享了学习Clojure一个月的体验,将其与Common Lisp和Scheme进行比较,并赞赏其一致性和务实设计。