一个随机构建图
摘要
本文探讨了Nix中的动态推导如何将其构建系统从应用式转向单子式,从而允许构建图在构建过程中定义,而不是预先定义。
暂无内容
查看缓存全文
缓存时间: 2026/09/22 12:51
# 一个掷骰子的构建图
来源:https://fzakaria.com/2026/09/20/a-build-graph-that-rolls-dice
我们即将迎来 NixCon 2026 (https://2026.nixcon.org/)。又是一年,很遗憾我将缺席。我看了看演讲列表,发现有不少关于动态推导的报告,再加上最近关于该主题的其他帖子,例如 cargo-dyndrv (https://blog.obsidian.systems/cargo-dyndrv-a-beginning/),这让我开始思考重新审视这个话题,因为我的早期帖子 (https://fzakaria.com/2025/03/10/an-early-look-at-nix-dynamic-derivations) (https://fzakaria.com/2025/03/11/nix-dynamic-derivations-a-practical-application) 涉及过该主题。我想更好地理解动态推导的含义,以及它们如何改变构建图。最初,我关注的是它如何能取代 *lang2nix* 风格的构建图生成,但我意识到其影响远不止于此。构建图无需预先知晓。它可以在构建过程中定义。
🧐 我是什么意思?
传统上,Nix 要求你请求它构建任何东西,它都会提前告诉你它将采取的确切步骤,这些步骤构成了构建图的“推导”。
```
$ nix-store -q --requisites \
$(nix-instantiate '' -A hello) | grep -c '\.drv$'
196
```
这是 Nix 的一个重要特性。它使得无需运行构建就能理解将*会*构建什么,从而对构建进行推理成为可能。许多 Nix 工具通过 `nix build --dry-run` 依赖这一特性。例如,nix-diff (https://github.com/Gabriella439/nix-diff) 可以告诉你两个闭包为何不同**而无需构建其中任何一个**。
为什么动态推导会改变这一点?
## § (https://fzakaria.com/2026/09/20/a-build-graph-that-rolls-dice#applicative-monadic-bind) 应用函子、单子、绑定
在函数式语言中,很容易变得抽象,使用“花哨”的词汇如“应用函子”和“单子”来描述不同计算类型之间的区别。这种区别很微妙,但意义深远。
Nix 传统上是一个“应用函子”构建系统。动态推导使其成为了一个“单子”构建系统。
区别在于:在应用函子构建系统中,整个构建图是预先知道的;而在单子构建系统中,构建图可以在构建过程中定义。¹
¹ 论文《Build Systems à la Carte》(https://www.microsoft.com/en-us/research/uploads/prod/2018/03/build-systems.pdf) 是该主题的事实标准读物。
思考这种差异的一种方式是看定义它们的两个操作的类型签名:
```
<*> apply :: f (a -> b) -> f a -> f b
>>= bind :: m a -> (a -> m b) -> m b
```
`apply`(应用函子)接受 `f a`,一个预先知道的值,并返回结果 `f b`。你可以在运行前看到整个图。
`bind`(单子)接受 `(a -> m b)`,一个*函数*,来返回 `m b`。你无法在运行前看到整个图。
**应用函子**可以理解为在离开家之前写下购物清单。你阅读食谱,写下每一种配料,开车去商店一次。清单取决于食谱,仅此而已。
**单子**则像是一个食谱,其中一个步骤写着*尝一尝,如果太咸就去买个土豆*。你无法预先写出那份购物清单。是否要买土豆取决于咸淡程度,而在你开始烹饪之前,咸淡程度是不存在的。
Nix 曾经仅是前者,而动态推导加入了后者。
## § (https://fzakaria.com/2026/09/20/a-build-graph-that-rolls-dice#actually-nix-always-had-bind) 实际上,Nix 一直都有绑定
(https://fzakaria.com/assets/images/always_has_been_bind_meme.png)
好吧,我想我应该说“Nix 现在在调度器中是单子的”。Nix 在求值器中一直都有绑定。我们多年来一直在进行单子构建。我们称之为从推导导入 (import from derivation) (https://fzakaria.com/2020/10/20/nix-parallelism-import-from-derivation)。
```nix
let
inner = pkgs.runCommand "inner" {} "sleep 10; echo hi > $out";
in
pkgs.runCommand "outer" {} "echo ${builtins.readFile inner} > $out"
```
对推导输出使用 `builtins.readFile` 是一个**绑定**,其含义完全如上所述:下一步构建什么取决于一个尚不存在的值。Nix 求值器在没有该值的情况下无法生成构建图,而获取它的唯一方法就是停止求值并运行一个构建器。不幸的是,这有很多陷阱,例如导致 `nix-instantiate` 耗时十秒 (https://fzakaria.com/2025/03/10/an-early-look-at-nix-dynamic-derivations),也是为什么 nixpkgs *完全禁止*这种技术。
如果说*从推导导入*是求值器中的绑定,那么动态推导现在在调度器中增加了绑定。区别在于调度器可以并行运行构建器、将它们发送到远程机器,并且可以从缓存中替换它们的结果。求值器无法做到这些。
动态推导并没有增加绑定。绑定一直都在。改变的是哪个层执行它。
🤓
## § (https://fzakaria.com/2026/09/20/a-build-graph-that-rolls-dice#lets-roll-some-dice) 让我们掷一些骰子
许多动态推导的例子都集中在通过 *lang2nix* 工具生成构建图,但我想更一般地探讨动态推导的含义。在真正的*单子*意义上,构建图可以在构建过程中定义。构建图的下一步可以依赖于前一步的结果。
这里是一个非常简单的例子,`chain.nix` 和 `step.sh`,它掷一个骰子,然后要么停止,要么通过向构建图添加一个新的推导步骤来继续链条。链条的深度是随机的,构建结果是我们达到的深度。
当你运行 `nix-instantiate` 时,只有最左边的方框存在。它右边的所有内容都是由构建器在构建进行中时写入的。
(https://fzakaria.com/assets/graphviz/219c1cc35d20e5b6.svg)
**chain.nix**
```nix
{ depth ? 1
, pkgs ? import <nixpkgs> { }
, bash ? pkgs.bash
, coreutils ? pkgs.coreutils
, nix ? pkgs.nixVersions.latest
, chain ? ./chain.nix
, step ? ./step.sh
}:
let
roller = derivation {
name = "step-${toString depth}.drv";
system = builtins.currentSystem;
builder = "${bash}/bin/bash";
args = [ "-e" "${step}" ];
DEPTH = toString depth;
BASHPKG = "${bash}";
COREUTILS = "${coreutils}";
NIXPKG = "${nix}";
CHAIN = "${chain}";
STEP = "${step}";
PATH = "${coreutils}/bin:${nix}/bin";
# 构建器实例化推导,因此它需要一个存储来通信。
requiredSystemFeatures = [ "recursive-nix" ];
# 此推导的输出是一个 .drv 文件。这正是它成为动态的原因。
__contentAddressed = true;
outputHashMode = "text";
outputHashAlgo = "sha256";
};
in
builtins.outputOf roller.outPath "out"
```
**step.sh**
```bash
set -eu
export NIX_CONFIG='experimental-features = nix-command ca-derivations dynamic-derivations'
roll=$(( $(od -An -N1 -tu1 < /dev/urandom) % 6 + 1 ))
echo "depth $DEPTH rolled a $roll"
if [ "$roll" -eq 6 ]; then
# 六点。停止。答案是我们达到的深度。
cat > answer.nix <<EOF
{ pkgs ? import <nixpkgs> { } }:
let
roller = derivation {
name = "answer.drv";
system = builtins.currentSystem;
builder = "\${pkgs.bash}/bin/bash";
args = [ "-c" "echo $DEPTH > \$out" ];
__contentAddressed = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
};
in
builtins.outputOf roller.outPath "out"
EOF
nix-build answer.nix
else
# 不是六点。我的答案就是下一级返回的答案。
cat > answer.nix <<EOF
{ pkgs ? import <nixpkgs> { } }:
let
roller = derivation {
name = "answer.drv";
system = builtins.currentSystem;
builder = "\${pkgs.bash}/bin/bash";
args = [ "-c" "nix-build ${chain} --arg depth ${toString ((depth + 1))} > \$out" ];
__contentAddressed = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
};
in
builtins.outputOf roller.outPath "out"
EOF
nix-build answer.nix
fi
```
相似文章
后现代构建系统
一篇博客文章,探讨理想中的'后现代'构建系统的设计,该系统优先考虑可信的增量构建、最大化计算复用和分布式构建,并以Nix作为参考。
使用Nix构建系统软件
一篇博客文章,讨论Nix如何帮助解决构建系统软件时的依赖和可重现性问题,特别是针对像BPF和io_uring这样快速演进的子系统。
Guix Nix 的怪异融合:在 Nix 中利用 Guix 派生
一项技术探索,展示了 Nix 如何构建 Guix 派生项,强调了共享底层“输入输出机”架构以及跨生态系统互操作的可能性。
不到100行代码实现nix-build
本文通过用不到100行Go代码重新实现nix-build,揭示了Nix构建过程,表明将派生转换为存储路径本质上就是一次执行。
Nix for Haskell: 静态构建
本教程介绍如何使用 Nix 为 Haskell 项目创建静态链接的可执行文件,涵盖 GHC 的静态构建配置以及与 Docker 的集成。