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 exceeded`max-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 恰好利用了这个机制来描述软件,但这并不意味着这个树必须是一个目录。再加上存储结果证明是一个不错的可重现状态机持久化层,这让我们的“包管理器”用来玩马里奥游戏也变得合理起来。🍄