Zig 0.16 深度拆解:当一门系统语言决定把「I/O」做成可替换接口——从 Io vtable 到 Future/Group/Batch 与 Cancelation 的全链路架构手术
8 个月,244 位贡献者,1183 次提交。Zig 0.16.0 的头条只有一句话:I/O as an Interface。
但这句话背后,是一次把标准库连根拔起的手术:
std.fs没了、std.net没了、std.Thread.Pool没了、std.io变成了std.Io,连main函数的签名都变了。这篇文章不打算复读 changelog。我想讲清楚三件事:为什么 Zig 要这么干、这套设计在架构上到底解决了什么别人解决不了的问题、以及你的代码要怎么改,会踩哪些坑。
一、背景:Zig 欠了社区一个「异步」
1.1 那个消失了好几年的 async/await
老 Zig 玩家都记得:Zig 曾经是有 async/await 关键字的。那套基于「无栈协程 + 编译器自动计算帧大小」的设计一度非常惊艳——它不需要堆分配,帧大小在编译期就能算出来,理论上是比 Rust 的 Future 更激进的方案。
然后自举编译器(self-hosted compiler)成为默认,这套机制因为新编译器没实现而被暂时下线。社区从 0.11 等到 0.12,从 0.12 等到 0.14,异步始终是「roadmap 上的一行字」。
在这几年里,整个系统编程界围绕「异步」打了一场硬仗:
- Rust 选了无栈协程 + 用户态 runtime,结果是
tokio/async-std/smol生态分裂,库作者被迫在Cargo.toml里写features = ["tokio"],「函数染色」(function coloring)问题从语言层下沉成了生态层的税。 - Go 选了 M:N 绿色线程 +
context.Context,代价是 runtime 绑死、无法做 freestanding、ctx传递纯靠约定,忘传一个编译器一句话都不说。 - C++ 的
std::execution折腾了十年还在委员会里打转。
Zig 团队在这几年里没有硬上,而是在等一个答案。0.16 给出的答案,比「把 async 关键字加回来」野心大得多。
1.2 答案:抄自己的作业
Zig 社区最成功的设计是什么?不是 comptime,不是 errdefer,而是 Allocator 显式传参。
// 这是 Zig 的招牌:内存分配是一个被显式传入的接口,不是隐式全局状态
fn parseConfig(gpa: std.mem.Allocator, text: []const u8) !Config {
var list: std.ArrayList(Entry) = .empty;
defer list.deinit(gpa);
// ...
}
这个设计带来了什么?
- 可替换:同一份代码,测试时塞
std.testing.allocator(自带泄漏检测),内核态塞 bump allocator,WASM 里塞 arena,嵌入式塞静态池。 - 可审计:函数签名里有
Allocator,你就知道它会分配内存;没有,就知道它不会。这是类型系统级别的文档。 - 无全局状态:库作者不需要争论「用哪个 allocator」,因为决定权在
main手里。
0.16 的核心洞察就一句话:
凡是「可能阻塞控制流」或者「引入不确定性」的东西,都应该和内存分配一样,由一个显式传入的接口来拥有。
原文是这么说的:"Generally, anything that potentially blocks control flow or introduces nondeterminism is grounds for being owned by the I/O interface."
于是有了 std.Io。
二、核心概念:Io 到底是什么
2.1 它不是「异步库」,它是一个 vtable
从 Zig 0.16.0 开始,所有 I/O 功能都要求传入一个 Io 实例。注意这个措辞:不是「异步 I/O」,是所有 I/O。
// 0.15 及以前
try file.writeAll("hello");
file.close();
// 0.16
try file.writeStreamingAll(io, "hello");
file.close(io);
连 close 都要传 io。第一反应大概是「这也太啰嗦了吧」。但想想 Allocator:free(ptr) 也要传 allocator,因为释放行为取决于分配策略。同理,close 的行为取决于 I/O 策略——在 io_uring 实现里,close 可能是往 SQ 里塞一个 IORING_OP_CLOSE,而不是一个同步 syscall。
Io 本质是一个接口(在 Zig 里就是「胖指针 + 函数表」的手写 vtable),标准库给出了这些实现:
| 实现 | 状态 | 机制 | 适用场景 |
|---|---|---|---|
Io.Threaded | feature-complete,测试充分 | 线程;I/O 直接调 read/write/open/close | 生产默认,等价于 0.15 行为 |
Io.Evented | 实验性,WIP | 用户态栈切换 + work stealing(M:N / 有栈协程) | 未来的高并发方案 |
Io.Uring | PoC | Linux io_uring | 性质极好,但还没完成 |
Io.Kqueue | PoC | BSD/macOS kqueue | 仅够修一个常见 bug |
Io.Dispatch | 可用 | macOS Grand Central Dispatch | Apple 平台 |
Io.failing | 完成 | 所有操作都失败 | 测试「不许碰 I/O」的代码路径 |
Io.Threaded 还分两档:
-fno-single-threaded:支持 task 级并发和取消。-fsingle-threaded:不支持 task 级并发和取消,但代码照样能编译能跑(async会退化成直接调用)。
最后这一点是整个设计的题眼,下面细说。
2.2 「函数染色」是怎么被绕过去的
Rust 的 async 之所以「染色」,是因为 async fn 和 fn 是两种不同的类型,async fn 返回 impl Future,必须在 async 上下文里 .await。于是同步世界和异步世界之间隔了一堵墙,跨墙要么 block_on,要么 spawn_blocking。
Zig 0.16 的做法是:根本不引入第二种函数。
fn fetchUser(io: Io, id: u64) !User { ... } // 就是个普通函数
fetchUser 没有任何「异步」标记。它是同步的还是异步的,取决于调用方怎么调它:
// 直接调:同步
const u = try fetchUser(io, 42);
// 通过 io.async 调:这个调用与后续逻辑「独立」
var fut = io.async(fetchUser, .{ io, 42 });
// ... 干点别的 ...
const u = try fut.await(io);
而 io.async 具体怎么实现,是 Io 实现说了算:
Io.Threaded可能扔到线程池;Io.Evented会切一个用户态栈;-fsingle-threaded的Io.Threaded直接就地把函数调了,然后await只是把结果取出来。
这就是关键:io.async 是「我允许它并发」,不是「它必须并发」。 所以创建 task 这件事是不会失败的(infallible),也是跨实现可移植的——哪怕底层运行时压根没有并发能力,你的代码依然正确,只是退化成顺序执行。
2.3 async vs concurrent:我认为这是 0.16 最精妙的一笔
如果只有 async,会漏掉一类场景:并发是正确性要求,不是性能优化。
典型例子是生产者-消费者用无界队列做背压:如果生产者和消费者被顺序执行,程序会直接死锁。这种时候「退化成同步」不是变慢,是变错。
Zig 给了第二个 API:
// io.async: "这俩可以并发" —— 不会失败
// io.concurrent: "这俩必须并发" —— 可能失败
const fut = io.concurrent(consumer, .{ io, &queue }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.NeedsThreads,
};
io.concurrent 必然需要内存分配(因为「同时进行」的本质就是要独立的栈/帧),因此它会返回 error.ConcurrencyUnavailable。
我的评价:这是把「并发语义」从注释提升到了类型系统。在 Rust 里,你没法在类型层面区分「这个 spawn 是为了快」和「这个 spawn 是为了不死锁」;在 Go 里,go f() 更是一视同仁。Zig 用两个函数名和一个错误码,把这个区分变成了编译期可读、运行期可处理的东西。这个设计的含金量,比 async 关键字本身高得多。
三、架构分析:四层并发抽象
0.16 的 std.Io 不是一个扁平的 API 集合,它有清晰的分层。搞不清这个分层,你就会在「该用哪个」上反复纠结。
┌──────────────────────────────────────────────────────┐
│ 高层:Group / Select / Queue(T) 批量与协调 │
├──────────────────────────────────────────────────────┤
│ 函数层:Future(T) = io.async / io.concurrent │
│ 灵活、符合直觉,但每个 task 要分配帧内存 │
├──────────────────────────────────────────────────────┤
│ 操作层:Batch = 一组 Operation 同时提交 │
│ 高效、可移植,但不好抽象(中间插逻辑很难) │
├──────────────────────────────────────────────────────┤
│ 底座:Io vtable(Threaded / Evented / Uring / ...) │
└──────────────────────────────────────────────────────┘
3.1 Future:函数层的抽象
var foo_future = io.async(foo, .{args});
defer if (foo_future.cancel(io)) |resource| resource.deinit() else |_| {}
var bar_future = io.async(bar, .{args});
defer if (bar_future.cancel(io)) |resource| resource.deinit() else |_| {}
const foo_result = try foo_future.await(io);
const bar_result = try bar_future.await(io);
这段官方推荐模式里有三个值得抠的细节:
第一,defer cancel 是必须的,不是可选的。 因为如果 foo_future.await 之前函数因为别的错误提前 return,那个 task 的资源(帧内存、可能已经打开的 fd)就泄漏了。cancel 在这里同时承担「请求中断」和「回收 task 资源」两个职责。
第二,cancel 和 await 都是幂等的。 所以 defer cancel + 后面显式 await 不会出问题,第二次调用是 no-op。
第三,cancel 依然会返回值。 这是最容易写出 bug 的地方:取消请求可能没被采纳。任务可能在你发出取消之前就成功完成了。所以:
// 打开文件然后立刻取消 —— 但必须处理「文件真被打开了」的情况
var file_task = io.async(Io.Dir.openFile, .{ .cwd(), io, "hello.txt", .{} });
defer if (file_task.cancel(io)) |file| file.close(io) else |_| {};
如果你写成 _ = file_task.cancel(io) catch {};,那个成功打开的 fd 就漏了。
3.2 Group:N 个同生命周期的任务,O(1) 开销
Group 是为「一批任务共享生命周期」准备的,官方明确说明它 spawn N 个 task 是 O(1) 开销(相比逐个 io.async 的 O(N) future 管理)。
官方的 sleep sort 例子把 Group 的用法讲得很透:
const std = @import("std");
const Io = std.Io;
test "sleep sort" {
const io = std.testing.io;
const rng_impl: std.Random.IoSource = .{ .io = io };
const rng = rng_impl.interface();
var array: [10]i32 = undefined;
for (&array) |*elem| elem.* = rng.uintLessThan(u16, 1000);
var sorted: [10]i32 = undefined;
var index: std.atomic.Value(usize) = .init(0);
var group: Io.Group = .init;
defer group.cancel(io); // 同样:defer 兜底
for (&array) |elem| group.async(io, sleepAppend, .{ io, &sorted, &index, elem });
try group.await(io); // 等全部完成
for (sorted[0 .. sorted.len - 1], sorted[1..]) |a, b| {
try std.testing.expect(a <= b);
}
}
fn sleepAppend(io: Io, result: []i32, i_ptr: *std.atomic.Value(usize), elem: i32) !void {
try io.sleep(.fromMilliseconds(elem), .awake);
result[i_ptr.fetchAdd(1, .monotonic)] = elem;
}
注意 io.sleep(.fromMilliseconds(elem), .awake):连「睡眠」都是 Io 的方法。这很合理——睡眠是典型的「阻塞控制流」,在 Io.Evented 下它应该是切栈而不是 nanosleep。第二个参数 .awake 是唤醒语义(相对于让设备进入低功耗状态),这是嵌入式和移动端会用到的区分。
还要注意 std.Random.IoSource:随机数也归 Io 管。因为随机数是「引入不确定性」的典型代表。这意味着你终于可以在测试里注入确定性随机源,而不用给业务代码加一层 Random 参数。
Group 的另一半配套是 Select(等待一组任务中的任意一个完成)和 Queue(T)(多生产多消费、线程安全、运行期可配缓冲区大小;空了消费者挂起、满了生产者挂起)。有了这三件套,Go 的 select + chan 那套东西基本就齐了,而且是不绑定 runtime 的版本。
3.3 Batch:操作层,给性能敏感路径用
Future 好用,但有代价:每个 task 要分配帧内存,而且在 async 语义下可能引入不必要的阻塞。
Batch 工作在更低的 Operation 层:它不抽象「函数」,只抽象「操作」,一次性提交一组 I/O 操作。0.16 里已经 Operation 化的有:
FileReadStreamingFileWriteStreamingDeviceIoControlNetReceive
官方说未来大部分 fs/net 功能都会迁移到 Operation 上,届时它们都能进 Batch,也都能用 operateTimeout——一个给任意 I/O 操作加超时的通用机制。
选型建议(我的总结):
- 只是「几个操作同时做」 → 用
Batch,零帧分配,最优。 - 操作之间需要插入业务逻辑 / 需要抽象 → 用
Future。 - 一批同生命周期的任务 → 用
Group。 - 先用
Future跑通,profile 之后再把热点换成Batch—— 这是官方明确认可的路径。
3.4 Cancelation:把「取消」写进错误集
这是我认为 0.16 最有工程价值的部分,甚至超过异步本身。
Go 的 context.Context 是一个约定:你得记得传 ctx,记得 select { case <-ctx.Done(): },忘了编译器不会管你。生产环境里因为某一层没透传 ctx 导致 goroutine 泄漏,是每个 Go 团队都踩过的坑。
Zig 0.16 的做法是把取消塞进错误集:绝大多数 I/O 操作的错误集里现在都有 error.Canceled。
fn download(io: Io, url: []const u8) !Data {
const conn = try connect(io, url); // 这里可能返回 error.Canceled
defer conn.close(io);
return try readAll(io, conn); // 这里也可能
}
你不需要为了支持取消写任何额外代码。try 会自然把 error.Canceled 往上传播。编译器强制你处理它,因为它在错误集里。
处理 error.Canceled 官方给了三种方式,按常见程度排序:
- 直接传播(99% 的情况)。
io.recancel()之后不传播:重新武装取消请求,让下一个检查点有机会再次检测到。用于「我要先做点清理/收尾,但取消这件事不能丢」。io.swapCancelProtection()让它变成unreachable:用于绝对不能被打断的临界区。
另外还有 io.checkCancel()——手动插入取消检查点。官方明确说「很少需要调用」,主要用途是长时间运行的 CPU 密集任务(比如你在算一个大矩阵,中间没有任何 I/O,那就没有天然的取消点)。
3.5 Io.Threaded 怎么取消一个阻塞的 syscall?
这是我看 release notes 时最惊讶的一段。
在纯线程实现里,一个线程卡在 read() 上,你怎么取消它?大部分运行时的答案是「取消不了,等着吧」,所以它们只在 await point 上支持取消。
Zig 的答案是:给线程发信号。
"Even
Io.Threadedsupports cancelation by sending a signal to a thread, causing blocking syscalls to returnEINTR, and responding to that error code by checking for a cancelation request before retrying the syscall."
拆开看这套机制:
- 取消请求 → 向目标线程发一个信号;
- 信号打断阻塞中的 syscall,syscall 返回
EINTR; - 标准库的 syscall 包装层捕获
EINTR,不是无脑重试,而是先检查有没有取消请求; - 有 → 返回
error.Canceled;没有 → 重试 syscall。
这套东西写起来不难,难在必须在标准库的每一个 syscall 包装点都正确实现,而且要处理 SA_RESTART、信号竞态、慢速设备等一堆边角。绝大多数语言的标准库不敢做这件事,因为 syscall 层是历史包袱最重的地方。
Zig 敢做,是因为它这几年一直在做另一件事:把 libc 依赖拆干净。0.16 里 Windows 网络完全绕开了 ws2_32.dll 直接走 AFD,Linux 上大量走裸 syscall。当整条链路都是自己的代码时,插一个取消检查点就变得可行了。
四、代码实战:从 0.15 迁移到 0.16
4.1 "Juicy Main":main 函数的新签名
先看一个 0.16 完整的 hello world:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const ptr = try gpa.create(i32);
defer gpa.destroy(ptr);
try std.Io.File.stdout().writeStreamingAll(io, "Hello, world!\n");
const args = try init.minimal.args.toSlice(init.arena.allocator());
for (args, 0..) |arg, i| {
std.log.info("arg[{d}] = {s}", .{ i, arg });
}
std.log.info("{d} env vars", .{init.environ_map.count()});
}
std.process.Init 的定义(官方原文):
pub const Init = struct {
minimal: Minimal,
/// 整个进程生命周期的永久存储,退出时自动清理。线程安全。
arena: *std.heap.ArenaAllocator,
/// 默认通用分配器,Debug 模式下自带泄漏检测。线程安全。
gpa: Allocator,
/// 基于 target 配置自动选择的 Io 实现。Debug 下自带泄漏检测。
io: Io,
/// 环境变量,用 gpa 初始化。非线程安全。
environ_map: *Environ.Map,
/// 父进程提供的具名文件(主要用于 WASI)。
preopens: Preopens,
pub const Minimal = struct {
environ: Environ,
args: Args,
};
};
main 的第一个参数现在可以是三选一:
- 不写:合法,但拿不到 CLI 参数和环境变量。
process.Init.Minimal:只有 argv 和 environ 的裸形式。process.Init:全套。
这个设计我很喜欢:它把「谁负责构造 Io 和 Allocator」这个问题给出了标准答案——main。以前每个项目都要在 main 里手写一遍 GPA 初始化、defer deinit、args 解析的样板代码,现在标准库替你做了,而且做得比大部分人手写的对(比如 arena 是线程安全的,环境变量非全局)。
另一个隐藏收益:环境变量和进程参数不再是全局状态。这对写库的人是解放——以前 std.process.getEnvVarOwned 是个全局函数,现在它挂在 init.environ_map 上,谁需要谁传。
4.2 一个 HTTP HEAD 请求,背后藏了多少东西
const std = @import("std");
const Io = std.Io;
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const host_name: Io.net.HostName = try .init(args[1]);
var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
defer http_client.deinit();
var request = try http_client.request(.HEAD, .{
.scheme = "http",
.host = .{ .percent_encoded = host_name.bytes },
.port = 80,
.path = .{ .percent_encoded = "/" },
}, .{});
defer request.deinit();
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
const response = try request.receiveHead(&redirect_buffer);
std.log.info("received {d} {s}", .{ response.head.status, response.head.reason });
}
看起来平平无奇,一段普通的同步代码。但因为网络栈现在跑在 std.Io 上,这段代码实际具备以下行为(官方列举):
- 并行向每个配置的 nameserver 发 DNS 查询——不是串行 fallback,是同时打出去。
- 每收到一个 DNS 响应,立即异步发起 TCP connect——不等所有 DNS 回来。
- 第一个 TCP 连接成功后,取消所有其他在途连接尝试,包括还没回来的 DNS 查询。
- 用
-fsingle-threaded编译照样能跑,只是这些操作退化成顺序执行。 - Windows 上全程不依赖
ws2_32.dll。
第 1~3 条合起来就是 Happy Eyeballs(RFC 8305)的核心行为。这东西在别的语言里要么得手写,要么得依赖一个几千行的网络库。在 Zig 0.16 里,它是「网络栈用了 Io 接口」的副产品。
而第 4 条才是这套设计真正的杀手锏:同一份源码,在有并发能力的环境里自动获得并发优化,在 freestanding / 单线程 / WASM 环境里自动退化成正确的顺序执行。 不需要 #[cfg(feature = "async")],不需要两套 API。
4.3 迁移清单一:文件系统
std.fs 整体搬到了 std.Io.Dir / std.Io.File。官方说这次迁移「虽然 diff 很大,但不需要什么批判性思考」——大部分时候就是加个 io 参数:
file.close(); → file.close(io);
std.fs.cwd() → std.Io.Dir.cwd()
std.fs.Dir → std.Io.Dir
std.fs.File → std.Io.File
但有一批重命名是语义性的,值得单独记:
| 0.15 | 0.16 | 为什么改 |
|---|---|---|
fs.Dir.makeDir | Io.Dir.createDir | 统一 create 动词 |
fs.Dir.makePath | Io.Dir.createDirPath | 同上 |
fs.Dir.makeOpenDir | Io.Dir.createDirPathOpen | 同上 |
fs.File.read | Io.File.readStreaming | 区分流式与定位式 |
fs.File.pread | Io.File.readPositional | 同上 |
fs.File.writeAll | Io.File.writeStreamingAll | 同上 |
fs.File.pwriteAll | Io.File.writePositionalAll | 同上 |
fs.File.setEndPos | Io.File.setLength | 说人话 |
fs.File.getEndPos | Io.File.length | 说人话 |
fs.File.Mode | Io.File.Permissions | Mode 这词在 Windows 上没意义 |
fs.Dir.chmod / chown | Io.Dir.setPermissions / setOwner | 去 POSIX 味 |
fs.realpath | Io.Dir.realPathFileAbsolute | 名字长了但无歧义 |
fs.selfExePath | std.process.executablePath | 这本来就不属于 fs |
streaming vs positional 这个区分特别重要。以前 read 和 pread 只差一个字母,但语义天差地别:一个动文件游标(有状态、多线程共享 fd 时是竞态源),一个不动(无状态、可安全并发)。在异步语境下,这个区分从「风格问题」变成了「正确性问题」——流式读写在并发场景下必须串行化,定位读写不用。改名把这个陷阱摆到了明面上。
还有一批 *Z / *W 后缀函数(realpathZ、deleteFileW……)被直接删除,无替代。这些是 null-terminated / wide-string 的平台特化版本,现在统一由内部处理。如果你的代码在用它们,说明你在跟平台细节搏斗——现在标准库替你搏斗了。
4.4 迁移清单二:线程池没了
std.Thread.Pool 被删除,取而代之的是 std.Io 的并发原语。官方给了一个非常典型的迁移示例:
// ===== 0.15 =====
fn doAllTheWork(pool: *std.Thread.Pool) void {
var wg: std.Thread.WaitGroup = .{};
pool.spawnWg(wg, doSomeWork, .{ pool, &wg, first_work_item });
wg.wait();
}
fn doSomeWork(pool: *std.Thread.Pool, wg: *std.Thread.WaitGroup, foo: Foo) void {
foo.doTheThing();
for (foo.new_work_items) |new| {
pool.spawnWg(wg, doSomeWork, .{ pool, wg, new });
}
}
// ===== 0.16 =====
fn doAllTheWork(io: std.Io) void {
var g: std.Io.Group = .init;
// 即使当前 doAllTheWork 不会失败,也建议加上:
// 万一以后它变成 fallible,这行能防止引入资源泄漏 bug
errdefer g.cancel(io);
g.async(io, doSomeWork, .{ io, &g, first_work_item });
try g.await(io);
}
fn doSomeWork(io: std.Io, g: *std.Io.Group, foo: Foo) void {
foo.doTheThing();
for (foo.new_work_items) |new| {
g.async(io, doSomeWork, .{ io, g, new });
}
}
这里有个必须注意的正确性陷阱(官方明确警告):从 Thread.Pool 迁到 std.Io 时,代码里所有的 Thread.Mutex、Thread.Condition、Thread.ResetEvent 必须同步换成 Io 版本。
原因很直白:Thread.Mutex 在竞争时会阻塞操作系统线程。如果你的 Io 实现是 Io.Evented(用户态栈切换),阻塞 OS 线程意味着把整个 work-stealing 调度器的一个 worker 干掉了——轻则性能雪崩,重则死锁。而 Io.Mutex 在 Io.Threaded 下会阻塞线程,在 Io.Evented 下会切栈。
完整的同步原语迁移表:
| 0.15 | 0.16 |
|---|---|
std.Thread.ResetEvent | std.Io.Event |
std.Thread.WaitGroup | std.Io.Group |
std.Thread.Futex | std.Io.Futex |
std.Thread.Mutex | std.Io.Mutex |
std.Thread.Condition | std.Io.Condition |
std.Thread.Semaphore | std.Io.Semaphore |
std.Thread.RwLock | std.Io.RwLock |
std.once | 删除,别用全局变量,或者自己手搓 |
std.once 被删掉这条我特别想说一句:这是 Zig 一以贯之的态度——全局单例初始化是设计问题,不是 API 缺失。给你一个 once 就等于鼓励你写全局状态。删掉它,逼你把依赖放进结构体、传进函数。写起来烦一点,但代码能测了。
4.5 迁移清单三:Reader/Writer 的收尾(Writergate 续集)
0.15 的 "Writergate" 重写了 Reader/Writer,0.16 把旧的彻底清理掉了:
std.io → std.Io
std.Io.GenericReader → std.Io.Reader
std.Io.AnyReader → std.Io.Reader
std.leb.readUleb128 → std.Io.Reader.takeLeb128
std.leb.readIleb128 → std.Io.Reader.takeLeb128
FixedBufferStream 也没了,改成直接构造:
// 读
var fbs = std.io.fixedBufferStream(data);
const reader = fbs.reader();
// ⬇️
var reader: std.Io.Reader = .fixed(data);
// 写
var fbs = std.io.fixedBufferStream(buffer);
const writer = fbs.writer();
// ⬇️
var writer: std.Io.Writer = .fixed(buffer);
少了一层间接,也少了一个「stream 对象要活到 reader 之后」的生命周期陷阱。
4.6 迁移清单四:@Type 被拆成 8 个 builtin
这是语言层最大的破坏性变更。@Type 被移除,替换为 8 个专用 builtin(实现的是 2021 年就通过的提案 #10710):
@EnumLiteral、@Int、@Tuple、@Pointer、@Fn、@Struct、@Union、@Enum
对比一下就明白为什么要改:
// 造一个 u10
@Type(.{ .int = .{ .signedness = .unsigned, .bits = 10 } })
// ⬇️
@Int(.unsigned, 10)
// 造一个 tuple
@Type(.{ .@"struct" = .{
.layout = .auto,
.fields = &.{
.{ .name = "0", .type = u32, .default_value_ptr = null, .is_comptime = false, .alignment = @alignOf(u32) },
.{ .name = "1", .type = [2]f64, .default_value_ptr = null, .is_comptime = false, .alignment = @alignOf([2]f64) },
},
.decls = &.{},
.is_tuple = true,
} })
// ⬇️
@Tuple(&.{ u32, [2]f64 })
// 造一个 *const u32
@Type(.{ .pointer = .{
.size = .one, .is_const = true, .is_volatile = false,
.alignment = @alignOf(u32), .address_space = .generic,
.child = u32, .is_allowzero = false, .sentinel_ptr = null,
} })
// ⬇️
@Pointer(.one, .{ .@"const" = true }, u32, null)
顺便,std.meta.Int 和 std.meta.Tuple 这两个「大家都在用的补丁」正式废弃——它们的存在本身就证明了 @Type 设计有问题。
新 builtin 用了「struct of arrays」的参数风格(字段名数组、字段类型数组、字段属性数组分开传)。这个风格初看别扭,但有个杀手级好处:@splat 可以一把梭。
// 所有参数都用默认属性
@Fn(param_types, &@splat(.{}), ReturnType, .{ .@"callconv" = .c })
// 造一个「字段名来自枚举、字段类型全部相同」的结构体 —— 一行
const MyStruct = @Struct(.auto, null, std.meta.fieldNames(MyEnum), &@splat(FieldType), &@splat(.{}));
最后那行在写序列化框架、ORM、状态机的时候会非常香。以前这需要一个 20 行的 comptime 循环。
4.7 迁移清单五:@cImport 弃用
@cImport 这个「语言内联 C 翻译」的 builtin 被标记为弃用,功能移交给 build system:
// ===== 旧:c.zig =====
pub const c = @cImport({
@cInclude("stdio.h");
@cInclude("GLFW/glfw3.h");
});
// ===== 新:src/c.h =====
#include <stdio.h>
#include <GLFW/glfw3.h>
// ===== 新:build.zig =====
const translate_c = b.addTranslateC(.{
.root_source_file = b.path("src/c.h"),
.target = target,
.optimize = optimize,
});
translate_c.linkSystemLibrary("glfw", .{});
const exe = b.addExecutable(.{
.name = "tetris",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.optimize = optimize,
.target = target,
.imports = &.{
.{ .name = "c", .module = translate_c.createModule() },
},
}),
});
然后 const c = @import("c");。
为什么要这么折腾? 因为 @cImport 是个架构污点:它让编译器必须内嵌一个 C 编译器前端。把它挪到 build system 之后,translate-c 变成一个普通的可替换包(官方还提供了独立的 translate-c 包,功能更多),编译器可以瘦身。这条线索会在下一节的「编译器架构」里继续。
4.8 其他值得一记的小改动
ArrayHashMap系列大清洗:ArrayHashMap/AutoArrayHashMap/StringArrayHashMap(managed 版本)全部删除。AutoArrayHashMapUnmanaged→array_hash_map.Auto,StringArrayHashMapUnmanaged→array_hash_map.String,ArrayHashMapUnmanaged→array_hash_map.Custom。「managed / unmanaged」这个含糊的词从此退出历史舞台——只剩一个变体,就不需要形容词了。std.mem命名规范化:新增cut/cutPrefix/cutSuffix/cutScalar/cutLast/cutLastScalar;「index of」统一改叫「find」。命名约定确立为「一个概念一个词,拼接成函数名」:find(返回子串索引)、pos(起始索引参数)、last(从尾部搜)、linear(朴素循环而非高级算法)、scalar(子串是单个元素)。这套规则一旦记住,你能猜出函数名,这比背 API 强太多。switch增强:packed struct和packed union现在可以做 switch 分支项,按 backing integer 比较;decl literal 和任何需要 result type 的表达式(如@enumFromInt)都能做分支项;union tag 捕获不再限于inline分支。std.testing.io:测试时的推荐用法,和std.testing.allocator一个套路。
五、性能与运行时优化
5.1 ArenaAllocator 变成无锁线程安全
heap.ArenaAllocator 被重写成无锁 + 线程安全,同时 heap.ThreadSafeAllocator 被删除。
这个改动的动机很有意思,不是「为了性能」,而是为了打破循环依赖:
"By avoiding locks, we avoid needing Sync Primitives and thereby avoid needing an Io instance, and also allow the Allocator to be used as the backing allocator for an Io instance."
拆解一下这个依赖环:
Io 实现需要分配内存 → 需要 Allocator
↓
线程安全的 Allocator 需要 Mutex
↓
0.16 的 Io.Mutex 需要 Io 实例
↓
回到起点 💥
用无锁算法实现 Arena,环就断了:Arena 不需要 Mutex → 不需要 Io → 可以做 Io 的后备分配器。
这是一个典型的「架构约束驱动实现选择」的案例。很多人以为无锁数据结构是为了性能才写的,实际上在系统软件里,更多时候是为了避免锁带来的依赖关系和可重入性问题。
性能数据(官方):单线程访问时与旧实现性能相当;相比「旧实现 + ThreadSafeAllocator 包装」,在约 7 线程以内有轻微加速。注意这个「7 线程」的天花板——无锁 Arena 的 bump 指针本质是一个原子 CAS 热点,线程再多就是缓存行争抢,不会线性扩展。官方也没吹这个牛。
heap.DebugAllocator 计划做同样的改造。
5.2 从零实现 deflate 压缩
0.16 新增了 deflate 压缩(此前只有解压),完全从零实现:writer 的 buffer 里保留 history window 做匹配,用链式哈希表找匹配,token 累积到阈值后作为一个 block 输出。
另外提供两个变体:
- Raw:只写 store block(不压缩),利用 data vector 高效发送 block header + 数据。
- Huffman:只做 Huffman 编码,不做匹配。
这两个变体因为不需要保留 history,能充分利用 writer 语义。
跟 zlib 的对比(官方 benchmark,20 轮):
压缩率:zlib 略胜——默认级别好 1.00%,最高级别好 0.77%。官方坦白说 zlib 选的匹配略有不同,总匹配字节数更少,「以后想搞清楚这个问题并追平」。
默认压缩级别的速度:
| 指标 | zlib (zpipe) | std-deflate | 差异 |
|---|---|---|---|
| wall_time | 252ms | 228ms | ⚡ -9.7% |
| cpu_cycles | 1.19G | 1.07G | ⚡ -9.8% |
| instructions | 1.83G | 2.18G | 💩 +18.9% |
| cache_references | 117M | 95.0M | ⚡ -18.7% |
| cache_misses | 1.66M | 874K | ⚡ -47.3% |
| branch_misses | 13.6M | 6.30M | ⚡ -53.7% |
最高压缩级别:
| 指标 | zlib | std-deflate | 差异 |
|---|---|---|---|
| wall_time | 803ms | 797ms | -0.8% |
| instructions | 5.32G | 8.19G | 💩 +54.1% |
| cache_misses | 7.91M | 4.63M | ⚡ -41.5% |
| branch_misses | 28.6M | 6.98M | ⚡ -75.6% |
这组数据值得单独品一下。 指令数多了 19%~54%,墙钟时间却更短甚至持平——这是现代 CPU 上非常典型的现象:分支预测失败和缓存未命中的代价,远远超过多执行几条指令。zlib 的代码是 1995 年的产物,为当年「指令昂贵、内存便宜」的机器优化;Zig 的新实现(可能大量用了分支消除、查表、数据布局优化)为「指令便宜、内存和分支昂贵」的现代流水线优化。
分支预测失败少 75.6% 这个数字,基本可以断定新实现在热路径上做了大量「用算术代替分支」的改写。release notes 也提到:literal / distance code 参数现在用数学推导而不是查表(更贵的部分仍保留查表,ReleaseSmall 下除外)。
解压性能(新 vs 旧):wall_time 44.1ms → 39.9ms(-9.5%),指令数 459M → 410M(-10.7%),分支预测失败 3.16M → 2.58M(-18.3%)。这次是靠简化位读取逻辑——利用新 Reader 支持 peek 的能力,砍掉了一大堆状态机代码。
5.3 Windows 网络:干掉 ws2_32.dll
Windows 上所有网络 API 现在直接走 AFD(Ancillary Function Driver,Winsock 底下的那层内核驱动),不再链接 ws2_32.dll。
收益(官方原文):
- 修了一批 bug;
- 让 Cancelation 和 Batch 在网络操作上正常工作;
- 绕开
ws2_32.dll的性能坑——比如它「为 socket handle 的附加数据维护了一个完全不必要的哈希表,需要分配和同步,而不是简单地把 socket mode 和 protocol 传给 accept 函数」。
第 2 点才是真正的动机。ws2_32.dll 是个黑盒,你没法在它内部插取消检查点。要让取消在 Windows 上和 Linux 上语义一致,就只能自己实现整条链路。
这体现了 0.16 的一个整体思路:为了让抽象在所有平台上语义一致,宁可下沉到更底层重写。这条路很贵,但走通之后,「一次编写,处处正确」才不是空话。
六、编译器与工具链:另一条暗线
如果只看 std.Io,会错过 0.16 同样重要的另一半:编译器架构。
6.1 增量编译:从「能用」到「真的快」
增量编译(incremental compilation)在 0.16 里有了质变。核心改进:
第一,消除「过度分析」。 以前改一行代码,编译器可能重新分析半个项目。0.16 通过 Reworked Type Resolution 让编译器内部依赖图变成无环的(除非真有依赖循环),从而精确定位需要重编的部分。
官方给的数据是:在 Zig 编译器自身上做增量编译,以前会重编几乎整个编译器的改动,现在在毫秒级完成。
这里有个值得所有做编译器/构建系统的人记住的教训:增量编译的瓶颈往往不是「增量算法不够聪明」,而是依赖图本身有环。有环就得保守地扩大重算范围,再聪明的缓存也救不回来。0.16 的提速主要来自把图改成无环,而不是优化了某个算法常数。这是架构问题,不是性能问题。
第二,消除增量与非增量的行为差异。 以前增量编译会报出非增量编译不会报的「dependency loop」错误,反之亦然。这是「增量编译不可信」的最大来源——同样的代码,--watch 里红了,全量编译却是绿的,你根本不知道该信谁。这个问题在 Reworked Type Resolution 里一并解决了。
第三,LLVM 后端现在也支持增量编译了。 注意范围:这不会加速「LLVM Emit Object」阶段(那是 LLVM 自己的事),但会加速 Zig 编译器构建 LLVM bitcode 的过程。更实际的收益是:当你的代码有编译错误时,能得到近乎即时的反馈——因为有编译错误时 "LLVM Emit Object" 会被跳过。
第四,怎么开。 增量编译仍然默认关闭(还有已知 bug,包括 miscompilation),但官方强烈建议打开试试:
zig build -fincremental --watch
这会起一个常驻进程,监听文件变化并自动执行增量更新。官方原话是「用户经常惊讶于自己能省下多少时间,哪怕只是近乎即时的编译错误反馈」。
我的建议:日常开发开,CI 关。有 miscompilation 风险的功能,不该出现在产出交付物的链路上。
6.2 新 ELF 链接器:链接时间砍掉 66%
新的 ELF 链接器可以用 -fnew-linker 打开,或在 build 脚本里 exe.use_new_linker = true。当同时使用 -fincremental 且目标是 ELF 时,它已是默认。
官方性能数据——构建 Zig 编译器,然后改一行代码,再改一行:
| 场景 | 首次 | 第二次 | 第三次 |
|---|---|---|---|
| 旧链接器 | 14s | 194ms | 191ms |
| 新链接器 | 14s | 65ms | 64ms |
| 完全跳过链接 | 14s | 62ms | 62ms |
新链接器比旧的快 66%,而且——这才是最有意思的——距离「完全不链接」只差 2~3ms。
官方由此得出一个很实际的结论:
"The performance is fast enough that there is no longer much benefit to exposing a
-Dno-binbuild step. You might as well keep codegen and linking always enabled."
很多 Zig 项目为了加快「只查错不出二进制」的循环,专门加了 -Dno-bin 的构建步骤。现在这个优化没意义了——开销小到不值得为它增加一个配置维度。这是我很欣赏的工程判断:当一个优化带来的收益小于它引入的配置复杂度时,就该删掉它。
但有个大坑:新链接器还不是 feature complete。最要命的一条是产出的可执行文件没有 DWARF 调试信息。所以旧链接器和 LLD 都还在。等新链接器补齐后,旧链接器会被删除,LLD 依赖会被移除。
6.3 后端现状:x86 稳、aarch64 停
x86 后端:修了 11 个 bug,常量 memcpy 的代码生成改善。官方对它和 LLVM 后端的对比说得很实在:
相比 LLVM 后端,这个后端通过更多 behavior test、编译速度显著更快、调试信息更好,但机器码质量更差。它仍然是 Debug 模式的默认后端。
这个定位非常清晰:Debug 用自研后端(编译快、调试信息好),Release 用 LLVM(代码质量高)。这也是 Zig 长期的战略——先在 Debug 场景把 LLVM 换掉,因为那里 LLVM 的优势(优化质量)根本用不上,劣势(慢)却被无限放大。
aarch64 后端:这个版本基本停摆。原文很直白:「本周期进展暂停,因为 I/O as an Interface 的改动太大。目前跑 behavior test 会崩溃。」
对 Apple Silicon 和 ARM 服务器用户来说,这意味着你在 Debug 模式下依然走 LLVM,享受不到自研后端的编译速度。官方说等标准库的动荡平息后会重新推进——而且在 roadmap 里,「完成 aarch64 后端并让它成为 Debug 默认后端」是 1.0 前的四大目标之一。
6.4 LLVM 21 与一个必须知道的坑
0.16 升级到 LLVM 21.1.0(含 Clang / libc++ / libc++abi / libunwind / libtsan)。
但是:循环向量化(loop vectorization)被完全禁用了。
原因:LLVM 21 有一个回归 bug,会在常见配置下把 Zig 编译器自己编错(miscompile)。团队试过用「禁用特定 CPU 特性」来绕,太脆弱,于是干脆整个关掉循环向量化。
代价和时间线(官方原文):
- 这会让某些场景的代码生成变差;
- bug 已上报并在上游修复;
- 但截至发布时,修复还没被 cherry-pick 进 LLVM 22.x 分支;
- 因此这个性能回归不仅影响 Zig 0.16.x,还会影响 0.17.x,要到 0.18.x 才能解决。
这条必须写进你的技术选型备忘录。 如果你的 Zig 项目是数值计算、图像处理、编解码这类高度依赖自动向量化的负载,0.16/0.17 会有可感知的性能下降。缓解手段:手写 @Vector SIMD(Zig 的向量类型是一等公民,不依赖 LLVM 的自动向量化),或者暂时留在 0.15。
顺带一提,@Vector 在这个版本也有相关变更:禁止运行期向量索引、向量与数组不再支持内存内强转(in-memory coercion)。如果你在写 SIMD 代码,这两条会直接影响你。
6.5 构建系统:两个真正好用的新功能
--fork=[path]:本地覆盖依赖包
zig build --fork=/home/andy/dev/dvui
指定路径下要有 build.zig.zon,含 name 和 fingerprint。只要依赖树里任何位置解析出 name + fingerprint 匹配的包,就整棵树替换成这个本地版本,完全忽略版本号,而且是在「可能去 fetch」之前解析。
有防呆:
$ zig build --fork=/home/andy/dev/mime
error: fork /home/andy/dev/mime matched no mime packages
$ zig build --fork=/home/andy/dev/dvui
info: fork /home/andy/dev/dvui matched 1 (dvui) packages
匹配不上直接报错,匹配上了明确提示——防止你以为在用 fork 结果其实在用线上版本,或者反过来。
这个设计的精妙之处在于「它是 CLI flag 而不是配置文件项」。 官方原话:「这个事实让它具有恰当的临时性。你一丢掉这个 flag,就回到了纯净的、已 fetch 的依赖树。」
对比一下 npm 的 link、Go 的 replace 指令、Cargo 的 [patch]——这些都是写进文件的,于是每个人都干过「本地 link 忘了删,提交上去把 CI 搞挂」的事。做成 flag 从机制上消灭了这类事故。
代价:这个功能依赖新的 hash 格式,因此旧 hash 格式支持被移除。老项目升级时会遇到。
--test-timeout:单测超时
zig build test --test-timeout 500ms
超时的单个 test 会被强制终止(杀掉并重启 test 进程),然后继续跑下一个:
test
└─ run test 1 pass, 2 timeout (3 total)
error: 'main.test.first slow test' timed out after 499.491ms
error: 'main.test.second slow test' timed out after 499.609ms
Build Summary: 1/3 steps succeeded (1 failed); 1/3 tests passed (2 timed out)
注意官方的提醒:超时按真实时间(real time)计算,不是 CPU 时间。在高负载机器上,调度压力可能导致意外超时。所以 CI 里设这个值要留足余量,或者只在本地用来抓「死循环测试」。
6.6 Fuzzer:Smith 接口
内置 fuzzer 的接口从 []const u8 换成了 *std.testing.Smith:
// 旧
fn fuzzTest(_: void, input: []const u8) !void {
var sum: u64 = 0;
for (input) |b| sum += b;
try std.testing.expect(sum != 1234);
}
// 新
fn fuzzTest(_: void, smith: *std.testing.Smith) !void {
var sum: u64 = 0;
while (!smith.eosWeightedSimple(7, 1)) {
sum += smith.value(u8);
}
try std.testing.expect(sum != 1234);
}
Smith 的基础方法:value(生成任意类型的值)、eos(生成流结束标记,保证最终会返回 true)、bytes(填充字节数组)、slice(填充 buffer 的一部分并给出长度)。
关键升级是 Smith.Weight(权重):可以给值分配被选中的概率,用来(1)让「有意思的值」更常出现,(2)降低做重活的概率,(3) 约束可选值范围。权重只能用于 64 位以内的类型;空权重切片意味着所有值权重为 0,都不会被选中。
配套还有 baselineWeights(覆盖某类型所有可能值的权重集)、boolWeighted / eosSimpleWeighted、valueRangeAtMost / valueRangeLessThan。
从 []const u8 到结构化生成,这是现代 fuzzer 的标准演进方向(对标 Rust 的 arbitrary crate、Go 的 f.Fuzz)。裸字节流 fuzz 对付解析器还行,对付「需要构造合法状态机输入」的场景效率极低——99.9% 的随机字节在第一个 check 就被拒了。结构化生成 + 权重,本质是把「语法约束」编码进 fuzzer,让它把算力花在语义层的边界上。
roadmap 里明确写了:要把内置 fuzzer 做到能和 AFL 等业界最强方案竞争。
6.7 平台支持的加减法
加:aarch64-freebsd、aarch64-netbsd、loongarch64-linux、powerpc64le-linux、s390x-linux、x86_64-freebsd、x86_64-netbsd、x86_64-openbsd 现在都在 Zig CI 上原生测试(感谢 OSUOSL 提供 AArch64 / Power ISA 硬件,IBM 提供 z/Architecture 硬件)。新增 aarch64-maccatalyst / x86_64-maccatalyst 交叉编译支持,初步支持 loongarch32-linux。Alpha、KVX、MicroBlaze、OpenRISC、PA-RISC、SuperH 有了基础支持(目前需走 C 后端 + GCC,或外部 LLVM/Clang fork)。
减:Solaris、AIX、z/OS 支持被移除。理由说得很硬气:
「总的来说,Zig 项目无法支持那些让获取系统头文件变得不合理地困难、从而无法审计贡献的专有操作系统。」
注意 illumos 不受影响——它是 OpenSolaris 的开源分支,继续支持。
这个决定值得单独说一句。 拒绝支持「拿不到头文件、没法审计代码」的闭源系统,表面上是资源问题,实质是供应链安全立场:如果一个 target 的贡献代码没人能验证正确性(因为验证需要专有 SDK),那它就是项目里的一个不可信区域。宁可砍掉,也不留一块黑箱。在软件供应链攻击成为常态的今天,这个态度比技术本身更重要。
另外,栈追踪(stack trace)支持全面改善,几乎所有主流 target 现在崩溃时都能打出栈追踪。以及一批影响弱内存序架构(AArch64 without LSE、LoongArch、Power ISA)和非常规页大小平台的标准库 bug 被修复,大端序主机上的编译器 bug 也清理了。
七、十条踩坑清单
按照我梳理这些变更时判断的「踩坑概率 × 排查难度」排序:
cancel返回值必须处理。_ = fut.cancel(io) catch {};会静默泄漏成功返回的资源(fd、内存、锁)。凡是任务返回需要释放的资源,一律写成if (fut.cancel(io)) |r| r.deinit(io) else |_| {}。这个坑不会报错,只会在压测两小时后表现为 fd 耗尽。从
Thread.Pool迁移时忘了换同步原语。 留着Thread.Mutex配Io.Evented= 阻塞调度器 worker = 性能雪崩或死锁。这是正确性 bug,不是性能问题。迁移时全局搜索std.Thread.挨个换。LLVM 21 循环向量化被禁用,影响 0.16 和 0.17 两个版本。 数值/多媒体/编解码类负载会有可感知回归。要么手写
@Vector,要么暂缓升级。这不是 bug,是官方明知代价的取舍,不要浪费时间去 profile 找原因。新 ELF 链接器没有 DWARF 信息。 你开了
-fincremental(ELF 下自动启用新链接器),然后发现 gdb/lldb 里啥都看不到。要调试就显式用旧链接器或 LLD。增量编译仍有 miscompilation 风险。 默认关闭是有原因的。本地开发开,CI 和 release 构建务必关闭。
io.async不保证并发。 如果你的逻辑依赖「这两件事必须同时进行」(比如无界队列的生产者/消费者),用io.async会在-fsingle-threaded或受限Io实现下死锁。这种场景必须用io.concurrent并处理error.ConcurrencyUnavailable。--test-timeout是墙钟时间。 CI 机器负载高的时候会假阳性。设值时留 3~5 倍余量,或者只用它抓死循环。--fork依赖新 hash 格式,旧格式支持已移除。 老项目升级 0.16 时,build.zig.zon里的旧 hash 会直接报错,需要重新zig fetch。fs.File.read→readStreaming不只是改名。streaming会动文件游标,多个并发任务共享同一个 File 时是竞态源。异步化之后,原本「顺序执行所以碰巧没事」的代码会开始出问题。并发访问同一文件请一律换readPositional/writePositional。std.once被删除。 如果你的代码里有懒初始化的全局单例,0.16 会直接编译失败。这是设计上的驱赶——正确做法是把状态放进结构体从main往下传,而不是自己手搓一个once。
附加提醒:如果你在某处实在拿不到 Io 实例(比如在改造一个巨大的老项目,做不到一次性透传),官方给了逃生舱:
var threaded: Io.Threaded = .init_single_threaded;
const io = threaded.io();
但官方明确说这是非理想的权宜之计——「就像你需要 Allocator 却没有时去抓 std.heap.page_allocator 一样」。它只在你不需要 task 级并发时管用。正确做法是让函数接受 Io 参数,或者把它存在 context struct 上。应用的 main 函数应当负责构造整个程序使用的 Io 实例。
八、往前看:0.17 和 1.0 之前
8.1 官方 Roadmap
0.17.0 将是一个短周期,主要目标只有两个:
- 升级到 LLVM 22;
- 完成 make 进程(build runner)与 configure 进程(build.zig)的分离。
之后的重大方向(1.0 之前的四大战役):
- 完成并稳定语言本身;
- 完成 aarch64 后端,让它成为 Debug 模式默认后端;
- 增强链接器实现,消除对 LLD 的依赖,并支持增量编译;
- 增强内置 Fuzzer,做到能和 AFL 等业界顶尖方案竞争;
- 把对 LLVM 的「库依赖」转变成对 Clang 的「进程依赖」。
最后一条影响最深远。现在的 Zig 编译器静态链接了整个 LLVM,这带来两个后果:编译器体积巨大、构建 Zig 本身极其痛苦(要先编 LLVM)。改成「进程依赖 Clang」之后,Zig 编译器可以做到很小,LLVM 变成一个可选的外部工具——这才是「Zig 是一个更好的 C 编译器」这个卖点的最终形态。
8.2 master 分支上已经发生的事
0.16 发布后,master 上(也就是未来的 0.17)已经落地了几个值得关注的改动:
包管理全部从编译器搬到 build system。 现在的进程树是:
zig build (编译器)
└─ maker (build system + 包管理)
└─ configurer (用户的 build.zig 逻辑)
搬走的东西包括:包获取逻辑、HTTP 客户端和网络、TLS 及相关加密、Git 协议、xz/gzip/zstd/flate/zip、build.zig.zon 的解析验证。
三个直接收益:
- 这些功能现在以源码形式分发,不重新编译编译器就能打补丁——用户和贡献者都更好折腾了。
- 包管理的网络部分现在开着安全检查,因为 maker 是用
ReleaseSafe模式编译的。 - 加密和哈希可以用宿主机上的特殊 CPU 指令,甚至那些罕见到平时不敢依赖的指令也能用。原文的说法很妙:「We can have AOT cake and eat JIT, too!」
副作用:Zig 可执行文件体积缩小 4%(无 LLVM、ReleaseSmall 下从 14.1 MiB 降到 13.5 MiB);--maker-opt flag 被 ZIG_DEBUG_MAKER 环境变量取代;--zig-lib-dir 被 ZIG_LIB_DIR 取代。
@bitCast 语义重新定义。 从「重解释内存字节」改成「重解释类型的逻辑位布局」。最大的行为差异在聚合类型上:
test "bitcast [2]u3 to @Vector(3, u2)" {
const arr: [2]u3 = .{ 0b001, 0b011 };
const vec: @Vector(3, u2) = @bitCast(arr);
// 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);
}
关键变化:[2]u8 bitcast 到 u16 现在在所有平台行为一致(第一个元素变成低 8 位),不再依赖目标平台的字节序。新语义大体上等同于旧语义在小端平台的行为。这对写跨平台二进制协议解析的人是个好消息——少了一类只在大端机器上炸的 bug。
SPIR-V 后端大幅推进:新增 @SpirvType builtin(解决了写 shader 的最大历史阻塞)、执行模式挂到调用约定上、能力和扩展改由 CPU feature set 驱动、codegen 多线程化。spirv64-vulkan 目标的 behavior test 通过率提升近 10% 到 49%,std.gpu 更名为 std.spirv。想用 Zig 写 shader / compute kernel 的人,现在是个不错的入场时机。
九、总结:我怎么看这个版本
9.1 这不是「Zig 加了个异步」
如果你把 0.16 理解成「Zig 终于有 async 了」,就完全没抓住重点。
std.Io 真正给出的是三样东西,异步只是其中之一:
第一,可替换性。 同一份业务代码,能跑在线程池上、绿色线程上、io_uring 上、GCD 上,也能跑在「什么 I/O 都不支持」的 Io.failing 上。这在内核开发、嵌入式、WASM、以及需要确定性回放的系统里,价值远超性能。
第二,可测试性。 std.testing.io 和 std.Random.IoSource 意味着时间、随机数、文件、网络都可以被注入。写过测试的人都知道,「怎么让这段代码在测试里不真的睡 3 秒 / 不真的连数据库」是永恒的痛。以前的解法是层层 mock 接口,现在标准库直接把这个能力做进了 I/O 抽象。
第三,可取消性。 error.Canceled 进错误集,配合编译器强制的错误处理,把「资源泄漏」这个运行期问题变成了编译期问题的一部分。这是对 Go context 模式的实质性改进。
异步只是这三样东西的自然推论。 你把「阻塞」抽象成接口,异步实现就成了接口的一种;你把「取消」做成错误,超时和竞速就成了组合子。这才是好的架构设计——不是为每个需求加一个功能,而是找到一个抽象,让这些需求变成推论。
9.2 代价也很实在
破坏性变更的规模是空前的。 fs、net、http、Thread.Pool、Reader/Writer、@Type、@cImport、ArrayHashMap……几乎所有非平凡的 Zig 项目升级 0.16 都要改上百处。官方自己都说 fs 的迁移「diff 会很大」。
实现的成熟度参差不齐。 唯一 production-ready 的是 Io.Threaded。Io.Evented(真正的高性能方案)是实验性的,Io.Uring 缺网络、缺错误处理、缺测试覆盖、缺最小化 task 栈分配。如果你冲着「Zig 能用 io_uring 了」升级,会失望。
aarch64 后端停摆,Apple Silicon / ARM 服务器用户 Debug 模式仍走 LLVM。
LLVM 21 的向量化回归要背两个版本。
9.3 一句话建议
- 新项目 / 玩具项目:直接上 0.16,享受 Juicy Main 和新的并发原语,日常开
-fincremental --watch。 - 有一定规模的现存项目:不着急。等 0.17(LLVM 22 + 构建系统分离)落地后一起升,那时候生态库也跟上了。现在升,你会花大量时间在给整个调用链透传
io参数上。 - 性能敏感的数值/多媒体项目:0.16 和 0.17 的向量化回归是硬伤,评估清楚再动。
- 想学习「接口驱动设计」的人:不管你写不写 Zig,
std.Io的设计文档都值得读一遍。async与concurrent的语义分野、Future与Batch的分层、把 cancelation 塞进错误集——这三个点,换到任何语言、任何运行时设计里都成立。
Zig 花了几年时间没有仓促地把 async 关键字加回来,而是等到想清楚「异步的本质是什么」才动手。0.16 给出的答案是:异步不是一种函数,而是一种依赖。
把它变成参数,剩下的问题就都好办了。
参考资料
- Zig 0.16.0 Release Notes(ziglang.org/download/0.16.0/release-notes.html)
- Zig Devlog 2026(ziglang.org/devlog/2026/)
- Zig 下载页与 master 构建信息(ziglang.org/download/)
本文所有性能数据、API 签名与代码示例均出自上述官方材料;架构分析、选型建议与踩坑清单为笔者基于官方材料的整理与判断。