Nix 语言惰性特性揭秘:竟能让包管理器玩《超级马里奥兄弟 3》! Nix 语言惰性特性大揭秘竟能让包管理器玩《超级马里奥兄弟 3》2026 年 8 月 5 日 * 阅读时长6 分钟Nix 语言有个令人惊讶的特性——“惰性”这对没用过惰性语言的人来说尤为新奇。正是这种惰性让 Nixpkgs 成为可能不过也带来了一定复杂性。要观察这种惰性最简单的办法就是明白只有被访问的属性才会被求值。就像下面这个例子$ nix eval --expr let pkgs { hello hi; broken throw never forced; }; in pkgs.hello hi更有意思的是属性集中可以存在“无限”递归。Nixpkgs 里就全是这种像无底洞一样的属性集看下面这些代码$ nix eval -f nixpkgs pkgs.hello --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2 $ nix eval -f nixpkgs pkgs.pkgs.pkgs.hello --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2 $ nix eval -f nixpkgs pkgs.python3Packages.pkgs.hello --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2每次得到的存储路径都一样。pkgs 包含自身其内部的每个包集也是如此是不是很让人头疼要是惰性让递归属性集能够终止那递归根本不需要有“尽头”看这个例子$ nix eval --expr let countdown n: { value n; next countdown (n 1); }; in (countdown 0).next.next.next.value 3这个属性集深度无限。对其进行三层索引就只需要进行三层求值而无限树的其余部分不会被构建因为没人去访问它们。所以属性路径就像在一棵惰性生成的树中漫步。这让人不禁思考如果把属性路径作为“某种东西”的输入会怎样呢有人决定将这个想法付诸实践把属性路径变成 《超级马里奥兄弟 3》 中的一系列按键操作。树中的每个节点代表游戏的一帧每个子节点则是一次按键操作会生成新的一帧。游戏状态本质上就是递归的。$ nix build .#level1.rightb.rightb.rightab.rightb $ file -L result result: PNG image data, 256 x 240, 8-bit/color RGB, non-interlaced.rightb 表示右 B在《超级马里奥兄弟 3》中是“向右跑”.rightab 表示跑并跳跃。输出的就是在真实硬件上玩这款游戏时按照这些顺序按键后看到的画面。这里的前缀 .#level1 是一组预设的按键序列能让你进入第 1 - 1 关的起始界面。在路径的任何位置加上 .play就能把整个游戏过程拼接成一个录像最酷的是这些画面中的每一帧在存储中都是一个独立的衍生对象。相关代码可以在 fzakaria/nes-nix 找到。这个项目具有通用性你可以将 ROM 作为 Flake 输入用于其他游戏。Flake 会根据属性路径计算衍生对象每次按键操作都是一个独立的衍生对象并且它会将“上一次按键操作的游戏存档状态”作为输入。每个衍生对象永远不会重新模拟其“祖先”帧。同时还会生成该帧的截图用于拼接视频序列。这样做的实际效果是存储变成了模拟器的游戏存档历史# 3 个衍生对象冷启动 $ nix build .#game.start4.wait2.right # 1 个衍生对象复用前缀 $ nix build .#game.start4.wait2.left # 1 个衍生对象全部复用 $ nix build .#game.start4.wait2.right.right在一百次按键操作的中途分支出去或者在末尾追加操作成本都只相当于一次按键操作。换个角度看依赖图就是输入序列所以我们可以让 Nix 告诉我们是哪些按键操作生成了某一帧$ nix-store --query --tree $(nix eval --raw .#game.start.wait4.start.drvPath) /nix/store/32n4ni0zg01b9c9v64x67am37rdmmr9y-nes-start.drv └───/nix/store/j5vy3385pgs9dzw0y7sdrdmn7xnrxgji-nes-wait4.drv └───/nix/store/w4zz5aqj5zxqhnialabdc7p3sy80v6dc-nes-start.drv └───/nix/store/k9wfz8w5157d0xdwaw1vvhf019dvw5s0-nes-boot.drv那么 .play 到底是怎么工作的呢其实它几乎没做什么。路径上的每一帧都已经作为一次按键操作的输出存在于存储中了所以录像过程不会进行任何模拟。它只是一个包含指向这些帧的符号链接的目录供 ffmpeg 处理。$ nix build .#level1.rightb.rightb.rightab.play $ ls -l result/frames | head -4 0000.png - /nix/store/3p2fxwngh…-nes-boot 0001.png - /nix/store/4ha88l0dk…-nes-start 0002.png - /nix/store/nh4zfsq6x…-nes-wait4 0003.png - /nix/store/ghbgn28f1…-nes-start我们能把这个输入序列的游戏输入想法发挥到什么程度呢默认情况下Nix 在大约 2400 次按键操作时就会出错$ nix eval --raw .#game.right.right.right…drvPath error: stack overflow; max-call-depth exceededmax-call-depth 默认值为 10000每次按键求值大约需要四层嵌套调用。这是为了防止无限递归而不是结构上的限制。我们可以把它提高到 1000 万这样就能处理 20000 次按键操作$ ulimit -s unlimited $ nix eval --raw --option max-call-depth 10000000 .#game.$( python3 -c print(..join([right]*20000))) .drvPath /nix/store/p4nm0a4p4k9bdjqsag1jj0baah9mj6hb-nes-right.drv在笔记本上对 20000 次按键操作进行求值大约需要 14 秒。成本与按键次数呈线性关系大约每次按键 0.7 毫秒。不过下一个瓶颈是机器内核在 21845 次按键操作时就会达到极限。属性路径是 argv 中的一个元素而 Linux 对参数列表的总大小和单个参数的大小都有限制。单个参数的限制是 131072 字节MAX_ARG_STRLEN每次按键操作大约占 6 个字节right.所以 21845 次按键操作就是能作为单个参数传递给 nix eval 的最大数量。解决办法是不再将操作序列作为参数传递而是从文件中读取输入序列$ nix build --impure --expr (builtins.getFlake (toString ./.)) .packages.x86_64-linux.game.sequenceFile ./runs/world1-1.txt这样生成的衍生对象与通过属性路径生成的是完全一样的所以存储在文件中的操作序列仍然可以共享相同的存储路径。到目前为止我们只是对 Nix 表达式进行了求值。现在还需要构建它。虽然 Nix 擅长并行构建衍生对象但这里的递归是尾递归因此只能串行进行。有人对不断增加的按键操作列表的构建时间进行了基准测试正如预期的那样成本与按键次数呈线性关系。启用替代器时每次按键操作的成本约为 1.27 秒禁用替代器时约为 0.28 秒。检查衍生对象是否在缓存中的往返成本明显高于模拟帧的成本。如果想避免这个成本可以设置 preferLocalBuild 或 allowSubstitutes。我们通常认为属性路径只是一个“名称”是指向现有事物目录的一个坐标。但惰性意味着它实际上是一个“程序”是求值器执行的一系列步骤根据需要生成所需的内容。Nixpkgs 恰好利用了这个机制来描述软件但这并不意味着这个树必须是一个目录。再加上存储结果证明是一个不错的可重现状态机持久化层这让我们的“包管理器”用来玩马里奥游戏也变得合理起来。