【Zig 日报】Zig 的 Io.Threaded 很酷

0 views
Skip to first unread message

ZigCC Forum

unread,
Aug 16, 2026, 10:46:47 PM (8 days ago) Aug 16
to zi...@googlegroups.com

原文链接:https://github.com/zigcc/forum/issues/364

std.Io.Threaded 是 Zig 全新的、支持并发的 IO 接口实现之一。这是一个无聊的“直接用线程就行”的实现。不过我个人觉得它很巧妙——它做了一件我一直想做、而且据我所知没有其他人能正确做到的怪事,并且它的实现比我想象的还要完美。 std.Io.Threaded 使用阻塞系统调用,并且完全支持取消操作。

并发与并行 (Concurrency vs Parallelism)

引用 tedinski 的话:

并发是关于处理(异步的、不确定的)事件。 并行是关于利用硬件资源同时做更多的事情。

我认为这个定义是正确的,但它没有直接提供有用的直觉。并发等同于状态转换器(state transducers)?是的,显然如此,但这对于你如何编写程序并没有太大的启发。

为了建立直觉,我喜欢这两个试金石。首先,并行是确定性的或“声明式的”:

use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
    input.par_iter()
         .map(|i| i * i)
         .sum()
}

你描述了如何将问题分割成独立的区(partitions),并实现一个函数来一次处理一个分区。平台负责验证分区是否正确(无竞态条件)、处理所有分区,并在完成后交还控制权。

其次,并发总是不可避免地涉及取消操作。每当你有两个同时进行的异步计算时,总会到一个时刻:其中一个计算意识到第二个计算已经不再必要,必须被积极地取消。通常情况下,你不可能只是干等着另一个计算完成:通常,你之所以想取消它,恰恰是因为你发现它无法完成(例如,它在等待一个永远不会收到的消息)。

而这就是问题所在,对于

“直接用线程就行” (Just Use Threads)

好吧,问题其实还有很多,其中最主要的是:虽然你完全可以派生(spawn)许多线程,但这通常需要系统级的配置更改,这对大多数应用程序来说是行不通的。但是,缺乏取消机制确实会让你迟早碰壁。问题出在系统调用(syscalls)上。在任何循环代码中,做下面这样的事情都很容易:

while (true) {
    if (is_canceled()) return error.Canceld; /// 太简单了!
    ...
}

但是,如果线程被阻塞在内核的系统调用内部,编程语言的 API 通常不会提供任何解除阻塞的方法:

const read_size = try read(fd, buffer); // ???

如果我们能够只使用标准的操作系统线程、阻塞式 API,避开像 io_uring 这样闪亮的新技术,但仍然能够可靠地取消任何工作,那不是很酷吗?这正是 Zig 的 std.Io.Threaded 所提供的。

SIGIO

这在 POSIX 系统上的工作方式有点像受了诅咒。事实证明,内核实际上提供了一种迂回的方法来取消阻塞的系统调用——信号(signals)。当一个线程在内核中被阻塞时,如果向该线程发送一个信号,线程就会被唤醒,并且系统调用会返回 EINTR。在这种情况下,习惯的做法是写一个循环重试系统调用,但其实并非必须如此。

信号本身并不是一个取消机制——向线程发送信号本质上是具有竞态条件的,信号可能在相关系统调用开始之前或结束之后到达。反过来,系统调用也可能被与取消无关的信号中断。

实际的协议是:取消线程在共享内存中设置一个标志来请求取消,然后在一个循环中向被取消线程发送信号,直到取消被确认(共享内存中的标志变为另一个值)。在从系统调用接收到 EINTR 后,可能被取消的线程会检查标志的值,要么重试系统调用,要么确认取消并开始栈解退(unwinding)。参见 signalCanceledSyscall 以及例如 fileReadPositionalPositionalPosix 来了解该协议的两半。

在用户端,取消请求被具象化为 error.Canceled。错误管理作为一个特性,是取消、分支和报告的结合,Zig 实现了前两项。取消不是一个错误,不是因为它代表偶然的成功,而是恰恰相反,错误就是“取消加上负载(payload)”。

在 Windows 上,有一个更直接的 NtCancelSynchronousIoFile(我喜欢这个名字!)。总的来说,在纤程(fibers)、IO 完成端口(IOCP)、作业对象(Job objects)和这个机制之间,看起来 NT 比 Unix 有着考虑得更周全的并发故事。

前人工作 (Prior Art)

在 Java 中,有一个看起来类似的线程中断机制。关键在于,它不支持中断系统调用:IOExceptionInterruptedException 都是受检异常且互不相关,这意味着 IO 函数是不可中断的。在 Zig 中,读取器和写入器接口完全对错误进行了类型擦除(type erase),因此支持取消,尽管除了通常的“别忘了刷新(flush)”之外,这需要额外的小心处理才能正确实现。

pthread_cancel 实现了一种类似的“信号 + 标志”机制。然而,它没有与语言级别的取消(try, defer)集成,这使得取消后的清理工作变得繁琐且缓慢。更广泛地说,围绕并发的大多数焦虑源于这样一个事实:它恰好落在了内核、运行时和语言之间的灰色地带。CPU 上几乎不存在并发(中断除外),那是由多方共同创作的一种错觉。语言通常是解决这个问题的更好工具,但传统上,它是由内核和 libc 处理的,这对语言设计产生了不利影响。

pthread_cancel 的另一个问题是它会摧毁整个线程,如果线程很廉价的话,这倒也无所谓。然而,创建线程仍然很慢,而且配置的系统线程数限制通常很低,所以通常最好使用线程池。Zig 的 IO 通过在接口级别将“可能并发运行(may run concurrently)”与“必须并发运行(must run concurrently)”分离开来,巧妙地解决了这个问题: https://kristoff.it/blog/asynchrony-is-not-concurrency/

这实现了类似于 std::launch 策略的效果(如果你手头有《Effective Modern C++》的话,这是第 36 条)。通过给正在发生的事情命名(io.asyncio.concurrent),Zig 使人们更容易理解实际发生的事情,并且获得了更精确的签名(concurrent 总是会失败的,async 永不失败)。当然,concurrent 是由线程池支撑的,只有在线程池耗尽时才会退化为派生一个新线程。

Zig’s Io.Threaded is Neat

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: 1. 供稿,分享自己使用 Zig 的心得 2. 改进 ZigCC 组织下的开源项目 3. 加入微信群QQ 群QQ 频道Telegram 群组Google Groups 与更多 Zig 爱好者交流

Reply all
Reply to author
Forward
0 new messages