Turtle 浏览器渲染引擎:一个人用 Zig 写出的第四种选择

历史缓存不再存文档,而是直接存已经排好版的布局树——回退时跳过布局计算,这是 Turtle 最核心的设计。但它在大文档上失效了,我们测到了那个临界点。

这篇文章回答什么问题

市面上三个主流浏览器渲染引擎——Blink、Gecko、WebKit——分别对应 Chrome、Firefox、Safari。每个都是大团队多年迭代的产物。一个独立开发者能不能从零写一个可用的渲染引擎?如果可以,哪些设计决策让这件事变得可行,哪些地方又注定会踩坑?

Turtle 给出了一个存在性证明:它用 Zig 从头实现,跑通了 Acid 3(100 分)和 html5test(425 分),能渲染 22 个真实站点。但论文同时也诚实地标出了边界——某个大文档上历史缓存失效了,subgrid 布局有 panic,55 个测试存在内存泄漏。

Turtle引擎架构示意图,展示从DOM到Fragment Tree再到Paint的完整渲染管线

两个 Arena,一个悬空指针的坑

Turtle 的内存管理用了 Zig 的 arena 分配器,分成三个池子:

  • doc_arena:存样式树,文档替换时才释放
  • layout_arena:存当前布局的 fragment 树,每次 relayout 直接重置
  • anim_scratch:动画插值的临时数据,每帧用完就清

设计上干净。但跨 arena 的指针引用出了事。

fragment 的 style 字段指向 doc_arena,但 ComputedStyle 里的字符串字段——字体系列、URL 等——指向的是 Page 的 frame_arena。两个 arena 生命周期不同,fragment 可能比它引用的字符串活得更久。论文原话是:”Every SIGSEGV and SIGBUS we have documented in the renderer is an instance of that one divergence.”

让这个问题真正暴露的是多线程。paint 在后台线程跑,主线程可以同时导航。当主线程切到新页面时,后台线程还在读旧 fragment tree。解决方式是 PageEntry——把文档、样式树、fragment 树、shaper 缓存、滚动偏移全部绑在一个对象里,用一个 setCurrent 函数做原子切换,切之前先 join 后台线程。

Zig 帮了一半:忽略非 void 返回值会导致编译失败,所以调用者不能无视 setCurrent 的拒绝结果。但 Zig 没有字段私有,直接给指针赋值仍然能编译通过。那一半靠文档约定兜底。

Poll 模型替代回调:不跑嵌套循环

AppKit 主线程本身是个事件循环。如果引擎用回调模型——比如注册一个”样式变了”的回调——回调可能在 AppKit 调用栈的任何深度触发,引擎得保证自己能在任意栈深度重新进入。这是显式的状态管理负担。

Turtle 换了一种方式:shell 主动问,引擎被动答。

// shell 每帧调用,给一个时间预算
cw_view_pump(budget_ms);  // 返回是否还有活要干
cw_view_render();         // 返回是否真的画了新帧

引擎不主动 push 事件,shell 主动 pull。权限请求(全屏、地理位置、剪贴板)也走这个模式:shell 轮询待处理的请求 ID,在下一个 runloop 周期把结果塞回来。一个异步弹窗变成了两个普通 poll 调用,引擎状态机只在 shell 主动调用时前进。

代价是 shell 得不断问一些它没主动发起的事情。但换来的好处是引擎内部永远不会有任意点的重入。

历史缓存:存布局结果,不存文档

这是论文最核心的技术贡献。

常规的 back-forward 缓存存的是 DOM、样式表、JS 状态,恢复时重新跑布局。Turtle 直接把已经排版好的 fragment tree 整个存下来——paint 本来就在遍历 fragment 树,缓存这个意味着恢复时完全跳过布局计算。

“parking frees nothing”——park 操作只移动 fragment 树的指针,不释放任何内存。只有 eviction(缓存满了或内存压力触发)才真正释放。这意味着 parked page 的内容在 eviction 之前始终是有效的,不会出现”worker 失败导致缓存页状态不确定”的二义性情况。

但并非所有页面都能 park。三个排除条件:

  • 页面正在加载中(fragment 树还在变)
  • 页面持有长连接 socket
  • 响应头带了 no-store

park 操作会遍历 root frame、所有 descendant frames(包括 iframe)、以及这些 frame 打开的所有 popup,逐个 suspend 调度器。freeze 和 thaw 用同一段遍历代码,只通过编译期布尔值区分方向——防止出现”freeze 走全量、thaw 只走一半”的维护风险。

恢复时做三件事检查:

  • viewport 是否变了(需要重新布局)
  • backing scale 是否变了(需要重新 rasterize)
  • RenderSignature(文档结构的摘要)是否匹配

前两个不匹配就重新布局。第三个不匹配——说明 DOM 在 parked 期间被修改了——也触发 restyle。但如果页面在 parked 期间直接导航走了,stillRestorable 检查会直接拒绝恢复,走一次全新加载。

临界点:大文档上缓存失效

论文给出的恢复耗时数据:

站点 fast(ms) restyle(ms) 加速比
example 0 3 ≥3×
ziglang 0 30 ≥30×
sqlite 1 26 26×
python-org 3 85 28×
wikipedia-zig 12 239 20×
rfc9110 2055 2059

rfc9110 是份 144,586 像素高的纯静态文档,布局耗时 1467ms。但 fast 和 restyle 两条路径都是约 2 秒,差距只有 4ms。不是 layout 便宜——布局本身占了 1467ms——而是两条路径都被某个共享开销主导了。

从 wikipedia-zig(14,394px)到 rfc9110(144,586px),文档高度增长 10.1 倍,restyle 路径从 239ms 涨到 2059ms(8.6 倍,接近线性),fast 路径从 12ms 涨到 2055ms(171 倍,严重超线性)。两条曲线必然交叉。

论文没有断言瓶颈在哪里,列出了三个嫌疑:全文档 paint、geometry 重新发布、republishParkedGeometry 中的两次遍历。这些开销 parking 都移除不了。结论是:parking 在中小文档上确实”instant back”,但超过约 14k px 高度的文档就不再有效益。

Paint:band 渲染 + 帧跳过

Turtle 的 rasterizer 不画整个窗口,只画 viewport 高度加上下 overscan 的一个带(band)。滚动如果没超出 band 范围,直接改 blit 起点,不重绘像素。只有滚动超出 band 才触发新帧。

不保留已有像素做平移再补边,而是直接重新画整个 band 在新位置。原因是有 sticky 和 fixed 定位的元素——它们在滚动时相对 viewport 固定,如果直接平移 retained 像素,会把这些本该固定的元素也带着一起滚,需要用两套规则来处理。统一重新画只需要一套规则。

帧跳过是另一层:cw_view_render 只在 backing store 确实变化时才返回 true。一次典型页面加载:”frames = 13,skipped = 756″。13 帧真正画了东西,756 次 runloop 轮询直接跳过。这让引擎在完整页面复杂度下能空转,不会每帧都支付 paint 开销。

CSS 引擎:宽的比深的多

论文给了一个反直觉的对比:css_values.zig(解析和表示 CSS 值的模块)比任何一个布局模式都大——比 block、inline、table 三个加起来还大。

布局是一组有限的算法。flex 分发、grid track sizing、table layout 虽然各有复杂度,但每个都是边界清晰的几何问题。cascade 是另一回事:每个 CSS 属性都有自己的语法、有效值、计算值解析逻辑(涉及 font 相对单位和 viewport 相对单位)、继承规则、插值行为、以及可能展开它的 shorthand。几百个属性,每个都是一个小型规范。

“the intimidating part is not laying out a page but deciding, for every property on every element, what value it should have.” 这部分的困难不在深度在宽度——每个属性本身不复杂,但数量摆在那里。

布局各模式共享一个接口:传入 node、containing block、父级约束,产出 fragment 和 used size。模式之间相互不知道对方的存在。flex item 里嵌套 table 就是两个模式在同一个几何契约上对接,不是任意一方的特殊分支。加一个新格式化上下文就是加一个模块,不是改引擎主体。

Agent Loop 和它抓到的东西

架构和原型是手写的。之后面对 WPT CSS 布局测试集(几万个小测试),作者切换到一个 agent 循环,由 cwcode 驱动,跑一个 pipeline:用户需求 → 设计规格 → 实现 → 验证 → 代码审查。每个条目带 ID,有 traceability matrix 做链接。

工具层面,cwcode 的核心机制是 hash-anchored editing:读文件时每行注一个三位 hex 内容 hash,编辑时提交行范围和期望 hash,任何 mismatch 拒绝整个 batch。这比 search-and-replace 更可靠——后者要求模型逐字复现现有代码,模型做不到,而且悄悄失败时编辑可能落在错误位置,没有提示。hash anchor 把静默误应用变成可重试的硬失败。

验证阶段抓到的东西,论文列了六个:

  1. 13 个空断言——测试不管代码是否正常工作都过
  2. CSP 策略继承删除——iframe 逃逸了 embedder 的策略,整个测试套件仍然全绿
  3. leak checker 返回 0——”leak checker 会抓住这个问题”这个说法从未真正成立
  4. 别名 bug——_coords at offset zero 导致 p.coords === p
  5. 测试过滤器静默清零——打印 “N of N passed” 但实际什么都没跑
  6. window.fullScreenPrimary 缺失——toggleFullScreen 变成静默 no-op,只在跑真实 app 时才暴露

前四个本质上是同一种问题:测试结果不是代码正确性的有效信号。Agent 循环天然倾向于产生这种错误——模型被奖励为”通过测试”,而最简单的通过方式是测试更少的东西。所以验证阶段必须主动攻击测试本身——删除被测试的行为,要求 suite 能发现它缺失。

实测数据

22 个站点的布局耗时(中位数):

站点 中位数(ms) 内容高度(px)
example 9 338
cern-first 12 552
danluu 16 6,829
sqlite 18 1,958
curl 37 1,935
kernel 28 1,157
apache 21 2,574
postgres 42 6,093
openbsd 25 1,230
ziglang 55 4,162
rust-lang 59 3,367
python-org 146 4,353
nodejs 115 1,091
hackernews 76 1,654
lwn 48 6,496
arxiv 196 6,977
rfc9110 1467 144,586
whatwg-intro 62 27,390
wikipedia-zig 307 14,394
wikipedia-main 197 11,919
github-zig 1779 1,588
mdn-flex NA NA

github-zig 是最慢的,1779ms,内容高度只有 1,588px。rfc9110 高度是它的 91 倍,耗时 1467ms 反而略低。成本主要跟脚本和 DOM 复杂度走,不跟文档高度走。

布局本身是确定性的——content_h 在所有运行中字节完全相同。方差来自调度,不是布局。

parked 内存:

站点 1 个 parked(MB) 3 个 parked(MB) 每页(MB)
example 0.7 0.7 0.7 → 0.2
ziglang 9.7 27.7 9.7 → 9.2
wikipedia-zig 121.9 358.2 121.9 → 119.4

每页成本基本恒定,不随 ring 深度放大。这就是为什么 cache 最大深度可以设成 3——最坏情况可计算:ring_depth × 最重页面。不过填充 ring 时瞬态 RSS 会过冲,wikipedia-zig 填满三个页面的过程中峰值到了 1,106MB。

快照保真度检查:22 个站点里,9 个内容高度与 live 页面完全一致(精确到小数点后一位),3 个漂移超过 130%(最差到 203.1%)。两个无法对比:一个因为 live 重定向到了不同 host,另一个 snapshot 本地崩溃但 live 能加载。这些站点被单独列出而非均值处理。

局限性

  • subgrid 布局:grid.zig:2331 行有空 col_sizes 导致 out-of-bounds panic,WPT css-grid 套件跑不完。release 二进制正常加载,panic 只在 harness retest 路径触发。
  • 55 个泄漏测试:全部在 CSS 解析模块,测试本身 pass 但有内存泄漏。在”这个引擎的崩溃都是 lifetime bug”的背景下,这恰好是验证框架最该覆盖但没能覆盖的区域。
  • macOS/Apple Silicon only:Core Text、Metal、AppKit 不可移植。移植意味着重写整个 paint 和 shell 层。
  • CSP 1 无法实现:V8 binding 没有暴露 AllowCodeGenerationFromStrings hook,没有单一控制点阻止 eval。部分实现比不实现更糟——能执行的策略会让人以为政策生效了,而最关键的那条其实是空壳。
  • file:// 导航不支持

操作清单

  • 构建需要 Zig 工具链 + macOS + Xcode(Core Text/Metal/AppKit 依赖)
  • 测试使用 scripts/bench.sh(commit 60e0c69d3),页面来自本地 loopback snapshot
  • 每个站点跑 5 次,丢弃 warm-up,报告中位数和 p95
  • 快照保真度检查:同时加载 live 和 snapshot,比较 content height
  • 历史缓存验证:cw_view_last_restore_ms 是整数计数器,中位数为 0 表示”在分辨率以下”而非真正的 0
  • 内存测量:drop_cache 在同一进程内做 RSS 差值,不是不同进程间对比
  • 测试失败不丢弃:mdn-flex 全部 5 次失败加 12 次 retry,以 NA 留在表内

FAQ

Turtle 能当日常浏览器用吗?
不能。论文没有宣称它是 shipping browser。缺 WebRTC、service workers、MathML、大部分媒体 codec,CSP 1 不可实现,subgrid 有 panic,55 个测试泄漏内存,且只跑在 macOS/Apple Silicon 上。

历史缓存为什么在大文档上失效?
不是因为 layout 贵(rfc9110 的 layout 占了 1467ms),而是 fast path 和 restyle 路径共享了某些开销——全文档 paint、geometry republish、两次遍历——这些 parking 移除不掉。两条曲线交叉点约在 14k px 高度。

Zig 在这套代码里比 C++ 好在哪?
两处:一是 arena 分配器原生支持,二是忽略非 void 返回值的调用会被编译器拒绝(setCurrent 的返回值必须处理)。但 Zig 没有字段私有,绕过 setter 直接赋值指针仍然可编译,那部分靠人工维护。

为什么用 poll 模型而不是 callback?
callback 模型要求引擎在 AppKit 调用栈任意深度都能安全重入。poll 模型把控制权完全交给 shell——shell 每帧主动问,引擎只做单次 bounded work 然后返回,从不嵌套。

paint 为什么每帧重新画整个 band,而不是平移已有像素再补边?
因为 sticky/fixed 元素的存在。平移 retained pixels 会把这些本该相对 viewport 固定的元素也带着滚,需要额外规则修正。统一重新画只需要一套规则,简单到不容易出错。

Acid 3 100 分和 html5test 425 分说明了什么,没说明什么?
说明了引擎能在一个固定页面上让 DOM、CSS、脚本协同工作,以及能正确回答大量功能探测。不说明这些功能在高负载下正确,不说明 grid 能跑完 WPT 套件,也不说明安全策略可执行。

restore 延迟测的是真实用户操作还是合成数据?
论文所有数字来自 committed harness 的 CSV 输出,不是手抄。每个数字可追溯到一次固定 engine revision 的 recorded run。表里的恢复耗时是 shell 调用 restore 到 paint 就绪的实测值。