Skip to content

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

Description

@jiacai2050

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 爱好者交流

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions