概述

我们将了解操作系统支持的事件队列是如何工作的,以及三种不同的操作系统如何以不同的方式处理这个任务。大多数异步运行时都使用操作系统支持的事件队列,事件队列技术在很多流行的库中都有使用,比如

我们与主机操作系统的所有交互都是通过系统调用完成的。要使用 Rust 进行系统调用,我们需要知道如何使用 Rust 的外部函数接口 (FFI)。除了知道如何使用 FFI 和进行系统调用,还需要介绍跨平台的抽象。当创建一个事件队列时,无论是你自己创建还是使用一个库,你会注意到,如果你只对 IOCP 如何在 Windows 上工作有一个高层次的概述,那么这些抽象可能看起来有点不直观。这样做的原因是,这些抽象需要提供一个 API,该 API 涵盖不同操作系统以不同方式处理相同任务的事实。这个过程通常涉及确定平台之间的共同点,并在此基础上构建新的抽象。将用一个简单的例子来轻松地讲解这个主题,而不是用一个相当复杂和冗长的例子来解释 FFI、系统调用 和 跨平台抽象

IOCP 的全称是 I/O Completion Port,中文译为 I/O完成端口。它是支持多个同时发生的异步 I/O 操作的应用程序编程接口(API),在Windows NT的3.5版本以后开始支持。IOCP 是性能最好的一种 I/O 模型,它是应用程序使用线程池处理异步 I/O 请求的一种机制。在 IOCP模型中,一个套接字(socket)文件句柄 可以与一个完成端口关联起来,当 I/O 操作完成时,操作系统会将I/O请求完成包添加到 I/O完成队列中。应用程序可以通过调用 GetQueuedCompletionStatus 等函数来查询并处理这些完成包,从而实现高效的异步I/O处理
  • OS-backed(基于 OS) event queue
  • Readiness-based(基于就绪) event queues
  • Completion-based(基于完成) event queues
  • epoll
  • kqueue
  • IOCP
  • Syscalls, FFI, and cross-platform abstractions

有一些流行的替代方案,尽管很少使用,但你应该知道,即使在这里不介绍它们

  • wepoll:这使用了 Windows 上的特定 API,并包装了 IOCP,所以它非常类似于 Linux 上 epoll 的工作方式,与常规的 IOCP 相比。这使得在两种不同的技术之上创建具有相同 API 的抽象层变得更加容易。libuv 和 mio 都使用它
  • io_uring:这是 Linux 上相对较新的 API,与 Windows 上的 IOCP 有许多相似之处

因为从零开始建立了一些基本的理解,这意味着有些理解是相当底层的,需要特定的操作系统和 CPU 家族才能运行。别担心;我选择了最常用和最受欢迎的 CPU,所以这应该不是问题,但这是您需要了解的事情。机器必须使用 Windows 和 Linux 上 x86-64 指令集的 CPU,英特尔和 AMD 的桌面 cpu 使用这种架构,但如果你在使用 ARM 处理器的机器上运行 Linux(或 WSL),你可能会遇到一些使用内联汇编的例子。接下来,我们将创建可以在所有平台上运行的示例,既可以原生运行,也可以使用 Windows Subsystem for Linux (WSL),但要理解跨平台抽象的基础知识,我们需要创建针对这些不同平台的示例

https://learn.microsoft.com/en-us/windows/wsl/install

If you use VS Code as your editor, there is a very simple way of switching your environment to WSL. Press Ctrl+Shift+P and write Reopen folder in WSL. This way, you can easily open the example folder in WSL and run the code examples using Linux there(vscode 也要安装好 WSL 插件)

Why use an OS-backed event queue

您现在已经知道,我们需要与操作系统密切合作,以使 I/O 操作尽可能高效。Linux、macOS 和 Windows 等操作系统提供了多种执行 I/O 的方式,有阻塞的也有非阻塞的。I/O 操作需要经过操作系统,因为它们依赖于操作系统抽象的资源。这可以是磁盘驱动器网卡其他外设。特别是在网络调用的情况下,我们不仅需要依赖自己的硬件,还需要依赖可能位于离我们很远的资源,从而导致严重的延迟。我们介绍了编程时处理异步操作的不同方式,虽然它们各不相同,但都有一个共同点:在进行系统调用时,处理异步操作的不同方式需要控制何时以及是否应该让操作系统调度器执行。在实践中,这意味着需要避免通常会屈服让步于操作系统调度器的系统调用(阻塞调用)**我们需要使用非阻塞调用**。我们还需要一种高效的方法来知道每个调用的状态,以便知道执行阻塞调用的任务何时可以继续执行**这是在异步运行时中使用操作系统支持的事件队列的主要原因。我们将以三种处理 I/O 操作**的方式为例

Blocking I/O

当我们让操作系统执行阻塞操作时,操作系统会挂起执行调用的操作系统线程。然后操作系统将存储我们调用时的 CPU 状态,并继续做其他事情。当数据通过网络到达时,操作系统将再次唤醒我们的线程,恢复 CPU 状态,让我们像什么都没有发生一样恢复。对于程序员来说,阻塞操作是最不灵活的操作,因为每次调用都要把控制权交给操作系统。这样做的最大好处是,一旦等待的事件准备好了,我们的线程就会被唤醒,以便继续执行。如果我们把在操作系统上运行的整个系统考虑在内,这是一个相当高效的解决方案,因为操作系统会让有工作要做的线程在 CPU 上进行。然而,如果我们缩小范围来单独查看我们的进程,我们会发现每次我们进行阻塞调用时,我们都会让线程进入睡眠状态,即使我们的进程仍然有工作可以做。这让我们可以选择生成新的线程来完成工作,或者只是接受我们必须等待阻塞调用返回

Non-blocking I/O

与阻塞 I/O 操作不同,操作系统不会挂起发出 I/O 请求的线程,而是给它一个句柄,线程可以使用它来询问操作系统事件是否准备好了。我们把这个查询过程称为状态轮询。非阻塞 I/O 操作给了程序员更多的自由,但是,和往常一样,这也伴随着一种责任。如果轮询太频繁,例如在循环中,我们将占用大量的 CPU 时间来请求更新状态,这是非常浪费的。如果轮询太不频繁,事件准备好和我们对它采取行动之间会有很大的延迟,从而限制了吞吐量

通过 epoll/kqueue 和 IOCP 进行事件排队

这是前面方法的一种混合。在网络调用的情况下,调用本身是非阻塞的。然而,我们可以将该句柄添加到事件队列中,而不是定期轮询该句柄,并且我们可以以很少的开销实现数以千计的句柄。可以定期查询队列,以检查我们添加的事件是否改变了状态。或者我们可以对队列进行阻塞调用,告诉操作系统,当队列中至少有一个事件改变状态时,我们希望被唤醒,以便继续等待该特定事件的任务。这使得我们只能在没有更多工作要做,并且所有任务都在等待事件发生时,才将控制权交给操作系统。我们可以自己决定何时发出这样的阻塞调用

注意:我们不会介绍 pollselect 等方法。大多数操作系统都有一些较旧的方法,在现代异步运行时中没有被广泛使用。只需要知道,我们可以进行其他调用,从本质上寻求提供与我们刚才讨论的事件队列相同的灵活性

Readiness-based event queues

Epollkqueue 被称为 基于就绪的事件队列,这意味着它们让您知道某个操作何时可以执行。一个例子是准备读取的套接字。为了说明它在实践中是如何工作的,我们来看一下使用 epoll/kqueue 从套接字读取数据时会发生什么

  1. 我们通过调用系统调用 epoll_create 或 kqueue 来创建事件队列
  2. 我们向操作系统请求一个表示网络套接字的文件描述符
  3. 通过另一个系统调用,我们在这个套接字上注册了对读取事件的兴趣。重要的是,我们还要通知操作系统,当我们在步骤1中创建的事件队列中准备好事件时,我们将期望收到通知
  4. 接下来,我们调用 epoll_wait 或 kevent 来等待事件。这将阻塞(挂起)调用它的线程
  5. 当事件就绪时,我们的线程被解除阻塞(恢复),并且我们从带有发生事件的数据的 wait 调用中回来
  6. 我们对第2步创建的 socket 调用 read

事件队列|系统调用|跨平台抽象(OS支持) - 图1

Completion-based event queues

IOCP 代表 input/output completion port 输入/输出完成端口。这是一个基于完成的事件队列。这种类型的队列在事件完成时通知您。例如,数据被读入缓冲区时。下面是对这种类型的事件队列中发生的事情的基本分解

  1. 我们通过调用系统调用 CreateIoCompletionPort 创建了一个事件队列
  2. 我们创建一个缓冲区,并请求操作系统为我们提供一个套接字的句柄
  3. 我们用另一个系统调用在这个套接字上注册了对读取事件的兴趣,但这一次我们还传入了(第2步)中创建的缓冲区,数据将被读取到该缓冲区
  4. 接下来,我们调用 GetQueuedCompletionStatusEx ,它会阻塞直到事件完成
  5. 我们的线程被解除阻塞,我们的缓冲区现在充满了我们感兴趣的数据

事件队列|系统调用|跨平台抽象(OS支持) - 图2

epoll、kqueue、IOCP

epoll 是 Linux 实现事件队列的方式。在功能方面,它与 kqueue 有很多相似之处。与 Linux 上的其他类似方法(如 select 或 poll )相比,使用 epoll 的优势在于,epoll 的设计能够非常高效地处理大量事件

kqueue 是 macOS 在 FreeBSD 和 OpenBSD 等操作系统中实现事件队列(源自 BSD)的方式。就高级功能而言,它在概念上与 epoll 类似,但在实际使用中有所不同

IOCP 是 Windows 处理这类事件队列的方式。在 Windows 中,完成端口会让你知道一个事件何时完成。现在,这听起来可能是一个小的差异,但它不是。当你想写一个库的时候,这一点尤其明显,因为抽象两者意味着你要么必须将 IOCP 建模为基于就绪的,要么将 epoll/kqueue 建模为基于完成的

将缓冲区借给操作系统也带来了一些挑战,因为在等待操作返回时保持缓冲区不变是非常重要的

事件队列|系统调用|跨平台抽象(OS支持) - 图3

Cross-platform event queues

当创建一个跨平台的事件队列时,你必须处理一个事实,即你必须创建一个统一的 API,无论它在 Windows 上 (IOCP), macOS 上 (kqueue) 还是 Linux上 (epoll) 都是一样的。最明显的区别是 IOCP 是基于完成度的,而 kqueue 和 epoll 是基于准备度的。这个根本的区别意味着你必须做出选择

  • You can create an abstraction that treats kqueue and epoll as completion-based queues
  • You can create an abstraction that treats IOCP as a readiness-based queue

从我的个人经验来看,创建一个模拟基于完成的队列的抽象,并在幕后处理 kqueue 和 epoll 是基于就绪的事实,要比反过来容易得多。如前所述,wepoll 是在 Windows 上创建就绪队列的一种方式。它将大大简化创建这样的 API,但我们暂时不讨论它,因为它不太为人所知,也没有 Microsoft 的官方文档。由于 IOCP 是基于完成的,它需要一个缓冲区来读取数据,因为当数据读取到缓冲区时,它会返回。而 Kqueue 和 epoll 则不需要这么做。它们只会在你可以不阻塞地将数据读入缓冲区时返回

通过要求用户提供他们首选大小的缓冲区给我们的API,我们让用户控制他们想要如何管理他们的内存。当使用 IOCP 时,用户定义缓冲区的大小,以及重新使用和控制将传递给操作系统的内存的所有方面。对于这种 API 中的 epoll 和 kqueue,可以简单地为用户调用 read 并填充相同的缓冲区,让用户看起来像是基于完成的 API。如果你想提供一个基于就绪的 API,你必须在 Windows 上执行 I/O 操作时创建一个两个独立操作的错觉。首先,当 socket 上的数据准备读取时请求一个通知,然后真正读取数据。虽然可以这样做,但你很可能会发现自己不得不创建一个非常复杂的 API,或者在 Windows 平台上接受一些低效率的情况,因为有中间缓冲区来保持基于就绪的 API 的幻觉。我们将把事件队列的主题留给创建一个简单的示例来展示它们是如何工作的。在此之前,我们需要真正熟悉 FFI 和 系统调用,我们将通过在三个不同的平台上编写一个系统调用的示例来做到这一点。我们还将利用这个机会讨论抽象级别,以及如何创建一个在三个不同平台上工作的统一API

System calls、FFI、cross-platform abstractions

我们将为三种体系结构实现一个非常基本的系统调用:BSD/macOS、Linux 和 Windows。我们还将看到这是如何在三个抽象级别上实现的。我们将实现的系统调用是我们向标准输出(stdout)写入内容时使用的系统调用,因为这是一种常见的操作,看看它的工作原理很有趣。我们将从可以用来进行系统调用的最底层抽象开始,并从头开始理解它们

The lowest level of abstraction

最低层次的抽象是编写通常称为“原始 raw”的系统调用。原始系统调用绕过了操作系统提供的用于进行系统调用的库而是依赖于操作系统具有稳定的系统调用 ABI 应用系统二进制接口application binary interfaces。稳定的系统调用 ABI 意味着,如果您将正确的数据放入某些寄存器,并调用将控制权传递给操作系统的特定 CPU 指令,它将始终做相同的事情。要进行原始的系统调用,我们需要编写一些内联汇编。在这个抽象层次上,我们需要为 BSD/macOS、Linux 和 Windows 编写不同的代码。如果操作系统运行在不同的 CPU 架构上,我们还需要编写不同的代码

Raw syscall on Linux

在 Linux 和 macOS 上,我们要调用的系统调用是 write。这两个系统的操作都基于文件描述符的概念,在启动进程时,stdout 已经存在了。您可以将代码复制并粘贴到 Rust Playground 中,也可以在Windows 中使用 WSL 运行它

  1. use std::arch::asm;
  2. #[inline(never)]
  3. fn syscall(message: String) {
  4. let msg_ptr = message.as_ptr();
  5. let len = message.len();
  6. unsafe {
  7. asm!(
  8. "mov rax, 1",
  9. "mov rdi, 1",
  10. "syscall",
  11. in("rsi") msg_ptr,
  12. in("rdx") len,
  13. out("rax") _,
  14. out("rdi") _,
  15. lateout("rsi") _,
  16. lateout("rdx") _
  17. );
  18. }
  19. }
  20. fn main() {
  21. let message = "Hello world from raw syscall!\n";
  22. let message = String::from(message);
  23. syscall(message); // 这就是抽象后的系统调用函数
  24. }
  25. // Hello world from raw syscall!

事件队列|系统调用|跨平台抽象(OS支持) - 图4

Raw syscall on macOS

现在,由于我们使用特定于 CPU 架构的指令,我们需要不同的函数,这取决于你运行的是带有英特尔 CPU 的旧 Mac 还是带有基于 Arm 64的 CPU 的新 Mac。我们只介绍适用于使用 ARM 64 架构的新 M系列 芯片的代码,但不用担心,如果你克隆了 Github 仓库,你会在那里找到适用于两个版本的 Mac 的代码

  1. use std::arch::asm;
  2. #[inline(never)]
  3. fn syscall(message: String) {
  4. let ptr = message.as_ptr();
  5. let len = message.len();
  6. unsafe {
  7. asm!(
  8. "mov x16, 4",
  9. "mov x0, 1",
  10. "svc 0",
  11. in("x1") ptr,
  12. in("x2") len,
  13. out("x16") _,
  14. out("x0") _,
  15. lateout("x1") _,
  16. lateout("x2") _
  17. );
  18. }
  19. }
  20. fn main() {
  21. let message = "Hello world from raw syscall!\n";
  22. let message = String::from(message);
  23. syscall(message);
  24. }

What about raw syscalls on Windows

这是一个很好的机会来解释为什么如果你希望你的程序或库能够跨平台工作,编写原始系统调用(就像我们刚才做的那样)是一个坏主意。如果你想让你的代码在未来还能正常工作,你就必须担心操作系统能提供什么保证。例如,Linux 保证写入rax 寄存器的值1总是指向 write,但 Linux 工作在许多平台上,并不是每个平台都使用相同的 CPU 体系结构。我们在 macOS 上也遇到了同样的问题,最近 macOS 从使用基于 intel 的 x86_64 架构转变为基于 ARM 64 架构。当涉及到像这样的底层内部操作时,Windows 绝对不会提供任何保证。Windows 已经改变了它的内部很多次,并且没有提供关于这个问题的官方文档。我们唯一拥有的东西是你可以在互联网上找到的反向工程表,但这不是一个健壮的解决方案,因为以前的 write 系统调用可以在下次运行 Windows 更新时更改为删除系统调用。即使不太可能,你也无法保证,这反过来又使你无法向程序的用户保证它将来会正常工作。因此,虽然原始系统调用在理论上是可行的,并且很好熟悉,但它们主要是作为一个例子,说明为什么我们宁愿链接到不同操作系统在进行系统调用时为我们提供的库

The next level of abstraction

Using the OS-provided API in Linux and macOS

下一个抽象层次是使用 API,这三个操作系统都为我们提供了 API。很快我们就会看到,这种抽象可以帮助我们删除一些代码。在这个特定的例子中,系统调用在 Linux 和 macOS 上是相同的,所以我们只需要担心在 Windows 上。我们可以使用 #[cfg(target_family = “windows”)]#[cfg(target_family = “unix”)] 条件编译标志来区分这两个平台

  1. use std::io;
  2. #[cfg(target_family = "unix")]
  3. #[link(name = "c")]
  4. extern "C" {
  5. fn write(fd: i32, buf: *const u8, count: usize) -> i32;
  6. }
  7. fn syscall(message: String) -> io::Result<()> {
  8. let msg_ptr = message.as_ptr();
  9. let len = message.len();
  10. let res = unsafe { write(1, msg_ptr, len) };
  11. if res == -1 {
  12. return Err(io::Error::last_os_error());
  13. }
  14. Ok(())
  15. }
  16. fn main() {
  17. let message = "Hello world from raw syscall!\n";
  18. let message = String::from(message);
  19. syscall(message).unwrap(); // 这就是抽象后的系统调用函数
  20. }
  21. // Hello world from syscall!

事件队列|系统调用|跨平台抽象(OS支持) - 图5

事件队列|系统调用|跨平台抽象(OS支持) - 图6

事件队列|系统调用|跨平台抽象(OS支持) - 图7

  1. use std::io;
  2. #[link(name = "kernel32")]
  3. extern "system" {
  4. fn GetStdHandle(nStdHandle: i32) -> i32;
  5. fn WriteConsoleW(
  6. hConsoleOutput: i32,
  7. lpBuffer: *const u16,
  8. nNumberOfCharsToWrite: u32,
  9. lpNumberOfCharsWritten: *mut u32,
  10. lpReserved: *const std::ffi::c_void,
  11. ) -> i32;
  12. }
  13. fn syscall(message: String) -> io::Result<()> {
  14. let msg: Vec<u16> = message.encode_utf16().collect();
  15. let msg_ptr = msg.as_ptr();
  16. let len = msg.len() as u32;
  17. let mut output: u32 = 0;
  18. let handle = unsafe { GetStdHandle(-11) };
  19. if handle == -1 {
  20. return Err(io::Error::last_os_error());
  21. }
  22. let res = unsafe {
  23. WriteConsoleW(
  24. handle,
  25. msg_ptr,
  26. len,
  27. &mut output,
  28. std::ptr::null(),
  29. )
  30. };
  31. if res == 0 {
  32. return Err(io::Error::last_os_error());
  33. }
  34. Ok(())
  35. }
  36. fn main() {
  37. let message = "Hello world from raw syscall!\n";
  38. let message = String::from(message);
  39. syscall(message).unwrap(); // 这就是抽象后的系统调用函数
  40. }
  41. // Hello world from raw syscall!

https://learn.microsoft.com/en-us/windows/console/getstdhandle

The highest level of abstraction

Rust 标准库为我们包装了对底层操作系统 api 的调用,因此我们不必关心要调用什么系统调用

  1. fn main() {
  2. println!("Hello world from the standard library");
  3. }

现在,你已经使用三个抽象层次编写了相同的系统调用。现在你知道 FFI 是什么样子了,也看到了一些内联汇编(我们将在后面更详细地介绍),还正确地执行了一次系统调用,在控制台打印了一些东西。你也已经看到了我们的标准库试图解决的问题之一,通过包装这些针对不同平台的调用,这样我们就不必知道这些系统调用就可以在控制台打印一些东西