Turtle 浏览器渲染引擎:一个人用 Zig 写出的第四种选择
“
历史缓存不再存文档,而是直接存已经排好版的布局树——回退时跳过布局计算,这是 Turtle 最核心的设计。但它在大文档上失效了,我们测到了那个临界点。
这篇文章回答什么问题
市面上三个主流浏览器渲染引擎——Blink、Gecko、WebKit——分别对应 Chrome、Firefox、Safari。每个都是大团队多年迭代的产物。一个独立开发者能不能从零写一个可用的渲染引擎?如果可以,哪些设计决策让这件事变得可行,哪些地方又注定会踩坑?
Turtle 给出了一个存在性证明:它用 Zig 从头实现,跑通了 Acid 3(100 分)和 html5test(425 分),能渲染 22 个真实站点。但论文同时也诚实地标出了边界——某个大文档上历史缓存失效了,subgrid 布局有 panic,55 个测试存在内存泄漏。
两个 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 | 1× |
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 把静默误应用变成可重试的硬失败。
验证阶段抓到的东西,论文列了六个:
-
13 个空断言——测试不管代码是否正常工作都过 -
CSP 策略继承删除——iframe 逃逸了 embedder 的策略,整个测试套件仍然全绿 -
leak checker 返回 0——”leak checker 会抓住这个问题”这个说法从未真正成立 -
别名 bug—— _coords at offset zero导致p.coords === p -
测试过滤器静默清零——打印 “N of N passed” 但实际什么都没跑 -
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 就绪的实测值。

