Zig:包管理功能移至构建系统及多项后端改进
Zig 主分支近期变更精选列表
也可通过 RSS 订阅查看更新。
本页面展示 2026 年的更新记录,其他年份内容可查看 开发日志归档页。
作者:Andrew Kelley
既然用户的 build.zig 脚本与构建系统本身已拆分为独立进程,那么包管理逻辑自然也应迁移至该进程中。
我将以下子命令移至 maker 进程:
zig buildzig fetchzig initzig libc
这意味着原编译器可执行文件中的大量内容,如今将以源码形式分发,包括:
- 包拉取逻辑
- HTTP 客户端与网络功能
- TLS(传输层安全)及相关加密模块
- Git 协议实现
- xz、gzip、zstd、flate、zip 压缩格式支持
build.zig.zon文件的解析、校验与处理逻辑
如此一来,无需重新编译编译器即可修补这些功能,方便用户与贡献者进行调试。
此外,由于 maker 可执行文件以 ReleaseSafe 模式编译,Zig 的包管理功能在进行网络操作时将启用安全检查。同时,网络通信与文件哈希所用到的加密算法,现在可充分利用主机上的特殊 CPU 指令——哪怕这些指令因过于小众,通常不会作为软件分发的依赖项。我们既能提前编译(AOT),又能即时编译(JIT),鱼与熊掌可兼得!
我最初推动这项变更的动机,是为了解决 maker/configurer 进程分离后,--build-runner 覆盖标志发生破坏性变更导致的 ZLS 阻塞问题,为此需要实现一套 构建服务器协议。
最初的进程结构如下:
zig build(Zig 编译器 + 包管理器)
└─ builder(用户 build.zig 逻辑 + 构建系统实现)
进程分离后,结构变为:
zig build(Zig 编译器 + 包管理器)
├─ configurer(用户 build.zig 逻辑)
└─ maker(构建系统)
此时若运行长期驻留的 zig build --watch 进程,监听文件变化并自动重建,一旦检测到 build.zig 或其执行过程中涉及的文件发生变更,就需要重新运行 configurer,这意味着 maker 进程必须退出,让 zig build 有机会重新执行包管理逻辑。
而在本次开发日志所述的变更完成后,进程结构变为:
zig build(Zig 编译器)
└─ maker(构建系统 + 包管理器)
└─ configurer(用户 build.zig 逻辑)
如此一来,当需要重新执行配置时,maker 进程作为父进程可以继续运行,无需退出。对于即将推出的构建服务器而言,这避免了服务器被迫退出、客户端需重新连接的尴尬场景,只需向客户端通知配置变更即可。
本次变更几乎完全兼容现有代码,但存在以下可观测差异:
- Zig 可执行文件大小:从 14.1 MiB 缩减至 13.5 MiB,减小 4%(不含 LLVM,ReleaseSmall 模式)
--maker-opt标志被替换为环境变量ZIG_DEBUG_MAKER--zig-lib-dir标志被替换为环境变量ZIG_LIB_DIR
以下后续任务是发布 Zig 0.17.0 的主要障碍:
- 构建服务器协议最小可行版本(MVP)(用于解除 ZLS 阻塞)
- 为构建脚本本身添加路径依赖的功能
- 实现
zig build --watch检测构建脚本修改并自动重启 - 当前工作目录不同导致构建脚本缓存失效
我在 7 月有两场会议需要准备演讲内容,因此实际来看,要到 8 月初才有时间完成这些任务。当然,非常欢迎社区贡献代码。
特别感谢 ZLS 团队的 Techatrix,主动联系我并合作推进构建服务器协议!顺便一提,他们正在寻求赞助。
作者:Ali Cheraghi
本次更新内容颇多。近期编译器变更后,SPIR-V 后端出现了多处代码腐烂问题,我过去几周一直在努力将其修复至更好的状态。
@SpirvType
SPIR-V 存在一些无法用 Zig 类型系统表达的类型。新引入的 @SpirvType 内置函数,解决了编写着色器时最长期存在的障碍。相关背景可查看 #20550、#23326 和 #35461。
const Sampler = @SpirvType(.sampler);
const Image = @SpirvType(.{ .image = .{
.usage = .{ .sampled = u32 },
.format = .unknown,
.dim = .@"2d",
.depth = .unknown,
.arrayed = false,
.multisampled = false,
.access = .unknown,
} });
const SampledImage = @SpirvType(.{ .sampled_image = Image });
const RuntimeArray = @SpirvType(.{ .runtime_array = u32 });
const sampled_image = @extern(*addrspace(.constant) const SampledImage, .{
.name = "sampled_image",
.decoration = .{ .descriptor = .{ .set = 0, .binding = 1 } },
});
调用约定中的执行模式
执行模式信息(工作组大小、片段原点等)现在通过调用约定传递,而非通过内联汇编 OpExecutionMode 生成。旧的 std.gpu.executionMode() 辅助函数已移除,SPIR-V 汇编器现在会拒绝手动编写的 OpExecutionMode 指令。同时新增了 spirv_task 和 spirv_mesh 两种调用约定,用于网格着色管线。
export fn vert() callconv(.spirv_vertex) void {}
export fn frag() callconv(.{ .spirv_fragment = .{ .depth_assumption = .greater } }) void {}
export fn comp() callconv(.{ .spirv_kernel = .{ .x = 8, .y = 8, .z = 1 } }) void {}
export fn task() callconv(.{ .spirv_task = .{ .x = 1, .y = 1, .z = 1 } }) void {}
export fn mesh() callconv(.{ .spirv_mesh = .{ .stage_output = .output_lines, .max_primitives = 1, .max_vertices = 2 } }) void {}
基于 CPU 特性的功能与扩展
过去,功能与扩展要么由代码生成器临时生成,要么通过内联汇编添加。现在它们完全由 CPU 特性集驱动,与其他目标平台一致,依赖链从 SPIRV-Headers 提取(目前暂不包含第三方厂商内容)。汇编器现在会拒绝任何直接生成 OpCapability 或 OpExtension 的尝试。
多线程代码生成
从一开始,SPIR-V 后端就在链接器线程中以单线程方式运行代码生成。现在每个代码生成任务都会像其他自托管后端一样生成 Mir 值,并调度到编译器的线程池中执行。
这一变更还恢复了之前重构中移除的两个指令选择(ISel)阶段:dedup_types(合并等价类型指令)和 prune_unused(从最终模块中剔除无用代码)。它们最初因代码生成单线程化而被删除。
对象文件链接
.spv 文件现在被视为对象文件。你可以编译多个 .zig 文件(或外部 .spv 对象),由 SPIR-V 链接器将它们合并为单个模块。
修复了数十个 bug 后,spirv64-vulkan 目标的行为测试通过率提升了近 10%,达到 49%。std.gpu 已更名为 std.spirv,SPIR-V 后端的实用性较一个月前有了显著提升,但仍有很长的路要走,许多行为测试在 SPIR-V 上仍处于跳过状态。不过,如果你一直在犹豫是否要尝试用 Zig 编写着色器或计算内核,现在是个不错的时机。欢迎在 Codeberg 提交 bug 报告。祝各位开发顺利!
作者:Matthew Lugg
(提前致歉,这篇开发日志有点长——我写得有点上头了!)
几周前,我开始在一个分支上改进 LLVM 后端,这项计划已经酝酿了很久。后来这项工作不断拓展,最终实现了几个可能让你感兴趣的语言提案。
LLVM 后端整数类型降级
Zig 一直以来都是将任意位宽的整数类型(如 u4、i13、u40)直接降级为 LLVM IR 的位整数类型(i4、i13、i40)。但我们早就知道这种方式并非最优,因为 LLVM 文档中定义的内存表示语义对优化器限制过多。更重要的是,Clang 从不生成这类 LLVM IR,因此 LLVM 中的相关代码路径从未经过充分测试,实际支持效果很差——过去几年,我们观察到很多 优化遗漏 甚至直接 编译错误 的情况。
因此,本次 PR 的初衷是:仅在静态单赋值(SSA)形式中操作时使用这些位整数类型,在内存中存储时则将其零扩展或符号扩展为符合 ABI 规范的类型(i8、i16、i32 等)。这种方式应该能得到良好支持,毕竟它与 Clang 处理 C 语言 _BitInt(N) 类型的方式一致!
这项变更本身相当直接,但我遇到了一个问题,进而引出了一系列后续工作。
@bitCast 的问题
@bitCast 是个很有意思的内置函数。过去它的定义等价于以下操作序列:
- 获取操作数的指针
- 将其转换为目标类型的指针
- 从该指针加载值
换句话说,它本质上是内存字节重解释的语法糖。但随着时间推移,我们偏离了这个定义——例如,允许使用 @bitCast 将 [3]u8 重解释为 u24,而在大多数目标平台上 @sizeOf(u24) 大于 @sizeOf([3]u8),按原定义这会触发非法行为。
到目前为止,LLVM 后端对 @bitCast 的实现一直遵循这种未明确规定的语义。但由于该定义涉及内存重解释,修改整数类型的内存存储方式会影响 @bitCast 的实现,甚至引入非法行为导致编译器测试套件崩溃。
最简单的解决方案可能是在 LLVM 后端中添加逻辑,近似匹配旧行为。但我选择了更优方案——重新定义 @bitCast。
重新定义 @bitCast
2024 年,Jacob Young 撰写了语言提案 #19755,旨在通过精确指定新语义解决 @bitCast 的问题。该提案提交后很快获得通过,而且其中描述的语义已经在自托管 x86_64 后端中实现!因此,要解决 LLVM 后端的问题,我不必匹配旧的 @bitCast 语义——这似乎是在所有后端实现新语义的绝佳时机。
顺带一提,这么做还有一个好处:我们可以利用编译器的 Legalize 阶段,将难以降级的操作重写为更简单的操作,这样编译器后端只需支持这些简单操作即可。Legalize 阶段已有供自托管 x86_64 后端使用的功能,可将复杂的 @bitCast 操作转换为简单操作,只需稍加调整就能支持其他编译器后端(主要是 LLVM 和 C 后端)——前提是它们实现新语义。
总之,我开启了一项支线任务(难度甚至超过了最初的任务),要在整个编译器中实现这些新语义。这不仅包括 LLVM 和 C 后端,还包括编译期(comptime)执行——毕竟 Zig 允许在编译期执行几乎所有操作,包括 @bitCast!由于新语义与旧语义存在显著差异(后文详述),我还必须审核标准库、编译器及支持库(如 compiler_rt)中大量使用 @bitCast 的代码。不过在修复了 CI 中出现的一些问题后,我的 PR 终于通过了所有测试,并于昨日合并到主分支(同时解决了不少相关问题!)。
新的 @bitCast 语义
讲完背景,终于可以介绍 @bitCast 的新行为了。它不再基于内存字节重解释,而是基于类型的逻辑位表示定义。
所有支持 @bitCast 的类型都有一个“逻辑位布局”——即该类型按顺序排列的位序列。例如,u5 由 5 个逻辑位组成,我们按从最低有效位到最高有效位的顺序排列;[2]u5 由 10 个逻辑位组成——先排列第一个元素的 5 位,再排列第二个元素的 5 位。新的 @bitCast 定义是:将一种类型的逻辑位重新解释为另一种类型的逻辑位。
最简单的例子是将无符号整数(如 u8)转换为相同大小的有符号整数(如 i8)。这个操作的结果完全符合预期:位保持不变,只是将最高有效位重新解释为符号位。整数类型与 packed struct/packed union 类型之间的 @bitCast 语义也保持不变。
新语义与旧语义的区别体现在聚合类型(数组和向量)的处理上。
例如,将 [2]u8 位转换为 u16。在旧语义下,结果取决于目标平台的字节序:大端平台上,第一个数组元素会成为 8 个最高有效位;小端平台上,第一个数组元素会成为 8 个最低有效位。而在新语义下,由于只关注逻辑位表示(与字节序无关),该操作在所有目标平台上的行为一致:第一个数组元素始终成为 8 个最低有效位。一般来说,新语义与旧语义在小端平台上的行为一致。
新定义还支持一些更特殊的操作,例如将 [2]u3 转换为 @Vector(3, u2):
test "bitcast [2]u3 to @Vector(3, u2)" {
const arr: [2]u3 = .{ 0b001, 0b011 };
const vec: @Vector(3, u2) = @bitCast(arr);
// 按 arr[0] 的最低有效位开始,拼接 arr 的所有位得到逻辑位序列,
// 然后从中按 2 位一组读取,得到结果向量 vec 的元素。
//
// arr[0] arr[1]
// 0b001 0b011
// ------------- -------------
// 1 0 0 1 1 0
// -------- -------- --------
// 0b01 0b10 0b01
// vec[0] vec[1] vec[2]
try expect(vec[0] == 0b01);
try expect(vec[1] == 0b10);
try expect(vec[2] == 0b01);
}
const expect = @import("std").testing.expect;
这种操作大多数时候用处不大,但如果需要的话它就在那里!例如,你想将整数解构为单个位的向量来操作,现在只需通过 @bitCast 转换为 @Vector(n, u1) 即可。
在做这些工作的同时,我还实现了几个已通过的小型提案——这里就不详细介绍了,感兴趣的话可以查看相关议题:
当然,所有这些语义变更都会在 0.17.0 版本的发布说明中详细解释(希望比我这里写得更简洁!),并给出迁移建议。
LLVM 后端性能
最后提一下,本次分支的最初动机——修改 LLVM 后端处理非 ABI 整数类型的方式——已被证明成功,恢复了之前遗漏的优化。实际上,即使 Zig 编译器内部并未大量使用任意位宽整数,其性能也提升了约 5%。这意味着在 0.17.0 版本中,你的程序运行时性能可能会有小幅提升!
感谢阅读,希望这篇内容对部分读者有所帮助。祝各位开发顺利!
作者:Matthew Lugg
过去几周,我一直在改进 Zig 0.16.0 中首次推出的新 ELF 链接器。0.16.0 发布时,该链接器还处于相当早期的阶段,仅支持链接纯 Zig 代码,无法链接外部库(甚至 libc)——这也是它默认禁用的原因(可通过 -fnew-linker 启用)。但自初始版本发布以来,我们已经取得了不少进展!
最近达成了一个重要里程碑:在我的 最新 PR 合并后,新 ELF 链接器能够编译启用 LLVM 和 LLD 库的自托管 Zig 编译器,这项任务需要大量底层功能的支持。
[mlugg@nebula master]$ # 使用新链接器编译 Zig 编译器:
[mlugg@nebula master]$ zig build -Dno-lib -Dnew-linker -Denable-llvm
[mlugg@nebula master]$ # 用编译好的编译器构建依赖 LLVM 和 LLD 的程序:
[mlugg@nebula master]$ ./zig-out/bin/zig build-exe ~/hello.zig -fllvm -flld
[mlugg@nebula master]$ ./hello
Hello, World!
[mlugg@nebula master]$
当然,ELF 链接器本身可能没那么令人兴奋,但这个新链接器的亮点特性是支持快速增量编译。经过近期增强,现在(在 x86_64 Linux 上)可以在链接外部库、C 源文件等的同时进行增量重建,且无额外性能开销!下面是我在 Andrew 的俄罗斯方块克隆版 上测试的结果:
而且快速增量重建在 Zig 编译器自身上也表现出色:
[mlugg@nebula master]$ zig build -Dno-lib -Denable-llvm -fincremental --watch
Build Summary: 4/4 steps succeeded
install success
└─ install zig success
└─ compile exe zig Debug native success 36s
Build Summary: 4/4 steps succeeded
install success
└─ install zig success
└─ compile exe zig Debug native success 244ms
Build Summary: 4/4 steps succeeded
install success
└─ install zig success
└─ compile exe zig Debug native success 228ms
Build Summary: 4/4 steps succeeded
install success
└─ install zig success
└─ compile exe zig Debug native success 288ms
Build Summary: 4/4 steps succeeded
install success
└─ install zig success
└─ compile exe zig Debug native success 283ms
目前这个链接器最大的缺失功能是还不支持为 Zig 代码生成 DWARF 调试信息——这无疑是我下一步的工作重点。但即便没有这个功能,即时重建也已经非常实用了,比如在频繁使用打印调试的场景中。
如果你正在使用 Zig 的主分支且运行在 x86_64 Linux 上,不妨尝试用新 ELF 链接器进行增量编译,如果之前你的项目无法使用该功能的话!我相信很多代码库都能很好地适配它,让你能够在毫秒级完成项目重建。当然,如果遇到任何 bug,请务必 提交议题。
如果你目前仍在使用 Zig 的正式版本,也不用担心——正如 Andrew 在他的上一篇开发日志中提到的,Zig 0.17.0 即将发布,用不了多久你就能体验到这项功能了!
作者:Andrew Kelley
一个大分支刚刚合并:将 maker 进程与 configurer 进程分离
这篇开发日志本质上是即将发布的版本说明预览,旨在提前通知那些想要测试新功能并提供反馈的用户,帮助指导 Zig 项目的发展方向。
在此之前,build.zig 文件与构建系统实现会被一起编译成一个庞大的 Debug 模式进程。build.zig 逻辑在内存中构建完构建图后,由“构建运行器”代码执行它。
现在,build.zig 文件会被编译成一个小型的 Debug 模式进程(即“configurer”)。该逻辑在内存中构建完构建图后,会将其序列化为一个二进制配置文件。父进程 zig build 会感知到这个文件并为下次构建缓存它。与此同时,它会异步编译构建图执行进程(即“maker”),并采用 Release 模式。一旦配置文件生成且 maker 进程编译完成,就会启动 maker 进程并传入配置文件。由于全局缓存的存在,每个 Zig 版本只需编译一次 maker 进程。随后 maker 进程会执行序列化配置文件中包含的构建图。
这项变更的主要动机是提升 zig build 的速度,具体体现在三个方面:
- 只有用户的
build.zig逻辑会在每次变更时重新编译,而非整个构建系统。随着我们引入--watch、--fuzz和--webui等功能,这一点的价值愈发凸显。构建系统可以添加更多功能,而不会增加zig build的耗时。 - 当确定不会有任何变更时,构建系统可以完全跳过重新执行
build.zig逻辑的步骤。例如,如果你在zig build命令行中添加-freference-trace,它会避免重复执行build.zig逻辑,直接使用上次的配置。 - 实际执行构建图的进程现在会以优化模式编译。
为了说明第二点和第三点,下面是 zig build --help 命令在变更前后的性能对比:
基准测试 1(34 次运行):master/zig build -h
指标 均值 ± 标准差 最小值 … 最大值 异常值占比 变化率
wall_time 150ms ± 5.52ms 145ms … 165ms 4 (12%) 0%
peak_rss 84.8MB ± 275KB 84.2MB … 85.1MB 0 ( 0%) 0%
cpu_cycles 593M ± 4.01M 588M … 608M 2 ( 6%) 0%
instructions 995M ± 52.5K 995M … 995M 0 ( 0%) 0%
cache_references 25.8M ± 165K 25.4M … 26.1M 0 ( 0%) 0%
cache_misses 651K ± 20.1K 619K … 697K 0 ( 0%) 0%
branch_misses 918K ± 7.44K 906K … 935K 0 ( 0%) 0%
基准测试 2(348 次运行):branch/zig build -h
指标 均值 ± 标准差 最小值 … 最大值 异常值占比 变化率
wall_time 14.3ms ± 744us 13.2ms … 23.3ms 8 ( 2%) ⚡- 90.4% ± 0.4%
peak_rss 78.5MB ± 562KB 77.1MB … 81.4MB 7 ( 2%) ⚡- 7.4% ± 0.2%
cpu_cycles 24.1M ± 821K 22.8M … 27.1M 3 ( 1%) ⚡- 95.9% ± 0.1%
instructions 43.7M ± 23.8K 43.7M … 43.8M 56 (16%) ⚡- 95.6% ± 0.0%
cache_references 1.46M ± 14.6K 1.40M … 1.50M 19 ( 5%) ⚡- 94.3% ± 0.1%
cache_misses 142K ± 4.87K 127K … 157K 2 ( 1%) ⚡- 78.1% ± 0.4%
branch_misses 126K ± 1.37K 120K … 129K 12 ( 3%) ⚡- 86.3% ± 0.1%
性能提升非常显著,因为之前每次执行 zig build 都会运行 build.zig 逻辑,而现在构建系统会使用缓存的序列化配置。
除了性能提升,我预计 ZLS 等第三方工具也能从中受益,它们可以直接读取序列化配置文件,而无需维护构建运行器的分支版本。
这项变更彻底重构了 Zig 构建系统的内部机制,但从 API 角度来看几乎是兼容的,仅在上文链接的 PR 中提到了少数例外情况。
大多数人可能会遇到的主要破坏性变更如下:
if (b.args) |args| {
run_cmd.addArgs(args);
}
⬇️
run_cmd.addPassthruArgs();
这移除了构建脚本观察命令行参数的能力,但换来了修改参数时无需重新编译构建脚本的便利。
如果你想影响 Zig 的发展方向,现在是升级项目到开发版本并测试这些变更的好时机。我们将在几周内发布 0.17.0 版本。不过如果你没有时间,等 0.17.0 发布后发现构建被破坏了也不用担心,我们会在 0.17.1 版本中提供修复机会。
作者:Matthew Lugg
上个月合并了 类型解析变更 后,我花了些时间做个人项目,但最近还是抽出时间对 LLVM 代码生成后端做了一些改进。这些改进涉及多个方面,目标各不相同,但其中一个不错的用户可见变更是:我实现了 LLVM 后端的增量编译支持。
遗憾的是,这无法加快令人头疼的“LLVM 生成对象文件”步骤——这部分耗时完全取决于 LLVM。但增量编译确实能减少在 Zig 编译器自身代码上花费的时间,这意味着如果你的代码存在编译错误(从而跳过“LLVM 生成对象文件”步骤),你通常能很快收到错误提示。(当然,在构建成功的情况下,它也能带来小幅速度提升。)
这项支持已在主分支构建版本中可用,并将包含在即将发布的 0.16.0 版本中。
如果你还没尝试过增量编译,尤其是在使用 Zig 主分支的话,不妨在 zig build 命令中加上 -fincremental --watch 试试!Zig 核心团队已经在工作流程中使用增量编译一年多了,也收到了用户的积极反馈。目前该功能相对稳定,人们常常惊讶于它能节省大量时间——只需几毫秒就能收到最新的编译错误,而非等待数秒。
我个人并未在 LLVM 后端上使用过增量编译,但 CI 中所有增量测试覆盖现在都已针对 LLVM 后端启用,而且用户反馈良好,绝对值得一试。一如既往,如果遇到增量编译的 bug,请务必提交报告!
感谢阅读,希望这项功能对你有用 :)
作者:Matthew Lugg
今天,我合并了一个长达 3 万行的 PR,这项工作耗时两个月(也可以说是三个月)。该分支的目标是将 Zig 编译器的内部类型解析逻辑重构为更合理、更简洁的设计。对我个人而言,这是一个非常令人兴奋的变更,因为它清理了大量编译器内部代码,同时也带来了一些不错的用户可见改进!
首先,Zig 编译器现在对类型字段的分析更加惰性:如果某个类型从未被初始化,那么 Zig 完全不需要关心该类型的具体结构。这在类型同时作为命名空间使用时尤为重要,这是现代 Zig 中的常见模式。例如,当使用 std.Io.Writer 时,你肯定不希望编译器同时引入 std.Io 中的大量代码!下面是一个简单的例子:
const Foo = struct {
bad_field: @compileError("i am an evil field, muahaha"),
const something = 123;
};
comptime {
_ = Foo.something; // `Foo`仅作为命名空间使用
}
以前这段代码会触发编译错误,现在则能正常编译,因为 Zig 根本不会去处理 @compileError 调用。
另一个改进是“依赖循环”的错误提示体验。任何在 Zig 中遇到过依赖循环编译错误的人都知道,之前的错误信息完全没有帮助——但现在情况变了!如果你遇到依赖循环(现在这种情况也比以前少了),会收到详细的错误信息,明确告诉你依赖循环的来源。来看这个例子:
const Foo = struct { inner: Bar };
const Bar = struct { x: u32 align(@alignOf(Foo)) };
comptime {
_ = @as(Foo, undefined);
}
$ zig build-obj repro.zig
error: 存在长度为 2 的依赖循环
repro.zig:1:29: 注意:类型 'repro.Foo' 在此处声明的字段依赖于类型 'repro.Bar'
const Foo = struct { inner: Bar };
^~~
repro.zig:2:44: 注意:类型 'repro.Bar' 在此处的对齐查询依赖于类型 'repro.Foo'
const Bar = struct { x: u32 align(@alignOf(Foo)) };
^~~
注意:移除其中任意一个依赖即可打破循环
当然,依赖循环可能比这个例子复杂得多,但在我测试过的所有案例中,错误信息都提供了足够的信息,让你能轻松理解问题所在。
此外,这个 PR 还大幅改进了 Zig 编译器的“增量编译”功能。简而言之,它修复了大量已知 bug,尤其是“过度分析”问题(即增量更新做了不必要的额外工作,有时甚至是大量工作)现在几乎完全消除了——这让增量编译在很多情况下速度显著提升!如果你还没尝试过,不妨 试试增量编译:它确实能带来很棒的开发体验。这无疑是最让我兴奋的改进,也是推动这项变更的主要动力之一。
这个 PR 还带来了很多其他变更——数十个 bug 修复、一些小的语言变更(大多比较小众)以及编译器性能提升。这里无法一一列举,如果你想了解更多,可以查看 Codeberg 上的 PR——当然,如果遇到任何 bug,请提交议题。祝各位开发顺利!
作者:Andrew Kelley
随着 0.16.0 发布周期临近结束,Jacob 一直在努力更新 std.Io.Evented,使其跟上所有最新的 API 变更:
这两种实现都基于用户态栈切换,有时也被称为“纤程”“有栈协程”或“绿色线程”。
它们现在已可供调试使用,你可以通过 std.Io.Evented 构建应用。但请注意,它们仍处于实验阶段,在能够可靠稳定地使用之前,还有一些重要的后续工作要做:
- 改进错误处理
- 移除日志输出
- 排查使用
IoMode.evented时编译器性能意外下降的问题 - 仍有几个函数未实现
- 需要更多测试覆盖
- 添加内置函数以获取给定函数的最大栈大小,以便在内存过度提交关闭时能实际使用这些实现
尽管存在这些限制,但我们似乎确实正在接近“应许之地”——Zig 代码可以轻松切换不同的 I/O 实现:
const std = @import("std");
pub fn main(init: std.process.Init.Minimal) !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
const gpa = debug_allocator.allocator();
var threaded: std.Io.Threaded = .init(gpa, .{
.argv0 = .init(init.args),
.environ = init.environ,
});
defer threaded.deinit();
const io = threaded.io();
return app(io);
}
fn app(io: std.Io) !void {
try std.Io.File.stdout().writeStreamingAll(io, "Hello, World!\n");
}
$ strace ./hello_threaded
execve("./hello_threaded", ["./hello_threaded"], 0x7ffc1da88b20 /* 98 vars */) = 0
mmap(NULL, 262207, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f583f338000
arch_prctl(ARCH_SET_FS, 0x7f583f378018) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
prlimit64(0, RLIMIT_STACK, {rlim_cur=16384*1024, rlim_max=RLIM64_INFINITY}, NULL) = 0
sigaltstack({ss_sp=0x7f583f338000, ss_flags=0, ss_size=262144}, NULL) = 0
sched_getaffinity(0, 128, [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31]) = 8
rt_sigaction(SIGIO, {sa_handler=0x1019d90, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x10328c0}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGPIPE, {sa_handler=0x1019d90, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x10328c0}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
writev(1, [{iov_base="Hello, World!\n", iov_len=14}], 1Hello, World!
) = 14
rt_sigaction(SIGIO, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x10328c0}, NULL, 8) = 0
rt_sigaction(SIGPIPE, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x10328c0}, NULL, 8) = 0
exit_group(0) = ?
+++ exited with 0 +++
只需切换 I/O 实现:
const std = @import("std");
pub fn main(init: std.process.Init.Minimal) !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
const gpa = debug_allocator.allocator();
var evented: std.Io.Evented = undefined;
try evented.init(gpa, .{
.argv0 = .init(init.args),
.environ = init.environ,
.backing_allocator_needs_mutex = false,
});
defer evented.deinit();
const io = evented.io();
return app(io);
}
fn app(io: std.Io) !void {
try std.Io.File.stdout().writeStreamingAll(io, "Hello, World!\n");
}
execve("./hello_evented", ["./hello_evented"], 0x7fff368894f0 /* 98 vars */) = 0
mmap(NULL, 262215, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f70a4c28000
arch_prctl(ARCH_SET_FS, 0x7f70a4c68020) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
prlimit64(0, RLIMIT_STACK, {rlim_cur=16384*1024, rlim_max=RLIM64_INFINITY}, NULL) = 0
sigaltstack({ss_sp=0x7f70a4c28008, ss_flags=0, ss_size=262144}, NULL) = 0
sched_getaffinity(0, 128, [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31]) = 8
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f70a4c27000
mmap(0x7f70a4c28000, 548864, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f70a4ba1000
io_uring_setup(64, {flags=IORING_SETUP_COOP_TASKRUN|IORING_SETUP_SINGLE_ISSUER, sq_thread_cpu=0, sq_thread_idle=1000, sq_entries=64, cq_entries=128, features=IORING_FEAT_SINGLE_MMAP|IORING_FEAT_NODROP|IORING_FEAT_SUBMIT_STABLE|IORING_FEAT_RW_CUR_POS|IORING_FEAT_CUR_PERSONALITY|IORING_FEAT_FAST_POLL|IORING_FEAT_POLL_32BITS|IORING_FEAT_SQPOLL_NONFIXED|IORING_FEAT_EXT_ARG|IORING_FEAT_NATIVE_WORKERS|IORING_FEAT_RSRC_TAGS|IORING_FEAT_CQE_SKIP|IORING_FEAT_LINKED_FILE|IORING_FEAT_REG_REG_RING|IORING_FEAT_RECVSEND_BUNDLE|IORING_FEAT_MIN_TIMEOUT|IORING_FEAT_RW_ATTR|IORING_FEAT_NO_IOWAIT, sq_off={head=0, tail=4, ring_mask=16, ring_entries=24, flags=36, dropped=32, array=2112, user_addr=0}, cq_off={head=8, tail=12, ring_mask=20, ring_entries=28, overflow=44, cqes=64, flags=40, user_addr=0}}) = 3
mmap(NULL, 2368, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_POPULATE, 3, 0) = 0x7f70a4ba0000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_POPULATE, 3, 0x10000000) = 0x7f70a4b9f000
io_uring_enter(3, 1, 1, IORING_ENTER_GETEVENTS, NULL, 8Hello, World!
) = 1
io_uring_enter(3, 1, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 1
munmap(0x7f70a4b9f000, 4096) = 0
munmap(0x7f70a4ba0000, 2368) = 0
close(3) = 0
munmap(0x7f70a4ba1000, 548864) = 0
exit_group(0) = ?
+++ exited with 0 +++
关键在于,这两个示例中的 app 函数完全相同。
除了“Hello World”示例,Zig 编译器本身使用 std.Io.Evented 也能正常工作,无论是基于 io_uring 还是 GCD,但如前所述,使用时会出现尚未排查清楚的性能下降问题。
祝各位开发顺利,
Andrew
作者:Andrew Kelley
如果你有一个包含依赖项的 Zig 项目,那么刚刚合并的两个重大变更可能会让你感兴趣。
拉取的包现在会本地存储在项目根目录的 zig-pkg 文件夹中(与 build.zig 文件同级)。
例如,在 awebo 项目中运行 zig build 后,结果如下:
$ du -sh zig-pkg/*
13M freetype-2.14.1-alzUkTyBqgBwke4Jsot997WYSpl207Ij9oO-2QOvGrOi
20K opus-0.0.2-vuF-cMAkAADVsm707MYCtPmqmRs0gzg84Sz0qGbb5E3w
4.3M pulseaudio-16.1.1-9-mk_62MZkNwBaFwiZ7ZVrYRIf_3dTqqJR5PbMRCJzSuLw
5.2M uucode-0.1.0-ZZjBPvtWUACf5dqD_f9I37VGFsN24436CuceC5pTJ25n
728K vaxis-0.5.1-BWNV_AxECQCj3p4Hcv4U3Yo1WMUJ7Z2FUj0UkpuJGxQQ
强烈建议将该目录添加到项目本地的版本控制忽略文件中(如 .gitignore)。不过,由于它位于 .zig-cache 之外,你可以生成包含所有依赖项的自包含源码压缩包,从而实现离线构建或归档保存。
同时,依赖项的额外副本会被缓存到全局目录中。在根据 paths 过滤器剔除所有未使用的文件后,内容会被重新压缩:
$ du -sh ~/.cache/zig/p/*
2.4M freetype-2.14.1-alzUkTyBqgBwke4Jsot997WYSpl207Ij9oO-2QOvGrOi.tar.gz
4.0K opus-0.0.2-vuF-cMAkAADVsm707MYCtPmqmRs0gzg84Sz0qGbb5E3w.tar.gz
636K pulseaudio-16.1.1-9-mk_62MZkNwBaFwiZ7ZVrYRIf_3dTqqJR5PbMRCJzSuLw.tar.gz
880K uucode-0.1.0-ZZjBPvtWUACf5dqD_f9I37VGFsN24436CuceC5pTJ25n.tar.gz
120K vaxis-0.5.1-BWNV_BFECQBbXeTeFd48uTJRjD5a-KD6kPuKanzzVB01.tar.gz
这项变更的动机是为了方便调试。你可以直接编辑这些文件,看看会发生什么;也可以用 git 克隆的目录替换包目录;还可以全局搜索所有依赖项,或者配置 IDE 基于 zig-pkg 目录提供自动补全功能,甚至 用 baobab 分析依赖树。此外,全局缓存使用压缩文件,更便于在多台计算机之间共享缓存数据。未来,我们计划 支持依赖树的点对点种子下载。通过将包重新压缩为标准格式,对等节点可以用最少的带宽共享 Zig 包。我很喜欢这个想法,因为它既能应对网络中断,又能形成一种“人气竞赛”——看看哪些开源包的种子数最多,就知道它们有多受欢迎了!
第二个变更是为 zig build 添加了 --fork 标志。
现在回想起来,这个功能似乎显而易见,我都不知道为什么一开始没想到。用法如下:
zig build --fork=[路径]
这是一个项目覆盖选项。给定一个项目的源码检出路径,整个依赖树中所有匹配该项目的包都会被替换。
由于包内容哈希包含名称和指纹信息,替换操作会在包被拉取之前就完成。
这是一种临时使用一个或多个独立目录中分支版本的简便方法。你可以在整个依赖树中迭代修改,直到项目正常工作,同时还能舒适地使用依赖项目的开发环境和版本控制。
作为 CLI 标志,它的临时性恰到好处。一旦移除该标志,你就会回到使用原始拉取依赖树的状态。
如果项目不匹配,会触发错误,避免混淆:
$ zig build --fork=/home/andy/dev/mime
error: 分支 /home/andy/dev/mime 未匹配到任何 mime 包
$
如果项目匹配,会显示提示信息,提醒你正在使用分支版本,避免混淆:
$ zig build --fork=/home/andy/dev/dvui
info: 分支 /home/andy/dev/dvui 匹配到 1 个(dvui)包
...
这项功能旨在改善处理生态系统故障的工作流程。我已经试用了一下,发现用起来非常顺手。新的工作流程如下:
- 因生态系统故障导致源码构建失败。
- 使用
--fork调试,直到项目恢复正常。在此期间,你可以使用上游项目的版本控制、测试套件,以及zig build test --watch -fincremental等功能。 - 现在你有两个选择:要么只专注于自己的项目,要么将补丁提交到上游。
……而且你可能可以跳过修改 build.zig.zon 指向分支版本的步骤,除非你预计上游需要很长时间才能合并你的修复。
作者:Andrew Kelley
Windows 操作系统为内核操作提供了庞大的 ABI 接口,但并非所有 ABI 都同等可靠。正如 Casey Muratori 在他的演讲 《唯一不可打破的法则》 中指出的,软件开发团队的组织结构会直接影响他们所开发软件的结构。
Windows 上的 DLL 是分层组织的,有些 API 是底层 API 的高层封装。例如,每当你调用 kernel32.dll 的函数时,实际工作最终都是由 ntdll.dll 完成的。你可以使用 ProcMon.exe 查看堆栈跟踪,直接观察到这一点。
我们从实践中发现,ntdll API 通常设计精良、合理且功能强大,而 kernel32 封装则引入了不必要的堆分配、额外的失败模式、不必要的 CPU 占用和冗余代码。
这就是为什么 Zig 标准库的策略是 优先使用原生 API 而非 Win32。我们还没有完全做到这一点——仍有很多调用 kernel32 的地方——但最近已经取得了重大进展。我举两个例子。
示例 1:熵获取
根据官方文档,Windows 没有直接获取随机字节的简单方法。
包括 Chromium、boringssl、Firefox 和 Rust 在内的许多项目 都会调用 advapi32.dll 中的 SystemFunction036,因为它在 Windows 8 之前的版本中都能正常工作。
但遗憾的是,从 Windows 8 开始,第一次调用该函数时,它会动态加载 bcryptprimitives.dll 并调用 ProcessPrng。如果加载 DLL 失败(例如系统负载过高,我们在 Zig CI 上多次观察到这种情况),它会返回错误码 38,而该函数的返回类型是 void,且文档声称它永远不会失败。
ProcessPrng 做的第一件事就是堆分配少量固定字节。如果分配失败,它会在 BOOL 类型中返回 NO_MEMORY,但文档声称它永远不会失败,始终返回 TRUE。
显然,bcryptprimitives.dll 每次加载时还会运行一个测试套件。
而 ProcessPrng 真正要做的,其实是通过 NtOpenFile 打开 "\\Device\\CNG",然后用 NtDeviceIoControlFile 读取 48 字节作为种子,接着初始化一个基于每个 CPU 的 AES 加密安全伪随机数生成器(CSPRNG)。
因此,我们可以同时避免依赖 bcryptprimitives.dll 和 advapi32.dll,还能避免第一次读取随机数时出现的非确定性失败和延迟问题。
示例 2:NtReadFile 和 NtWriteFile
ReadFile 的定义如下:
pub extern "kernel32" fn ReadFile(
hFile: HANDLE,
lpBuffer: LPVOID,
nNumberOfBytesToRead: DWORD,
lpNumberOfBytesRead: ?*DWORD,
lpOverlapped: ?*OVERLAPPED,
) callconv(.winapi) BOOL;
NtReadFile 的定义如下:
pub extern "ntdll" fn NtReadFile(
FileHandle: HANDLE,
Event: ?HANDLE,
ApcRoutine: ?*const IO_APC_ROUTINE,
ApcContext: ?*anyopaque,
IoStatusBlock: *IO_STATUS_BLOCK,
Buffer: *anyopaque,
Length: ULONG,
ByteOffset: ?*const LARGE_INTEGER,
Key: ?*const ULONG,
) callconv(.winapi) NTSTATUS;
提醒一下,上面的函数是通过调用下面的函数实现的。
我们已经可以看到使用底层 API 的一些好处:例如,真正的 API 直接将错误码作为返回值,而 kernel32 封装则将状态码隐藏起来,返回一个 BOOL,然后需要你调用 GetLastError 才能知道具体错误。想象一下!函数直接返回结果 🌈
此外,OVERLAPPED 是一个虚构的类型。Windows 内核根本不知道也不关心它!真正的原语是事件、异步过程调用(APC)和 IO_STATUS_BLOCK。
如果文件句柄是同步的,那么 Event 和 ApcRoutine 必须为 null,你会立即从 IO_STATUS_BLOCK 中得到结果。如果这里传入 APC 例程,一些老旧的 32 位代码会运行,导致结果出错。
另一方面,如果文件句柄是异步的,那么你需要使用 Event 或 ApcRoutine。kernel32.dll 使用事件,这意味着它在读取文件时会进行额外的、不必要的资源分配和管理。而 Zig 现在会传入 APC 例程,然后调用 NtDelayExecution。这与取消操作无缝集成,使得在执行文件 I/O 时可以取消任务,无论文件是同步还是异步打开的。
如需深入了解这个话题,请参考以下议题:
作者:Andrew Kelley
过去一个月左右,几位积极的贡献者开始关注 zig libc 子项目。该项目的理念是逐步删除冗余代码,将 libc 函数作为 Zig 标准库的包装器提供,而非以 vendored C 源码文件的形式存在。在很多情况下,这些函数是一对一的映射,例如 memcpy 或 atan2,或者只是简单地包装通用函数,比如 strnlen:
fn strnlen(str: [*:0]const c_char, max: usize) callconv(.c) usize {
return std.mem.findScalar(u8, @ptrCast(str[0..max]), 0) orelse max;
}
到目前为止,Zig 仓库中已经删除了约 250 个 C 源码文件,还剩 2032 个。
每将一个函数迁移到 Zig 实现,Zig 就进一步摆脱了对第三方项目和 C 语言的依赖,编译速度得到提升,Zig 的安装包也更加精简,静态链接 libc 的用户应用程序二进制大小也会减小。
此外,最近的一项 增强功能 让 zig libc 与其他 Zig 代码共享 Zig 编译单元(ZCU),而非作为单独的静态库在后续链接。这是 Zig 集成编译器和链接器的优势之一。当导出的 libc 函数共享 ZCU 时,由于函数可以一起优化,冗余代码会被消除。这有点像在 libc 边界启用链接时优化(LTO),但它是在前端正确完成的,而非在链接器中太晚进行。
此外,将这项工作与最近的 std.Io 变更 结合,用户有望无缝控制 libc 的 I/O 行为——例如,强制所有 read 和 write 调用参与 io_uring 事件循环,即使这些代码并非为此场景编写。或者,可以为第三方 C 代码启用 资源泄漏检测。目前这还只是一个尚未验证的想法,但它让我很感兴趣。
特别感谢 Szabolcs Nagy 的 libc-test 项目。这个项目在确保数学函数不出现回归方面帮了大忙。
提醒各位用户,既然 Zig 正在转型为静态 libc 提供者,如果你在使用 Zig 提供的 musl、mingw-w64 或 wasi-libc 功能时遇到问题,请先在 Zig 仓库提交 bug 报告,避免因 Zig 自身的 bug 打扰其他 libc 实现项目的维护者,毕竟我们不再依赖这些独立项目的 vendored 版本了。
就在我像个懦夫一样坐在家里写这篇开发日志的同一天,不到五英里之外,违背民选官员意愿进驻我市的武装部队,无端向和平抗议者发射催泪瓦斯。下次我希望自己有勇气加入邻居们的行列,也希望自己不会像 Alex Pretti 和 Renée Good 那样遭到枪击。