Editor’s Note: This article contains an exclusive excerpt from Latency by Pekka Engberg, which helps readers diagnose latency problems and master the low-latency techniques that have been predominantly “tribal knowledge” until now. You can download three chapters for free from ScyllaDB. And if you really want to geek out on latency, join the community at P99 CONF (free and virtual).
异步处理是一种强大的技术,可用于提升系统并发性,并在进一步降低延迟变得不切实际或不可能的系统中隐藏延迟。通过异步执行 I/O 操作并推迟非关键工作,您可以显著提升感知响应性,因为最终用户不会感知到延迟。虽然分区、缓存和其他延迟优化技术可以降低绝对延迟,但异步处理提供了一种互补策略——它通过允许系统在某些操作耗时时仍保持响应来隐藏延迟。
“异步处理提供了一种互补策略——它通过允许系统在某些操作耗时的情况下仍保持响应来隐藏延迟。”
在本文中,我们将探讨同步处理与异步处理之间的根本区别,研究事件循环如何实现高效的异步执行,并探讨异步系统必须解决的关键挑战和权衡。
异步处理与同步处理
异步处理允许任务独立执行,并在重叠的时间段内进行,而同步处理中任务则是顺序执行的。换句话说,在同步系统中,一个任务必须执行完成才能开始下一个任务。例如,假设一个单线程同步服务器正在读取和处理消息。它首先从套接字读取一条消息,这可能需要线程阻塞,直到消息完全到达。然后服务器处理消息,最后发送响应,再开始处理下一条消息。如果请求处理耗时较长或发生阻塞,系统就会同步等待。
异步处理消除了这一约束,允许多个任务同时推进。例如,使用异步处理的服务器可以通过 I/O 多路复用(操作系统接口)轮询各种连接的状态来并发处理多个独立请求。然后,服务器可以响应事件(如套接字变为可读或可写)来处理请求。同样,异步服务器可以启动通过网络发送响应,然后在不等待响应的情况下处理其他任务。
异步处理类似于并发编程。然而,异步处理与并发编程不同,因为它具有显式接口。例如,使用线程的并发编程允许服务器同步处理请求,同时通过线程间的上下文切换来保留并发性。服务器执行 send() 和 recv() 系统调用,如果套接字没有可读内容或不可写,这些调用就会阻塞。当服务器阻塞时,操作系统会切换到另一个线程进行并发执行。相比之下,在异步处理中,服务器使用 I/O 多路复用接口来轮询套接字状态。I/O 多路复用接口会告知服务器哪些套接字可读,服务器可以在不阻塞线程的情况下读取它们。同样,当服务器发送响应时,它使用异步接口发送响应,但随后可以立即继续工作而不阻塞,让操作系统在后台发送响应。
图 1 展示了同步处理与异步处理之间的区别(这些区别也在题为“异步、并发和并行处理之间的差异”的侧边栏中进行了总结)。在这个例子中,我们有两个任务 A 和 B,必须运行这两个任务才能完成全部工作。假设一个后端系统需要与外部系统 A 和 B 通信才能完成收到的请求。在同步处理中,每个任务必须在下一个任务开始前完成。我们运行任务 A 直到完成(包括它提交的 I/O),然后再运行任务 B。所需总时间是所有任务时间相加。如果后端服务需要执行所有这些任务,用户必须等待这个总时间才能收到响应。

然而,在异步处理中,我们可以同时执行任务 A 的 I/O 和任务 B,当两者都完成时我们就完成了。如果 I/O 同时运行,用户的等待时间会短得多,尽管每个任务仍需相同的时间。这在后端服务进行数据库调用或其他服务调用时效果很好,因为这些任务可以独立运行而不会相互阻塞。但有一个注意事项:如果您的 I/O 无法并行运行,使用异步处理不会带来帮助,反而可能使运行变慢,因为管理异步任务会为系统增加额外工作。
异步处理也是隐藏延迟的关键技术。尽管您已尽最大努力降低延迟,但某些操作仍需较长时间才能完成,因此必须在无需等待其完成的情况下执行操作。例如,后端系统通常与第三方服务、数据库服务器和消息队列等外部系统交互,每次交互都会增加一些延迟。在同步处理中,您构建的系统往往无法利用固有的并行性,并导致等待系统完成工作的空闲时间。相比之下,异步处理允许您通过异步启动操作并在完成时做出响应来最小化等待时间。
在同步处理中,您将代码结构化为相互依赖的操作序列。例如,同步服务器的请求处理函数可能如下所示。
Listing 1 A simple example of a synchronous system
fn process_requests(socket: &Socket) {
loop {
process_request(socket);
}
}
fn process_request(socket: &Socket) {
let msg = socket.recv();
let request = parse_message(msg);
let resp = match request {
Request::GetUserInfo(id) => get_user_info(id);
};
let resp = format_response(resp);
socket.send(resp);
}
从高层次来看,我们有 process_requests 函数,它处理来自套接字的任何传入请求。在 process_request 函数中,每个步骤都在我们开始下一步之前运行完成。我们从套接字读取消息,解析消息以确定请求是什么,处理请求,最后通过套接字发送响应。更重要的是,在发送响应之前我们不会启动另一个 process_request,我们也不允许处理来自多个套接字的请求。
虽然协程和 future 等并发原语能够实现并行执行,但它们对于高效的异步处理(特别是 I/O)是不够的。如果服务器要处理数千个并发连接,您必须以不同的方式构建应用程序。事件循环是高效跨多个连接多路复用 I/O 操作的基础。
事件循环
事件循环是所有输入和输出操作的中央协调器——它是异步系统的核心。虽然传统的同步程序一次处理一个连接(就像单个工作进程按顺序处理任务一样),但事件循环则充当调度器,同时管理数千个 I/O 操作。这种架构模式有时被称为 I/O 循环或 I/O 调度器,正是异步处理高效处理并发操作的方式。事件循环不是为每个连接分配单独的资源,而是通过跟踪各种 I/O 来源(网络连接、文件操作、定时器等)的状态并在准备就绪时进行处理来实现多路复用。
“事件循环是所有输入和输出操作的中央协调器——它是异步系统的核心。”
事件循环遵循一个简单而强大的模式:
- 轮询事件。
- 处理事件。
- 运行已调度的任务。
- 重复。
事件循环使用操作系统特定的 I/O 多路复用接口(如 Linux 上的 io_uring 和 epoll、macOS 上的 kqueue 以及 Windows 上的 IOCP)轮询事件,例如来自套接字的传入数据、过期的定时器或 I/O 完成。这些接口允许您注册对事件源的兴趣,并在事件发生时获得通知。例如,应用程序不是直接从套接字读取数据,而是表达对套接字变为可读的兴趣。当数据从网络到达套接字时,操作系统通过 I/O 多路复用接口通知应用程序套接字现在可读。事件循环通过轮询发现这一点,并调用应用程序的事件处理逻辑来处理从套接字新到达的数据。
让我们用 Rust 实现一个基本的事件循环来更好地理解其结构:
struct EventLoop {
// Holds registered event sources like sockets, files, timers
sources: Vec<EventSource>,
}
impl EventLoop {
fn run(&mut self) {
loop {
// Create a new collection to store events
let mut events = Events::new();
// Poll for new events with a timeout
self.poll(&mut events, Duration::from_millis(100));
// Process each event that was found
for event in events.iter() {
self.process_event(&event);
}
// Run any scheduled tasks
self.run_scheduled_tasks();
}
}
}
EventLoop::run() 方法展示了事件驱动编程的核心功能:持续轮询并处理事件。poll() 方法使用操作系统特定的 I/O 多路复用接口(如 io_uring)在事件源上获取事件。正如您在示例代码中看到的,我们还为事件轮询指定了超时时间。需要超时是因为事件循环中的 I/O 轮询通常是阻塞线程直到事件发生的唯一同步代码。如果系统空闲且没有事件发生,轮询可能会阻塞,而这种阻塞可以在没有事情可做时减少浪费的 CPU 周期。但是,为了确保事件循环不会永远阻塞,超时确保我们从 poll() 返回。这还允许事件循环执行不依赖于事件的工作,例如执行后台工作。然而,在某些情况下,您可能会使用忙等待轮询来避免某些对延迟敏感的事件循环的睡眠/唤醒周期延迟。
process_event 函数负责处理轮询期间发现的任何事件。例如,如果应用程序注册了对来自网络的数据到达的兴趣(如套接字变为可读),process_event 函数就会从套接字读取数据,并将数据转发给应用程序进行处理。一个简单的 process_event 函数可能如下所示:
struct EventLoop {
// Holds registered event sources like sockets, files, timers
sources: Vec<EventSource>,
}
impl EventLoop {
fn run(&mut self) {
loop {
// Create a new collection to store events
let mut events = Events::new();
// Poll for new events with a timeout
self.poll(&mut events, Duration::from_millis(100));
// Process each event that was found
for event in events.iter() {
self.process_event(&event);
}
// Run any scheduled tasks
self.run_scheduled_tasks();
}
}
}
如您所见,每个事件由一个 Event 枚举表示,其中包含不同事件的变体。事件处理逻辑特定于事件循环的结构方式。例如,如果事件循环使用回调进行事件处理,它会调用这些回调,将工作委托给应用程序。应用程序随后可以在回调中执行工作或将工作提交给另一个线程进行处理。
图 10.2 可视化了事件循环如何执行工作。在这个例子中,工作被拆分为三个独立的任务:
- 接受连接
- 处理请求
- 发送响应

当 I/O 多路复用器通知事件循环有传入连接时,第一个任务运行。应用程序通过接受连接做出响应,然后注册对被接受的套接字何时变为可读的兴趣。当数据从网络到达时,操作系统通知事件循环套接字可读。应用程序通过从套接字读取并处理传入请求来响应。最后,应用程序注册对套接字变为可写的兴趣。当操作系统有足够的缓冲内存用于传出响应时,它会通知应用程序,应用程序将响应写入套接字。
“虽然事件循环是异步处理的底层基础设施,您还需要一些并发原语来指定各个任务之间的依赖关系。”
如果将事件循环与同步服务器(您在清单 1 中看到的)进行对比,您会发现这两种方法之间有两个关键区别:
- 非阻塞操作——事件循环不会阻塞线程,而是注册对诸如套接字变为可读等事件的兴趣,并在该条件为真时推迟从套接字读取,同时处理其他事件。
- 资源效率——运行事件循环的单个线程可以处理数千个并发连接,因为它不需要等待 I/O 操作完成。相反,I/O 多路复用操作系统接口允许事件循环同时轮询多个事件源(如套接字)的状态,执行基于事件的处理。
虽然事件循环是异步处理的底层基础设施,您还需要一些并发原语(如回调或 future)来指定各个任务之间的依赖关系。
挑战
虽然异步处理可以显著提升应用程序性能,但它也带来了一些需要考虑的重要陷阱:
- 复杂性——异步代码通常比同步代码更复杂。您需要仔细管理任务依赖关系、跨多个操作处理错误并应对竞态条件。
- 资源管理——同时运行许多任务可能会消耗大量内存和系统资源。您需要实现适当的限流和资源管理。
- 可调试性——当异步代码出现问题时,追踪问题可能更难,因为执行顺序并不总是显而易见,堆栈跟踪可能无法说明全部情况。
- 错误处理——由于多个操作独立运行,错误处理变得更加复杂。您需要决定一个任务中的失败应如何影响其他正在运行的任务。
Group Created with Sketch.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.