编程 Zig 0.16 深度拆解:当一门语言决定把 async/await 从语法里删掉——I/O as an Interface、有栈协程与「可取消性」的架构手术

2026-08-10 03:20:22

Zig 0.16 深度拆解:当一门语言决定把 async/await 从语法里删掉——I/O as an Interface、有栈协程与「可取消性」的架构手术

8 个月,244 位贡献者,1183 个 commit。Zig 0.16.0 最扎眼的一行更新日志是:从此以后,所有输入输出都必须传入一个 Io 实例。

这不是一次库层面的重构。这是一门系统语言在回答一个所有语言都回避的问题:异步到底应该是语法特性,还是应该是一个参数?


一、背景:Zig 曾经有 async/await,然后亲手删掉了它

先把时间线捋清楚,否则你会误以为 0.16 是「Zig 终于支持异步了」——恰恰相反。

2019–2020:Zig 曾经拥有过一套被社区津津乐道的「无色异步」(colorless async/await)。同一个函数,编译成阻塞版本还是异步版本,由调用点决定,编译器通过分析生成对应的状态机。那时候的宣传语是「Zig 解决了函数颜色问题」。

2023,0.11 起:自托管编译器接管后端,async/await 被标记为「暂时移除」。理由很技术:无色异步要求编译器对每一个可能挂起的函数做栈帧大小推断,而这与增量编译、与 comptime 求值、与递归函数的栈计算全部冲突。这一「暂时」,就是三年多。

2025,0.15:社区称之为 Writergate 的事件发生了。std.io.Reader / std.io.Writer 从泛型 GenericReader(Context, ...) 被整个重写成非泛型的、自带缓冲区的具体类型。几乎所有 Zig 代码库都被迫改一遍。当时很多人骂,但现在回头看,那是 0.16 的前置手术——你必须先让 I/O 的数据通道去泛型化,才可能让 I/O 的控制流可插拔。

2026,0.16.0:官方 Release Notes 的原话是:

Starting with Zig 0.16.0, all input and output functionality requires being passed an Io instance. Generally, anything that potentially blocks control flow or introduces nondeterminism is grounds for being owned by the I/O interface.

翻译成人话:凡是可能阻塞控制流、或者引入不确定性的东西,都归 Io 接口管。 文件、网络、进程、时间、随机数、锁、睡眠——全部。

注意这个定义的野心。它不是「I/O 归 Io 管」,而是「非确定性归 Io 管」。随机数不是 I/O,但它非确定;Mutex 不是 I/O,但它会阻塞。于是 std.crypto.random 没了,std.Thread.Mutex 没了,std.time.Timer 没了。

这条线划得比任何一门主流语言都狠。


二、核心概念:Io 不是运行时,是一个胖指针

2.1 它长什么样

Zig 的 Io 在结构上跟 Allocator 是同构的——一个 userdata 指针 + 一张 vtable。Release Notes 里给出了真实的方法签名,我们可以从中反推出它的形态:

pub fn random(io: Io, buffer: []u8) void {
    return io.vtable.random(io.userdata, buffer);
}

pub const RandomSecureError = error{EntropyUnavailable} || Cancelable;

/// 从进程外部获取密码学安全的熵。
/// 总是发起系统调用,不依赖进程内存中存储的 RNG 状态。
pub fn randomSecure(io: Io, buffer: []u8) RandomSecureError!void {
    return io.vtable.randomSecure(io.userdata, buffer);
}

三个细节值得停下来看:

  1. io.vtable.xxx(io.userdata, ...) —— 经典的手写动态分发,没有语言级 interface/trait,全靠函数指针表。Zig 的一贯风格:不给你抽象糖,但给你抽象能力。
  2. || Cancelable —— 错误集合的并集运算。Cancelable 大概率就是 error{Canceled}。这意味着取消不是一个约定,是一个类型。你不处理它,编译不过。
  3. random 无错误返回,randomSecure 可能失败 —— 前者允许实现缓存进程内 RNG 状态,后者强制每次走 syscall。老版本里 std.Options.crypto_always_getrandom 和 crypto_fork_safety 这两个全局编译选项被删掉,取而代之的是两个不同的方法。全局配置 → API 选择,这是整个 0.16 的思想主线。

2.2 四个官方实现

实现机制成熟度支持取消适用场景
Io.Threaded线程 + 直接 syscall功能完整、测试充分✅(含 -fno-single-threaded)默认选择、生产可用
Io.Evented用户态栈切换 + work stealing(M:N 绿色线程 / 有栈协程)实验性部分高并发探索
├ Io.UringLinux io_uringPoC,缺网络/错误处理/测试部分未来的性能天花板
├ Io.KqueueBSD/macOS kqueuePoC only部分参考实现
└ Io.DispatchGrand Central Dispatch (macOS)实验性部分macOS 集成
Io.failing全部操作直接失败完整—故障注入测试

Io.Threaded 的实现方式极其朴素:文件系统操作就是直接调 read / write / open / close。从 0.15.x 升级过来,用它就是行为等价的。这一点非常重要——它意味着升级 0.16 不等于把你的程序改成异步的,你只是把「谁来执行 I/O」这个决定权从全局硬编码变成了参数。

而 Io.Evented 的定位,官方说得很坦白:

work-in-progress, experimental, serving to inform the evolution of the interface.

它存在的目的不是给你用,是为了验证接口设计是否站得住。这是一种非常克制的工程态度:先证明抽象能容纳最难的实现,再谈优化。

2.3 为什么这是「能力式安全」(Capability-based Security)

这是很多人没意识到的一层。当 Io 变成参数之后:

fn parseConfig(text: []const u8) !Config { ... }        // 保证不碰任何 I/O
fn loadConfig(io: Io, dir: Io.Dir) !Config { ... }      // 可能读文件

函数签名本身成了权限声明。一个不接收 Io 的函数,在类型系统层面就无法读文件、无法发网络请求、无法获取当前时间、无法拿随机数。这对以下场景是降维打击:

  • 审计第三方依赖:grep 一遍谁接收了 Io,就知道谁可能有副作用。
  • 纯函数测试:不需要 mock 框架,不需要 monkey patch,参数传 Io.failing 就行。
  • 确定性重放:自定义一个记录/回放的 Io 实现,整个程序的非确定性输入都被收口。

Go 用 context.Context 做了取消,但没做能力隔离;Rust 用 async fn 做了颜色,但 std::fs::read 谁都能调。Zig 这一刀,切的是副作用的可见性。


三、五层并发原语:Future / Group / Queue / Select / Batch

官方给出的概览是这五个(外加 Clock / Duration / Timestamp / Timeout 四个单位类型)。它们不是同一抽象层的五个 API,而是两层抽象 × 不同生命周期模型。

3.1 Future:函数层的异步

// 表达「独立性」:这个调用与其它逻辑无关,可以并行也可以不并行
var f = io.async(foo, .{args});

// 表达「必须并发」:正确性依赖于真正同时执行
var g = try io.concurrent(bar, .{args});  // 可能返回 error.ConcurrencyUnavailable

这两个 API 的区别是整个设计里最精妙的一处,值得掰开讲。

io.async 是 infallible 的。因为它只表达「这两件事互相独立」,而一个合法的 Io 实现完全可以直接同步调用这个函数然后返回一个已完成的 Future。Release Notes 原话:

It is legal for Io implementations to implement async calls simply by directly calling the function before returning.

这就是为什么 io.async 可以在 -fsingle-threaded、在 WASM、在裸机上工作。它是可移植的并发意图声明,不是并发承诺。

io.concurrent 是 fallible 的。因为「必须同时跑」这件事本质上要求为新任务分配栈/上下文内存,而内存分配可能失败——于是有了 error.ConcurrencyUnavailable。

什么时候必须用 concurrent?典型例子是生产者-消费者通过无界通道通信:如果生产者被同步执行到底,消费者永远启动不了,程序死锁。这种「不并发就是错」的场景,语义上必须表达出来。

对比一下其他语言:

  • Go:go f() 永远成功(除非 OOM panic),你无法表达「这里可以顺序执行」。
  • Rust:async fn 创建 Future 不分配,但 tokio::spawn 分配且返回 JoinHandle,失败模式藏在运行时 panic 里。
  • Zig:把「可选并发」和「必需并发」在类型层面分开了。

3.2 Future 的两个方法与那个 defer 惯用法

Future(T) 只有两个方法:

  • await —— 逻辑上阻塞直到任务完成,返回函数返回值。
  • cancel —— 等价于 await,外加请求 Io 实现中断该操作并返回 error.Canceled。

官方给出的资源安全惯用法长这样:

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);

这段代码里藏着三个必须理解的点,否则你一定会写出泄漏:

第一,await 和 cancel 都是幂等的。 上面的代码里,正常路径先 await 拿结果,defer 里再 cancel 一次是安全的空操作。

第二,defer cancel 不是为了「取消」,是为了「释放任务资源」。 如果 foo_future.await 成功了但 bar_future.await 返回错误,函数会提前 return,此时 foo 的任务槽位需要被回收。cancel 承担了这个职责。Release Notes 说得很清楚:

The cancel is necessary however because it releases the async task resource when errors (including error.Canceled) are returned.

第三,被取消的任务可能已经成功了。 这是 Zig 取消模型和所有人直觉相反的地方——cancel 返回的是 T!Error,你可能拿到一个真实的、需要释放的资源。所以那个 if (...) |resource| resource.deinit() else |_| {} 不是防御性编程,是必须的。

如果 foo 不返回需要释放的资源,可以简化成 _ = foo_future.cancel(io) catch {};;如果返回 void,连 discard 都可以省掉。

3.3 Group:O(1) 开销的批量任务

当 N 个任务共享同一个生命周期时,逐个持有 Future 是浪费的。Io.Group 提供 O(1) 的 spawn N 个任务开销。官方的 sleep sort 示例堪称教科书:

const std = @import("std");
const Io = std.Io;

test "sleep sort" {
    const io = std.testing.io;

    // 初始化 10 个随机数
    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);

    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;
}

注意几个工程细节:

  • var group: Io.Group = .init; —— 0.16 大量使用 decl literal(.init 而不是 Io.Group.init()),这是语言层面的类型推断增强。
  • defer group.cancel(io); —— 和 Future 一样的模式,保证异常路径下所有子任务被回收。
  • group.async(...) 不返回 Future,也不返回错误。这就是 O(1) 的来源:Group 内部不为每个任务维护独立的结果槽。
  • io.sleep(.fromMilliseconds(elem), .awake) —— 第二个参数是唤醒语义(推测是 .awake vs 某种低功耗/可延迟模式)。时间也是 Io 的能力。

3.4 Queue(T):多生产者多消费者

many producer, many consumer, thread-safe, runtime configurable buffer size. When buffer is empty, consumers suspend and are resumed by producers. When buffer is full, producers suspend and are resumed by consumers.

关键词是 runtime configurable buffer size。Go 的 channel 容量在 make 时固定,Zig 允许运行时配置。而且「suspend / resume」这个措辞说明它是与 Io 实现协同的——用 Threaded 时挂起线程,用 Evented 时切换栈。

3.5 Select vs Batch:两个抽象层的对称设计

这是整套设计里最容易搞混的地方,用一张表说清楚:

函数抽象层(task)操作抽象层(operation)
单个独立性Future——
多个一起等SelectBatch
灵活性高(任意函数)低(预定义 operation)
开销需要分配任务内存无任务内存
可移植性可能 ConcurrencyUnavailable高效可移植

Batch 当前只支持四种 operation:

  • FileReadStreaming
  • FileWriteStreaming
  • DeviceIoControl
  • NetReceive

官方明确表示,未来大部分文件系统和网络功能都会迁移到 Operation 基础上,从而变得可以配合 Batch 使用,也可以配合 operateTimeout——一个给任意 I/O 操作加超时的通用机制。

选型建议(官方原话的工程化翻译):

如果你只是需要「同时做几件 I/O」,用 Batch——它是最优解。
如果你需要在操作之间跑一些逻辑,用 Future——否则你等于在重新发明 Future。
也可以先用 Future 把功能跑通,之后再把热点路径改写成 Batch 来削减任务开销。

这条建议非常「Zig」:先正确,再优化,而且优化路径是明确的、局部的。


四、取消(Cancelation):被严重低估的那一半设计

先说一句 Release Notes 里的原文彩蛋,因为它透露了作者对这个特性的重视程度:

Lo! Lest one learn a lone release lesson, let proclaim: "cancelation" should seriously only be spelt thusly (single "l"). Let not evil, godless liars lead afoul.

(整句话每个词都以 L 音节开头,就为了强调「cancelation 只有一个 l」。能为一个拼写写出这种句子,说明这一节是他们最想让你读的。)

4.1 为什么取消是异步编程真正的难点

写并发代码,起任务人人都会。难的是任务起来了怎么停。

  • Go:context.Context 是纯约定。你把 ctx 传下去,被调用方可以选择不看。os.ReadFile 不接受 ctx,一旦进去就出不来。取消是「礼貌请求」,不是保证。
  • Rust:Future drop 即取消,看起来很优雅,但引出了臭名昭著的 cancel safety 问题——select! 里丢弃一个进行到一半的 read,缓冲区状态是什么?很多库为此写了专门的文档章节。
  • JS:AbortController 需要每个 API 单独支持,fetch 支持,fs.readFile 部分支持,你自己的 Promise 默认不支持。
  • Java:Thread.interrupt() 依赖被中断方检查 isInterrupted(),InterruptedException 常年被 catch {} 吞掉。

共同问题:取消是可选的、是外挂的、是可以被忽略的。

4.2 Zig 的答案:把 Canceled 焊进 error set

Most I/O operations now have error.Canceled in their error sets.

这一句话就把游戏规则改了。在 Zig 里,你不能忽略一个 error union——要么 try 传播,要么 catch 处理,要么显式 unreachable。当 error.Canceled 出现在 readFile 的错误集合里,编译器强制你面对它。

更狠的是 Io.Threaded 的实现方式:

Even Io.Threaded supports cancelation by sending a signal to a thread, causing blocking syscalls to return EINTR, and responding to that error code by checking for a cancelation request before retrying the syscall.

给线程发信号 → 阻塞的 syscall 返回 EINTR → 在重试前检查取消请求。 这是 POSIX 世界里唯一能可靠中断阻塞 syscall 的办法,而 Zig 把它变成了标准库的默认行为。你写一个最普通的、基于线程的同步程序,它天然是可取消的。

我做过好几个「优雅关闭」相关的项目,最痛苦的永远是那个卡在 recv() 上的线程。Go 里你得把 fd 设成 non-blocking + poll;C 里你得自己装 signal handler 还要处理 SA_RESTART;Java 里 Socket.close() 从另一个线程调用是 UB 边缘。Zig 把这套 boilerplate 收进了 Io.Threaded。

4.3 三种处理方式,按常见程度排序

只有发起取消请求的那段逻辑才能安全地忽略 error.Canceled。其余情况有三条路:

1. 传播(最常见)

const data = try readConfig(io, dir);  // Canceled 自动向上冒泡

2. io.recancel() 后不传播

const data = readConfig(io, dir) catch |err| switch (err) {
    error.Canceled => {
        // 我需要先做点清理,不能立刻返回
        try cleanup(io);
        io.recancel();       // 重新武装取消请求
        return error.Aborted; // 用自己的错误类型返回
    },
    else => |e| return e,
};

recancel 的语义是重新武装(rearm)取消请求,使得下一次检查点仍能检测到并确认这个请求。这解决了一个真实痛点:你在清理路径上还要做 I/O,而清理路径上的 I/O 又会立刻被同一个取消请求打断。

3. io.swapCancelProtection() 让它 unreachable

用于「这段代码绝对不能被取消」的临界区,比如事务提交的最后一步、必须成对出现的资源释放。

4.4 额外的取消检查点

fn crunchNumbers(io: Io, data: []const f64) !f64 {
    var sum: f64 = 0;
    for (data, 0..) |x, i| {
        if (i % 4096 == 0) try io.checkCancel();  // CPU 密集任务的主动检查点
        sum += heavyTransform(x);
    }
    return sum;
}

官方说「很少需要调用这个函数」,唯一的用例就是长时间运行的 CPU 密集任务。因为纯计算不经过任何 I/O 操作,自然没有 error.Canceled 的注入点。

这里有个调优权衡:检查频率太高,分支预测失败 + 原子读的开销会吃掉性能;太低,取消延迟大。经验值是让检查间隔落在 100μs ~ 1ms 量级,用循环计数取模是最便宜的做法(注意用 2 的幂做掩码而不是取模:if (i & 4095 == 0))。

4.5 取消 ≠ 中止:资源账本必须对上

再强调一遍这个反直觉的点,官方专门给了一个示例:

const std = @import("std");
const Io = std.Io;

test "trivial cancel demo" {
    const io = std.testing.io;

    var file_task = io.async(Io.Dir.openFile, .{ .cwd(), io, "hello.txt", .{} });
    defer if (file_task.cancel(io)) |file| file.close(io) else |_| {};
}

打开文件,然后立刻取消。但文件可能已经打开成功了——取消请求可能在 open syscall 返回之后才被处理。所以 cancel 返回的 payload 必须被检查和释放。

把这个模型和 Rust 的 drop-based cancel 对比一下:Rust 里 drop 一个 Future 是不可能失败也不可能返回值的,所以如果操作已完成、资源已获取,那个资源要么在 Future 内部被 drop(可能不是你想要的语义),要么泄漏。Zig 选择了把这个决定还给调用方——代价是你必须写那个 if。

这就是 Zig 的一贯交易:不隐藏复杂度,但让复杂度可见、可控、编译期可检查。


五、那 20 行 http-get 背后发生了什么

Release Notes 用一个例子证明这套抽象的价值:

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 });
}
$ zig build-exe http-get.zig
$ ./http-get example.com
info: received 200 OK

看起来平平无奇。但官方列出的五条性质,每一条都是别的语言要写几百行才能拿到的:

  1. 异步向每一个配置的 nameserver 并发发出 DNS 查询
  2. 每收到一个响应,立即异步尝试 TCP 连接返回的 IP
  3. 第一个 TCP 连接成功后,取消所有其它在途的连接尝试——包括还没返回的 DNS 查询
  4. 用 -fsingle-threaded 编译也能正常工作(操作变成顺序执行,语义仍正确)
  5. 在 Windows 上,全程不依赖 ws2_32.dll

第 1、2、3 条合起来就是 Happy Eyeballs(RFC 8305) 的核心语义。你在 Go 里想拿到这个行为,得自己写 net.Resolver + goroutine 扇出 + context.WithCancel + sync.Once 选出赢家 + 处理 goroutine 泄漏。在 C 里,getaddrinfo 是同步阻塞的,你只能开线程池或者上 c-ares。

第 4 条是这套设计真正的胜利。同一份业务代码,在有并发能力时并发跑,在没有时顺序跑,语义都对。 这就是为什么 io.async 必须是 infallible 的——它表达的是「可以独立」,不是「必须并行」。

第 5 条来自另一条暗线:Completed Migration to NtDll。Zig 标准库的 Windows 策略是「优先用原生 API 而非 Win32」。官方的判断很直接:kernel32.dll 的函数实际工作都由 ntdll.dll 完成,ntdll 的 API 设计精良,而 kernel32 这层封装「有诸多问题」。0.16 完成了这次迁移,附带产物就是 Windows 网络不再需要 ws2_32.dll。

对交付方来说这意味着:更少的 DLL 依赖 = 更小的攻击面 + 更容易的静态分发。


六、Io.Evented:用户态栈切换的工程细节

6.1 为什么是有栈协程,不是状态机

Zig 2020 年的无色异步是无栈的——编译器把 async 函数改写成状态机,栈帧大小编译期算出来。这套方案的死穴是:递归、函数指针、动态分发全部无法推断栈大小。

0.16 的 Io.Evented 换成了有栈协程(stackful coroutines):

based on userspace stack switching with work stealing, also known as M:N threading, "green threads", or stackful coroutines.

三个关键词:

  • userspace stack switching:切换时保存/恢复寄存器和栈指针,不进内核。x86_64 上大约 20~30 条指令。
  • work stealing:M 个 OS 线程调度 N 个协程,空闲线程从繁忙线程的队列尾部偷任务。Go runtime、Tokio multi-thread、Java 虚拟线程都是这个模型。
  • M:N threading:用户态调度器。

有栈 vs 无栈的工程权衡表:

维度有栈(Zig Evented / Go / Java Loom)无栈(Rust async / C++20 coroutine)
编译器负担低(不需要栈大小推断)高(状态机改写)
内存开销每协程一个栈(2KB~1MB,可增长)状态机大小,精确到字节
递归/间接调用天然支持栈大小无法推断,需 Box::pin
C 互操作复杂(C 代码在协程栈上运行会踩坑)简单
栈溢出需要栈增长或 guard page编译期已知
调试体验调用栈完整清晰状态机展开后可读性差
函数颜色无颜色有颜色

Zig 选有栈,本质上是用运行时内存换编译器复杂度和语言表达力。对一门自称「no hidden control flow」的语言来说这有点反直觉,但注意:协程切换发生在 Io 的方法调用里,而 Io 是显式参数。所以控制流仍然是可见的——你能从函数签名看出这里可能挂起。这是对「no hidden control flow」原则的一次巧妙保全。

6.2 Io.Uring 的现状与缺口

官方对 io_uring 后端的描述非常诚实:

although it was not the focus of this release cycle, there is already a proof-of-concept implementation based on Linux's excellent io_uring API. This backend has really nice properties but it's not finished yet. It's lacking Networking, error handling, test coverage, and minimal task stack allocations.

四个缺口里,最后一个「minimal task stack allocations」最值得关注。有栈协程的内存瓶颈就在这——如果每个任务默认分配 64KB 栈,10 万并发就是 6.4GB。Go 的解法是 2KB 起步 + 栈复制增长;Java Loom 的解法是堆上分配的 chunked stack。Zig 走哪条路,会直接决定 Io.Evented 能不能真正用于 C10M 场景。

Io.Kqueue 那条注释也很有意思:

proof-of-concept only, enough to fix a common bug in other async runtimes.

一个 PoC 级别的实现,顺手暴露并修掉了别的 async runtime 的常见 bug。这基本可以确定是 kqueue 的 EV_ONESHOT / EVFILT_* 边缘触发语义相关的经典陷阱。

6.3 性能现状:慢,且官方承认

Zig 编译器使用 std.Io.Evented 能正常工作,但存在性能下降问题。

编译器本身可以用 Evented 后端跑通,但更慢。 这是一个非常有价值的信号:

  • 好消息:抽象是完备的,最复杂的真实程序能跑。
  • 坏消息:现在换过去只有负收益。

我的判断是 0.16 阶段不要在生产里碰 Io.Evented。它的价值是让你现在就把代码写成「接受 Io 参数」的形态,等 0.18 / 0.19 后端成熟了,改一行 main 函数就能切过去。这才是接口化的真正红利——决策延迟。


七、代码实战:六个真实场景

以下代码基于 0.16.0 的 API 形态编写。Io.Evented 相关部分仍在演进,签名可能微调,请以你本地 zig std 文档为准。

7.1 最小骨架:Juicy Main

0.16 引入了所谓的 "Juicy Main"——给 main 加一个 process.Init 参数,一次性拿到全套基础设施:

pub const Init = struct {
    minimal: Minimal,
    /// 整个进程的永久存储,退出时自动清理。线程安全。
    arena: *std.heap.ArenaAllocator,
    /// 默认通用分配器,Debug 模式下自动开泄漏检查。线程安全。
    gpa: Allocator,
    /// 基于目标平台配置选出的默认 Io 实现。
    io: Io,
    /// 环境变量,用 gpa 初始化。非线程安全。
    environ_map: *Environ.Map,
    /// 父进程提供的具名文件(主要用于 WASI)。
    preopens: Preopens,

    pub const Minimal = struct {
        environ: Environ,
        args: Args,
    };
};

实际用法:

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()});
}

main 的第一个参数现在有三种选择:

写法能拿到什么
pub fn main() !void什么都没有,拿不到 argv 和环境变量
pub fn main(init: std.process.Init.Minimal) !void原始 argv + environ
pub fn main(init: std.process.Init) !void全套:arena / gpa / io / environ_map / preopens

升级建议:直接用完整版 Init。它是唯一能拿到「平台默认最优 Io 实现」的地方,而这个选择应该由 main 做,不该散落在库里。

如果你在改造过程中发现某个深层函数拿不到 Io,官方给了应急方案:

var threaded: Io.Threaded = .init_single_threaded;
const io = threaded.io();

但同时警告:这等价于在需要 Allocator 时伸手去拿 std.heap.page_allocator——能跑,但是设计气味。正确做法是把 Io 加进函数签名,或者存到 context struct 上。

7.2 并发抓取 N 个源,首个成功即取消其余

这是 Happy Eyeballs 的通用化版本,也是 Future + cancel 最典型的组合拳:

const std = @import("std");
const Io = std.Io;

/// 并发请求多个镜像源,返回第一个成功的响应体。
/// 其余在途请求会被取消。
fn fetchFastest(
    io: Io,
    gpa: std.mem.Allocator,
    urls: []const []const u8,
) ![]u8 {
    const Task = Io.Future(anyerror![]u8);
    const tasks = try gpa.alloc(Task, urls.len);
    defer gpa.free(tasks);

    var spawned: usize = 0;
    // 无论走哪条路径,退出时都要回收所有任务
    defer for (tasks[0..spawned]) |*t| {
        if (t.cancel(io)) |body| gpa.free(body) else |_| {}
    };

    for (urls) |url| {
        // 这里必须用 concurrent:如果被顺序执行,
        // "先完成者胜出" 的语义就退化成 "第一个 URL 胜出"
        tasks[spawned] = io.concurrent(fetchOne, .{ io, gpa, url }) catch |err| switch (err) {
            // 并发资源耗尽时,降级为同步执行剩余任务
            error.ConcurrencyUnavailable => break,
            else => return err,
        };
        spawned += 1;
    }

    var last_err: anyerror = error.AllSourcesFailed;
    for (tasks[0..spawned]) |*t| {
        const body = t.await(io) catch |err| {
            last_err = err;
            continue;
        };
        return body; // defer 会取消剩下的
    }
    return last_err;
}

fn fetchOne(io: Io, gpa: std.mem.Allocator, url: []const u8) ![]u8 {
    var client: std.http.Client = .{ .allocator = gpa, .io = io };
    defer client.deinit();
    // ... 省略请求构造,返回 body
    _ = url;
    return gpa.dupe(u8, "ok");
}

三处工程细节:

  1. defer 里的清理循环放在 spawn 循环之前声明,靠 spawned 计数控制范围。Zig 的 defer 在作用域退出时逆序执行,这个写法保证了「已创建的一定被回收,未创建的不会被误碰」。
  2. error.ConcurrencyUnavailable 被降级处理而不是直接失败。真实系统里,并发资源耗尽时能顺序完成总比整体失败好。
  3. 返回成功值后由 defer 统一取消其余任务,且取消返回的 body 需要 free——因为它们可能已经下载完了。

这段代码用 Go 写,你需要:context.WithCancel + sync.WaitGroup + 一个 buffered channel 收结果 + 一个 sync.Once 保证只写一次 + 确保所有 goroutine 都能退出不泄漏。行数差不多,但泄漏的可能性完全不同——Zig 版本里,忘记 defer cancel 会被 Debug 模式的任务泄漏检测抓到;Go 版本里 goroutine 泄漏是静默的。

7.3 给任意操作加超时

const std = @import("std");
const Io = std.Io;

fn readWithTimeout(
    io: Io,
    file: Io.File,
    buf: []u8,
    budget: Io.Duration,
) !usize {
    var read_task = io.async(Io.File.readStreaming, .{ file, io, buf });
    defer _ = read_task.cancel(io) catch {};

    var timer_task = io.async(Io.sleep, .{ io, budget, .awake });
    defer _ = timer_task.cancel(io) catch {};

    // 等待两者之一先完成
    switch (io.select(.{ &read_task, &timer_task })) {
        0 => return try read_task.await(io),
        1 => return error.Timeout,
        else => unreachable,
    }
}

注意:一旦目标操作迁移到 Operation 抽象层,更优的写法是直接用官方的 operateTimeout,它不需要额外的 task 内存。Batch 目前覆盖的四种 operation(FileReadStreaming / FileWriteStreaming / DeviceIoControl / NetReceive)已经可以走这条路径。

上面这个手写版本的成本是两个 task 的内存 + 一个 select 的等待结构。生产环境里如果每个请求都这么搞,开销会很可观。这就是官方强调「先 Future 跑通、后 Batch 优化」的现实原因。

7.4 Group 扇出 + 错误聚合

Group 本身不返回每个任务的结果,所以错误聚合要自己做:

const std = @import("std");
const Io = std.Io;

const Outcome = struct {
    err: ?anyerror = null,
    bytes: usize = 0,
};

fn processAll(io: Io, gpa: std.mem.Allocator, paths: []const []const u8) !usize {
    const outcomes = try gpa.alloc(Outcome, paths.len);
    defer gpa.free(outcomes);
    @memset(outcomes, .{});

    var group: Io.Group = .init;
    defer group.cancel(io);

    for (paths, outcomes) |path, *out| {
        group.async(io, processOne, .{ io, path, out });
    }
    try group.await(io);

    var total: usize = 0;
    var failures: usize = 0;
    for (outcomes, paths) |out, path| {
        if (out.err) |e| {
            std.log.warn("{s}: {t}", .{ path, e });
            failures += 1;
        } else {
            total += out.bytes;
        }
    }
    if (failures * 2 > paths.len) return error.TooManyFailures;
    return total;
}

fn processOne(io: Io, path: []const u8, out: *Outcome) void {
    out.bytes = doWork(io, path) catch |e| {
        out.err = e;
        return;
    };
}

模式要点:把「可能失败的任务」包装成「永不失败但把错误写进结果槽」的函数。因为 Group 的语义是共享生命周期的批量任务,不是 Result 收集器。每个任务写自己独占的 *Outcome,没有数据竞争,也不需要锁。

7.5 杀手锏:自定义 Io 实现做故障注入

这是 I/O as an Interface 最被低估的收益。标准库提供了 Io.failing(模拟一个不支持任何操作的系统),但你可以走得更远——按概率注入失败:

const std = @import("std");
const Io = std.Io;

/// 混沌测试用的 Io 包装器:按概率让操作失败或延迟。
pub const ChaosIo = struct {
    inner: Io,
    prng: std.Random.DefaultPrng,
    fail_rate: u8,      // 0~100
    slow_rate: u8,      // 0~100
    extra_delay: Io.Duration,

    pub fn init(inner: Io, seed: u64) ChaosIo {
        return .{
            .inner = inner,
            .prng = .init(seed),
            .fail_rate = 5,
            .slow_rate = 20,
            .extra_delay = .fromMilliseconds(50),
        };
    }

    pub fn io(self: *ChaosIo) Io {
        return .{ .userdata = self, .vtable = &vtable };
    }

    const vtable: Io.VTable = .{
        .fileReadStreaming = fileReadStreaming,
        // ... 其余方法转发给 inner
    };

    fn fileReadStreaming(userdata: *anyopaque, file: Io.File, buf: []u8) anyerror!usize {
        const self: *ChaosIo = @ptrCast(@alignCast(userdata));
        const roll = self.prng.random().uintLessThan(u8, 100);
        if (roll < self.fail_rate) return error.InputOutput;
        if (roll < self.fail_rate + self.slow_rate) {
            try self.inner.sleep(self.extra_delay, .awake);
        }
        return self.inner.vtable.fileReadStreaming(self.inner.userdata, file, buf);
    }
};

配合固定 seed,你得到的是可复现的混沌测试。同一个 seed 永远产生同一串失败序列,CI 挂了能精确复现。

这在其它语言里意味着什么?在 Go 里你得给所有 I/O 定义 interface 然后到处传;在 Rust 里你得给整个代码库加泛型参数或者上 dyn Trait + Arc;在 Java 里你得上 Mockito 字节码增强。在 Zig 里,这是语言标准库已经替你做好的架构决策。

同理,你还能实现:

  • 记录/回放 Io:录制一次生产流量,离线确定性重放。
  • 限速 Io:给整个子系统统一限流,业务代码零改动。
  • 审计 Io:记录每一次文件访问,做合规日志。
  • 沙箱 Io:把所有路径操作重定向到某个前缀下,实现纯用户态的 chroot。

一个接口参数,换来了整条横切关注点(cross-cutting concern)的注入能力。

7.6 同步原语迁移

0.16 把所有会阻塞的同步原语从 std.Thread 搬到了 std.Io:

0.150.16
std.Thread.ResetEventstd.Io.Event
std.Thread.WaitGroupstd.Io.Group
std.Thread.Futexstd.Io.Futex
std.Thread.Mutexstd.Io.Mutex
std.Thread.Conditionstd.Io.Condition
std.Thread.Semaphorestd.Io.Semaphore
std.Thread.RwLockstd.Io.RwLock
std.once已删除,避免全局变量或自己手写

理由说得很清楚:

这样才能保证,用 std.Io.Threaded 时,一个竞争的互斥锁会阻塞线程;而用 std.Io.Evented 时,它会切换栈。

这是有栈协程的经典陷阱:在协程里调用一个会阻塞 OS 线程的锁,等于把整个调度器的一个 worker 干掉了。Go 通过 runtime 深度集成 sync.Mutex 解决;Java Loom 为此改造了整个 java.util.concurrent;Rust 让你区分 std::sync::Mutex 和 tokio::sync::Mutex(用错了就是 deadlock 或者性能悬崖)。

Zig 的答案是:锁也拿 Io 参数。

var mutex: Io.Mutex = .init;

fn criticalSection(io: Io, state: *State) !void {
    try mutex.lock(io);        // Threaded → 阻塞线程;Evented → 切换栈
    defer mutex.unlock(io);
    state.counter += 1;
}

注意最后一句补充:无锁同步原语(std.atomic.*)不需要 Io 集成。因为它们不阻塞。这条边界划得很干净——Io 管的是「会让控制流停下来」的东西。


八、连带的架构改动:不只是 I/O

8.1 环境变量不再是全局的

这条改动会让很多人翻车,但理由无懈可击:

在 C 里,在多线程上下文中调用 setenv 这类修改环境的函数是不安全的(unsound),因为 environ 可以(而且经常)在没有任何锁的情况下被直接访问。

setenv 不是线程安全的——这是 POSIX 世界里一个几乎没人真正在意但确实存在的定时炸弹。glibc 的 getenv 会返回指向 environ 数组里字符串的裸指针,另一个线程的 setenv 触发数组 realloc 之后,那个指针就悬空了。

Zig 标准库还有一个自己的坑:std.os.environ 本意是对标 C 的 environ,但在不链接 libc 的库里根本没法填充它。

0.16 的解法:环境变量只在 main 函数里可用。需要环境变量的函数,要么接收具体的值作为参数,要么接收 *const process.Environ.Map。

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    for (init.environ_map.keys(), init.environ_map.values()) |key, value| {
        std.log.info("env: {s}={s}", .{ key, value });
    }
}

不用完整 Init 时的 minimal 版本:

pub fn main(init: std.process.Init.Minimal) !void {
    var arena_allocator: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
    defer arena_allocator.deinit();
    const arena = arena_allocator.allocator();

    std.log.info("contains HOME: {any}", .{init.environ.contains(arena, "HOME")});
}

迁移建议:在 main 里一次性把需要的环境变量读出来,塞进你的配置结构体,往下传配置而不是传 environ_map。这本来就是好架构,现在语言逼你这么做了。

8.2 ArenaAllocator 变成无锁线程安全,ThreadSafeAllocator 被删

ThreadSafeAllocator is an anti-pattern. This is a situation when tighter coupling is called for.

这句话值得裱起来。

包装型的 ThreadSafeAllocator 唯一合理的实现方式是加互斥锁;而互斥锁现在需要 Io 实例;而分配器又要能作为 Io 实例的后备内存来源——循环依赖。

解法是把线程安全下沉到具体分配器实现里,做成无锁的。新的 ArenaAllocator:

  • 单线程访问时,性能与旧实现相当
  • 多线程并发时,在约 7 个线程以内比「旧实现 + ThreadSafeAllocator 包装」略快
  • 官方计划对 heap.DebugAllocator 做同样改造

这里的设计教训很通用:通用的包装器(wrapper)在遇到基础设施层的循环依赖时会失效,此时正确答案是紧耦合而不是抽象。 很多 Java/C# 出身的工程师会本能地反对这个结论,但在系统编程层面它是对的。

8.3 posix / os.windows 的「中间层」被删

Most std.posix and std.os.windows functions existed at an awkward medium-level abstraction and have thus been removed.

现在只有两个方向:

  • 往上:用 std.Io(跨平台、可插拔、可取消)
  • 往下:直接用 std.posix.system(裸 syscall,你自己负责)

而且官方明说「More removals are planned」——还会删。

这个决策的本质是拒绝维护一个既不够抽象又不够底层的中间层。那种「跨平台但泄漏平台细节」的 API 是维护地狱:每加一个平台都要重新定义语义,每个用户都对它有不同的期待。砍掉它,让用户在「完全抽象」和「完全裸露」之间二选一,反而清爽。

顺带被删的还有 std.Thread.Pool——因为 Io.Threaded 本身就是线程池,再留一个是重复概念。

8.4 从零实现的 deflate 压缩

0.16 新增了 deflate 压缩(之前只有解压),完全从零实现:

  • 历史窗口保存在 writer 的 buffer 里用于匹配
  • 链式哈希表查找匹配
  • token 累积到阈值后作为一个 block 输出
  • 额外提供 Raw(只写 store block,用 data vector 高效发送块头和数据)和 Huffman(只做 Huffman 不做匹配)两种 writer

与 zlib 的对比数据(官方基准):

  • 压缩率:zlib 在默认级别好 1.00%,在最佳级别好 0.77%
  • 速度:默认级别下 wall_time 约 252ms ± 1.07ms,peak_rss 5.46MB,cpu_cycles 1.19G

官方对压缩率差距的分析是:「zlib 似乎选择了略微不同的匹配,但总匹配字节数更少」——也就是说不是算法档次差距,是启发式选择的细微差异,未来有希望追平。

这里的工程意义是:Zig 标准库正在系统性地消除 C 依赖。deflate 之前,是 NtDll 迁移消除了 kernel32 依赖;deflate 之后,是 .def 文件生成导入库消除了对 LLVM 的一处依赖。每一步都在为「Zig 工具链自给自足」铺路。

另外解压侧也简化了:利用底层 reader 的 peek 能力,大幅简化了位读取逻辑,顺带修了几个 limit 处理的 bug。这就是 Writergate 的红利兑现——统一的、自带 buffer 的 Reader 接口让上层算法能写得更简单。


九、编译器、构建系统、链接器:另外三条主线

I/O 抢走了所有注意力,但这三块的改动对日常开发体验的影响可能更直接。

9.1 新 ELF 链接器:增量链接 191ms → 64ms

启用方式:CLI 加 -fnew-linker,或者构建脚本里 exe.use_new_linker = true。当传了 -fincremental 且目标是 ELF 时,它已经是默认的。

官方性能数据(构建 Zig 编译器本身,然后做单行修改,再做一次):

方案首次构建第一次增量第二次增量
旧链接器14s194ms191ms
新链接器14s65ms64ms(快 66%)
完全跳过链接14s62ms62ms(快 68%)

新链接器只比「完全不链接」慢 2~3ms。 这个数字的含义是:链接这个环节在增量构建里基本被消灭了。

官方的推论很有意思:

性能已经快到没必要再提供 -Dno-bin 构建步骤了。你干脆一直开着 codegen 和链接,反正编译速度差异可以忽略,而且最后你还能拿到一个可执行文件。

这条对 CI 和本地开发循环都是实打实的改善。以前那种「只做类型检查不生成二进制」的加速技巧,现在没有存在必要了。

但它还不完整:

  • 生成的可执行文件缺少 DWARF 调试信息(社区博客提到这是接下来的首要任务)
  • 初版只支持链接纯 Zig 代码,不支持外部库(后续已能构建启用 LLVM 和 LLD 的自托管 Zig 编译器)
  • 因此旧链接器和 LLD 都还在,等新链接器功能完整后,旧的删除、LLD 去依赖

9.2 增量编译:那个 30000 行的类型解析重写

0.16 的增量编译有质变,功臣是 Reworked Type Resolution——一个约 30000 行的 PR,重构了编译器内部的类型解析逻辑。

核心改动是让编译器分析类型字段时更「懒」:把一个类型当命名空间用的时候,不需要关心这个类型具体长什么样。 这个改动的连锁反应是:

  1. 消除了「过度分析」:编译器内部依赖图变成无环的(依赖循环情况除外)。以前改一行会重编几乎整个编译器,现在是毫秒级。
  2. 修复了增量与非增量的行为不一致:以前增量构建会报「依赖循环」错误而全量构建不会,反之亦然。这是之前版本里最大的一致性问题。
  3. 依赖循环有了详细错误信息:真的循环时,你能看清循环链路。
  4. LLVM 后端也支持增量了:这不会加快 LLVM 生成目标文件本身,但能减少 Zig 编译器侧的执行时间。更关键的是——代码有编译错误时,「LLVM Emit Object」阶段会被跳过,所以你能近乎瞬时地拿到错误反馈。

但增量编译在 0.16 仍然默认关闭,因为还有已知 bug,包括误编译(miscompilation)。官方的态度是「仍然鼓励你打开」:

zig build -fincremental --watch

我的实操建议:本地开发循环开 -fincremental --watch,享受毫秒级反馈;CI 和 release 构建一律关掉。遇到诡异行为,第一件事是关掉增量重试一遍——这能省你半天时间。

9.3 构建系统:configure / make 分离

这是 0.16 周期后期合入、0.17 完成的大改动。

改动前:build.zig 和整个构建系统代码被编译成一个 debug 模式的臃肿进程。

改动后:

  1. build.zig 被编译成一个轻量级的「配置器(configurator)」
  2. 构建图建好后,序列化成一个二进制配置文件
  3. 父 zig build 进程缓存这个配置,并异步编译一个「构建器(builder)」进程
  4. 构建器以优化模式编译,负责实际执行构建图

三条提速路径:

  • 只编译用户的 build.zig 逻辑,不再编译整个构建系统
  • 确定配置无变化时,跳过重新运行 build.zig 逻辑
  • 真正干活的进程以 ReleaseFast 而非 Debug 编译

副产品:第三方工具(比如 ZLS 语言服务器)也能直接消费那个序列化的构建图,不用再自己解析 build.zig。

破坏性改动只有一处:

// 旧
if (b.args) |args| { run_cmd.addArgs(args); }

// 新
run_cmd.addPassthruArgs();

9.4 包管理:zig-pkg 与 --fork

依赖不再放全局缓存目录,而是放项目本地的 zig-pkg/(和 build.zig 同级)。官方建议加进 .gitignore,但也理解有人会为了方便直接提交。

抓取后的处理流程很讲究:

  1. 按 build.zig.zon 里的 paths 字段应用过滤器,删掉不参与 hash 计算的文件
  2. 重新压缩成规范化的 $GLOBAL_ZIG_CACHE/p/$HASH.tar.gz,避免下次重复联网

动机是「让你更容易折腾」:直接编辑那些文件看会发生什么、把某个包目录换成 git clone、把所有依赖一起 grep、让 IDE 基于 zig-pkg 目录做自动补全、对依赖树跑 baobab 看体积分布。

同时全局缓存改存压缩文件,便于在不同机器间共享——社区博客还提到未来计划支持依赖树的点对点种子分享(P2P seeding)。这个想法很大胆,本质上是把包分发从中心化 registry 转向内容寻址 + P2P。

--fork 标志是我个人最喜欢的改进:

zig build --fork=/home/andy/dev/dvui

指定目录里的 build.zig.zon 有 name 和 fingerprint 字段,依赖树中任何 name + fingerprint 匹配的包都会被替换成这个本地目录,跨整棵树生效,完全忽略版本号。而且解析发生在抓取之前——没网、忘了 fetch、但本地正好有个 git 仓库?一个 CLI flag 就能解封。

不匹配时会明确报错,避免困惑:

$ zig build --fork=/home/andy/dev/mime
error: fork /home/andy/dev/mime matched no mime packages

匹配时会提醒你正在用 fork:

$ zig build --fork=/home/andy/dev/dvui
info: fork /home/andy/dev/dvui matched 1 (dvui) packages

为什么它是 CLI flag 而不是配置文件? 官方原话:「The fact that it is a CLI flag makes it appropriately ephemeral.」——临时性是它的特性。你一旦不加这个 flag,立刻回到纯净的、已抓取的依赖树。

对比 Go 的 replace 指令(写在 go.mod 里,经常忘记删除后提交上去)、npm 的 link(全局副作用,环境污染)、Cargo 的 [patch](写在 Cargo.toml 里)——Zig 选择了唯一不会被误提交的方案。

另外,fingerprint 字段现在是强制的。没有 fingerprint、或者 name 写成字符串而不是 enum literal,zig build 会直接失败。原因是需要 fingerprint 才能判断「两个不同版本的包是同一个项目的不同版本」。而且依赖树里出现同 fingerprint、同版本、不同 hash 会成为错误——因为这意味着要么有人忘了升版本号,要么有人在搞恶意的包分叉,这时候你必须选边站。

9.5 单元测试超时

zig build test --test-timeout 500ms

超时的测试会被强制终止(杀掉并重启测试进程),然后继续下一个:

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)

注意坑:超时按真实时间而非 CPU 时间计算,所以在高负载机器上,调度器压力会导致意外超时。CI 里设阈值要留足冗余,建议至少是本地 P99 的 5 倍。

9.6 Fuzzer:Smith 结构化生成器

fuzz test 的参数从 []const u8 换成了 *std.testing.Smith。这是从「字节流 fuzzing」到「结构化 fuzzing」的升级:

基础方法:

  • value —— 生成任意类型的值
  • eos —— 生成流结束标记,保证最终一定会返回 true(防止无限生成)
  • bytes —— 填充字节数组
  • slice —— 填充 buffer 的一部分并给出长度

还能给值配权重 []const Smith.Weight:

  • 让有意思的值被更频繁选中
  • 降低产生大工作量的概率
  • 约束可选值域

配套的还有 baselineWeights(覆盖某类型所有可能值)、boolWeighted、eosSimpleWeighted、valueRangeAtMost、valueRangeLessThan。

最巧妙的一点:每个方法都有一个接受 hash 的对应版本,hash 相同的值在变异时更可能被关联变异。而常规方法已经用「调用方返回地址」作为 hash——也就是说,同一个调用点生成的值天然被归为一组变异。这是自动化的结构感知,不需要你手写语法定义。

这个设计和 Rust 的 arbitrary crate、Python 的 hypothesis 是同一思路,但把 hash 关联做进了默认行为里。


十、性能与调优:七条经验规律

规律一:0.16 阶段一律用 Io.Threaded。
Io.Evented 官方承认有性能回退,Io.Uring 缺网络支持。生产上没有理由现在切。但代码要写成接受 Io 参数的形态,这样将来切换只改 main。

规律二:默认用 io.async,只在正确性依赖并发时用 io.concurrent。
判据很简单:如果这个函数被同步执行到底,程序还能正确完成吗? 能→async;不能(会死锁、会退化语义)→concurrent。用错方向的代价:滥用 concurrent 会在低配环境下无谓失败;该用却没用会在单线程模式下死锁。

规律三:热路径上,Batch 优于 Future。
Future 需要分配任务内存。每请求几百个 Future 的服务,任务分配会成为可观测的开销。当前 Batch 只覆盖四种 operation,但如果你的场景正好是「一次发起多个文件读」或「一次收多个网络包」,直接上 Batch。

规律四:defer cancel 是纪律,不是可选项。
每一个 io.async / io.concurrent / Io.Group 后面立刻跟一行 defer。把这条写进 code review checklist。Zig 的 defer 紧跟资源获取是惯例,异步任务也是资源。

规律五:CPU 密集任务插 checkCancel,间隔控制在 100μs~1ms。
用 2 的幂掩码而不是取模:if (i & 4095 == 0) try io.checkCancel();。太频繁会污染分支预测器。

规律六:缓冲区归调用方所有——这是 Writergate 留下的契约。
receiveHead(&redirect_buffer) 这种签名遍布 0.16 标准库。栈上开固定大小 buffer 是首选,避免在热路径上 alloc。给 buffer 定尺寸时,宁可大一点:一个 1KB 的栈 buffer 和 128 字节的栈 buffer 在成本上没有区别,但前者能少一次「buffer 太小」的错误路径。

规律七:Debug 模式默认走 x86 自托管后端,Release 走 LLVM。
官方给的对比:自托管 x86 后端通过更多 behavior test、编译速度显著更快、调试信息更好,但机器码质量更差。这个组合正好匹配 Debug/Release 的需求,所以它在 Debug 模式下是默认后端。不要在 Debug 模式下测性能,差距会误导你。aarch64 后端这个周期因为 I/O 改动暂停了开发,跑 behavior test 还会崩,Apple Silicon 上仍然走 LLVM。


十一、十条踩坑清单

1. 以为升级 0.16 就变异步了。
用 Io.Threaded 时,file.read 就是直接 read() syscall。0.16 给你的是可插拔性,不是性能。别拿 0.16 去跑基准然后失望。

2. 忘记 cancel 可能返回成功值,导致资源泄漏。
if (task.cancel(io)) |resource| resource.deinit() else |_| {}——那个 if 分支不是摆设。文件可能已经打开了,连接可能已经建立了,内存可能已经分配了。

3. 在 error.Canceled 的清理路径上做 I/O,然后被同一个取消请求再次打断。
这就是 io.recancel() 存在的原因。清理逻辑要么用 swapCancelProtection 保护,要么接受被打断并设计成幂等。

4. 到处用 Io.Threaded.init_single_threaded 应急。
这等价于到处用 std.heap.page_allocator。短期能编译,长期你会发现整个代码库无法切换到 evented 后端,前面所有的架构收益归零。在 code review 里把这个当成 red flag。

5. 用了 io.concurrent 但没处理 error.ConcurrencyUnavailable。
在 -fsingle-threaded、在 WASM、在内存吃紧时,这个错误会真实出现。设计降级路径:能顺序完成就顺序完成。

6. 在协程里用错锁。
用了 Io.Evented 却调用某个第三方库里的 std.Thread.Mutex(如果还有的话)或者 C 库的 pthread_mutex——直接干掉一个调度器 worker。审计所有第三方依赖的阻塞点。

7. 忘了环境变量已经不是全局的。
库代码里 std.process.getEnvVarOwned 那套用法全废。改造方式:main 里读完塞进配置结构体往下传。别偷懒传 *const Environ.Map 到处走——那只是把全局变量换了个马甲。

8. --test-timeout 在 CI 上偶发失败。
它按 wall clock 计时。CI 机器负载高的时候,一个平时 50ms 的测试可能跑 800ms。阈值留 5 倍冗余,或者只在本地开。

9. 开了 -fincremental 后遇到诡异 bug,浪费半天排查业务代码。
增量编译在 0.16 默认关闭是有原因的——有已知的误编译。遇到「这代码明明是对的」的情况,第一反应是关掉增量重试。

10. 用新 ELF 链接器后发现 gdb/lldb 断点打不上。
新链接器还不为 Zig 代码生成 DWARF 调试信息。需要调试时切回旧链接器(不加 -fnew-linker,或者不加 -fincremental)。这是当前最大的功能缺口,官方已列为首要任务。


十二、总结与展望

12.1 路线图

官方给出的 0.17 计划是一个短周期:主要目标是升级到 LLVM 22,以及完成 make 进程(build runner)与 configure 进程(build.zig)的分离。

之后的主要方向:

  1. 完成并稳定语言本身
  2. 完成 aarch64 后端,让它成为 debug 模式的默认后端
  3. 增强链接器实现,消除对 LLD 的依赖,支持增量编译
  4. 增强内置 Fuzzer,使其能与 AFL 等业界最强方案竞争
  5. 从「对 LLVM 的库依赖」转变为「对 Clang 的进程依赖」

第 5 条影响最深远。把 LLVM 从「链进来的库」变成「调用的外部进程」,意味着 Zig 编译器二进制会瘦身一个数量级,构建 Zig 自身会快得多,交叉编译分发也更简单。代价是与 LLVM 的集成度下降——但既然自托管后端在 Debug 模式已经全面胜出,这个交易是划算的。

12.2 这套设计对其他语言的启示

抛开 Zig 本身,I/O as an Interface 提出的是一个通用问题:运行时能力应该是环境(ambient authority),还是应该是参数(explicit capability)?

主流语言全部选了前者。open() / File.open / fs.readFile 在任何地方都能调用,这是 UNIX 进程模型的直接映射——进程有权限,进程内的任何代码就都有权限。

Zig 选了后者。这带来四个连锁收益:

  1. 可测试性:不需要 mock 框架,传个不同的 Io 就行。
  2. 可审计性:函数签名即权限声明,grep 就能做安全审计。
  3. 可插拔性:同一份业务代码,跑在线程上、协程上、io_uring 上、或者一个记录回放的假实现上。
  4. 可移植性:-fsingle-threaded、WASM、裸机——语义一致,能力降级。

代价也很实在:

  • 所有函数签名都多一个参数。大型代码库的改造成本以人月计。
  • 抽象泄漏仍然存在。Io.Evented 不支持网络,Batch 只覆盖四种操作,Io.Uring 缺一半功能。「接口统一」目前只是承诺,不是现状。
  • 生态断裂。0.15 → 0.16 的迁移,加上此前的 Writergate,两次大规模破坏性变更连着来,社区库的跟进速度会是接下来一年的主要瓶颈。

12.3 一个程序员的判断

我对 Zig 0.16 的态度是:架构上激进正确,工程上还需要两个版本。

「正确」在于,它是我见过对异步问题最诚实的一次回答。Go 用 goroutine 把异步藏起来,代价是无法控制调度、无法做真正的取消、CGO 边界处处是坑;Rust 用 async/await 把异步暴露在类型系统里,代价是函数颜色、Pin 的心智负担、cancel safety 的隐蔽陷阱;Node/JS 用事件循环单线程规避问题,代价是 CPU 密集任务无解。

Zig 说:异步不是语法问题,是「谁来执行 I/O」这个依赖注入问题。 把它变成参数,语法层面一行 async 都不需要,而颜色问题、取消问题、可测试性问题、能力安全问题一起解决。

「还需要两个版本」在于,Io.Evented 是实验性的、Io.Uring 缺一半功能、新 ELF 链接器没有调试信息、增量编译默认关闭。这不是能拿去跑核心业务的成熟度。

所以现在应该做什么?

  • 新项目:直接上 0.16,把 Io 参数化的习惯从第一行代码开始建立。
  • 现有项目:如果还在 0.14/0.15,评估一次性升到 0.16 的成本。文件系统那部分改动虽然量大,但官方说得对——「不需要太多批判性思考」,file.close() → file.close(io),机械劳动而已。真正难的是并发模型和环境变量那两块。
  • 观望的人:至少读一遍那个 20 行的 http-get 例子。并发 DNS + 并发 connect + 首个成功取消其余 + 单线程也能跑 + 不依赖 ws2_32.dll,这五条性质加起来,是这个设计交出的第一份实证答卷。

一门语言删掉自己曾经引以为傲的语法特性,四年后用一个函数参数把它还回来——这种自我否定的勇气,比任何 benchmark 都更值得关注。


参考

  • Zig 0.16.0 Release Notes — https://ziglang.org/download/0.16.0/release-notes.html
  • Zig 官方开发日志(2026 年 2 月 / 3 月 / 4 月 / 5 月条目):std.Io 的 io_uring 与 GCD 实现、类型解析重写、LLVM 后端增量编译、ELF 链接器改进、构建系统 configure/make 分离
  • 相关背景:Zig 0.15 的 Reader/Writer 重写(社区称 "Writergate")

本文中标注为「推测」「大概率」的部分为基于公开文档的工程推断,实验性 API(Io.Evented / Io.Uring / Batch)的签名在 0.17 可能发生变化,请以本地 zig std 文档为准。

推荐文章

程序员茄子在线接单