Roc 编译器 RcEffect 所有权表一致性校验:从 #10023 泄漏事故到双层级验证体系
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
导读
本文围绕 Roc 编译器(A fast, friendly, functional language)中一张极其关键但长期“无人监管”的表——RcEffect所有权行为表展开。这张表由LowLevel.rcEffect()为每个底层内置操作(builtin)逐项返回,是借用推断与 ARC(引用计数自动插入)的唯一决策依据;但表中每一行都是手写的“声称”,此前没有任何机制验证它与对应 Zig builtin 的真实行为是否一致。PR roc-lang/roc#10023(issue #9953)证明了一行错误的表项会以静默引用计数失衡的形式泄漏内存。读完本文,你将掌握RcEffect的字段语义与构造函数族、两类错误的判定方法、以及当前仓库中已落地的双层校验方案(comptime 结构校验 + 解释器驱动的逐操作一致性测试)的源码实现细节。
问题背景:一张无人校验的中央所有权表
所有权决策集中化的架构
Roc 编译器把每个底层内置操作的“所有权行为”集中放在一张表中——RcEffect。该表由LowLevel.rcEffect()按操作逐个返回(定义见 src/base/LowLevel.zig 起的 switch 分支),并同时被两个 ARC 相关消费点使用:
- 借用推断:
src/lir/arc_solve.zig中的result_shares_args逻辑把结果链接到出借方(lender)参数(src/lir/arc_solve.zig 附近的assign_low_level分支); - ARC 插入:
result_unique声明结果诞生于一次全新的独立分配(见 src/lir/arc_solve.zig 附近及 src/lir/arc_solve.zig 对result_unique的消费)。
这种架构本身是正确的:所有权只在编译器中被决策一次并携带传递,而不是在每个后端各自重新决策一遍,从而保证所有后端行为一致。
问题本质:手写声称无人检验
但表中每一行都是手写的、关于对应 Zig builtin 实际行为的“声称”,此前没有任何东西去检验它。RcEffect的标记位表达能力足够强,足以描述微妙的所有权状态:
- 一个外层全新、但内部共享某参数分配的返回值;
- 运行时唯一性探测操作在“原地修改”与“复制”两条路径上结果都唯一。
表达能力强的另一面是:它也足以把内置操作描述错。而错误的一行不是编译错误、不是 panic、也不是测试失败——它是生成代码里一次静默的引用计数失衡:要么泄漏(refcount 偏高),要么 use-after-free(refcount 偏低)。
#10023:一行错误表项的真实事故
PR roc-lang/roc#10023(issue #9953)就是证明:retainsSharingArgs构造函数族在设置result_shares_args的同时设置了result_unique = true,于是 ARC 既把结果链接到出借方参数,又统计了一次“全新诞生”——每一次成功的Json.parse都会向它的输入字符串泄漏一个引用。
这个 bug 的传播路径值得注意:
- bug 藏在表中(属于结构性合法、但行为不符的行,见下文“两类错误”);
- 由于所有权表是集中的,bug 通过所有后端一致地传播(集中化发挥了作用);
- 它逃过了类型检查、全部功能测试,最终只是被一个手写的 host-effects 测试偶然捕获——那个测试恰好在该路径上断言了存活分配计数。
也就是说,今天仓库中对 refcount 平衡的覆盖,只存在于“某个测试碰巧想到要断言它”的地方。
RcEffect 核心结构:字段、构造函数族与证明义务
结构定义与字段语义
RcEffect定义于 src/base/LowLevel.zig,是一个携带逐操作掩码与标记位的结构体,字段如下:
| 字段 | 类型 | 语义 |
|---|---|---|
may_allocate | bool | 该操作可能调用allocateWithRefcount诞生分配 |
may_retain_or_release | bool | 该操作可能改变某个分配的计数 |
may_runtime_uniqueness_check_args | u64 掩码 | 运行时检查这些参数是否唯一以选择原地修改还是复制 |
consume_args | u64 掩码 | 该操作消费这些参数的一个所有权单元 |
result_aliases_consumed_args | u64 掩码 | 从被消费参数取得的所有权单元存活于结果中 |
retain_args | u64 掩码 | 该操作为这些参数增加一个计数(典型场景:结果中存了指向它们的句柄) |
retain_result | bool | 结果别名指向保持存活的结构(元素、box payload、捕获、借用视图),ARC 需在操作后为其 retain |
result_borrows_args | u64 掩码 | 结果指向这些参数的 payload 但不拥有它们;ARC 跨结果的所有使用保持出借方存活 |
result_shares_args | u64 掩码 | 结果是全新的自有外层值,但其内部共享这些参数的分配(无缝切片、字节重解释) |
result_unique | bool | 返回时结果最外层分配计数为 1;与参数共享外层分配的结果永不unique,内部共享与最外层计数无关 |
证明义务(doc comment 明确写下的契约)
src/base/LowLevel.zig 的文档注释现在精确陈述了每一行的“证明义务”:
- 行内省略
may_allocate即承诺该操作从不诞生分配; - 行内省略
may_retain_or_release即承诺该操作能触达的每个计数都不被改动; may_runtime_uniqueness_check_args命名的位置必须是该操作消费的位置,且原地路径在参数真的唯一时必须健全(ARC 可能证明检查冗余并直接传入unique_args);consume_args命名的每个参数,操作必须在所有路径上 release 或移入结果;retain_args命名的参数操作必须自己 retain——ARC 不会代为发出 retain,声明却不 retain 会让存储的句柄计数不足;result_borrows_args与result_aliases_consumed_args不能重叠:结果不能从一个已被消费的参数借用;result_unique意味着返回时最外层分配计数为 1;运行时唯一性检查操作在两条路径上都满足(原地保留原本计数为 1 的分配,复制路径返回全新分配)。
写新行应当是“对这些义务逐条兑现”,而不是模式匹配相邻行。
构造函数族
行通常不直接写裸字段,而是通过构造函数族生成(src/base/LowLevel.zig),常用族包括:
none():无任何所有权效应;allocates()/allocatesRetainingArgs(mask)/allocatesConsumingArgs(mask):纯分配,或分配并保留/消费参数;retainsOrReleases()/retainsResult()/retainsResultBorrowingArgs(mask):调整计数、保留结果、保留结果并借用参数;runtimeUniqueness(mask):运行时唯一性检查族,含consume_args、result_aliases_consumed_args、result_unique全套;runtimeUniquenessMaybeSharedResult(mask):与上者唯一区别是不声明result_unique(trim 族返回参数分配的切片);runtimeUniquenessRetainingArgs(runtime_mask, retain_mask):检查的同时还保留某些参数;retainsSharingArgs(mask):#10023涉及的族,声明保留参数且结果内部共享参数分配(如str_drop_prefix返回参数分配的切片);retainsOrReleasesSharingArgs(mask)/allocatesAndRetainsOrReleasesSharingArgs(mask):共享 + 计数调整/分配;consumesArgsRetainingArgs/consumesArgsReturningConsumedArgsRetainingArgs/consumesArgsReturningConsumedArgs:消费参数的组合。
表中代表性行速览
rcEffect()的 switch(src/base/LowLevel.zig)是数百行手写行的集合,例如:
str_concat→runtimeUniqueness(argMask(&.{0})):运行时唯一性检查第 0 参数;str_trim/str_trim_start/str_trim_end→runtimeUniquenessMaybeSharedResult:共享字符串的 trim 返回参数分配的无缝切片,外层计数继承参数;str_drop_prefix/str_drop_suffix/str_substring_unsafe→retainsSharingArgs:保留参数、结果内部共享其分配;list_sublist_borrowed→retainsResultBorrowingArgs;box_box→allocatesRetainingArgs;box_unbox_borrowed→retainsResultBorrowingArgs;box_prepare_update→runtimeUniqueness;ptr_store→consumesArgsRetainingArgs;ptr_alloca等 TRMC 引入的指针操作 →none()。
两类错误:结构性无效与非一致性
文档明确指出错误分两类,对应两层不同的验证手段:
- 结构性无效行(structurally invalid rows):任何内置操作都不可能真实拥有的组合,或掩码引用了该操作根本不存在的参数位置。这类错误仅凭表本身即可在 comptime 检查。
- 非一致性行(non-conforming rows):结构上合法、但声称与实际
src/builtins/*.zig的实现不符(#10023正是此类)。这类错误只能通过运行内置操作并观察引用计数来发现。
当前仓库已按此设计落地了两层机制(详见下文),其模块职责划分在 src/base/rc_effect_rules.zig 的头部注释中有明确说明。
第一层:comptime 结构校验(rc_effect_rules.zig)
规则集:每条规则对应一个字段
src/base/rc_effect_rules.zig用“字段之间的蕴含式”表达规则,规则只拒绝行、绝不修补或重解释行,13 条规则覆盖RcEffect的每个字段:
| 规则 | 含义 |
|---|---|
retain_args_without_rc_flag | retain_args命名了参数却没设may_retain_or_release |
consume_args_without_rc_flag | consume_args命名了参数却没设may_retain_or_release |
retain_result_without_rc_flag | retain_result为 true 却没设may_retain_or_release |
shares_args_without_effect | result_shares_args非零却既无may_allocate也无may_retain_or_release |
retain_and_consume_same_arg | 同一参数既被保留又被消费,两者抵消,实为借用却两者都未命名 |
alias_of_unconsumed_arg | result_aliases_consumed_args命名了未被消费的参数 |
runtime_check_of_unconsumed_arg | 运行时唯一性检查命中了未消费的参数(原地路径会修改不拥有的值) |
runtime_check_without_allocate | 有运行时唯一性检查却无may_allocate(复制路径必然分配) |
borrow_of_consumed_arg | result_borrows_args命中了被消费参数(出借方单元已消失) |
borrow_and_share_same_arg | 同一参数既被命名为借用出借方又被命名为内部共享方 |
borrow_without_result_retain | result_borrows_args命名了出借方却无retain_result(借用被静默丢弃) |
unique_result_without_source | result_unique为 true 但既不分配(may_allocate)也不从被消费参数取单元 |
unique_result_that_is_retained | retain_result与result_unique同时为 true |
violation(effect)(src/base/rc_effect_rules.zig)返回一行打破的第一条规则;assertRowConforms(src/base/rc_effect_rules.zig)在 comptime 触发@compileError,报错消息命名操作与规则,例如:
RcEffect row for low-level op 'xxx' is invalid: <描述> [rule: <规则名>]整表校验与掩码越界检查
assertTableConforms()(src/base/rc_effect_rules.zig)在 comptime 遍历std.enums.values(LowLevel)的全部操作,逐行调用assertRowConforms,并额外拒绝“representation-only 操作数却带分配效应”的行;它由 src/base/mod.zig 调用,因此任何编译出的编译器都不可能携带自相矛盾的所有权表。
掩码与参数数量(arity)的核对被刻意不放在这张表旁:操作参数个数并未记录在表旁边,再手写一张 arity 表只会增加一条未经检验的声称。取而代之的是maskExceedsArgCount(src/base/rc_effect_rules.zig):一个掩码位引用超过真实参数个数的位置时,ARC 会把它当作“无此位置”,行想描述的所有权被静默丢弃。它在两个接缝处被调用:ARC 借用认证器对每个 debug 构建的 low-level 语句调用,以及一致性观察器对解释器执行的每个操作调用。
否定编译探针:让“错误行是编译错误”本身被测试
src/base/rc_effect_rejected_row_probe.zig 是一个故意不编译的文件:它把#10023中删除的那行(retainsSharingArgs(1)再置result_unique = true)重新引入并调用assertRowConforms("str_drop_prefix", ...),构建步骤run-test-rc-effect-row-rejected要求编译失败且报错携带规则标签。这样“一行错误行 = 编译错误”这一保证本身就被测试,而不是被假设。
结构层的单元测试
rc_effect_rules.zig底部附带若干测试(src/base/rc_effect_rules.zig):
- “表中每一行结构有效”的全表扫描;
- “
#10023的行被拒绝”:retainsSharingArgs(1)加result_unique = true命中unique_result_without_source,而干净的retainsSharingArgs(1)无违规; - “每条规则都能被某行触发且每条规则都可达”:13 个精心构造的反例分别命中 13 条规则,并用
EnumSet断言所有规则都被覆盖; - 掩码越界:
maskExceedsArgCount(consumesArgsRetainingArgs(0, 0b100), 2)报告位置 2,arity 3 时无违规。
第二层:逐操作一致性测试(rc_conformance.zig)
为什么结构校验不够
结构校验无法发现#10023这类错误:一行可以结构合法却与实际行为不符。只有运行内置操作才能分辨。因此第二层机制让解释器对执行的每条 low-level 语句报告它实际对可触达引用计数做了什么——哪些分配诞生、哪些计数被移动、结果中哪些分配来自参数——再拿这份报告与行比对(src/eval/rc_conformance.zig)。
检查方向:只报“行未解释的行为”
一致性规则的方向是单向的,全部读作“观察到的行为行未解释”,绝不读作“声明了的行为这次没发生”:
- 一行是对操作可能做什么的声称,单次执行只走一条路径;
- 写时复制操作拿到唯一输入时从不走复制路径,因此“未行使的声称”不是发现;
- 而“未声称的效果”永远是发现——那是 ARC 没有为之规划的计数往来。
因此两个效应必须在两种状态下都被驱动——唯一输入与共享输入——即result_unique与写时复制。逐操作扫描(src/eval/test/rc_conformance_tests.zig)正是负责同时提供这两种状态的。
谁为计数负责
规则区分“操作自己加的计数”与“ARC 加的计数”,因为行决定了哪个是哪个:
retain_args意味着ARC 发出 retain(src/lir/arc.zig的retainMaskedArgs),所以内置操作不得再为参数计数——操作和 ARC 都计数会泄漏;- 无
retain_args的result_shares_args相反:ARC 什么都不发,内置操作必须自己为结果中存储的句柄计数——两边都不计数会留下悬垂句柄。
观察机制与规则集
观察仅在 debug 构建开启(enabled = builtin.mode == .Debug and builtin.target.os.tag != .freestanding,src/eval/rc_conformance.zig):release 构建不携带观察器、不分支、解释器语句循环中无钩子。观察器通过DebugRefcountTracker的引用计数事件日志工作,复用单条 scratch 观察记录,并用open_statements防止重入导致判断半填充的观察。
一致性规则共 9 条(src/eval/rc_conformance.zig):
| 规则 | 触发条件 |
|---|---|
allocated_without_may_allocate | 操作分配了,行未设may_allocate |
adjusted_counts_without_may_retain_or_release | 操作改了计数,行未设may_retain_or_release |
argument_counted_by_op_and_arc | 操作给retain_args已让 ARC 计数的参数又加了一次计数(句柄高一格) |
argument_counted_without_claim | 操作给既不在retain_args也不在result_shares_args中的参数计数 |
argument_released_without_consume | 操作释放了行未在consume_args中命名的参数(ARC 会再释放一次) |
shared_argument_not_counted_by_op | 结果在result_shares_args下持有参数分配(ARC 不计数),操作自己也没计数 |
result_outlives_uniqueness_claim | result_unique声称最外层计数为 1,实际不是 |
result_holds_unnamed_argument_allocation | 结果持有一个行未链接的参数分配 |
mask_names_missing_argument | 掩码引用了操作未被给予的参数位置 |
计数读取的安全性也做了处理:wasFreedInWindow(src/eval/rc_conformance.zig)在操作后读取任何计数前,先检查事件窗口内该 rc 地址是否被 free/realloc,避免 use-after-free。静态数据(REFCOUNT_STATIC_DATA常量计数)不可观察,被isObservable排除。
覆盖率门禁:新内置不能带着未验证的行发布
coverageGaps(src/eval/rc_conformance.zig)找出行非none()但扫描从未执行过、且不在豁免表的操作——这是未验证的行。豁免表(Exemption)要求为每个“解释型程序不可达”的操作注明理由;staleExemptions还会检出“其实已被覆盖但仍列在豁免表中”的过期豁免。相关单元测试覆盖了这三种情形(src/eval/rc_conformance.zig)。
关键测试:重新引入 #10023 行会被抓住
rc_conformance.zig中的一个测试(src/eval/rc_conformance.zig)直接构造#10023形状的观察:
- 操作
str_drop_prefix,参数外层分配计数 3(字符串被别处持有),结果外层是同一分配; - 用当前行判断 → 0 发现(共享内部、无诞生,行正确解释);
- 用
overrideRow把行改回#10023形态(retainsSharingArgs(1)+result_unique = true)→ 1 个发现,规则为result_outlives_uniqueness_claim,操作名str_drop_prefix。
另有测试验证“操作在 ARC 的 retain 之上自己再加计数”命中argument_counted_by_op_and_arc、“释放行未消费的参数”命中argument_released_without_consume,以及静态数据不参与观察。
逐操作扫描:两种唯一性状态都要驱动
src/eval/test/rc_conformance_tests.zig 中的扫描用解释器运行 Roc 程序,观察器全程在场,每个被执行的操作都会按其行被评判;程序结束后,任何“行非 trivial 却没有任何 case 驱动过”的操作都会使测试失败。关键设计在文件头注释中写得很清楚:
- 唯一输入:只使用一次的值是唯一的,操作走原地路径,错误的
result_unique声称看上去反而成立; - 共享输入:同一个值同时被一个列表持有,计数大于 1,声称必须自己站住脚——这个状态才是抓
#10023形状的地方。
因此 case 会显式构造共享值:shared = Str.concat(...)后放入holder = [shared, shared],再对同一字符串分别做 trim、lower/upper、reserve 等操作。字符串被刻意构造得足够长,避免落入小字符串表示——静态数据计数恒定,任何操作都无法移动它(src/eval/test/rc_conformance_tests.zig)。另有专门 case 覆盖“把空值拼接到共享值上”——这是写时复制操作可以不经复制、不经检查直接交还参数的路径(src/eval/test/rc_conformance_tests.zig)。Case结构还支持inline_wrappers与expected_live_allocations等控制项。
项目成功标准与评估方式
按文档,以下标准全部满足才算完成:
- comptime 校验器存在,覆盖
RcEffect每个字段至少一条蕴含规则,且测试中添加的故意错误行编译失败并报出操作名与违反的规则; - 重新引入精确的
#10023行(retainsSharingArgs上result_unique = true,外层为参数分配)被抓住:可由 comptime 层表达则在该层拒绝,无论如何可由一致性测试证明失败; - 一致性测试随
zig build test(debug)运行,覆盖所有rcEffect()非none()的操作,任何无覆盖的新操作都响亮失败——通过测试构建中添加 dummy 操作并观察失败来验证; #9953回归(Json.parse 输入引用计数平衡)作为固定的端到端测试保留;- release 构建零成本:校验器是 comptime,测试仅存在于测试构建,
rcEffect()本身不被改动。
评估的两个维度:
- 正确性理想:错误的所有权行无法静默发布——结构性错误是编译错误,非一致性错误是命名操作与不匹配增量的失败 debug 测试。表保持唯一事实来源,项目增加的是执行机制而非第二个事实来源。
- 性能理想:运行时零开销,纯测试/comptime 机制;扫描通过解释器用小的值驱动操作,保持总测试时间合理。
测试计划与相关工程
文档规划的测试清单包括:
- 结构性无效行的否定编译检查(Zig
error测试或构建步骤探针,与仓库其他 comptime 契约的测试方式一致); - 逐操作一致性扫描本身;
- 固定回归:
#9953(str 共享),加上每个构造函数族至少一个 list 操作与一个 str 操作,使每个辅助函数的语义都被行使。
相关项目silent-drift-guards(已落地,文档移除)是同一“对镜像事实施加执行机制”哲学的另一个实例,本文档是其在所有权表上的落地。
结语:把“手写声称”变成“可验证契约”
RcEffect表的集中化架构让所有权决策在所有后端保持一致,但也把所有错误的后果放大为“所有后端一致地错”。仓库给出的答案是两层执行机制:comptime 结构规则把自相矛盾的行变成编译错误(src/base/rc_effect_rules.zig),debug-only 的逐操作一致性扫描把行为不符的行变成命名操作与增量的测试失败(src/eval/rc_conformance.zig 与 src/eval/test/rc_conformance_tests.zig),否定编译探针(src/base/rc_effect_rejected_row_probe.zig)则保证“错误行 = 编译错误”这一承诺本身被测试。#10023式的静默泄漏不再有容身之处,而 release 构建依然零成本。
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考