Rust中的异步编程——理解操作系统支持的事件队列、系统调用和跨平台抽象

314 阅读20分钟

在本章中,我们将探讨操作系统支持的事件队列的工作原理,以及三种不同操作系统如何以不同方式处理此任务。之所以进行这个讲解,是因为我所知的大多数异步运行时都将这种操作系统支持的事件队列作为实现高性能I/O的基本组成部分。在了解异步代码的实际工作原理时,你很可能会频繁遇到这些概念的引用。

基于本章所讨论技术的事件队列被许多流行库使用,例如:

  • mioGitHub 链接),是流行运行时如Tokio的重要组成部分
  • pollingGitHub 链接),用于Smol和async-std的事件队列
  • libuvlibuv官网),用于创建Node.js(JavaScript运行时)和Julia编程语言中的事件队列
  • C# 用于异步网络调用
  • Boost.Asio,一个C++的异步网络I/O库

我们与主机操作系统的所有交互都是通过系统调用(syscalls)完成的。要使用Rust进行系统调用,我们需要了解Rust的外部函数接口(FFI)。

除了了解如何使用FFI并进行系统调用,我们还需要讨论跨平台抽象。在创建事件队列时,无论是自己编写还是使用库,如果你对IOCP在Windows上的工作方式只有一个高层理解,你会发现这些抽象看起来可能有些难以理解。原因在于这些抽象需要提供一个统一的API,以应对不同操作系统在处理相同任务时的差异。这个过程通常涉及识别平台间的公分母,并在其之上构建新的抽象。

为了更轻松地理解FFI、系统调用和跨平台抽象,我们将使用一个简单的示例来引入主题。这样当我们后续遇到这些概念时,将会更加熟悉,为后续章节中的更有趣的示例做好充分准备。

本章的主要内容包括:

  • 为什么使用操作系统支持的事件队列?
  • 基于就绪的事件队列
  • 基于完成的事件队列
  • epoll
  • kqueue
  • IOCP
  • 系统调用、FFI和跨平台抽象

注释

这里还有一些流行但较少使用的替代方案,虽然我们不会在此覆盖它们,但值得了解:

  • wepoll:使用Windows特定API并封装IOCP,使其与Linux上的epoll更相似,而非普通的IOCP。这使得在这两种不同技术之上创建统一API的抽象层更容易。libuv和mio都使用了它。
  • io_uring:这是一个在Linux上相对较新的API,与Windows上的IOCP有许多相似之处。

我相信,完成接下来的两章内容后,如果你想深入了解这些替代方案,将会更加得心应手。

技术要求

本章无需额外设置新环境,但由于我们会编写适用于三个不同平台的低级代码,因此若想运行所有示例,你需要访问这些平台。

最佳的学习方式是在电脑上打开本书的配套代码库,并导航至ch03文件夹。

本章较为特殊,因为我们从基础开始构建一些基本理解,涉及的内容相对低级,且需要特定的操作系统和CPU架构才能运行。不用担心,我选择了最常用和流行的CPU架构,这不应成为问题,但你仍需注意这一点。

你的计算机需使用x86-64指令集的CPU来运行Windows和Linux上的示例。Intel和AMD的桌面CPU使用该架构,但如果你在ARM处理器上运行Linux(或WSL),使用内联汇编的某些示例可能会遇到问题。在macOS上,本书的示例适用于较新的M系列芯片,但代码库中也包含了面向旧款Intel架构Mac的示例。

不幸的是,一些针对特定平台的示例需要在该特定操作系统上运行。然而,这将是唯一需要访问三个不同平台来运行所有示例的章节。之后的章节中,我们将编写可以在所有平台上运行的示例,要么直接运行,要么通过Windows Subsystem for Linux(WSL)运行。但为了理解跨平台抽象的基础,我们需要实际创建一些针对这些不同平台的示例。

运行Linux示例

如果你没有设置Linux机器,可以在Rust Playground上运行Linux示例;如果你使用的是Windows系统,建议设置WSL(Windows Subsystem for Linux)并在那里运行代码。可以在 Microsoft的WSL安装说明页面 上找到安装指南。请记得还需要在WSL环境中安装Rust,参考本书前言部分的说明,了解如何在Linux上安装Rust。

如果使用VS Code作为编辑器,可以很简单地切换到WSL环境。按下Ctrl+Shift+P,输入“Reopen folder in WSL”,这样可以轻松在WSL中打开示例文件夹,并在Linux环境中运行代码示例。

为什么使用操作系统支持的事件队列?

你已经知道,要使I/O操作尽可能高效,就需要与操作系统密切协作。Linux、macOS和Windows等操作系统提供多种方式来执行阻塞和非阻塞的I/O操作。

I/O操作需要通过操作系统进行,因为这些操作依赖于由操作系统抽象的资源,如磁盘驱动器、网络卡或其他外设。特别是网络调用中,我们不仅依赖自己的硬件,还可能依赖位于远处的资源,这会带来显著延迟。

在上一章中,我们讨论了编程时处理异步操作的不同方式。尽管这些方式各不相同,但它们有一个共同点:需要控制在系统调用时何时以及是否将控制权让给操作系统的调度器。

实际上,这意味着应避免通常会让出控制权的阻塞系统调用,而应使用非阻塞调用。还需要一种高效的方式来知道每个调用的状态,以便了解当某个任务的阻塞调用准备就绪时,可以继续执行。这正是异步运行时中使用操作系统支持的事件队列的主要原因。

我们将用三个不同的I/O操作处理方式作为示例进行说明。

阻塞I/O

当我们要求操作系统执行阻塞操作时,它会挂起发出调用的OS线程。此时,它将保存我们发出调用时的CPU状态并继续执行其他操作。当通过网络收到数据后,操作系统会唤醒我们的线程,恢复CPU状态,并让我们继续运行,仿佛没有中断。

对于程序员而言,阻塞操作的灵活性最小,因为每次调用都会让出控制权给操作系统。其主要优势在于:当我们等待的事件准备就绪后,线程会被唤醒并继续执行。从操作系统的角度来看,这是一个非常高效的解决方案,因为操作系统会将有工作要做的线程分配到CPU上。然而,如果我们只考虑自己的进程,每次进行阻塞调用时,即使进程还有其他工作可以执行,线程也会被挂起。因此,我们要么选择生成新线程来继续工作,要么接受必须等待阻塞调用返回。稍后我们将进一步详细讨论这一点。

非阻塞I/O

不同于阻塞I/O操作,操作系统不会挂起发出I/O请求的线程,而是会提供一个句柄,线程可以用它询问操作系统事件是否已准备好。

这种查询状态的过程称为轮询。

非阻塞I/O操作为程序员提供了更多自由,但同时也带来了责任。如果我们在循环中频繁轮询,CPU时间将主要用于查询状态更新,极为浪费。如果轮询频率太低,当事件准备就绪后会产生显著延迟,从而限制吞吐量。

通过epoll/kqueue和IOCP实现的事件队列

这是前面两种方法的混合。在网络调用的情况下,调用本身是非阻塞的。然而,代替定期轮询句柄,我们可以将该句柄添加到事件队列中,并且可以在几乎无开销的情况下将成千上万个句柄添加到队列中。

作为程序员,我们现在有了新的选择。可以定期查询队列,以检查我们添加的事件是否有状态更改,或者可以对队列发出阻塞调用,告诉操作系统希望在队列中至少一个事件状态更改时被唤醒,以便等待该特定事件的任务能够继续执行。

这样,我们只有在没有更多工作可做、所有任务都在等待事件发生时,才将控制权让给操作系统,可以自行决定何时发出这样的阻塞调用。

注释

本书不讨论pollselect等方法。大多数操作系统还支持较老的、在现代异步运行时中不广泛使用的方法。只需了解还有其他调用可以实现与上述事件队列类似的灵活性。

基于就绪的事件队列

epollkqueue被称为基于就绪的事件队列,这意味着它们会在某个操作可以执行时通知你。例如,当一个套接字可以读取时。

为了说明其工作原理,我们可以看看使用epollkqueue从套接字读取数据时的流程:

  1. 通过调用epoll_createkqueue系统调用创建一个事件队列。
  2. 请求操作系统提供一个代表网络套接字的文件描述符。
  3. 通过另一个系统调用,在该套接字上注册对“读取”事件的关注。重要的是,我们还需告知操作系统,当事件准备就绪时,我们希望在步骤1创建的事件队列中接收到通知。
  4. 调用epoll_waitkevent来等待事件,这会阻塞(挂起)当前线程。
  5. 当事件准备就绪时,线程被解除阻塞(恢复),我们从等待调用返回,获得发生事件的相关数据。
  6. 在第2步创建的套接字上调用read读取数据。

image.png

基于完成的事件队列

IOCP(输入/输出完成端口)是一种基于完成的事件队列。这种队列会在事件完成时通知你。例如,当数据被读取到缓冲区中时。

以下是这种事件队列的基本流程:

  1. 通过调用系统调用CreateIoCompletionPort创建一个事件队列。
  2. 创建一个缓冲区,并请求操作系统提供一个套接字句柄。
  3. 通过另一个系统调用在该套接字上注册对“读取”事件的关注,同时传入在步骤2中创建的缓冲区,数据将被读取到该缓冲区中。
  4. 调用GetQueuedCompletionStatusEx,该调用会阻塞线程,直到某个事件完成。
  5. 线程被解除阻塞,此时缓冲区已填充了所需的数据。

image.png

epoll, kqueue 和 IOCP

epoll 是Linux实现事件队列的方式,在功能上与 kqueue 有很多共同之处。相比于Linux上的其他方法(如 selectpoll),epoll 设计更高效,尤其是在处理大量事件时表现优异。

kqueue 是macOS(以及FreeBSD和OpenBSD等操作系统)中实现事件队列的方法,源于BSD。在高层功能上,它与 epoll 类似,但在实际使用上有所不同。

IOCP 是Windows实现此类事件队列的方式。在Windows中,完成端口(completion port)会在事件完成时通知你。这听起来似乎是个小区别,但实际上影响很大,尤其是当你想要编写一个库时。抽象出一个跨平台的模型意味着你要么将IOCP建模为基于就绪的模型,要么将 epoll/kqueue 建模为基于完成的模型。

在等待操作返回期间,将缓冲区借给操作系统会带来一些挑战,因为确保缓冲区在等待期间不被修改非常重要。

平台事件队列基于完成/就绪
WindowsIOCP基于完成
Linuxepoll基于就绪
macOSkqueue基于就绪

跨平台事件队列

创建跨平台事件队列时,必须创建一个在Windows(IOCP)、macOS(kqueue)或Linux(epoll)上通用的API。最显而易见的差异是IOCP基于完成,而 kqueueepoll 基于就绪。

这意味着需要做出以下选择:

  1. 创建一个将 kqueueepoll 作为基于完成队列的抽象,或
  2. 创建一个将IOCP作为基于就绪队列的抽象

根据个人经验,将kqueueepoll幕后作为基于完成的队列处理要比将IOCP作为基于就绪的队列更为简单。使用wepoll是一种在Windows上创建基于就绪的队列的方法,这可以极大简化API创建,但我们在此不详细讨论,因为wepoll并不广为人知,且Microsoft并未正式记录这种方法。

由于IOCP基于完成,因此它需要一个缓冲区来接收数据(操作完成时返回已读取到缓冲区的数据)。而 kqueueepoll 不需要缓冲区;它们仅在可以无阻塞地将数据读取到缓冲区时返回。

通过要求用户提供自己选择大小的缓冲区,API让用户控制内存管理方式。用户可以定义缓冲区大小,并完全控制在使用IOCP时传递给操作系统的内存。对于 epollkqueue,可以简单地为用户调用 read 填充相同的缓冲区,从而让用户认为API是基于完成的。

如果希望实现基于就绪的API,则在Windows上执行I/O时需制造两步操作的假象:首先请求当套接字数据准备就绪时的通知,然后实际读取数据。尽管可行,但可能需要构建一个非常复杂的API,或接受在Windows平台上因使用中间缓冲区保持基于就绪的假象而带来的一些效率损失。

我们将在构建简单示例来展示事件队列的具体工作方式时深入探讨该主题。在此之前,我们需要熟悉FFI和系统调用,因此将在三个不同平台上编写一个系统调用示例来进行演示。

我们还将利用这个机会讨论抽象层级,以及如何创建一个在三个不同平台上通用的API。

系统调用、FFI和跨平台抽象

我们将为BSD/macOS、Linux和Windows这三种架构实现一个非常基础的系统调用。我们还将展示如何在三个抽象层级实现该调用。

本章中实现的系统调用是用于向标准输出(stdout)写入内容的调用,因为这是一个非常常见的操作,很有趣可以了解其工作原理。

我们将从系统调用的最低抽象层级开始,通过基础构建我们的理解。

最低抽象层级

最低的抽象层级就是所谓的“原始”系统调用。原始系统调用绕过操作系统提供的库,直接依赖操作系统的稳定系统调用ABI(应用程序二进制接口)。稳定的系统调用ABI意味着,如果你将正确的数据放入特定寄存器并调用一个传递控制权给操作系统的特定CPU指令,它将始终执行相同的操作。

编写原始系统调用需要一些内联汇编,但不用担心,我们会逐行解释。在本书的第5章中,我们还将深入介绍内联汇编,使你更熟悉它。

在这个抽象层级上,我们需要为BSD/macOS、Linux和Windows编写不同的代码。如果操作系统运行在不同的CPU架构上,代码也会有所不同。

Linux上的原始系统调用

在Linux和macOS上,我们要调用的系统调用是write。这两个系统基于文件描述符的概念,当你启动进程时,stdout已经存在。

如果你的计算机上没有运行Linux,可以将代码复制到Rust Playground中或使用Windows的WSL运行它。

以下是Linux原始系统调用的实现步骤:

  1. 引入asm宏所需的标准库模块。
  2. 编写系统调用函数。
  3. 创建一个字符串并调用系统调用函数,将字符串传递给它。

代码示例:

use std::arch::asm;

#[inline(never)]
fn syscall(message: String) {
    let msg_ptr = message.as_ptr();
    let len = message.len();
    unsafe {
        asm!(
            "mov rax, 1",
            "mov rdi, 1",
            "syscall",
            in("rsi") msg_ptr,
            in("rdx") len,
            out("rax") _,
            out("rdi") _,
            lateout("rsi") _,
            lateout("rdx") _
        );
    }
}

fn main() {
    let message = "Hello world from raw syscall!\n";
    let message = String::from(message);
    syscall(message);
}

运行该代码时,你会在控制台上看到:

Hello world from raw syscall!

macOS上的原始系统调用

对于macOS,如果你使用的是基于ARM 64架构的M系列芯片的较新Mac,需要稍微调整代码。不同的是,在macOS上写操作的代码为4,且触发软件中断的指令为svc 0而不是syscall。以下是适用于M系列芯片的代码:

use std::arch::asm;

fn main() {
    let message = "Hello world from raw syscall!\n";
    let message = String::from(message);
    syscall(message);
}

#[inline(never)]
fn syscall(message: String) {
    let ptr = message.as_ptr();
    let len = message.len();
    unsafe {
        asm!(
            "mov x16, 4",
            "mov x0, 1",
            "svc 0",
            in("x1") ptr,
            in("x2") len,
            out("x16") _,
            out("x0") _,
            lateout("x1") _,
            lateout("x2") _
        );
    }
}

运行该代码时,你会在macOS控制台上看到:

Hello world from raw syscall!

Windows上的原始系统调用?

这为我们提供了一个解释为什么如果想让程序或库跨平台运行,直接写原始系统调用并不是好主意的机会。

在Windows上,低级系统调用的内部实现没有任何官方保证。Windows可能会随着系统更新改变系统调用的行为或编号,缺乏正式文档和稳定性保证。因此,依赖Windows的低级原始系统调用是不可取的。

因此,尽管原始系统调用在理论上可以工作,且熟悉它们是有益的,但它们主要作为示例,展示了为什么在进行系统调用时我们更愿意链接不同操作系统提供的库。

下一层抽象

下一层抽象是使用所有三种操作系统提供的API。这种抽象帮助我们减少了一些代码。在这个示例中,Linux和macOS的系统调用相同,因此我们只需要处理Windows的特殊情况。我们可以使用#[cfg(target_family = "windows")]#[cfg(target_family = "unix")]条件编译标志来区分不同平台。代码如下:

use std::io;
fn main() {
    let message = "Hello world from syscall!\n";
    let message = String::from(message);
    syscall(message).unwrap();
}

唯一的不同是我们引入了io模块,而不是asm模块。

在Linux和macOS中使用操作系统提供的API

该代码可以直接在Rust Playground上运行(它基于Linux),或者在本地Linux机器或macOS上运行。以下是代码示例:

#[cfg(target_family = "unix")]
#[link(name = "c")]
extern "C" {
    fn write(fd: u32, buf: *const u8, count: usize) -> i32;
}
fn syscall(message: String) -> io::Result<()> {
    let msg_ptr = message.as_ptr();
    let len = message.len();
    let res = unsafe { write(1, msg_ptr, len) };
    if res == -1 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}

此代码通过FFI调用外部C函数write,它接收一个文件描述符fd(这里1表示stdout),一个u8数组指针buf,以及缓冲区的长度count

调用约定

调用约定指定了函数如何被调用,包括参数传递方式、返回值、以及如何设置堆栈等。C调用约定是最常见的调用约定类型。

Windows中的API使用

在Windows中,我们使用的抽象不同。Windows系统使用“句柄”来代表对象。以下是我们在Windows上运行该程序的代码:

#[link(name = "kernel32")]
extern "system" {
    fn GetStdHandle(nStdHandle: i32) -> i32;
    fn WriteConsoleW(
        hConsoleOutput: i32,
        lpBuffer: *const u16,
        numberOfCharsToWrite: u32,
        lpNumberOfCharsWritten: *mut u32,
        lpReserved: *const std::ffi::c_void,
    ) -> i32;
}

GetStdHandle返回stdout的句柄,WriteConsoleW将Unicode文本写入控制台。

系统调用函数

以下是我们在Windows平台上的系统调用函数:

fn syscall(message: String) -> io::Result<()> {
    let msg: Vec<u16> = message.encode_utf16().collect();
    let msg_ptr = msg.as_ptr();
    let len = msg.len() as u32;
    let mut output: u32 = 0;
    let handle = unsafe { GetStdHandle(-11) };
    if handle == -1 {
        return Err(io::Error::last_os_error());
    }
    let res = unsafe {
        WriteConsoleW(handle, msg_ptr, len, &mut output, std::ptr::null())
    };
    if res == 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}

在这个函数中,我们将UTF-8编码的文本转换为Windows使用的UTF-16编码。我们调用GetStdHandle获取stdout的句柄,并通过WriteConsoleW函数将数据写入控制台。

备注

通过本节,我们学习了如何创建跨平台的系统调用。

最高抽象层级

这很简单,但为了完整性,我还是想加上这一节。Rust标准库为我们封装了对底层操作系统API的调用,因此我们不需要关心要调用哪些系统调用。

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

恭喜你!你已经使用三个抽象层级实现了相同的系统调用。你现在知道FFI(外部函数接口)是什么样的,见过一些内联汇编(稍后会更详细地介绍),并完成了一个在控制台打印内容的系统调用。你还了解了标准库通过封装这些不同平台的调用为我们解决了什么问题,这样我们不需要掌握系统调用也能在控制台打印内容。

总结

在本章中,我们介绍了操作系统支持的事件队列,概述了它们的工作原理。我们还讨论了epollkqueueIOCP的特点,并着重说明了它们之间的区别。

在本章的后半部分,我们介绍了一些系统调用的示例。讨论了原始系统调用和“普通”系统调用,展示了它们的区别并提供了示例。我们还借此机会讨论了抽象层级,以及在适用时依赖良好抽象的优势。

作为系统调用的一部分,你还了解了Rust的FFI。

最后,我们创建了一个跨平台的抽象,并且看到了创建统一API以适配多个操作系统时的挑战。

下一章将带你通过一个示例,使用epoll创建一个简单的事件队列,让你了解这一过程的实际操作。在代码仓库中,你还可以找到适用于Windows和macOS的相同示例,如果你希望为这些平台实现事件队列,这些代码可以供你参考。