ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Racket换用Chez Scheme:一场运行时迁移的深度解析

Racket换用Chez Scheme:一场运行时迁移的深度解析 1. 为什么要冒险把 Racket 的整个运行时替换成 Chez SchemeRacket 走到今天用得顺手的人很难想象它的核心其实经历过一次“换心脏”级别的大手术。早在 2018 年左右Racket 团队就对外宣布了一个计划把原来用 C 语言自行实现的编译器后端和运行时逐步替换成基于 Chez Scheme 的编译管线。刚听到这个消息时我的第一反应和很多人一样——Racket 不是自己有 JIT 吗不是有完善的字节码虚拟机吗为什么还要折腾一次等真正参与进这个项目或者说坐在旁边看完整场迁移之后我才意识到Racket 换到底层不是性能跑分不够漂亮而是长期维护成本的分配方式出了大问题。原来的 Racket 实现在社区里习惯叫“Racket BC”也就是 Before Chez 的缩写这个 BC 并不是简单一个解释器。它包含了一套 C 语言写的运行时一个字节码编译器一个 JIT 编译器一套精确的垃圾回收器以及一长串需要手动维护的底层库。每加一个语言特性编译器前端做完展开之后字节码后端就得跟着改每修一个 GC bug要在一个不是自己设计的内存模型里打补丁。久而久之整个核心变成了少部分人才能碰的“遗产代码”哪怕社区再活跃新人的门槛也高得吓人。Chez Scheme 之所以在这种情况下被选中不是因为它是最流行的 Scheme 实现——它恰恰是很长一段时间里被商业公司握在手里的“古典”实现。Chez 的编译器质量在业界是出了名的好它的一个核心优势是增量式编译对尾调用、底层 CPS 变换、寄存器分配这些脏活累活Chez 团队已经打磨了几十年。另一个不常被提到、但对我们这种做语言平台的人来说极其重要的点是Chez Scheme 的运行时对“嵌入”这件事非常友好。它可以被当成一个库来链接宿主程序可以自由控制输入输出、内存分配回调甚至可以对编译后的代码再做一层封装这种设计让 Racket 团队觉得可行而另一个候选方案“直接使用某个 LLVM 后端”反而没那么合适。这次迁移给所有做语言实现的人留下了一个很值得回味的经验当你觉得自己的语言核心像一团乱麻时比起继续重建自己那套轮子找一棵成熟的大树嫁接上去可能更靠谱。但嫁接的过程才是真正需要动脑筋的地方。2. 真正要搬的不是“功能”而是“语义的精确性”很多人以为把 Racket 搬到 Chez Scheme 上就是保留原来的前端解析器然后让 Chez 去做编译执行。这个想法对了一半。Racket 确实保留了它引以为傲的宏展开系统、模块系统、contract 系统和文档系统这些前端层面的东西只有很小的改动。但真正的困难出在后端Racket 底层运行时不只是“编译器”和“执行器”它还承载着一整套语言语义包括续延continuation、异常处理、参数化parameters、异常逃逸、EOF 对象、嵌套命名空间、动态绑定以及以模块为基础重现系统等。我一开始以为这些都能直接用 Chez Scheme 的运行时基础来映射后来很快发现Racket 的传统执行模型和 Chez 的执行模型在若干关键点上不一致。比如 Racket 的 continuation 是“delimited”的有 prompt 的概念也就是我们今天说的 composable continuation而 Chez Scheme 的 continuation 是“undelimited”的也就是整个调用栈的全量持续化。为了让 Racket 的“prompt/control/abort”语义能在 Chez 上不丢失我们必须在 Chez 的基础上包一层带 prompt 标记和上下文切换的逻辑。这一步是整座移植大楼里的承重墙。另一个让我印象深刻的点是 Racket 的内存模型比一般 Scheme 要更复杂。Racket 的 struct 默认是不可变的但又允许自定义 mutable 字段还支持 cyclic 结构。Racket 的 GC 原来是自己写的分代精确 GC可迁移到 Chez 之后不能直接沿用那个 GC 逻辑而要让 Chez 的 GC 来管理 Racket 对象。可 Racket 对象又分很多种不可变 cons、可变 cons、连续块对象、哈希表、向量、字节串、盒子等等。如果直接让 Chez 的 allocator 处理所有这些类型会引起一个潜在问题Chez 编译器的类型推断会假设某些字段是不可变的进而做某些激进优化可 Racket 那边却允许这个字段被修改于是就会发生诡异的内存破坏。解决这个问题的路线最后不是去改 Chez 的类型系统而是做成一个“隔热层”把 Racket 的运行时对象尽量映射成 Chez 的 vector 或者不可变 pair再在需要可变语义的地方通过 Racket 自定义的访问器去包裹。这层隔热层成了整个迁移工程里最庞大的 C 库与 Scheme 库交界。它不产生用户的可见功能但缺了它整个系统会在运行十几分钟后毫无征兆地报出随机 segmentation fault。所以经验报告里最值得记录的一条是进行这类底层迁移时你要搬的绝不是“函数库”而是“语义闭包”——用户代码能依赖的所有边界条件都要重新论证一遍。Racket 的文档里有很多关于“对未定义行为的最小化”的保证这些保证正是原来 C 运行时里一条条 assert 和弱检查维护出来的换到 Chez 后也得一条条找回。3. 移植过程里最容易翻车的三个坑续延、FFI 与 GC3.1 续延的坑当“拍板”变成一个复杂概念我在第一次打通“hello world”时心里还挺高兴因为基本表达式、函数调用和算术全都能跑了。可一旦测试续延立刻翻车。Racket 里call-with-current-continuation配合 prompt能够实现协程、生成器和各种高级控制流。而 Chez 的 continuation 只是把控制状态整体捕获一旦你call/cc捕获之后在捕获的 continuation 里再次调用call/cc语义和 Racket 的混杂控制栈完全不同。为了在 Chez 上实现 prompt 语义我们在 Scheme 层写了一个“上下文栈”的模拟机制每次进入或者离开一个 prompt 时都要用当前 Chez continuation 把控制流带到上下文栈的操作层再决定是重新调度还是直接返回。这听起来优雅实际上性能开销很高因为每执行一次 prompt 相关操作都要封包和解包一组 continuation。后来我们学会了把一个抽象的 prompt 标记直接塞进 Chez 的 thread-local 状态里用得多了才把性能追回来。这段经历告诉我们当你说“在 Chez 上重建 Racket”时不能指望 Chez 原生支持 Racket 的续延语义你必须重新造一个“控制流解释层”来补全差异。这层东西是用户完全看不到的但它的代码量比想象中大得多而且最容易产生隐蔽的 stack overflow 或者内存泄漏。3.2 FFI 的坑Racket 外挂代码不能直接照搬Racket 有非常强的 C 接口ffi/unsafe很多图形库、数据库驱动都是直接通过它访问 C 库的。在原来的 C 运行时里FFI 的实现可以直接拿到 C 指针然后通过 Racket 的定义对象来管理内存生命周期。换到 Chez 后FFI 层面对对象存活性的判断逻辑就失效了因为现在所有 Racket 对象都可能被 Chez 的 GC 移动而 C 指针一旦作为裸指针被暂时保存GC 的每次移动都可能带来悬挂指针。我们最初的 FFI 迁移版里只要处理大量 C 字符串往返传输的库就会随机崩溃。过程非常痛苦因为崩溃不是必现的只有当 GC 恰好在memmove的间隙触发时才会出问题。最后我们仿照很多 FFI 库的做法加入了“持有区域”机制在进入 C 调用前把所有可能引用的对象先钉在固定内存区域调用结束之后再释放。这套机制虽然减少了并发调用场景下的性能却恢复了稳定性。简单说FFI 移植不是重新编译一下动态库就能完成的它本质上要求你重新设计所有的“对象引用”和数据生命周期的边界规则。一个不注意就是那种你找三天找不到原因、又不好意思怪 GC 的恼人情况。3.3 GC 的压力测试某些精细的分配模式会彻底压垮引擎Racket 程序常常会大量创建小而短命的对象比如频繁的字符串替换、参数包装、临时列表。原有的 C 运行时为了这类模式做了很多特判。而 Chez Scheme 的 GC 虽然性能很好但它偏重稳定的普通 Scheme 工作负载对 Racket 那种“对象类型非常多、突变非常频繁”的情况并没有专门优化。测试阶段我们常常在连续跑了几小时基准程序后看见堆内存曲线一路爬上去最后触发 GC 报错。定位问题的方法是写一个“只创建某种结构”的微型基准一层层排查。我记得最让我们提起警惕的是 Racket 的struct-copy宏它生成大量中间临时结构如果不做特殊优化在 Chez 上的 GC 压力甚至比 C 实现高出三倍。后来我们在宏展开阶段增加了一层“合并 constructor 与 field 访问”的优化规则才把这部分开销降下来。这给我的教训是移植语言实现时GC 的调参不是“改一改下一代就变大”那么轻松。你要理解目标实现 GC 的分配路径必要时连宏生成的代码模式都得重新设计否则性能不是线性下降而是会在某个特定负载点上突然断崖式崩溃。4. 编译器对接把 Racket 的宏展开器塞进 Chez 的编译流程Racket 最大的招牌之一就是它的“宏展开系统”包括 hygiene、模块内展开、局部展开控制等。这些功能多年来一直是 Racket 前端的一部分。在迁移过程中团队决定不重写这些宏系统而是直接保留原来的 expander 代码让它在 Chez Scheme 之上运行。由此出现一个很别致的架构Racket 源码先经过原有的宏展开/模块系统生成的是 Racket 内部的 core form然后这一层 core form 不会被原来的编译器直接执行而是转译为一组更贴近 Chez 的lambda/letrec/call/cc等表达式再交给 Chez 的编译器去生成机器码。这种“半前端半后端”的组合在工程上有几个好处一方面保留了 Racket 用户已熟悉的宏开发体验另一方面Chez 只需要看到干净、紧凑、具有明确边界的 core form不需要理解 Racket 的模块系统和语法对象对象等复杂概念。不过对接处永远产生新问题。比如 Racket 的#%app、#%datum、#%module-begin这类特殊语法在 core form 里是用 syntax object 包装的转译时要保住 syntax object 里的源代码位置、属性properties和绑定信息。一旦某个核心 form 转换出错报错时的源代码位置都会丢失导致用户拿到一个完全没有行号的错误提示调试成本爆表。这个阶段我们习惯把 Chez 编译器的中间表示比喻成“汇编”它已经非常接近机器码你做不了多少高层的变换。所以大量需要 Racket 语义保证的地方比如模块分离、变量遮蔽、作用域边界的连续性必须在转译层提前处理好否则后面就来不及了。也因此转译层的测试用例写得比任何其他部分都多。我们准备了一个“corpus”包含了数千个从真实课程作业、图形库、Web 框架里抽出来的 Racket 文件每一次改动都批跑一遍任何一行源位置的偏差都会被标红。5. 如何让两套实现一起生活版本策略与“新鲜感”测试迁移工程最艰难的一点是你不能在完成之前就把整个 Racket 生态一把切换掉。Racket 社区有大量第三方包很多包都是在 C 运行时上测试过的。因此 Racket 团队做了很实用的策略新实现在后面的版本里被命名为“Racket CS”旧的 C 实现继续作为选项保留用户可以手动选择运行哪个版本。两个版本共享同一套前端、同一套宏展开、同一套模块系统和文档系统只是底层 runtime 不同。这个策略的好处是我们得以在真实世界中逐步淘汰旧实现而不需要先过“全有或全无”的死门。每次新版本发布后社区跑测试的机器会自动对两套实现跑同一套测试所有不一致的地方都会被单独标记出来。这些测试不只是单元测试还包括很多“模糊测试”随机生成大量表达式让两边执行比较输出是否完全一致。这种“新鲜的诱惑”是发现语义边角差异的利器。我们不止一次捕捉到某些行为的微妙差异——比如用exit关闭的当前端口列表顺序、read在上层可自定义读取器时抛出的错误信息文本不同——这些看起来都无关紧要但对类型检查器、IDE 的集成、编译器和 Macro Stepper 之类的工具来说一点点不一致都会酿成 bug。这种双实现共存的策略也让我意识到重构语言底层最怕的不是“难”而是“没法做对照”。后端的更替本质上是一次需要极高容错率的变更必须有一个长期运行的双轨测试通道把所有用户代码当成试验田慢慢打磨才能真正信任新的实现。6. 我们最后留下的几项核心工程经验6.1 移植不是“翻译代码”而是“重写契约”很多人误以为把 Racket 运行时换成 Chez Scheme相当于把 C 代码里的某个模块替换成等价的 Scheme 模块。实际上Chez Scheme 本身是 Scheme 的一个极佳实现有了 compiler、runtime、linker、profiler 等一系列工具但它不知道 Racket 的 package 系统、raco 命令、scribble 文档生成、编译器和链接器等外围生态。你要做的不是翻译而是定义新的边界哪些事情由 Chez 做哪些事情由 Racket 自己的运行时覆盖。我们最常用的比喻是Racket 原来是一栋自己设计自己盖的大楼。现在你搬进了一栋别人设计好的摩天大楼但你需要在大楼里面重新装修出水暖电气、消防通道、甚至重新定义楼层的用途。墙壁、承重柱和大梁可以借但插座、水管、电梯必须按你对户型的需求重新规划。这要求你对两种不同实现的内在约束都有足够深入的了解。6.2 模块化的编译器边界是最重要的投资回头想想这个项目之所以能持续推进很大程度是因为 Racket 团队多年来坚持的模块化前端设计。宏展开器和 core form 的表达非常稳定这使得后面的替换变成了“替换编译后端”而不是“重写编译器”。这种设计原则对任何语言的维护者都有启发你的编译器的“前端/后端”边界越干净将来技术栈上的创新才越容易。Racket 前端通过 syntax objects 表达各种元数据即使后端完全不同也能把这些元数据转译给 Chez 的编译单元这是一个非常实用的工程决策。6.3 性能优化要放在语义正确之后但也不能不闻不问开发过程中我们承受了很多来自外部的质疑“Racket CS 性能比 C 实现好吗”确实在早期Racket CS 比旧版本慢的多某些基准甚至慢一倍多因为那会儿大量语义适配层还没有做优化。但当我们把宏展开的中间表示更紧凑地映射到 Chez 的 core form 后、把 prompt 的上下文切换从“每次捕获全栈”改成更细粒度的局部标记后Racket CS 的速度逐渐追了上来。我的经验是移植的第一阶段只要做到“正确”性能上可以允许一定程度的下降但一旦跑通和验证一定要尽早引入性能回归测试把每一处可测量的劣化挂在 bug tracker 上否则性能问题会像滚雪球一样堆到发布前夜。6.4 底层的兼容性远比想象中重要当我把 Racket 的原有代码迁移到 Chez Scheme 时会发现很多原来由 C 实现“默默保证”的小细节在 Chez 上并不存在。例如 Racket 的错误消息惯例里所有 term 要打印成可被 read 的表达式而 Chez 那边直接打印成内部结构。这些差异看起来是消息文本微调实际会影响很多 test suite 以及依赖精确错误传播的应用。处理这些兼容性的过程让我们对“语言定义的边界”有了更深刻的认识用户常常依赖那些没有写进规范里的行为只有在换实现时你才会发现它们竟然如此重要。7. 如果让我再来一次我会更早依赖差分测试与真实工件在整个重建过程中如果没有一个庞大的真实代码集作为试金石很难想象我们怎么找到那些潜藏的边界问题。Racket 生态里有许多重要的真实程序DrRacket、Raco 构建系统、Typed Racket 的类型检查器、科学计算库、教学作业框架。我们几乎每天都会在这套真实代码集上运行 Racket CS 和 Racket BC 两套版本对比结果。一旦有差异就立即用“delta debugging”一类的自动化最小化工具把差异缩减到最小可复现表达式。这种做法看似朴素但效率极高。如果我给其他团队做移植提供建议第一句话就会是先准备一份“影子测试集”和“双实现差分框架”而不是一开始就写移植代码。另外建议一定要从一开始就搞清目标运行时里那些“私有 API”的可访问性。Chez Scheme 虽然开源但它的内部 API 并不会被文档完整覆盖。Racket 团队在很多地方会用一些不稳定的内部函数来实现性能优化而这就意味着他们必须承担潜在的 ABI 变化的维护成本。对此我们选择了很务实的路线尽量把对 Chez 内部 API 的使用限制在一个很小的领域内防止未来上游变动导致整个平台崩盘。这个“隔离风险区域”的架构模式值得学习。在经历这场漫长而折磨人的“换心脏”过程之后我个人最大的体会是不要被“用别人现成的底部实现”这句话冲昏头脑。真正的工程量不在于“用”而在于“让别人的东西理解你的东西还要不出岔子地替你完成用户期待的语义”。Chez Scheme 给了我们一个非常优秀的起点但 Racket 能做到今天这样准确执行数千个第三方库靠的是那套语义转换层、双实现差分机制和团队成员对“确定性”近乎偏执的坚持。这不是一次轻松的更换而是一次重新认识自己语言实现的好机会。今天再回头看我反而觉得这条路走得很值。
返回列表