Zig's Io.Threaded is Neat

Zig’s Io.Threaded is Neat

std.Io.Threaded is one of the implementations of Zig’s new Io interface that enables concurrency. This is a boring “just use threads” impl. I personally find it neat though — it does this weird thing that I wanted to do for ages, that to my knowledge no one else is doing properly, and implements it better than I thought to be possible. std.Io.Threaded 是 Zig 新 Io 接口中用于实现并发的实现之一。这是一个平淡无奇的“直接使用线程”的实现。但我个人觉得它很巧妙——它做了一件我渴望已久、据我所知目前还没有人能妥善处理的事情,而且它的实现比我预想的还要好。

Io.Threaded uses blocking syscalls and fully supports cancelation. Io.Threaded 使用阻塞式系统调用,并完全支持取消操作。

Concurrency vs Parallelism

并发与并行

Quoting @tedinski, Concurrency is about handling (asynchronous, nondeterministic) events. Parallelism is about using hardware resources to do more at the same time. I think this definition is correct, but doesn’t provide useful intuition directly. Concurrency is the same thing as state transducers? Yes, obviously, but not really illuminating as to how you’d program the thing. 引用 @tedinski 的话:并发是关于处理(异步、非确定性)事件的;并行是关于利用硬件资源同时完成更多工作。我认为这个定义是正确的,但它并没有直接提供有用的直觉。并发和状态转换器是一回事吗?是的,显而易见,但这对于如何编程并没有太大的启发。

For intuition, I like these two litmus tests. First, parallelism is deterministic or “declarative”: 为了建立直觉,我喜欢用这两个试金石。首先,并行是确定性的或“声明式”的:

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

You describe how to split the problem into independent partitions, and implement a function to process one partition at a time. It’s platform’s job to verify the partitioning to be correct (non-racy), process all partitions, and yield control back once that is done. 你描述了如何将问题拆分为独立的区块,并实现一个函数来一次处理一个区块。由平台负责验证分区是否正确(无竞态)、处理所有分区,并在完成后交还控制权。

Second, concurrency invariably involves cancelation. Whenever you have two asynchronous computations happening at the same time, there comes a moment when one computation becomes aware that the second computation is no longer necessary, and must be canceled, actively. In general, it is not possible to just wait until the other computation completes: often, the reason why you want to cancel it in the first place is precisely because you’ve learned that it can’t complete (e.g., it is waiting for a message it will never receive). 其次,并发总是涉及取消操作。每当有两个异步计算同时进行时,总会有一个时刻,其中一个计算意识到第二个计算不再必要,必须主动将其取消。通常情况下,你无法仅仅等待另一个计算完成:很多时候,你想要取消它的原因恰恰是因为你发现它无法完成(例如,它正在等待一条永远不会收到的消息)。

And that is the problem with “Just Use Threads”. Well, there are more, the chief being that, while you totally can spawn many threads, this often requires system-wide configuration change, which is a non-starter for most application. But absence of cancelation really makes you hit a wall sooner or later. 这就是“直接使用线程”的问题所在。当然还有其他问题,最主要的是,虽然你完全可以创建许多线程,但这通常需要修改系统级的配置,这对大多数应用程序来说是不可行的。但缺乏取消机制迟早会让你碰壁。

The problem are syscalls. It’s easy enough, in any loopy code, to do something like: 问题在于系统调用。在任何循环代码中,做类似下面的事情很容易:

while (true) {
    if (is_canceled()) return error.Canceled;
    // Easy!
    ...
}

But, the thread is instead blocked inside the syscall in the kernel, programming language APIs generally doesn’t give any way to unblock it: 但是,如果线程被阻塞在内核的系统调用中,编程语言的 API 通常没有任何方法来解除阻塞:

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

Wouldn’t it be cool if we could just use standard OS threads, blocking APIs, avoid new shinies like io_uring, but still get to cancel any work reliably? That’s exactly what Zig’s std.Io.Threaded provides. 如果我们能直接使用标准的操作系统线程和阻塞式 API,避开像 io_uring 这样复杂的新技术,同时还能可靠地取消任何工作,那该多酷啊?这正是 Zig 的 std.Io.Threaded 所提供的。

SIGIO

SIGIO

The way this works on POSIX is a bit cursed. Turns out, the kernel actually provides a roundabout way to cancel a blocking syscall — signals. When a thread is blocked in the kernel, and a signal is delivered to the thread, the thread is woken up and the syscall returns EINTR. It is customary to just loop re-try the syscall in such cases, but one doesn’t have to. 在 POSIX 系统上实现这一点有点“邪门”。事实证明,内核确实提供了一种迂回的方式来取消阻塞的系统调用——信号。当线程在内核中被阻塞时,如果向该线程发送信号,线程会被唤醒,系统调用会返回 EINTR。在这种情况下,通常的做法是循环重试系统调用,但其实不必非得这样做。

By itself, signals are not a cancelation mechanism — signaling a thread is inherently racy, the signal might get delivered before the relevant syscall starts, or after it finishes. Conversely, a syscall might get interrupted by signal unrelated to cancelation. 信号本身并不是一种取消机制——向线程发送信号本质上存在竞态,信号可能在相关系统调用开始之前或结束之后送达。反之,系统调用也可能被与取消无关的信号中断。

The actual protocol is that the canceling thread sets a flag in shared memory to request cancelation, and then signals the cancelee, in a loop, until the cancelation is acknowledged (a different value for a flag in the shared memory). Upon receiving EINTR from a syscall, the thread potentially being canceled checks the value of the flag and either retries the syscall, or acknowledges the cancelation and begins unwinding. 实际的协议是:取消线程在共享内存中设置一个标志来请求取消,然后在一个循环中向被取消线程发送信号,直到取消被确认(共享内存中的标志变为另一个值)。当从系统调用收到 EINTR 时,可能被取消的线程会检查标志的值,要么重试系统调用,要么确认取消并开始栈展开(unwinding)。

See signalCanceledSyscall and, e.g., fileReadPositionalPosix for the two halves of the protocol. On the user-side, cancelation request is materialized as error.Canceled. 请参阅 signalCanceledSyscall 以及 fileReadPositionalPosix 来了解该协议的两个部分。在用户侧,取消请求体现为 error.Canceled

Error management as a feature is a combination of cancelation, branching, and reporting, and Zig implements the first two. Cancelation isn’t an error not because it is serendipitous success, but because, vice versa, an error is a cancelation plus a payload. 作为一项特性,错误管理是取消、分支和报告的结合,而 Zig 实现了前两者。取消不是一种错误,并不是因为它是一种意外的成功,而是反过来,错误其实就是“取消加上负载信息”。

On Windows, there’s a much more direct NtCancelSynchronousIoFile (Love the name!). In general, between fibers, IO Completion Ports, Job objects, and this, it seems that NT has a better thought through concurrency story than Unix. 在 Windows 上,有一个更直接的 NtCancelSynchronousIoFile(我喜欢这个名字!)。总的来说,在纤程(fibers)、IO 完成端口(IOCP)、作业对象(Job objects)以及这个机制之间,NT 似乎比 Unix 有着更深思熟虑的并发方案。

Prior Art

前人经验

In Java, there’s a similarly looking thread interruption mechanism. Critically, it doesn’t support interrupting syscalls: IOException and InterruptedException are both checked and unrelated, meaning that IOing functions are not interruptible. 在 Java 中,有一个看起来类似的线程中断机制。关键在于,它不支持中断系统调用:IOExceptionInterruptedException 都是受检异常且互不相关,这意味着 IO 函数是不可中断的。

In Zig, reader and writer interfaces completely type erase errors and therefore support cancelation, though this requires some extra care to handle correctly, on top of the usual “don’t forget to flush”. 在 Zig 中,读取器和写入器接口完全对错误进行了类型擦除,因此支持取消操作,尽管除了通常的“别忘了刷新缓冲区”之外,还需要额外小心处理。

pthread_cancel implements a similar signal+flag machinery. However, it doesn’t integrate with language-level cancelation (try, defer) which makes post-cancelation cleanup cumbersome and slow. pthread_cancel 实现了类似的“信号+标志”机制。然而,它没有与语言层面的取消机制(try, defer)集成,这使得取消后的清理工作变得繁琐且缓慢。

More generally, a lot of angst around concurrency stems from a fact that it falls exactly into the twilight zone between the kernel, the runtime, and the language. There’s almost (interrupts excepted) no concurrency on the CPU, it’s an illusion with a mixed authorship. The language is usually the better equipped one to tackle the problem, but, traditionally, it is handled by the kernel and libc, with adverse effects on language design. 更广泛地说,围绕并发的许多焦虑源于这样一个事实:它恰好处于内核、运行时和语言之间的灰色地带。在 CPU 上几乎(中断除外)不存在并发,这是一种由多方共同制造的幻觉。语言通常是解决该问题的更好工具,但传统上它是由内核和 libc 处理的,这对语言设计产生了不利影响。

Another problem with pthread_cancel is that it tears down the entire thread, which would be an OK thing to do if threads were cheap. However, creating threads is still slow, and the configured system limit for a number of threads is typically low, so it’s usually a good idea to pool OS threads. Zig’s Io solves this problem ingeniously, separating, at the interface level, “may run concurrently” from “must run concurrently”. pthread_cancel 的另一个问题是它会销毁整个线程,如果线程很廉价,这倒没问题。然而,创建线程仍然很慢,而且系统配置的线程数限制通常很低,因此使用操作系统线程池通常是个好主意。Zig 的 Io 巧妙地解决了这个问题,在接口层面将“可能并发运行”与“必须并发运行”分离开来。