Zig的Io.Threaded:并发实现的巧妙之作
std.Io.Threaded
是 Zig 新 Io 接口的一个实现,用于支持并发。这是一个“只用线程”的朴素实现。不过我个人觉得它很巧妙——它做了
我多年来一直想做的事,据我所知没有其他人真正做好过,而且它实现得比我预想的还要好。
Io.Threaded 使用阻塞式系统调用,并完全支持取消操作。
并发与并行
引用 @tedinski 的话,
- 并发是处理(异步、非确定性的)事件。
- 并行是利用硬件资源同时做更多事情。
我认为这个定义是正确的,但并不能直接提供有用的直觉。并发和状态转换器是一回事吗?显然是的,但这并不能真正启发你如何编程实现。
为了获得直觉,我喜欢用这两个试金石测试。第一,并行是确定性的或“声明式的”:
use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
input.par_iter()
.map(|i| i * i)
.sum()
}
你描述如何将问题拆分成独立的部分,并实现一个函数一次处理一个部分。验证分区是否正确(无竞争)、处理所有分区,并在完成后交回控制权,这是平台的责任。
第二,并发总是涉及取消。每当有两个异步计算同时进行时,总有一个时刻,一个计算意识到另一个计算不再必要,必须主动取消它。一般来说,不能只是等待另一个计算完成:通常,你一开始想取消它的原因恰恰是因为你得知它无法完成(例如,它在等待一个永远不会收到的消息)。
而这就是问题所在
只用线程
其实还有更多问题,最主要的是,虽然你完全可以创建大量线程,但这往往需要修改系统级配置,对大多数应用来说根本行不通。但真正让你迟早碰壁的是缺少取消机制。问题出在系统调用上。在任何循环代码里,做类似下面这样的事很容易:
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 上,其实现方式有点邪门。原来,内核确实提供了一种迂回的方式来取消阻塞的系统调用——信号。当线程阻塞在内核中时,如果信号被传递给该线程,线程会被唤醒,系统调用返回 EINTR。通常的做法是
循环重试系统调用,但并非必须如此。
信号本身并不是取消机制——向线程发送信号天生就存在竞态,信号可能在相关系统调用开始前或结束后才送达。反过来,系统调用也可能被与取消无关的信号中断。
实际协议是:取消线程在共享内存中设置一个标志来请求取消,然后循环向被取消线程发送信号,直到取消被确认(共享内存中另一个标志的不同值)。当线程从系统调用中收到 `EINTR` 时,可能被取消的线程会检查标志的值,要么重试系统调用,要么确认取消并开始展开。参见signalCanceledSyscall
和例如
fileReadPositionalPosix
,它们分别对应协议的两半。
在用户侧,取消请求以 `error.Canceled` 的形式体现。错误管理作为一种特性,是取消、分支和报告的结合,而 Zig 实现了前两者。取消之所以不是错误,并非因为它是偶然的成功,而是因为反过来,错误就是取消加上一个负载。
在 Windows 上,有一个更直接的 `NtCancelSynchronousIoFile`,这个名字真棒!总的来说,在纤程、IO 完成端口、Job 对象以及这个机制之间,NT 在并发设计上似乎比 Unix 考虑得更周全。
先前工作
在 Java 中,有一个类似的线程中断机制。关键问题是,它不支持中断系统调用:`IOException` 和 `InterruptedException` 都是受检异常且互不相关,这意味着 IO 函数不可中断。在 Zig 中,读写接口完全类型擦除了错误,因此支持取消,尽管这需要在通常的别忘了刷新之上,额外小心处理。pthread_cancel 实现了类似的信号加标志机制。然而,它并未与语言级别的取消操作(如 try、defer)集成,这使得取消后的清理工作既繁琐又缓慢。更广泛地说,围绕并发的大量焦虑源于一个事实:它恰好处于内核、运行时和语言之间的模糊地带。CPU 上几乎没有并发(中断除外),这是一种混合作者身份的幻觉。语言通常是解决这个问题的最佳工具,但传统上,这个问题由内核和 libc 处理,对语言设计产生了不利影响。
pthread_cancel 的另一个问题是它会销毁整个线程,如果线程成本低廉,这或许可行。然而,创建线程仍然缓慢,且系统配置的线程数上限通常较低,因此通常最好对 OS 线程进行池化。Zig 的 Io 巧妙解决了这个问题,在接口层面区分了“可能并发”与“必须并发”:
https://kristoff.it/blog/asynchrony-is-not-concurrency/
这实现了类似于 std::launch policy 的效果(如果你手头有《Effective Modern C++》,可参考第 36 条)。通过明确命名正在发生的事情(io.async 对比 io.concurrent),Zig 使理解实际发生的情况变得更加容易,同时也获得了更精确的签名(concurrent 总是可能失败,而 async 则不会)。当然,concurrent 由线程池支持,仅在池耗尽时才回退到创建新线程。