Skip to content

【Zig 日报】Zig 编程语言异步 I/O 的新设计 #293

@jiacai2050

Description

@jiacai2050

Zig 编程语言的设计者一直在努力寻找适合异步代码的设计方案。Zig 是一种精简的语言,其最初的异步 I/O 设计与它的其他特性不太协调。现在,该项目通过 Zig SHOWTIME 视频宣布了一种新的异步 I/O 方法,该方法有望解决“函数着色”问题,并允许编写在同步或异步 I/O 中都能正确执行的代码。

许多语言(包括 Python、JavaScript 和 Rust)的异步代码使用特殊语法。这使得在程序的同步和异步部分之间重用代码变得困难,给库作者带来诸多麻烦。不使用语法区分的语言(如 Haskell)基本上通过使所有内容都异步来解决这个问题,这通常要求语言的运行时环境内建关于程序执行方式的理念。

Zig 的设计者认为这两种选择都不合适。他们希望找到一种不会给语言增加太多复杂性、仍然允许对异步操作进行精细控制、并且实际编写高性能事件驱动 I/O 仍然相对轻松的方法。新的方法通过隐藏异步操作在一个新的泛型接口 Io 后面来解决这个问题。

任何需要执行 I/O 操作的函数都需要访问该接口的一个实例。通常,这通过将实例作为参数传递给函数来实现,类似于 Zig 的内存分配接口 Allocator。标准库将包含该接口的两个内置实现:Io.ThreadedIo.Evented。前者使用同步操作,除非明确要求并行执行(使用特殊函数;见下文),此时它使用线程。后者(仍在开发中)使用事件循环和异步 I/O。然而,该设计并未阻止 Zig 程序员实现他们自己的版本,因此 Zig 用户仍然可以精细控制程序的执行方式。

Loris Cro,Zig 的社区组织者之一,撰写了一篇解释新行为的文章,以证明这种方法的合理性。同步代码几乎没有变化,除了使用已移至 Io 下方的标准库函数外。像下面的例子一样,不涉及显式异步性的函数将继续工作。该示例创建一个文件,设置在函数结束时关闭该文件,然后将数据缓冲区写入该文件。它使用 Zig 的 try 关键字来处理错误,并使用 defer 确保文件已关闭。返回类型 !void 表示它可能会返回一个错误,但不会返回任何数据:

const std = @import("std");
const Io = std.Io;

fn saveFile(io: Io, data: []const u8, name: []const u8) !void {
    const file = try Io.Dir.cwd().createFile(io, name, .{});
    defer file.close(io);
    try file.writeAll(io, data);
}

如果该函数收到 Io.Threaded 的实例,它将创建文件、写入数据,然后使用普通系统调用关闭它。如果收到 Io.Evented 的实例,它将使用 io_uringkqueue 或其他适合目标操作系统的异步后端。无论哪种方式,在 writeAll() 返回时,操作都保证完成。编写涉及 I/O 的函数的库作者无需关心库的最终用户选择执行哪种操作。

另一方面,假设一个程序想要保存两个文件。这些操作可以并行完成。如果库作者想要启用该功能,他们可以使用 Io 接口的 async() 函数来表达两个文件的保存顺序无关紧要:

fn saveData(io: Io, data: []const u8) !void {
    // 调用 saveFile(io, data, "saveA.txt")
    var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
    var b_future = io.async(saveFile, .{io, data, "saveB.txt"});

    const a_result = a_future.await(io);
    const b_result = b_future.await(io);

    try a_result;
    try b_result;

    const out: Io.File = .stdout();
    try out.writeAll(io, "save complete");
}

在使用 Io.Threaded 实例时,async() 函数实际上不需要执行任何异步操作(尽管实际实现可能会根据其配置方式将函数分派到单独的线程)——它可以立即运行提供的函数。因此,使用该接口的版本,函数首先保存文件 A,然后保存文件 B。使用 Io.Evented 实例时,操作实际上是异步的,程序可以同时保存两个文件。

这种方法的真正优势在于它将异步代码变成了一种性能优化。程序或库的第一个版本可以编写正常的直线代码。稍后,如果异步性被证明对性能有用,作者可以返回并使用异步操作编写它。如果函数的最终用户未启用异步执行,则不会发生任何更改。但是,如果他们启用了异步执行,该函数将透明地变得更快——函数签名或其与代码库其余部分交互的方式不会发生任何更改。

然而,一个问题是对于需要同时执行两个部分才能保证正确的程序。例如,假设一个程序想要侦听端口上的连接并同时响应用户输入。在这种情况下,等待连接然后才要求用户输入是不正确的。对于这种用例,Io 接口提供了一个单独的函数 asyncConcurrent()(该函数在开发期间已重命名;concurrent() 是最新的名称),它明确要求提供的函数并行运行。Io.Threaded 使用线程池中的线程来完成此操作。Io.Evented 将其视为对 async() 的正常调用。

const socket = try openServerSocket(io);
var server = try io.concurrent(startAccepting, .{io, socket});
defer server.cancel(io) catch {};

try handleUserInput(io);

如果程序员在应该使用 concurrent() 的地方使用了 async(),那将是一个错误。Zig 的新模型不会(也不能)阻止程序员编写不正确的代码,因此在使用新接口调整现有的 Zig 代码时,仍然需要注意一些细微之处。

这种设计产生的代码风格比那些为异步函数提供特殊语法的语言更冗长,但语言的创建者 Andrew Kelley 表示“它看起来像标准的、惯用的 Zig 代码”。特别是,他指出这种方法允许程序员使用 Zig 的典型控制流原语,例如 trydefer;它没有引入任何特定于异步代码的新语言特性。

为了演示这一点,Kelley 给出了一個使用新接口实现异步 DNS 解析的例子。用于查询 DNS 信息的标准 getaddrinfo() 函数不足之处在于,虽然它并行向多个服务器(IPv4 和 IPv6)发出请求,但它会在所有查询完成之前才返回答案。Kelley 的示例 Zig 代码返回第一个成功的答案,并取消其他正在进行的请求。

然而,Zig 中的异步 I/O 远未完成。Io.Evented 仍然是实验性的,并且尚未为所有受支持的操作系统实现。计划了一种与 WebAssembly 兼容的第三种 Io(尽管,正如该问题所述,实现它取决于其他一些新的语言特性)。Io 的原始 pull request 列出了 24 个计划的后续项目,其中大多数仍需要完成。

尽管如此,Zig 中异步代码的整体设计似乎已经确定。Zig 尚未发布 1.0 版本,因为社区仍在试验正确实现许多功能的方式。异步 I/O 是剩余的较大优先级之一(与本机代码生成一起,本机代码生成今年也在某些架构的调试构建中默认启用)。Zig 似乎正在稳步朝着完成设计迈进——这应该减少 Zig 程序员被要求重写 I/O 代码的次数,因为接口再次发生了变化。

Zig's new plan for asynchronous programs [LWN.net]

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

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