在本章中,我们将使用 epoll 创建一个简单版本的事件队列。我们将借鉴 mio(github.com/tokio-rs/mi…),这是一个用 Rust 编写的低级 I/O 库,它是 Rust 异步生态系统的基础之一。从 mio 获取灵感的一个额外好处是,如果你希望深入探索一个真实的生产级库的工作方式,这将使你更容易理解其代码库。
本章结束时,你应该能够理解以下内容:
- 阻塞 I/O 和非阻塞 I/O 的区别
- 如何使用 epoll 创建你自己的事件队列
- 跨平台事件队列库(如 mio)的源代码
- 如果希望程序或库能在不同平台上工作,为什么我们需要在 epoll、kqueue 和 IOCP 之上增加一个抽象层
我们将本章分为以下几个部分:
- epoll 的设计与介绍
- ffi 模块
- Poll 模块
- 主程序
技术要求
本章专注于 Linux 特有的 epoll。遗憾的是,epoll 并不属于可移植操作系统接口(POSIX)标准的一部分,因此此示例要求你运行 Linux,不能在 macOS、BSD 或 Windows 操作系统上运行。
如果你正在使用 Linux 系统,那么你已经准备好,可以直接运行示例,无需其他步骤。
如果你使用的是 Windows,我的建议是设置 WSL(learn.microsoft.com/en-us/windo… WSL 上运行的 Linux 操作系统中安装 Rust。
如果你使用的是 Mac,你可以创建一个运行 Linux 的虚拟机(VM),例如,通过使用基于 QEMU 的 UTM 应用程序(mac.getutm.app/)或任何其他管理 Mac 上虚拟机的解决方案。
最后一个选择是租用一台 Linux 服务器(有些提供商甚至提供免费层),安装 Rust,并通过控制台使用 Vim 或 Emacs 编辑器,或者通过 SSH 使用 VS Code 在远程机器上开发(code.visualstudio.com/docs/remote… Linode 的服务时有很好的体验(www.linode.com/),但还有许多其他选项…
理论上可以在 Rust Playground 上运行这些示例,但由于我们需要一个延迟服务器,我们必须使用一个接受纯 HTTP 请求(而不是 HTTPS)的远程延迟服务器服务,并修改代码,使所有模块都放在一个文件中。虽然在紧急情况下可行,但并不推荐这样做。
延迟服务器
此示例依赖于调用一个服务器,该服务器会延迟响应一段可配置的时间。在仓库中,根目录下有一个名为 delayserver 的项目。
你可以通过进入该文件夹并在单独的控制台窗口中运行 cargo run 来设置服务器。只需让服务器在一个单独的、保持打开的终端窗口中运行,我们将在示例中使用它。
delayserver 程序是跨平台的,因此它可以在 Rust 支持的所有平台上无修改地运行。如果你在 Windows 上运行 WSL,我建议也在 WSL 中运行 delayserver 程序。根据你的设置,你可能可以在 Windows 控制台中运行该服务器,并且在 WSL 中运行示例时仍能访问它。只需注意,它可能无法开箱即用。
默认情况下,服务器会监听 8080 端口,示例中假设使用的是这个端口。你可以在启动服务器之前修改 delayserver 代码中的监听端口,但记得在示例代码中做出相应的修改。
delayserver 的实际代码不到 30 行,因此如果你想查看服务器的功能,阅读代码大约只需几分钟。
设计与epoll简介
好的,本章将围绕一个主要的示例展开,您可以在仓库中的 ch04/a-epoll 目录下找到该示例。我们将从设计该示例的过程开始。
正如我在本章开始时提到的,我们将从 mio 中获取灵感。这样做有一个很大的优点和一个缺点。优点是,我们能轻松地理解 mio 的设计方式,这使得如果您想深入了解比本示例更多的内容,能够更容易地进入该代码库。缺点是,我们在 epoll 之上引入了一个过于复杂的抽象层,包括一些非常特定于 mio 的设计决策。
我认为优点大于缺点,原因很简单:如果您将来想实现一个生产级别的事件循环,您可能需要查看现有的实现,了解那些已经存在的方案。同样,如果您想深入研究 Rust 中异步编程的构建块,这些库也会是重要的参考。在 Rust 中,mio 是支撑大多数异步生态系统的关键库之一,因此,稍微熟悉它无疑是一个额外的好处。
需要注意的是,mio 是一个跨平台库,它为 epoll、kqueue 和 IOCP 创建了一个抽象(通过 Wepoll,正如我们在第三章中描述的)。不仅如此,mio 还支持 iOS 和 Android,未来可能还会支持其他平台。因此,为了统一多个平台的 API,这样的设计不可避免地会做出一些妥协,尤其是如果您只打算支持单一平台时,与跨平台的实现相比,可能会有更多的优化空间。
mio
mio 自称是一个“快速、低级别的 I/O 库,专注于 Rust 中的非阻塞 API 和事件通知,以构建高性能的 I/O 应用程序,并尽可能减少操作系统抽象层的开销。”
mio 驱动了 Tokio 中的事件队列,Tokio 是 Rust 中最流行、最广泛使用的异步运行时之一。这意味着,mio 为像 Actix Web(actix.rs/)、Warp(http… Rocket(rocket.rs/)等流行框架提供了 I/O 支持。
在本示例中,我们将使用版本 0.8.8 的 mio 作为设计灵感。该 API 以前有过变化,未来也可能会发生变化,但我们在这里涉及的部分 API 自 2019 年以来已经稳定,因此可以合理预期,短期内不会发生重大变化。
和所有跨平台的抽象一样,通常需要选择最小公倍数。一些选择可能会在某些平台上限制灵活性和效率,目的是实现一个在所有平台上都能工作的统一 API。我们将在本章中讨论一些这样的选择。
在深入之前,我们先创建一个空项目并给它命名。我们接下来会将它称为 a-epoll,但您当然可以使用您选择的名字。
进入文件夹后,输入 cargo init 命令。
在本示例中,我们将项目分为几个模块,并将代码拆分到以下几个文件中:
src
|-- ffi.rs
|-- main.rs
|-- poll.rs
它们的描述如下:
- ffi.rs:该模块将包含与操作系统通信所需的系统调用代码。
- main.rs:这是示例程序本身。
- poll.rs:该模块包含主要的抽象,它是对 epoll 的一个薄层封装。
接下来,在 src 文件夹中创建前述的四个文件。
在 main.rs 中,我们需要声明模块:
mod ffi;
mod poll;
现在,我们已经设置好了项目,接下来开始设计我们将使用的 API。主要的抽象在 poll.rs 中,所以我们先打开这个文件。
让我们从定义需要的结构和函数开始。将它们放在前面讨论会更容易:
use std::{io::{self, Result}, net::TcpStream, os::fd::AsRawFd};
use crate::ffi;
type Events = Vec<ffi::Event>;
pub struct Poll {
registry: Registry,
}
impl Poll {
pub fn new() -> Result<Self> {
todo!()
}
pub fn registry(&self) -> &Registry {
&self.registry
}
pub fn poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()> {
todo!()
}
}
pub struct Registry {
raw_fd: i32,
}
impl Registry {
pub fn register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()> {
todo!()
}
}
impl Drop for Registry {
fn drop(&mut self) {
todo!()
}
}
我们暂时将所有实现替换为 todo!()。这个宏让我们在尚未实现函数体时也能编译程序。如果程序的执行路径进入 todo!(),它会触发 panic。
首先,您会注意到,我们不仅引入了 ffi 模块,还引入了一些来自标准库的类型。我们还将使用 std::io::Result 作为我们自己的 Result 类型。这样做的原因是,大多数错误将来自我们与操作系统的交互,而操作系统错误可以映射到 io::Error 类型。
有两个主要的 epoll 抽象。一个是名为 Poll 的结构体,另一个是名为 Registry 的结构体。这两个名称和功能与 mio 中的结构体相同。命名这样的抽象结构体出奇的困难,这两个构造体也完全有可能采用其他名称,但我们选择依赖已有的设计,在示例中使用这些名称。
Poll 是一个表示事件队列本身的结构体。它有几个方法:
new:创建一个新的事件队列。registry:返回一个对注册表的引用,用于注册感兴趣的事件。poll:阻塞当前线程,直到事件准备好或超时,先发生的事件将被返回。
Registry 是另一个关键部分。Poll 代表事件队列,而 Registry 是一个句柄,用于注册感兴趣的事件。
Registry 只有一个方法:register。我们再次模仿了 mio 的 API(mio 0.8.8 文档),我们没有为不同的兴趣注册方法接受预定义的列表,而是接受一个 interests 参数,用来表示我们希望事件队列关注的事件类型。
还有一点需要注意的是,我们不会为所有来源使用泛型类型。我们只为 TcpStream 实现这个功能,尽管我们可以追踪事件队列中的很多其他类型。
当我们希望实现跨平台时,这一点尤其重要,因为不同平台上可能会有许多不同类型的事件源需要追踪。mio 通过让 Registry::register 接受实现了 mio 定义的 Source 特征的对象来解决这个问题。只要您为来源实现了这个特征,就可以使用事件队列来追踪它的事件。
在以下伪代码中,您可以看到我们计划如何使用这个 API:
let queue = Poll::new().unwrap();
let id = 1;
// 注册对 TcpStream 上事件的兴趣
queue.registry().register(&stream, id, ...).unwrap();
let mut events = Vec::with_capacity(1);
// 这将阻塞当前线程
queue.poll(&mut events, None).unwrap();
//...数据已经准备好,来自一个被追踪的流
您可能会想,为什么我们需要 Registry 结构体?
要回答这个问题,我们需要记住,mio 抽象了 epoll、kqueue 和 IOCP。它通过让 Registry 包裹一个 Selector 对象来实现这一点。Selector 对象是条件编译的,以确保每个平台都有自己的 Selector 实现,对应相关的系统调用,让 IOCP、kqueue 和 epoll 执行相同的功能。
Registry 实现了一个我们在示例中不会实现的重要方法,叫做 try_clone。我们不实现这个方法是因为我们理解事件循环的工作原理不需要它,而且我们希望保持示例的简洁易懂。然而,理解这个方法对理解为何注册事件和事件队列本身的职责分离非常重要。
重要提示
通过将注册兴趣的责任转移到像 Registry 这样的独立结构体中,用户可以调用 Registry::try_clone 来获取一个拥有的 Registry 实例。这个实例可以通过 Arc<Registry> 传递给其他线程或与其他线程共享,允许多个线程注册对同一个 Poll 实例的兴趣,即使 Poll 在等待新事件发生时阻塞了其他线程(在 Poll::poll 中)。
Poll::poll 需要独占访问,因为它接受的是 &mut self。因此,当我们在 Poll::poll 中等待事件时,如果依赖于 Poll 来注册兴趣,就无法在不同的线程同时进行注册,因为 Rust 的类型系统会阻止这种行为。
这也使得通过调用 Poll::poll 让多个线程在同一个实例上等待事件变得几乎不可能,因为这将需要同步机制,而同步机制本质上会让每个调用按顺序执行。
这种设计使得用户可以通过注册兴趣与队列进行交互,可能涉及多个线程,而某一个线程会进行阻塞调用,并处理来自操作系统的通知。
备注
mio 并不支持让多个线程在同一调用 Poll::poll 上阻塞,这并不是 epoll、kqueue 或 IOCP 的限制。它们都允许多个线程在同一实例上调用 Poll::poll 并获得事件队列中的通知。实际上,epoll 甚至允许通过特定标志来决定操作系统应该唤醒一个线程还是所有等待通知的线程(具体来说是 EPOLLEXCLUSIVE 标志)。
问题的关键部分在于不同平台如何决定在多个线程等待同一个队列中的事件时,应该唤醒哪个线程,并且部分原因在于似乎并没有太大需求去实现这一功能。例如,epoll 默认会唤醒所有在 Poll 上阻塞的线程,而 Windows 默认只会唤醒一个线程。您可以在一定程度上修改这种行为,未来也有人考虑在 Poll 上实现 try_clone 方法。目前,我们的设计遵循的是我们所描述的方式,且我们在示例中将继续沿用这种设计。
这引出了另一个话题,在我们开始实现示例之前需要先覆盖一下。
所有 I/O 都是阻塞的吗?
最后,这是一个容易回答的问题。答案是一个响亮的… 也许。问题在于,并不是所有的 I/O 操作都会在操作系统将调用线程挂起的意义上阻塞,切换到另一个任务可能会更高效。原因是操作系统非常智能,会将大量的信息缓存到内存中。如果信息已经在缓存中,请求这些信息的系统调用会立即返回数据,因此强制进行上下文切换或重新调度当前任务可能比同步处理数据更低效。问题在于,我们无法确定 I/O 是否会阻塞,这取决于你正在做什么。
让我给你两个例子。
DNS 查找
在创建 TCP 连接时,首先需要将像 www.google.com 这样的域名转换为 216.58.207.228 这样的 IP 地址。操作系统会维护一个本地地址和先前查找过的地址的映射,并将它们缓存在缓存中,通常能几乎立即解析。但是,第一次查找一个未知的地址时,操作系统可能需要向 DNS 服务器发起请求,这会花费很多时间,如果没有以非阻塞方式处理,操作系统会将调用线程挂起,等待响应。
文件 I/O
本地文件系统中的文件是操作系统进行大量缓存的另一个领域。小文件,尤其是频繁读取的文件,通常会被缓存到内存中,因此请求这些文件可能根本不会阻塞。如果你有一个提供静态文件的 Web 服务器,那么你服务的文件集通常会比较小,很可能已经被缓存到内存中。然而,这并不能完全保证——如果操作系统内存不足,它可能会将内存页面映射到硬盘,这会使得本应非常快速的内存查找变得异常缓慢。如果访问的小文件数量非常多,或者你提供的是非常大的文件,那么操作系统只能缓存有限的信息,你也会遇到这种不可预测的情况。如果系统中有许多无关的进程,它可能不会缓存对你重要的信息。
处理这些情况的一种常见方法是忽略非阻塞 I/O,实际上做一个阻塞调用。你不希望在运行 Poll 实例的同一个线程中执行这些调用(因为任何小的延迟都会阻塞所有任务),但你可能会将这些任务交给线程池来处理。在线程池中,你有一个有限数量的线程,负责进行常规的阻塞调用,比如 DNS 查找或文件 I/O。
一个做法就是像 libuv (docs.libuv.org/en/v1.x/thr…)这样的运行时,它正是这样做的。libuv 是 Node.js 的异步 I/O 库。
虽然它的范围比 mio 大(mio 只关心非阻塞 I/O),但 libuv 对 Node.js 的作用就像 mio 对 Tokio 在 Rust 中的作用。
备注
使用线程池来处理文件 I/O 的原因是,历史上对于非阻塞文件 I/O 的跨平台 API 并不好。虽然许多运行时选择将此任务委派给线程池来执行阻塞的操作系统调用,但随着操作系统 API 的不断发展,未来这种方式可能会有所改变。
创建一个线程池来处理这些情况超出了本示例的范围(就连 mio 也将这部分内容排除在外,仅供说明)。我们将专注于展示 epoll 的工作原理,并在文本中提到这些话题,尽管我们在这个示例中并不会实际实现解决方案。
现在,既然我们已经覆盖了关于 epoll、mio 以及我们示例设计的很多基本信息,是时候写些代码,亲自看看这些内容在实践中是如何运作的了。
ffi 模块
让我们从不依赖其他模块的部分开始,逐步讲解。ffi 模块包含了我们与操作系统通信所需的系统调用(syscalls)和数据结构的映射。我们将在介绍完系统调用后,详细解释 epoll 的工作原理。
这部分代码只有几行,所以我将首先列出一部分,以便更容易跟踪文件中的位置,因为接下来有很多内容需要解释。打开 ffi.rs 文件,并编写以下代码:
ch04/a-epoll/src/ffi.rs
pub const EPOLL_CTL_ADD: i32 = 1;
pub const EPOLLIN: i32 = 0x1;
pub const EPOLLET: i32 = 1 << 31;
#[link(name = "c")]
extern "C" {
pub fn epoll_create(size: i32) -> i32;
pub fn close(fd: i32) -> i32;
pub fn epoll_ctl(epfd: i32, op: i32, fd: i32, event: *mut Event) -> i32;
pub fn epoll_wait(epfd: i32, events: *mut Event, maxevents: i32, timeout: i32) -> i32;
}
你首先会注意到我们声明了几个常量,分别是 EPOLL_CTL_ADD、EPOLLIN 和 EPOLLET。稍后我会解释这些常量的含义。首先,我们来看一下需要调用的系统调用。幸运的是,我们已经详细讲解了系统调用,所以你已经知道了 ffi 的基本概念,以及为什么在上述代码中要链接 C:
-
epoll_create是我们用来创建 epoll 队列的系统调用。你可以在 epoll_create 文档 中找到它的详细信息。这个方法接受一个参数size,但它仅仅是出于历史原因存在的。这个参数会被忽略,但必须大于 0。 -
close是我们需要用来关闭文件描述符的系统调用,该文件描述符是在创建 epoll 实例时获得的,关闭它以正确释放资源。你可以在 close 文档 中阅读它的详细信息。 -
epoll_ctl是我们用来执行 epoll 操作的控制接口。我们通过它来注册我们对某个源的事件感兴趣。它支持三种主要操作:添加、修改或删除。第一个参数epfd是我们想要操作的 epoll 文件描述符,第二个参数op表示我们希望执行的操作类型:添加、修改或删除。在我们的例子中,我们只关心为事件添加兴趣,因此我们只会传入
EPOLL_CTL_ADD,它表示我们想要执行添加操作。epoll_event结构体稍微复杂一些,我们稍后再详细讨论。它完成了两件重要的事情:首先,events字段表示我们感兴趣的事件类型,并且它还可以修改我们何时和如何接收到通知。其次,data字段传递给操作系统一段数据,当事件发生时,操作系统会将这段数据返回给我们。后者很重要,因为我们需要这段数据来准确识别发生了什么事件,因为这是唯一能帮助我们识别源的返回信息。你可以在 epoll_ctl 文档 中找到更多信息。 -
epoll_wait是用于阻塞当前线程并等待两种情况发生之一的调用:收到事件发生的通知,或者超时。epfd是我们通过epoll_create创建的 epoll 文件描述符,events是一个Event结构体数组,和epoll_ctl中使用的结构体相同。不同的是,这里events字段会给我们提供实际发生的事件信息,特别是data字段会包含我们在注册事件时传入的相同数据。例如,
data字段让我们能够识别哪个文件描述符已经准备好读取数据。maxevents参数告诉内核我们为事件数组预留了多少空间。最后,timeout参数告诉内核我们将等待多长时间才能再次被唤醒,以避免潜在的永远阻塞。你可以在 epoll_wait 文档 中找到详细信息。
文件中的最后一部分代码是 Event 结构体:
ch04/a-epoll/src/ffi.rs
#[derive(Debug)]
#[repr(C, packed)]
pub struct Event {
pub(crate) events: u32,
// 用于标识事件的 Token
pub(crate) epoll_data: usize,
}
impl Event {
pub fn token(&self) -> usize {
self.epoll_data
}
}
该结构体用于在 epoll_ctl 中与操作系统进行通信,操作系统也会使用相同的结构体在 epoll_wait 中与我们通信。
事件被定义为一个 u32 类型,但它不仅仅是一个数字。这个字段被称为位掩码(bitmask)。我会在后面的章节中解释位掩码,因为它在大多数系统调用中都很常见,而且并不是每个人都接触过。在简单的术语中,位掩码是通过将位表示作为一组是/否的标志来指示是否选择了某个选项。
不同的选项可以在我为 epoll_ctl 系统调用提供的链接中找到。我不会在这里详细解释所有选项,而是只介绍我们会用到的:
EPOLLIN代表一个位标志,表示我们对文件句柄的读取操作感兴趣。EPOLLET代表一个位标志,表示我们对启用边缘触发模式(edge-triggered mode)的事件感兴趣。
稍后我会再次解释位标志、位掩码以及边缘触发模式的具体含义,但让我们先完成代码的部分。
Event 结构体中的最后一个字段是 epoll_data。在文档中,这个字段被定义为一个联合体(union)。联合体类似于枚举(enum),但是与 Rust 的枚举不同,联合体不会携带类型信息,因此我们需要自己确保知道它所持有的数据类型。
我们使用这个字段来存储一个 usize,这样我们就能传入一个整数,标识每个事件,在使用 epoll_ctl 注册事件时。传入一个指针也是完全可以的,只要我们确保在 epoll_wait 中返回时,这个指针仍然有效。
我们可以把这个字段看作是一个标识符,正如 mio 所做的那样。为了让 API 尽可能相似,我们模仿 mio 提供了一个 token 方法,来获取这个值。
#[repr(packed)] 是什么作用?
#[repr(packed)] 注解对我们来说是新的。通常,一个结构体会在字段之间或者结构体末尾添加填充字节。即便我们指定了 #[repr(C)],这种情况仍然会发生。
这样做的原因是为了高效地访问存储在结构体中的数据,避免多次获取结构体字段中的数据。在 Event 结构体的例子中,通常的填充会在 events 字段的末尾添加 4 字节的填充。操作系统如果期望 Event 是一个紧凑结构体(packed struct),而我们提供的是一个有填充的结构体,它可能会将部分 event_data 写入字段之间的填充区域。之后,当你试图读取 event_data 时,可能只能读取到与 event_data 最后一部分重叠的数据,从而读取到错误的内容。
操作系统需要一个紧凑(packed)的 Event 结构体这一点并不是从 Linux 的手册页中可以直接看出的,所以你需要阅读相应的 C 头文件来确定。当然,你也可以简单地依赖 libc crate(github.com/rust-lang/l…),我们也会这么做,如果我们不是想亲自学习这些东西的话。
好了,现在我们已经走完了代码流程,有几个话题我们承诺会回过头来讨论。
位标志(Bitflags)和位掩码(Bitmasks)
当你进行系统调用时,几乎每次都会遇到这些(实际上,位掩码的概念在低级编程中很常见)。位掩码是一种将每个位作为开关或标志使用的方式,用于表示某个选项是启用还是禁用。
一个整数,比如 i32,可以表示为 32 位。EPOLLIN 的十六进制值为 0x1(即十进制的 1)。用位表示时,它看起来像这样:00000000000000000000000000000001。
另一方面,EPOLLET 的值为 1 << 31,这表示十进制数 1 的位表示向左移动 31 位。十进制数 1 刚好与 EPOLLIN 相同,所以通过将其表示形式向左移动 31 次,我们得到一个位表示为 10000000000000000000000000000000 的数。
我们使用位标志的方式是用 OR 运算符 |,通过将值进行 OR 操作,我们得到一个包含每个已启用标志的位掩码。在我们的例子中,位掩码会看起来像 10000000000000000000000000000001。
位掩码的接收者(在本例中是操作系统)可以进行相反的操作,检查哪些标志被设置,然后做出相应的反应。
我们可以在代码中创建一个非常简单的示例,以展示这是如何在实践中工作的(你可以直接在 Rust playground 中运行这个代码,或创建一个新项目进行实验):
fn main() {
let bitflag_a: i32 = 1 << 31;
let bitflag_b: i32 = 0x1;
let bitmask: i32 = bitflag_a | bitflag_b;
println!("{bitflag_a:032b}");
println!("{bitflag_b:032b}");
println!("{bitmask:032b}");
check(bitmask);
}
fn check(bitmask: i32) {
const EPOLLIN: i32 = 0x1;
const EPOLLET: i32 = 1 << 31;
const EPOLLONESHOT: i32 = 0x40000000;
let read = bitmask & EPOLLIN != 0;
let et = bitmask & EPOLLET != 0;
let oneshot = bitmask & EPOLLONESHOT != 0;
println!("read_event? {read}, edge_triggered: {et}, oneshot?: {oneshot}")
}
运行此代码的输出将如下:
10000000000000000000000000000000
00000000000000000000000000000001
10000000000000000000000000000001
read_event? true, edge_triggered: true, oneshot?: false
本章接下来将引入边缘触发(edge-triggered)事件的概念,这可能需要一些解释。
水平触发(Level-triggered)与边缘触发(Edge-triggered)事件
在理想的世界里,我们不需要讨论这个话题,但是在使用 epoll 时,几乎不可能不去了解这两者的区别。通过阅读文档无法明显看出这种区别,尤其是在你以前没有接触过这些术语的情况下。特别有趣的是,这让我们可以将 epoll 中事件的处理方式与硬件级别的事件处理方式做一个类比。
epoll 可以以水平触发模式或边缘触发模式通知事件。如果你主要的编程经验是高层次语言,这听起来可能相当抽象(当我第一次了解到这一点时也是如此),但请耐心读下去。在 Event 结构体的事件位掩码中,我们设置 EPOLLET 标志来启用边缘触发模式(如果没有指定任何标志,默认是水平触发模式)。
这种事件通知和事件处理的方式与计算机处理中断的方式有很多相似之处。
水平触发意味着,只要中断线上报告的电信号为高电平,回答“是否发生了事件”这个问题的答案就一直为真。如果我们将其转化到我们的例子中,只要文件句柄关联的缓冲区中有数据,读取事件就会一直存在。
在处理中断时,你可以通过处理触发中断的硬件来清除中断,或者可以屏蔽中断,这简单地禁用了该线路上的中断,直到它被明确地取消屏蔽。
在我们的例子中,我们通过读取所有缓冲区中的数据来清除中断。当缓冲区被清空时,答案变为否。
当在默认的水平触发模式下使用 epoll 时,我们可能会遇到同一个事件产生多个通知的情况,因为我们还没有来得及清空缓冲区(记住,只要缓冲区中有数据,epoll 就会一遍又一遍地通知你)。当我们有一个线程报告事件,然后将处理事件(从流中读取数据)的任务委派给其他工作线程时,这一点尤其明显,因为即便我们正在处理事件,epoll 仍会持续报告事件已准备好。
为了解决这个问题,epoll 提供了一个名为 EPOLLONESHOT 的标志。
EPOLLONESHOT 告诉 epoll 一旦我们在该文件描述符上收到事件,它应该在兴趣列表中禁用该文件描述符。它不会删除该描述符,但我们不会再收到该文件描述符的任何通知,除非我们通过调用 epoll_ctl 并使用 EPOLL_CTL_MOD 参数和一个新的位掩码显式地重新激活它。
如果我们没有添加这个标志,可能会发生以下情况:如果线程 1 是调用 epoll_wait 的线程,那么一旦它收到一个读取事件的通知,它就会在线程 2 中启动一个任务来从该文件描述符中读取数据,然后再次调用 epoll_wait 来获取新事件的通知。在这种情况下,epoll_wait 的调用会再次返回,并告诉我们同一文件描述符上的数据已准备好,因为我们还没有来得及清空该文件描述符上的缓冲区。我们知道该任务正在由线程 2 处理,但仍然会收到通知。没有额外的同步和逻辑,我们可能会将读取该文件描述符的任务分配给线程 3,这可能会导致难以调试的问题。
使用 EPOLLONESHOT 解决了这个问题,因为线程 2 必须在完成任务后重新激活事件队列中的文件描述符,从而通知 epoll 队列它已处理完毕,并表示我们再次对该文件描述符的通知感兴趣。
回到我们的硬件中断类比,EPOLLONESHOT 可以被看作是屏蔽了中断。你还没有实际清除事件通知的来源,但你不希望收到更多通知,直到你完成清除并明确取消屏蔽。在 epoll 中,EPOLLONESHOT 标志会禁用该文件描述符的通知,直到你通过调用 epoll_ctl 并将 op 参数设置为 EPOLL_CTL_MOD 来显式启用它。
边缘触发意味着“是否发生事件”的回答只有在电信号从低变高时才为真。如果我们将其转化到我们的例子中:当缓冲区从没有数据变为有数据时,发生了一个读取事件。只要缓冲区中有数据,就不会报告新事件。你仍然通过清空套接字的所有数据来处理该事件,但在缓冲区完全清空并重新填充新数据之前,你不会收到新的通知。
边缘触发模式也有一些陷阱。最大的问题是,如果你没有正确清空缓冲区,你将永远不会再次收到该文件句柄的通知。
在编写本文时,mio 不支持 EPOLLONESHOT,并在边缘触发模式下使用 epoll,我们在示例中也会采用相同的方式。
那么在多个线程上等待 epoll_wait 怎么样?
只要我们只有一个 Poll 实例,就能避免多个线程对同一个 epoll 实例调用 epoll_wait 时的复杂问题。使用水平触发的事件会唤醒所有在 epoll_wait 调用中等待的线程,导致它们全部尝试处理该事件(通常称为“惊群问题”)。epoll 提供了一个名为 EPOLLEXCLUSIVE 的标志来解决此问题。设置为边缘触发的事件默认情况下只会唤醒阻塞在 epoll_wait 的一个线程,从而避免此问题。
由于我们只在单线程中使用一个 Poll 实例,因此这对我们来说不会成为问题。
我知道,这听起来很复杂。事件队列的基本概念相对简单,但细节可能会稍显复杂。话虽如此,在我的经验中,epoll 是最复杂的 API 之一,因为该 API 显然是随着时间的推移不断演变的,以适应现代需求,而且不涵盖至少我们在这里讨论的这些主题,就无法真正正确地使用和理解它。
值得欣慰的是,kqueue 和 IOCP 的 API 更容易理解。而且 Unix 现在有了一个新的异步 I/O 接口 io_uring,未来将会越来越普及。
现在我们已经完成了本章的难点,并对 epoll 的工作原理有了高层次的概述,是时候在 poll.rs 中实现我们受 mio 启发的 API 了。
Poll 模块
如果你还没有写出或复制我们在“设计和 epoll 介绍”一节中展示的代码,现在是时候这样做了。我们将实现先前使用 todo!() 占位的所有函数。
我们从 Poll 结构体的方法实现开始。首先打开 impl Poll 块并实现 new 函数:
ch04/a-epoll/src/poll.rs
impl Poll {
pub fn new() -> Result<Self> {
let res = unsafe { ffi::epoll_create(1) };
if res < 0 {
return Err(io::Error::last_os_error());
}
Ok(Self {
registry: Registry { raw_fd: res },
})
}
}
在 ffi 模块一节中已深入介绍了 epoll 的概念,因此这里的实现应该比较直接。我们使用参数 1 调用 ffi::epoll_create(记住,这个参数会被忽略,但必须是非零值)。如果调用出错,我们让操作系统报告进程的最后错误并返回它。如果调用成功,则返回一个新 Poll 实例,该实例简单地包装了保存 epoll 文件描述符的 registry。
接下来是 registry 方法,它仅返回对内部 Registry 结构体的引用:
ch04/a-epoll/src/poll.rs
pub fn registry(&self) -> &Registry {
&self.registry
}
Poll 中最后一个方法是最有趣的。它是 poll 函数,用于挂起当前线程,并通知操作系统在我们跟踪的源上发生事件或超时时唤醒线程,以先到者为准。我们还会在此处关闭 impl Poll 块:
ch04/a-epoll/src/poll.rs
pub fn poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()> {
let fd = self.registry.raw_fd;
let timeout = timeout.unwrap_or(-1);
let max_events = events.capacity() as i32;
let res = unsafe { ffi::epoll_wait(fd, events.as_mut_ptr(), max_events, timeout) };
if res < 0 {
return Err(io::Error::last_os_error());
}
unsafe { events.set_len(res as usize) };
Ok(())
}
}
我们首先获取事件队列的原始文件描述符并存储在 fd 变量中。接下来设置 timeout 参数,如果有值则解包,否则设置为 -1,表示我们希望阻塞直到发生事件,即使这可能永远不会发生。
在文件顶部,我们将 Events 定义为 Vec<ffi::Event> 的类型别名,因此我们接下来获取 Vec 的容量。这里需要注意的是,不依赖 Vec::len,因为它表示 Vec 中的项目数量。Vec::capacity 表示我们分配的空间,这正是我们需要的。
接下来是对 ffi::epoll_wait 的调用。如果它返回的值为 0 或更大,则说明有事件发生。
注意:如果在事件发生前超时,我们会得到 0 的返回值。
函数体的最后一步是进行 events.set_len(res as usize) 的不安全调用。由于我们可以将长度设置为尚未初始化的内存区域,因此此函数被标记为不安全调用。不过操作系统保证返回的事件数指向 Vec 中的有效数据,因此在此情况下是安全的。
接下来是 Registry 结构体。我们将只实现一个名为 register 的方法,最后我们会为它实现 Drop 特征以关闭 epoll 实例:
ch04/a-epoll/src/poll.rs
impl Registry {
pub fn register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()> {
let mut event = ffi::Event {
events: interests as u32,
epoll_data: token,
};
let op = ffi::EPOLL_CTL_ADD;
let res = unsafe {
ffi::epoll_ctl(self.raw_fd, op, source.as_raw_fd(), &mut event)
};
if res < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
register 函数接收一个 &TcpStream 作为源、一个 usize 类型的 token 和一个命名为 interests 的位掩码(类型为 i32)。
注意:在这里 mio 的处理方式有所不同。source 参数对于每个平台是特定的。mio 的 register 实现在平台特定的 source 参数上处理,而不是直接在 Registry 中实现。
我们首先创建一个 ffi::Event 对象。events 字段简单地设置为接收到的位掩码 interests,而 epoll_data 设置为我们在 token 参数中传入的值。
我们希望在 epoll 队列上执行的操作是添加对新文件描述符的事件兴趣。因此,将 op 参数设置为 ffi::EPOLL_CTL_ADD 常量。
接下来是 ffi::epoll_ctl 的调用。我们首先传入 epoll 实例的文件描述符,然后传入 op 参数以指示希望执行的操作类型。最后两个参数是我们希望队列跟踪的文件描述符以及表示我们感兴趣事件类型的 Event 对象。
函数体的最后部分只是错误处理,这部分应已熟悉。
poll.rs 的最后部分是 Registry 的 Drop 实现:
ch04/a-epoll/src/poll.rs
impl Drop for Registry {
fn drop(&mut self) {
let res = unsafe { ffi::close(self.raw_fd) };
if res < 0 {
let err = io::Error::last_os_error();
eprintln!("ERROR: {err:?}");
}
}
}
Drop 实现只是调用 ffi::close 关闭 epoll 文件描述符。在 drop 中添加 panic 通常不是一个好主意,因为 drop 可能在发生 panic 时被调用,这会导致进程直接终止。mio 在其 Drop 实现中会记录错误,但不会以其他方式处理它们。对于我们的简单示例,我们只是打印错误,以便在出现问题时可以看到,因为我们没有实现任何形式的日志记录。
最后一部分是运行示例的代码,这引出了 main.rs。
主程序
让我们看看这些代码在实践中的工作原理。确保 delayserver 已启动并运行,因为我们需要它来使这些示例正常工作。
我们的目标是向 delayserver 发送一组具有不同延迟的请求,然后使用 epoll 等待响应。因此,在此示例中,我们仅使用 epoll 来跟踪读取事件。当前程序的功能仅限于此。
首先,确保 main.rs 文件设置正确:
ch04/a-epoll/src/main.rs
use std::{io::{self, Read, Result, Write}, net::TcpStream};
use ffi::Event;
use poll::Poll;
mod ffi;
mod poll;
我们从标准库以及我们自己的 crate 中导入了一些将要使用的类型,并声明了两个模块。
在此示例中,我们将直接处理 TcpStream,这意味着我们需要自己格式化向 delayserver 发出的 HTTP 请求。
服务器将接受 GET 请求,因此我们创建一个小的辅助函数来为我们格式化有效的 HTTP GET 请求:
ch04/a-epoll/src/main.rs
fn get_req(path: &str) -> Vec<u8> {
format!(
"GET {path} HTTP/1.1\r\n\
Host: localhost\r\n\
Connection: close\r\n\
\r\n"
)
.into_bytes()
}
前面的代码只是将 path 作为输入参数,并使用它格式化一个有效的 GET 请求。路径是 URL 中架构和主机之后的部分。在我们的例子中,路径是以下 URL 中粗体部分:http://localhost:8080/2000/hello-world。
接下来是我们的 main 函数。它分为两个部分:
- 设置和发送请求
- 等待并处理传入的事件
main 函数的第一部分如下所示:
fn main() -> Result<()> {
let mut poll = Poll::new()?;
let n_events = 5;
let mut streams = vec![];
let addr = "localhost:8080";
for i in 0..n_events {
let delay = (n_events - i) * 1000;
let url_path = format!("/{delay}/request-{i}");
let request = get_req(&url_path);
let mut stream = TcpStream::connect(addr)?;
stream.set_nonblocking(true)?;
stream.write_all(&request)?;
poll.registry()
.register(&stream, i, ffi::EPOLLIN | ffi::EPOLLET)?;
streams.push(stream);
}
首先,我们创建一个新的 Poll 实例,并指定在示例中要创建和处理的事件数。
接下来,创建一个变量来保存 Vec<TcpStream> 类型的集合,并存储 delayserver 地址到 addr 变量中。
接下来创建一组请求,发送给 delayserver,后者最终会向我们发送响应。对于每个请求,我们期望稍后在发送请求的 TcpStream 上发生读取事件。
在循环的第一步中,我们设置延迟时间(以毫秒为单位)。(n_events - i) * 1000 设定了第一个请求具有最长的超时时间,因此我们应预期响应的到达顺序将与发送顺序相反。
注意
为了简化代码,我们使用事件在 streams 集合中的索引作为其 ID。此 ID 与循环中的变量 i 相同。例如,在第一次循环中,i 为 0,它也将是第一个推入 streams 集合的流,因此索引也为 0。因此,我们在整个过程中将 0 作为该流/事件的标识符,因为检索与此事件关联的 TcpStream 只需在 streams 集合中索引到该位置即可。
接下来的一行 format!("/{delay}/request-{i}") 格式化了 GET 请求的路径。我们按照前面描述的方式设置超时,并设置一个包含事件标识符 i 的消息,这样我们也可以在服务器端跟踪此事件。
接下来创建一个 TcpStream。您可能注意到 Rust 中的 TcpStream 不接受 &str,而是接受实现了 ToSocketAddrs 特征的参数。该特征已为 &str 实现,这就是我们在本示例中可以直接写的原因。
在 TcpStream::connect 实际打开套接字之前,它会尝试将传入的地址解析为 IP 地址。如果失败,它会将其解析为域名地址和端口号,然后请求操作系统对该地址执行 DNS 查找,以便实际连接到我们的服务器。因此,您会看到,在进行简单的连接时,可能会涉及相当多的处理。
您可能还记得我们之前讨论过 DNS 查找的一些细微差别,并提到这种调用可能会非常快,因为操作系统已经将信息存储在内存中,也可能会在等待 DNS 服务器响应时阻塞。如果希望完全控制整个过程,使用标准库中的 TcpStream 可能会有些不足。
Rust 中的 TCPSTREAM 和 Nagle 算法
这里有一个小知识(我本打算称其为“有趣的事实”,但意识到这对“有趣”一词的要求有点高了!)。在 Rust 的 TcpStream(以及旨在模仿标准库的 TcpStream 的 API,如 mio 或 Tokio)中,流是默认使用 TCP_NODELAY 标志设置为 false 创建的。这意味着使用了 Nagle 算法,这可能会导致一些延迟异常,并可能在某些工作负载下降低吞吐量。
Nagle 算法旨在通过合并小的网络包来减少网络拥塞。如果您查看其他语言中的非阻塞 I/O 实现,许多(如果不是大多数)默认禁用此算法。而在大多数 Rust 实现中情况并非如此,值得注意的是,您可以通过调用 TcpStream::set_nodelay(true) 来禁用它。如果您尝试创建自己的异步库或依赖于 Tokio/mio,并发现吞吐量低于预期或出现延迟问题,值得检查该标志是否设置为 true。
继续代码的下一步是通过调用 TcpStream::set_nonblocking(true) 将 TcpStream 设置为非阻塞模式。
之后,我们将请求写入服务器,然后在 interests 位掩码中设置 EPOLLIN 位,以表示我们对读取事件的兴趣。
在每次迭代中,我们将流推入 streams 集合的末尾。
main 函数的下一部分是处理传入事件。让我们看看 main 函数的最后部分:
let mut handled_events = 0;
while handled_events < n_events {
let mut events = Vec::with_capacity(10);
poll.poll(&mut events, None)?;
if events.is_empty() {
println!("TIMEOUT (OR SPURIOUS EVENT NOTIFICATION)");
continue;
}
handled_events += handle_events(&events, &mut streams)?;
}
println!("FINISHED");
Ok(())
}
我们首先创建一个名为 handled_events 的变量,用于跟踪我们已处理的事件数。
接下来是事件循环。只要已处理的事件少于我们预期的事件数量,就继续循环。所有事件处理完成后,退出循环。
在循环内部,我们创建一个 Vec<Event>,容量为 10。这里使用 Vec::with_capacity 创建 Vec 很重要,因为操作系统假定我们传递的是已分配的内存。我们可以选择任何事件数,但设得太低会限制每次唤醒时操作系统可以通知我们的事件数量。
接下来是对 Poll::poll 的阻塞调用。正如您所知,这会告诉操作系统挂起我们的线程,并在发生事件时唤醒它。
如果我们被唤醒,但列表中没有事件,这可能是超时或伪事件(这种情况可能发生,因此需要一种方法来检查是否确实超时,如果这是我们关心的)。如果是这种情况,我们只需再次调用 Poll::poll。
如果有事件要处理,我们将这些事件连同对 streams 集合的可变引用一起传递给 handle_events 函数。
main 的最后部分只是将 FINISHED 写入控制台,让我们知道程序已在该点退出 main。
本章的最后一段代码是 handle_events 函数。该函数接受两个参数,一个是 Event 结构体的切片,另一个是 TcpStream 对象的可变切片。
让我们看看代码,然后再解释:
fn handle_events(events: &[Event], streams: &mut [TcpStream]) -> Result<usize> {
let mut handled_events = 0;
for event in events {
let index = event.token();
let mut data = vec![0u8; 4096];
loop {
match streams[index].read(&mut data) {
Ok(n) if n == 0 => {
handled_events += 1;
break;
}
Ok(n) => {
let txt = String::from_utf8_lossy(&data[..n]);
println!("RECEIVED: {:?}", event);
println!("{txt}\n------\n");
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) => return Err(e),
}
}
}
Ok(handled_events)
}
首先,我们创建一个 handled_events 变量,用于跟踪每次唤醒时已处理的事件数量。接下来,我们遍历接收到的事件。
在循环中,我们检索标识收到事件的 token,即 TcpStream 的索引。正如我们之前在示例中解释的那样,此标识符与 streams 集合中特定流的索引相同,因此我们可以使用它在 streams 集合中索引并检索相应的 TcpStream。
在开始读取数据之前,我们创建一个大小为 4096 字节的缓冲区(当然,可以根据需要分配更大或更小的缓冲区)。
我们创建一个循环,因为可能需要多次调用 read 以确保已清空缓冲区。请记住,在边缘触发模式下使用 epoll 时,完全清空缓冲区非常重要。
我们匹配 TcpStream::read 的结果,因为我们希望根据结果采取不同的操作:
- 如果返回
Ok(n)且值为 0,说明已清空缓冲区;我们认为事件已处理,并跳出循环。 - 如果返回
Ok(n)且值大于 0,我们将数据读取到字符串中并打印输出,并且不跳出循环,因为我们必须继续调用read直到返回 0(或发生错误),以确保缓冲区已完全清空。 - 如果返回
Err且错误为io::ErrorKind::WouldBlock类型,则跳出循环。我们还未视该事件为已处理,因为WouldBlock表示数据传输尚未完成,但当前没有数据可用。 - 如果返回其他任何错误,则返回该错误并视为失败。
注意
通常,你还需要处理另一种错误情况,即 io::ErrorKind::Interrupted。从流中读取可能会因操作系统发出的信号而中断。这种情况是预期的,不应被视为故障。处理方法与遇到 WouldBlock 类型错误时相同。
如果读取操作成功,我们返回已处理的事件数量。
谨慎使用 TcpStream::read_to_end
在使用非阻塞缓冲区时,谨慎使用 TcpStream::read_to_end 或其他会自动清空缓冲区的函数。如果遇到 io::WouldBlock 错误,即使在此之前有若干次成功的读取,它仍然会报告为错误。除了观察传入的 &mut Vec 的任何变化外,无法确切知道成功读取了多少数据。
运行程序后,我们应能得到如下输出:
RECEIVED: Event { events: 1, epoll_data: 4 }
HTTP/1.1 200 OK
content-length: 9
connection: close
content-type: text/plain; charset=utf-8
date: Wed, 04 Oct 2023 15:29:09 GMT
request-4
------
RECEIVED: Event { events: 1, epoll_data: 3 }
HTTP/1.1 200 OK
content-length: 9
connection: close
content-type: text/plain; charset=utf-8
date: Wed, 04 Oct 2023 15:29:10 GMT
request-3
------
RECEIVED: Event { events: 1, epoll_data: 2 }
HTTP/1.1 200 OK
content-length: 9
connection: close
content-type: text/plain; charset=utf-8
date: Wed, 04 Oct 2023 15:29:11 GMT
request-2
------
RECEIVED: Event { events: 1, epoll_data: 1 }
HTTP/1.1 200 OK
content-length: 9
connection: close
content-type: text/plain; charset=utf-8
date: Wed, 04 Oct 2023 15:29:12 GMT
request-1
------
RECEIVED: Event { events: 1, epoll_data: 0 }
HTTP/1.1 200 OK
content-length: 9
connection: close
content-type: text/plain; charset=utf-8
date: Wed, 04 Oct 2023 15:29:13 GMT
request-0
------
FINISHED
可以看到,响应是按发送顺序的逆序返回的。可以通过查看 delayserver 实例运行时的终端输出来确认这一点。输出应如下所示:
#1 - 5000ms: request-0
#2 - 4000ms: request-1
#3 - 3000ms: request-2
#4 - 2000ms: request-3
#5 - 1000ms: request-4
有时顺序可能会不同,因为服务器几乎同时接收到请求,可能会选择以稍微不同的顺序处理它们。
假设我们正在跟踪 ID 为 4 的流上的事件:
- 在
send_requests中,我们将 ID 4 分配给我们创建的最后一个流。 - 套接字 4 向
delayserver发送请求,设置了 1,000 毫秒的延迟,并附带request-4消息,以便在服务器端标识它。 - 我们在事件队列中注册套接字 4,确保将
epoll_data字段设置为 4,以便识别事件发生在哪个流上。 delayserver收到请求后,延迟 1,000 毫秒,然后发送 HTTP/1.1 200 OK 响应,并附上我们最初发送的消息。epoll_wait被唤醒,通知我们有事件已准备好。在Event结构体的epoll_data字段中,我们会获得注册事件时传入的相同数据,指示事件发生在流 4 上。- 然后,我们从流 4 中读取数据并打印出来。
在这个示例中,我们保持了相当低的层级,即使我们使用标准库处理了连接建立的细节。您实际上向本地服务器发送了一个原始 HTTP 请求,设置了一个 epoll 实例来跟踪 TcpStream 上的事件,并使用 epoll 和系统调用来处理传入的事件。
这并不容易——恭喜完成!
在结束此示例之前,我想指出,要让示例使用 mio 作为事件循环而不是我们创建的循环,只需要进行很少的更改。
在仓库的 ch04/b-epoll-mio 目录中,您会看到一个示例,在其中我们使用 mio 完成了完全相同的任务。这只需从 mio 导入几个类型,而不是使用我们自己的模块,并对代码进行五处小改动!
不仅您已复制了 mio 的功能,而且您也基本了解了如何使用 mio 创建事件循环!
总结
从高层次上看,epoll、kqueue 和 IOCP 的概念相对简单,但细节中充满了复杂性。这些系统并不容易理解并正确运用。即使是处理这些系统的程序员,往往也会专注于一个平台(epoll/kqueue 或 Windows)。很少有人能了解所有平台的细节,仅此一个主题就足以写一本书。
如果总结本章中学到的知识和亲身实践的经验,列表相当令人印象深刻:
- 你深入了解了 mio 的设计,可以更轻松地进入该仓库,知道如何查找内容并快速上手。
- 你学习了如何在 Linux 上进行系统调用。
- 你创建了一个 epoll 实例,注册了事件并处理了这些事件。
- 你学到了 epoll 的设计和 API。
- 你了解了边缘触发和水平触发,这是非常底层但在 epoll 之外也有用的概念。
- 你发送了一个原始 HTTP 请求。
- 你观察了非阻塞套接字的行为,以及操作系统报告的错误码如何用于传达需要处理的某些条件。
- 你了解到并非所有 I/O 都是完全“阻塞”的,通过 DNS 解析和文件 I/O 就可以看出这一点。
对于一个章节来说,这相当不错!
如果深入探讨这些主题,很快会发现各种陷阱和深入的知识,特别是当你试图将这个例子扩展到涵盖 epoll、kqueue 和 IOCP 的抽象时。可能在不知不觉中,你就会开始阅读 Linus Torvalds 的邮件,了解边缘触发模式在管道上应该如何工作。
至少你现在拥有了一个良好的基础,能够进一步探索。你可以在这个简单的示例上扩展,创建一个更完善的事件循环,处理连接、写入、超时和调度;你也可以通过研究 mio 是如何解决这一问题的,深入研究 kqueue 和 IOCP;或者你可以欣慰地认为不用再直接处理它们,并欣赏诸如 mio、polling 和 libuv 等库所付出的努力。
到这里为止,我们已经掌握了异步编程基本构建块的许多知识,所以接下来我们将开始探索不同编程语言是如何为异步操作构建抽象的,并利用这些构建块为我们编程人员提供高效、表达力强且富有生产力的异步编程方式。
首先是我最喜欢的一个例子,我们将通过自己实现来探究 fibers(或绿色线程)是如何工作的。
现在你已经值得休息一下。没错,下一章可以稍等片刻。泡一杯茶或咖啡,调整心情,以便在精神焕发的状态下开始下一章。我保证下一章既有趣又充满知识性。