掘金 后端 ( ) • 2024-04-03 11:46

异步 IO 基础知识

Rust 中的异步编程构建在操作系统的异步 IO 设施之上。虽然可以只使用 async/await 来控制流,但大多数人使用 async 来实现异步 IO。运行时和库抽象了 IO 的许多细节(这很好,因为直接与操作系统一起工作既繁琐又容易出错),但了解异步 IO 在高层次上的工作原理仍然很有用,这样您就可以理解性能特征,并可以为您的应用选择最佳的系统和库。

请注意,我不是这方面的专家,这篇博文只是介绍性的。

到底什么是异步

当谈论 IO(以及大多数情况下一般的异步编程)时,异步意味着我们不会阻塞等待 IO 完成的线程。因此,如果用户从操作系统请求一些 IO,并且操作系统执行 IO 而不返回,然后在完成后返回结果,即同步(也称为阻塞)IO。使用异步 IO,用户请求 IO,然后可以继续处理其他事情,当 IO 完成时,获取结果。

在伪代码中(看起来与现实生活完全不同):

// Synchronous 同步
let result = do_some_io(); // This call could take a while.

// Async  异步
start_some_io();
// Do some other work ...
// When the IO is done:
let result = get_result_of_io(); // This call is quick because the IO was already finished.

注意,我们仅讨论单个用户线程。我们总是可以通过使用多个线程来使事情异步,但这并不是大多数人在谈论异步 IO 或异步编程时的意思。

其他术语是将这种 IO 称为非阻塞 IO,并为 async IO Linux 系统调用保留 async。至少在 Rust 社区中这似乎不太常见。

准备和完成模型(Readiness and completion models )

异步 IO 有两种高级模型:准备就绪和完成(我不清楚这些术语在定义异步模型时的使用有多广泛,但它们是常见术语)。 epoll 和 Kqueue 是就绪模型的例子; IOCP 和 io_uring 是完成模型的示例。基本区别在于,在就绪模型中,操作系统在资源准备好读取或写入时通知用户;在完成模型中,操作系统模型在对资源的读取或写入完成时通知用户

更多伪代码:

// Readiness
start_some_io();
when io_is_ready {
    let mut buf = ...;
    read(&mut buf);
    // Do something with the data we read into buf.
}

// Completion
let mut buf = ...;
start_some_io(&mut buf);
when io_is_complete {
    // Do something with the data we read into buf.
}

注意,我们仍然对操作系统如何通知用户IO准备好或完成进行了大量的讨论有很多方法可以做到这一点:在最高级别,用户必须检查操作系统(轮询),或者操作系统必须中断用户。中断方法似乎没有被广泛使用。我认为 Linux 异步 IO 系统调用(AIO,不要与一般的异步 IO 混淆)是最著名的例子。

一个重要的观察结果是,为每个正在进行的 IO 单独轮询 操作系统的效率极低。相反,用户应该轮询操作系统有关正在进行的 所有或多个 IO,并找出哪些已准备好/已完成(称为多路复用)

我将描述一些常见的异步 IO 机制以及每个机制的通知如何工作。

select, poll, and epoll

select 和 poll 系统调用以及 epoll 系列系统调用基本上执行相同的操作:它们为操作系统提供 一组 要监视的资源,并等待(超时阻塞),直到其中至少一个准备好读/写。它们之间的区别在于如何指定该组资源

通过 select 或 poll,每次进行系统调用时,资源集都会传递到操作系统。换句话说,它是由用户维护的。它们都是 POSIX 调用,因此可移植并且可在所有 Unix 系统上使用。

使用 epoll,资源集在操作系统内部维护,用户使用单独的系统调用( epoll_ctl 、 c.f.、 epoll_wait 等待资源准备好来修改资源集)。这很方便,但更重要的是,在处理大量资源时,epoll 比 poll 或 select 的性能要高得多。然而,它是特定于 Linux 的。

通过这些方法,当 IO 资源准备就绪时,用户必须调用读取或写入系统调用来传输数据。

边沿和水平触发

一个细节是,如果资源已准备好读/写但尚未读/写该怎么办?操作系统可以继续报告资源就绪(称为水平触发 IO),或者操作系统可以停止报告资源就绪,即操作系统仅报告资源就绪一次(称为边缘触发 IO)。您可以将这些替代方案视为基于当前状态的报告与基于更改的报告。

Select和poll始终是 水平触发的。 epoll 可以配置为水平触发或边缘触发,默认为水平触发。 Epoll 还支持一次性模式,这就像边缘触发的更极端版本,即使发生多个事件,也仅通知用户资源已准备好。

IOCP

IOCP 是 基于完成模型并且仅限于 Windows(请注意,Windows 上的异步 IO 通常称为重叠 IO)。完成端口(IOCP 中的 CP)类似于 epoll 对象,它是用户间接访问的操作系统内部管理的一组 IO 资源。主要区别在于,epoll 通知用户资源准备就绪后,用户必须读取或写入资源。当IOCP通知用户IO完成时,数据已经被读入用户的内存中,或者从中写入(即IO完成)。

为此,用户必须在启动 IO 时分配一个缓冲区来读入或写出,并使其保持活动状态(而不是覆盖它),直到 IO 完成。

io_uring

io_uring 与 IOCP 类似,因为它是基于完成模型的,并且要求用户在 IO 异步发生时维护缓冲区。区别在于用户如何设置和检查通知的细节(当然还有实施过程中)。特别是,使用环形缓冲区来最大限度地减少系统调用的数量(我需要更好地理解这部分!)。

哪个更好?

基于就绪的 IO 的主要优点是不需要提前分配缓冲区。这意味着在启动 IO 之前,没有任何内存必须保持活动状态,直到可以将数据复制到操作系统或从操作系统复制数据为止。这减少了内存使用量,使代码更简单(因为它简化了缓冲区管理),并允许同一线程上的多个 IO 共享缓冲区。

基于完成的 IO 确实需要急切分配缓冲区,但它允许 零拷贝 方法,其中数据可以直接写入用户内存或从用户内存写入数据,而无需操作系统复制。


原文地址