std.Io.Threaded 是 Zig 全新的、支持并发的 IO
接口实现之一。这是一个无聊的“直接用线程就行”的实现。不过我个人觉得它很巧妙——它做了一件我一直想做、而且据我所知没有其他人能正确做到的怪事,并且它的实现比我想象的还要完美。
std.Io.Threaded
使用阻塞系统调用,并且完全支持取消操作。
引用 tedinski 的话:
并发是关于处理(异步的、不确定的)事件。 并行是关于利用硬件资源同时做更多的事情。
我认为这个定义是正确的,但它没有直接提供有用的直觉。并发等同于状态转换器(state transducers)?是的,显然如此,但这对于你如何编写程序并没有太大的启发。
为了建立直觉,我喜欢这两个试金石。首先,并行是确定性的或“声明式的”:
use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
input.par_iter()
.map(|i| i * i)
.sum()
}你描述了如何将问题分割成独立的区(partitions),并实现一个函数来一次处理一个分区。平台负责验证分区是否正确(无竞态条件)、处理所有分区,并在完成后交还控制权。
其次,并发总是不可避免地涉及取消操作。每当你有两个同时进行的异步计算时,总会到一个时刻:其中一个计算意识到第二个计算已经不再必要,必须被积极地取消。通常情况下,你不可能只是干等着另一个计算完成:通常,你之所以想取消它,恰恰是因为你发现它无法完成(例如,它在等待一个永远不会收到的消息)。
而这就是问题所在,对于
好吧,问题其实还有很多,其中最主要的是:虽然你完全可以派生(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 所提供的。
这在 POSIX
系统上的工作方式有点像受了诅咒。事实证明,内核实际上提供了一种迂回的方法来取消阻塞的系统调用——信号(signals)。当一个线程在内核中被阻塞时,如果向该线程发送一个信号,线程就会被唤醒,并且系统调用会返回
EINTR。在这种情况下,习惯的做法是写一个循环重试系统调用,但其实并非必须如此。
信号本身并不是一个取消机制——向线程发送信号本质上是具有竞态条件的,信号可能在相关系统调用开始之前或结束之后到达。反过来,系统调用也可能被与取消无关的信号中断。
实际的协议是:取消线程在共享内存中设置一个标志来请求取消,然后在一个循环中向被取消线程发送信号,直到取消被确认(共享内存中的标志变为另一个值)。在从系统调用接收到
EINTR
后,可能被取消的线程会检查标志的值,要么重试系统调用,要么确认取消并开始栈解退(unwinding)。参见
signalCanceledSyscall 以及例如
fileReadPositionalPositionalPosix 来了解该协议的两半。
在用户端,取消请求被具象化为
error.Canceled。错误管理作为一个特性,是取消、分支和报告的结合,Zig
实现了前两项。取消不是一个错误,不是因为它代表偶然的成功,而是恰恰相反,错误就是“取消加上负载(payload)”。
在 Windows 上,有一个更直接的
NtCancelSynchronousIoFile(我喜欢这个名字!)。总的来说,在纤程(fibers)、IO
完成端口(IOCP)、作业对象(Job objects)和这个机制之间,看起来 NT 比
Unix 有着考虑得更周全的并发故事。
在 Java
中,有一个看起来类似的线程中断机制。关键在于,它不支持中断系统调用:IOException
和 InterruptedException 都是受检异常且互不相关,这意味着 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.async 与
io.concurrent),Zig
使人们更容易理解实际发生的事情,并且获得了更精确的签名(concurrent
总是会失败的,async
永不失败)。当然,concurrent
是由线程池支撑的,只有在线程池耗尽时才会退化为派生一个新线程。
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: 1. 供稿,分享自己使用 Zig 的心得 2. 改进 ZigCC 组织下的开源项目 3. 加入微信群、QQ 群、QQ 频道、Telegram 群组、Google Groups 与更多 Zig 爱好者交流