Skip to content

【Zig 日报】You Must Fix Your Asserts #337

Description

@jiacai2050

恐惧是思维的杀手……也是代码库的杀手。

一个讨论平台上的用户写道:我想“在生产环境中禁用断言”是一种很常见的技术,对吧?

据我所知,这或许是一个正确的陈述,但我认为这是一种无可救药的糟糕做法。首先让我们回顾一下背景,因为这次讨论的起因是 std.debug.assert 在 Zig 中的工作方式。

关于断言

断言(Assert)是一行向程序引入新事实的代码,例如“该参数绝不为 null”或“该整数绝不为偶数”,它们看起来像这样:

assert(my_arg != null);
assert(my_num % 2 != 0);

如果你的类型系统可以用来强制执行这些约束中的某一项,那么你可能更倾向于使用语言本身提供的功能,而不是断言。例如,在 Zig 中,普通指针(如 *Foo)永远不会为 null,而可选指针(?*Foo)则可以,但它们也强制要求你在访问值之前进行检查(Zig 为此有专门的惯用法)。

断言可以用来明确表述代码中的前置/后置条件和不变量。这很有用,因为如果你选择了好的断言,它们比单元测试更能有效地保护你免受编程错误的影响,特别是如果你对代码进行了模糊测试(fuzzing)。

一个断言胜过一千个单元测试(如果你进行模糊测试,效果还要高出几个数量级),但这将是下一篇文章的主题。

Zig 中的断言

Zig 中的断言基于 unreachable,这是一种标记无效代码路径的语言特性。

const Op = enum { a, b, c };

fn execute(orig_op: Op) void {
  var op = orig_op;

  if (op == .a) {
    op = .b; // 将 .a 转为 .b
  }

  const op_cost = switch(op) {
    .a => unreachable, // 不可能到达
    .b => 50,
    .c => 100,
  };

  // 结束操作
}

在这个例子中,.a 情况总是被 if 语句修改为 .b 情况,这意味着一旦我们进入 switch,就不可能进入 .a 情况。unreachable 的另一个简洁特性是,它既可以用作语句,也可以用于任何预期表达式(任何类型)的地方。在上面的例子中,我们正在计算操作的“成本”,而 .a 根本没有相关的成本。多亏了 unreachable,我们甚至不需要为一个永远不会发生的情况想出一个尴尬的占位符值。

Zig 的标准库 assert 函数也利用了 unreachable,其实现如下:

pub fn assert(ok: bool) void {
  if (!ok) unreachable; // 断言失败
}

构建模式

Zig 有多种构建模式:

  • Debug
  • ReleaseSafe
  • ReleaseFast
  • ReleaseSmall

这并不是一个全局性的设置:每个依赖项都可以以不同的模式构建,你甚至可以使用 @setRuntimeSafety 在单个函数内的块级别进行精细控制。

当断言被触发时,会发生“非法行为”。已检查模式(Debug, ReleaseSafe, @setRuntimeSafety(true))通过 panic 保证程序崩溃;而未检查模式(ReleaseFast, ReleaseSmall, @setRuntimeSafety(false))则会导致“未检查的非法行为”。

简而言之,未检查的非法行为意味着程序将出现不可预知的错误。在这个特定的例子中,由于机器码生成方式的原因,当前情况是那个为 op_cost 赋值的 switch 语句会“落空”到其他分支之一。但这并非绝对保证,不同版本的编译器可能会生成导致不同错误行为的机器码。

这是一个 godbolt 链接,你可以亲自查看。

这是一个锋利的工具,但它驱动了许多强大的优化,例如在我们的例子中,实现 switch 语句第一个分支所需的机器码在最终的可执行文件中实际上被删除了。

这是另一个 godbolt 链接,你可以看到断言在 ReleaseSafe 和 ReleaseFast 中如何与后续的 switch 语句交互(注意在 ReleaseFast 中,函数跳过了所有比较直接返回 true)。

这就是视频游戏和其他实时媒体应用大量依赖的东西。并非每个断言都会带来性能提升,但优化编译器有能力传播 unreachable 信息,从而产生程序员可能无法轻易预见的非局部优化。

Zig 断言不是宏

接触 Zig 时,令 C/C++ 开发者惊讶的一点是 std.debug.assert 不是一个宏(顺便说一下,Zig 没有宏)。

在那些语言中,禁用断言的常见方式实际上相当于把每个 assert 调用都注释掉了,包括传给宏的任何表达式。这意味着在 C/C++ 中,你绝不能把带有副作用的表达式放入 assert 调用中,因为当断言被禁用时,整个操作都会被注释掉。

在 Zig 中,这不是问题,因为 std.debug.assert 是一个普通函数,这意味着无论函数内部逻辑如何,它的参数在调用前都会被求值。结果就是你可以放心地在断言中放入带有副作用的表达式:

// 断言 remove 操作不是无操作(noop):
assert(my_map.remove("expected-to-exist"));

反过来说,这也意味着如果你有一个依赖于执行复杂计算的断言,那么在未检查模式下构建时,这些计算不一定会省略。在这种情况下,你需要小心使用 comptime if 来保护代码:

const builtin = @import("builtin");

if (builtin.mode == .Debug) {
  var condition = ...;
  // 计算条件所需的任何簿记工作
  assert(condition == .ok);
}

如果你习惯了 C/C++ 语义,这种行为可能会让你惊讶,但同时,如果你是一个老练的开发者,你应该能够最终理解函数调用的语义。这是一个摆脱宏带来的创伤后应激障碍(PTSD)并拥抱简单性的好机会,特别是因为在 Zig 中你通常不会禁用断言——这引出了我这篇文章的重点。

在生产环境中禁用断言

总结一下,对于断言,你可以做三件事:

  1. 将其保留为运行时检查,在触发时让进程 panic。
  2. 将其用于性能优化,代价是如果断言错误则会导致程序行为异常。
  3. 完全禁用它们。std.debug.assert 默认不支持这一点,但你可以实现自己的版本,内部检查构建时标志,从而近似 C/C++ 的行为。

正如我在开头提到的,我认为 (3) 是一个无可救药的糟糕选择。想要禁用断言的原因是什么?基本上是上述两种情况的并集的否定:

  • 你不想保留运行时检查,要么是因为性能成本,要么是因为你不希望应用程序崩溃。
  • 你不想将断言用于优化,因为你不相信它们是正确的,因此害怕程序出现不可预知的错误。

正如 matklad 最近在相关讨论中提醒我的,在某些情况下,你可能确实有合法的工程理由去避免崩溃,但在我看来,对于通用软件而言,这是一个非常糟糕的默认选择。

禁用断言意味着当那些所谓“不可能”的条件发生时,程序会继续运行而不是崩溃。于是,你拥有了一个基于错误假设继续运行的程序,这本身就是一种错误行为,即便它不是上述那种“未检查非法行为”(UIB)。

天真的内存安全倡导者可能会辩称 UIB(或 C 语言中所谓的未定义行为)无限糟糕,但我不同意。UIB 的危险之处在于它能将程序变成“怪异机器”(weird machine),但在足够复杂的软件中,你并不一定需要 UIB 就能扭曲程序。在运行时使断言失效,定义上就是背离了规范,这很容易导致程序执行它从未打算执行的操作。这不仅仅是技术上的吹毛求疵:SQL 注入就是一个具体且广泛存在的、不需要 UIB 的“怪异机器级”错误行为示例。

如果程序行为异常的代价大到让你无法承担风险,那么你应该保持断言开启;如果性能重要到你愿意冒险,那么你实际上是在牺牲性能,同时还以为自己比实际更安全。

但系统性地禁用生产环境中的所有断言还有一个更重大的反作用。

自欺欺人

总结一下,这个问题的核心在于断言可能出错的可能性及其后果。如果我们能保证所有断言永远正确,那么为了性能优化而使用它们就不会有争议。同样,如果我们能保证测试能捕捉到所有错误的断言,那么生产环境也可以安全地进行优化。

你读这篇文章的原因是,我们知道自己可能会写出错误的断言,而且不能保证测试一定能发现它们——这并非假设。有很多项目的断言可以通过测试,但在生产环境中会触发。

如果你处于这种情况,那么尽早发现代码中所有错误的断言最符合你的利益,否则你会不断写出更多错误地依赖这些错误断言的代码,从而使问题恶化。

想象一下,一个大型代码库中某处有这样的代码:

fn processThing(thing: Thing) void {
   // 该函数必须在已启动的 thing 上调用
   assert(thing.is_started);
   // ...
}

随着时间推移,断言似乎是成立的,因为测试中从未触发,你从未发现这个断言实际上是可以被证伪的,因为你在生产环境中禁用了断言。

后来有人添加了更多代码:

fn processThing(thing: Thing) void {
   assert(thing.is_started);
   // ...
   // 既然 thing 已经启动,在 baz 之前不需要 foo
   assert(thing.is_fooed);
   thing.baz(qux);
}

假设第二个开发者写了一个正确的断言,但由于测试中第一个断言从不触发,第二个也从不触发。讽刺的是,这可能正是引入可利用漏洞的时刻,而你却因为禁用了生产环境的断言而浑然不觉。

编写正确的代码本来就很难,当代码中充斥着实际上是在“煤气灯操纵”(gaslighting)你的断言时,这几乎是不可能完成的任务。

结论

根据上下文,不同的程序会有不同的优先级。对于某些程序,优先考虑性能而非降低错误风险是完全正确的选择,在这种情况下,将断言转化为优化机会是合理的。

但在生产环境中常规性地禁用断言,既不如保持断言开启,也不如专注于性能优化。我认为人们不加批判地进行这种实践,同时又对 ReleaseFast 极度挑剔,这是极其荒谬的。

我正在从事两个严肃的项目:
一个是 Zine,一个静态网站生成器。我没有定义它的威胁模型,因为目前它主要用于构建个人博客,我喜欢看到它的运行速度比 Hugo 快一个数量级,所以我发布 ReleaseFast 版本。
另一个是 Awebo,一个处于预 Alpha 阶段、可自托管的 Discord 替代品。我知道它会处理个人信息,且旨在暴露给互联网。届时,我会发布 ReleaseSafe 版本,但即便如此,我仍会以 ReleaseFast 构建某些关键依赖(如 FFmpeg, Xiph Opus, SQLite 等),因为对它们而言,性能提升在优先级上绝对高于进一步降低程序行为异常的风险。

另外两个有趣的例子:TigerBeetle(金融数据库)始终保持断言开启,而 Ghostty(终端模拟器)为 macOS 发行 ReleaseFast 版本,并建议下游用户(如 Linux 发行版维护者)也这样做。有趣的是,目前为止 Ghostty 公布的仅有的两个相对严重的 CVE 均与任意命令执行有关,且是在没有内存损坏的情况下实现的,尽管 Ghostty 是以 ReleaseFast 发布的。

谈到“在生产环境中禁用断言”这种普遍做法,我敢说外面有大量项目中的错误断言在代码库中滋生蔓延,这反过来既增加了对 UIB 的偏执,又让开发者下意识地太害怕开启断言去面对其行为的后果。

现实情况是,你无法绕过它:你必须修复你那该死的断言,并追求程序的正确性,而不是仅仅追求其中的一部分。


更新(2026-06-01): 在讨论这篇文章后,有人指出我本可以给“在生产中保留断言的其他变体”留出更多空间。为简洁起见,我在此粘贴我参与讨论时所写消息的引用。虽然缺少一些上下文,但要点都在那里:

“你添加断言的原因有很多,但无论如何,它们对编译器来说具有固有的公理性(即如果它们不是显而易见的冗余,那么它们就是编译器无法自行推导出的事实),因此该事实可以以多种方式被利用:

  1. 更好的可调试性;
  2. 运行时的额外检查以降低程序错误行为的几率;
  3. 编译器可用于优化的额外事实;

对于第二种情况,Zig 选择了崩溃作为默认机制,但如果你真的想,你可以折腾 panic 处理程序,实现类似于 Rust/Go 那样的可恢复 panic。

我这辈子从来没有为了获得性能提升而特意写过断言,如果以后写了,那将是一个涉及测量过程的特殊处理。我的期望是,在 ReleaseFast 构建时,编译器会尽力利用我提供的额外公理,就像它对待语言级断言(边界检查、溢出等)一样。

事实是,你可以通过切换构建模式(并在必要时适当整理你的断言,例如将昂贵的断言保留在 debug 模式)同时获得所有这些好处。

第二种情况的另一种变体是让断言打印日志行而不是让程序崩溃。当崩溃比继续运行更糟糕、且 panic 恢复很复杂且容易出错时,这样做是有意义的,而且人类会有机会接触这些日志并最终修复可证伪的断言。这是 Zig 默认不提供但实现起来非常简单的事情。

综上所述,在我看来,在发布构建中关闭断言是人们能做的最愚蠢的事情,仅次于从代码库中删除断言或干脆从来不写。”

将断言转化为日志消息听起来很像禁用它们,但两者并不一样,因为彻底禁用意味着没有任何东西能够通知你程序偏离了规范。

You Must Fix Your Asserts | Loris Cro's Blog

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions