在上一章中,我们从总体上讨论了异步程序流、并发和并行。在本章中,我们将缩小范围,具体探讨编程语言和库中不同的并发建模方法。
请记住,线程、futures、fibers、goroutines、promises等都是帮助我们构建异步程序流的抽象方式。它们各有优缺点,但都致力于为程序员提供一种易用、高效、且难以误用的方式,以便在非顺序、往往不可预测的情况下处理任务。
这里的定义不够精确,因为许多术语最初源自某个具体的实现,但随后被赋予了更广泛的含义,涵盖了不同的实现及其变种。
我们将首先介绍一种基于相似性将不同抽象方式分组的方法,然后再讨论各自的优缺点。此外,我们还会介绍贯穿全书的重要定义,并详细讨论操作系统线程。
本章涉及的主题较为抽象和复杂,因此如果一开始无法完全理解也不必担心。随着本书的深入,你会逐渐通过实际例子熟悉这些术语和技术,更多的概念会逐步清晰起来。
具体来说,本章将涵盖以下主题:
- 相关定义
- 操作系统提供的线程
- 绿色线程/有堆栈协程/fibers
- 基于回调的方法
- Promises、Futures 和 async/await
定义
我们可以将并发操作的抽象大致分为两类:
- 协作式:这些任务会自愿让出执行权,要么通过显式的
yield
操作,要么在无法继续推进(例如进行网络调用)时调用一个挂起任务的函数。通常,这些任务会将控制权交给某种调度器。这方面的例子包括 Rust 和 JavaScript 中由async/await
生成的任务。 - 非协作式:这些任务不一定会自愿让出执行权。在这种系统中,调度器必须具备抢占正在运行任务的能力,即调度器可以停止任务并控制CPU,即使该任务还能够继续工作和推进。典型例子包括操作系统线程和 Goroutines(在 Go 1.14 版本之后)。
注释
在调度器可以抢占运行任务的系统中,任务也可以像在协作式系统中那样自愿让出执行权,而仅依赖抢占的系统很少见。
我们还可以根据实现特性,将这些抽象进一步分为两大类:
- 有堆栈(Stackful) :每个任务都有自己的调用堆栈,通常实现为类似操作系统为线程使用的堆栈。有堆栈任务可以在程序的任何位置挂起执行,因为整个堆栈状态会被保留。
- 无堆栈(Stackless) :每个任务没有独立的堆栈,它们共享同一个调用堆栈。任务不能在栈帧中途挂起,这限制了运行时抢占任务的能力。然而,由于任务切换时需要存储/恢复的信息较少,因此无堆栈的方式可以更高效。
在本书后面,我们会实现一个有堆栈协程(fiber)和一个无堆栈协程(由 async/await
生成的 Rust futures),你将深入理解这两类的细微差异。目前,我们简要概述,避免过多细节。
线程
本书中会频繁提到“线程”,所以在深入之前,先明确地定义“线程”非常重要,这是一个容易引发混淆的基本术语。
从最广泛的角度来看,线程指的是执行线程,即需要按顺序执行的一组指令。如果回顾本书第一章“并发与并行”的小节,线程类似于我们定义的“任务”,即一个包含多个步骤并需要资源推进的任务。
这一定义的宽泛性会带来一些困惑。对于某些人,线程可能指操作系统线程;对另一些人来说,它可能指任何代表系统中执行线程的抽象。
线程通常分为两类:
- 操作系统线程:由操作系统创建并由操作系统调度管理。在Linux上,这被称为内核线程。
- 用户级线程:由程序员创建和管理,操作系统并不直接管理这些线程。
这里的区别开始变得复杂:大多数现代操作系统的OS线程具有许多相似之处,这些相似性来自于现代CPU的设计。例如,大多数CPU假设存在一个可以执行操作的堆栈,并设有堆栈指针寄存器和堆栈操作指令。
用户级线程广泛地指任何实现了创建和调度任务的系统(运行时),并且不能像OS线程那样做出相同假设。用户级线程可以通过为每个任务使用单独的堆栈来接近OS线程的实现方式(在第5章中,我们会讲解fiber/绿色线程的例子),也可以完全不同(我们会在本书第3部分深入探讨Rust如何建模并发操作)。
无论定义如何,一组任务需要一个管理者来决定谁获得资源以推进。计算机系统上所有任务都需要的最明显资源是CPU时间。我们将决定谁获得CPU时间的“管理者”称为调度器。
通常,当有人提到“线程”而未添加额外上下文时,他们指的是OS线程/内核线程,因此我们也将按此定义。接下来,我会将“执行线程”简称为“任务”。在讨论异步编程时,尽可能减少因上下文不同而引发的术语混淆有助于我们更容易理解该主题。
重要提示!
不同书籍或文章中定义会有所不同。例如,如果你阅读关于某个特定操作系统的工作原理,可能会看到进程或线程被称为表示“任务”的抽象,这似乎与这里使用的定义相矛盾。正如之前提到的,选择参照框架非常重要,这就是我们在本书中遇到术语时会仔细定义它们的原因。
即使在今天,大多数流行系统共享类似的线程定义,但定义在操作系统间仍可能有所不同。例如,Solaris系统(Solaris 9之前,发布于2002年)使用了一个两级线程系统,将应用程序线程、轻量级进程和内核线程区分开来。这种M
线程实现将在线程模型部分详细讨论。请注意,如果阅读较旧的资料,其线程定义可能与今天常用的定义显著不同。
现在我们已经理解了本章最重要的定义,是时候讨论编程中处理并发的最流行方法了。
操作系统提供的线程
注释! 我们称这种方式为1:1线程模型。每个任务被分配一个OS线程。
本书接下来不会专注于将OS线程作为并发处理方式,因此我们在这里更详细地讨论它们。
首先,使用操作系统提供的线程,需要一个操作系统。在讨论线程作为并发处理手段之前,必须明确我们讨论的操作系统类型,因为操作系统的种类不同。
嵌入式系统比以往更为普及。这类硬件可能没有操作系统的资源支持,即使有,也可能使用一种完全不同、专门为特定需求量身定制的操作系统,因为嵌入式系统通常不是通用的,而是专用的。
它们对线程的支持,以及调度线程的特性,可能与Windows或Linux等常见操作系统有所不同。
由于涵盖所有不同的设计本身就是一本书的内容,我们将范围限制在Windows和Linux系统上使用的线程,这些系统运行在常见的桌面和服务器CPU上。
OS线程简单易用:我们只需让操作系统处理所有事情。通过为每个要完成的任务生成一个新的OS线程,我们可以按照正常方式编写代码。我们使用的并发运行时就是操作系统本身。除此之外,还能自动获得并行处理能力。然而,直接管理并行性和共享资源也带来了一些缺点和复杂性。
创建新线程需要时间
创建一个新的OS线程涉及一些记录和初始化的开销,因此尽管在同一进程中切换两个已存在的线程非常快速,但创建新线程以及清理不再使用的线程仍需要时间。系统若需要频繁创建和销毁大量线程,额外的开销将限制吞吐量。如果有大量小任务需要并发处理,特别是涉及大量I/O时,这可能会成为问题。
每个线程都有自己的栈
本书稍后会详细讨论栈,目前只需了解栈占用固定大小的内存。每个OS线程都有自己的栈,尽管许多系统允许配置栈大小,但它们大小固定,不能动态增长或缩小。配置过小会导致栈溢出,配置过大则会浪费内存。如果有许多小任务仅需要少量栈空间,而我们分配了远超所需的内存,那么可能会耗尽系统内存。
上下文切换
线程和调度器密切相关。上下文切换发生在CPU停止执行一个线程并开始执行另一个线程时。尽管这个过程高度优化,但它仍需要保存和恢复寄存器状态,这会消耗时间。每次让出控制权给操作系统的调度器时,调度器可以选择在该CPU上调度不同进程的线程。
线程属于进程。当你启动一个程序时,它启动一个进程,并在最初的线程上执行你编写的程序。每个进程可以生成多个线程,这些线程共享相同的地址空间。也就是说,同一进程中的线程可以访问共享内存以及相同的资源(例如文件和文件句柄)。这意味着当操作系统在同一进程内切换上下文时,它只需保存和恢复与该线程相关的状态,而不必保存与整个进程相关的状态。
另一方面,当操作系统从一个进程的线程切换到另一个进程的线程时,新进程将使用不同的地址空间,操作系统必须采取措施确保进程“A”无法访问进程“B”的数据或资源。如果不这样做,系统将不安全。结果是可能需要清空缓存并保存和恢复更多状态。在高并发的负载系统中,这些上下文切换会增加时间,从而在发生频繁时限制吞吐量,使系统表现出一定的不可预测性。
调度
操作系统的任务调度可能与你预期的不同。每次让出控制权给操作系统时,你将进入系统上所有线程和进程共享的队列中。
此外,由于没有保证线程会在让出后继续在同一个CPU核心上运行,也无法确保两个任务不会并行运行并试图访问同一数据,因此需要同步数据访问以防止数据竞争和多核编程相关的其他陷阱。Rust语言能够帮助避免许多这些问题,但数据访问同步仍需要额外工作并增加程序的复杂性。尽管我们常说使用OS线程来处理并发让我们“免费”获得并行处理,但实际上它并不“免费”,因为需要处理数据访问同步和复杂性增加的问题。
将异步操作与操作系统线程解耦的优势
将异步操作与线程概念解耦带来了许多好处。
首先,使用操作系统线程来处理并发意味着我们需要使用操作系统的抽象来表示任务。而如果使用单独的抽象层来表示并发任务,我们可以自由选择如何处理并发操作。例如,创建一个并发操作的抽象,如Rust中的future、JavaScript中的promise或Go中的goroutine,具体如何处理这些并发任务将由运行时的实现决定。
一个运行时可以简单地将每个并发操作映射到一个操作系统线程,也可以使用fiber/绿色线程或状态机来表示任务。编写异步代码的程序员无需在代码中做出任何改变,即使底层实现发生变化也不受影响。理论上,若有相应的运行时,即使是在没有操作系统的微控制器上,同样的异步代码也可以用于处理并发操作。
总结而言,使用操作系统提供的线程来处理并发有以下优点:
- 容易理解
- 易于使用
- 任务切换速度较快
- 自动获得并行处理能力
然而,它们也有一些缺点:
- 操作系统级别的线程占用较大的栈空间。如果有许多任务同时等待(例如在高负载的Web服务器中),内存会很快耗尽。
- 上下文切换的开销可能较大,性能可能不稳定,因为调度完全由操作系统负责。
- 操作系统有很多任务要处理,可能不会像期望的那样迅速切换回你的线程。
- 与操作系统的抽象紧密耦合,在某些系统上可能不可用。
示例
由于本书接下来不会花更多时间讨论OS线程,这里提供一个简单的例子展示其用法:
use std::thread::{self, sleep};
fn main() {
println!("So, we start the program here!");
let t1 = thread::spawn(move || {
sleep(std::time::Duration::from_millis(200));
println!("The long running tasks finish last!");
});
let t2 = thread::spawn(move || {
sleep(std::time::Duration::from_millis(100));
println!("We can chain callbacks...");
let t3 = thread::spawn(move || {
sleep(std::time::Duration::from_millis(50));
println!("...like this!");
});
t3.join().unwrap();
});
println!("The tasks run concurrently!");
t1.join().unwrap();
t2.join().unwrap();
}
在这个例子中,我们简单地生成多个操作系统线程并将它们置于休眠状态。休眠基本上相当于让出控制权给操作系统调度器,并请求在特定时间后重新调度执行。为了确保主线程不会在子线程运行完成之前结束并退出(这会导致整个进程退出),我们在主函数的末尾使用join
来等待子线程。
运行此示例时,可以看到不同的操作顺序,取决于每个线程让出控制权的时长:
So, we start the program here!
The tasks run concurrently!
We can chain callbacks...
...like this!
The long running tasks finish last!
因此,尽管操作系统线程适用于某些任务,但也有一些理由考虑替代方案。接下来我们将探讨第一个替代方案:fiber和绿色线程。
Fiber 和绿色线程
注释!
这是M
线程模型的一个例子,多个任务可以在一个OS线程上并发运行。Fiber和绿色线程通常被称为有堆栈协程。
“绿色线程”这个名称最初源于Java中早期实现的M
线程模型,现在也常用来指代不同的M线程实现。你可能会遇到一些变体,例如Erlang中的“绿色进程”,这与我们这里讨论的内容有所不同。还有一些定义绿色线程的范围比我们在此更宽泛。
在本书中,我们将绿色线程定义为Fiber,因此这两个术语在接下来的内容中可以互换使用。
Fiber和绿色线程的实现意味着存在一个运行时带有调度器,负责调度任务(M)在OS线程(N)上获得运行时间。任务数量远多于OS线程,并且这种系统完全可以在只有一个OS线程的情况下正常运行。这种情况通常称为M:1线程模型。
Goroutines 是一种特定的有堆栈协程实现,但存在一些细微差异。“协程”通常意味着它们具有协作性质,但Goroutines可以由调度器抢占(至少从版本1.14开始),因此它在我们这里的分类中处于某种灰色地带。
绿色线程和Fiber使用了与操作系统类似的机制:为每个任务设置一个栈,保存CPU状态,通过上下文切换在任务(线程)之间跳转。我们将控制权交给调度器(在这种系统中是运行时的核心部分),调度器然后继续运行另一个任务。执行状态存储在每个栈中,因此在这种解决方案中不需要 async
、await
、Future
或 Pin
。
绿色线程在很多方面模拟了操作系统实现并发的方式,构建它们是一个很好的学习体验。
使用Fiber/绿色线程处理并发任务的运行时具有很高的灵活性。任务可以在执行过程中的任何时间点被抢占并进行上下文切换,因此理论上,运行时间过长的任务可以被运行时抢占,以避免因极端情况或编程错误而导致系统整体被阻塞。
这使得运行时调度器几乎具备与操作系统调度器相同的能力,这是使用Fiber/绿色线程的系统的最大优势之一。
典型流程如下:
- 执行一些非阻塞代码
- 向外部资源发出阻塞调用
- CPU跳转回主线程,调度另一个线程运行,并跳转到该线程的栈
- 在新线程上运行一些非阻塞代码,直到出现新的阻塞调用或任务完成
- CPU跳回主线程,调度另一个准备继续运行的线程,并跳转到该线程
每个栈有固定空间
由于Fiber和绿色线程类似于OS线程,它们也有一些相同的缺点。每个任务都配有一个固定大小的栈,因此仍然需要分配比实际使用更多的空间。不过,这些栈可以是可增长的,即当栈满时,运行时可以扩展栈。尽管这听起来简单,但实际上解决这个问题非常复杂。
扩展栈不像生长树那么简单,通常需要以下两种方案之一:
- 分配一块新的连续内存,并处理栈分布在两个不连续内存段中的情况。
- 分配一个更大的栈(例如,将原栈大小加倍),将所有数据移动到新栈上,然后从那里继续执行。
第一个解决方案看起来简单,因为可以保持原栈不变,在需要时切换到新栈继续执行。然而,由于现代CPU利用缓存和数据预测可以极快地处理连续的内存,因此将栈分布在两个不连续内存段上会影响性能。特别是在循环恰好位于栈边界时,每次循环迭代可能需要多达两次上下文切换,这种情况尤其明显。
第二个解决方案通过将栈保持为连续内存来解决第一个方案的问题,但也带来了一些挑战:
首先,需要分配一个新栈并将所有数据移动到新栈。但当所有内容移至新位置时,指向原栈上数据的所有指针和引用会发生什么?正如你所猜测的,栈上所有内容的指针和引用都需要更新,指向新位置。这过程复杂且耗时,但如果运行时已有垃圾收集器,并已承担追踪所有指针和引用的开销,那么这种栈扩展对垃圾收集程序的影响可能会小一些。然而,每次栈扩展时都需要垃圾收集器和运行时的高度集成,因此这种运行时的实现会非常复杂。
其次,还要考虑如果存在许多长时间运行的任务,这些任务只在短时间内需要大量栈空间(例如在任务开始时有大量递归),但在其余时间主要受限于I/O操作。这种情况下,为任务的某个特定部分多次扩展栈空间,但最终要决定是否接受该任务占用的空间比所需的更多,或者在某些时候将其移回较小的栈。程序的影响会根据工作类型有很大差异,但这依然是需要注意的一个问题。
上下文切换
尽管Fiber/绿色线程相比OS线程轻量得多,但每次上下文切换时仍需保存和恢复寄存器。这在大多数情况下不会是问题,但相比不需要上下文切换的替代方案,它可能效率略低。
此外,实现正确的上下文切换也相当复杂,尤其是如果你希望支持多个不同的平台。
调度
当Fiber/绿色线程让出控制权给运行时调度器时,调度器可以简单地继续执行另一个准备运行的新任务。这意味着你不会每次让出控制权时都进入系统的公共队列中。对于操作系统来说,你的线程似乎始终在工作,因此操作系统会尽量避免抢占它们。
但这种方法的一个意想不到的缺点是:大多数操作系统调度器通过为每个OS线程分配一个时间片来确保所有线程都获得运行时间,当时间片结束后操作系统会抢占该线程并调度一个新线程到该CPU上。一个使用多个OS线程的程序可能获得更多时间片,而使用M
线程模型的程序则通常只使用少量OS线程(大多数系统上每个CPU核心一个线程是常见的起点)。因此,取决于系统上其他运行的进程,你的程序获得的总时间片可能比使用多个OS线程时更少。然而,考虑到大多数现代CPU的核心数量以及典型并发系统的工作负载,这种影响应当是微小的。
FFI(外部函数接口)
由于你创建了自己的栈空间,支持在特定条件下增长/收缩,并且可能有一个假设可以随时抢占运行任务的调度器,因此使用FFI时需要采取额外的措施。大多数FFI函数将假定一个正常的由操作系统提供的C栈,因此在Fiber/绿色线程中调用FFI函数可能会出现问题。你需要通知运行时调度器,切换到另一个OS线程,并有某种方式通知调度器任务已完成,Fiber/绿色线程可以继续。这自然增加了运行时实现者和调用FFI的用户的开销和复杂性。
优点
- 用户使用简单,代码看起来和使用OS线程时相似。
- 上下文切换相对快速。
- 内存使用相比OS线程更加高效。
- 对任务调度完全控制,并可以根据需要优先排序。
- 易于集成抢占功能,这是一种强大的特性。
缺点
- 栈需要在空间不足时扩展,增加了额外的工作和复杂性。
- 每次上下文切换时仍需保存CPU状态。
- 若要支持多个平台和/或CPU架构,实现复杂。
- FFI会带来较大的开销,并增加意外的复杂性。
基于回调的方法
注释!
这也是一种M
线程模型。多个任务可以在一个OS线程上并发运行,每个任务由一系列回调组成。
你可能已经在JavaScript中接触过基于回调的方法(大多数人对其比较熟悉)。回调方法的核心思想是保存指向一组需要稍后执行的指令的指针以及所需的状态。在Rust中,这通常是一个闭包。
在大多数语言中实现回调相对简单,不需要上下文切换,也不需要为每个任务预分配内存。然而,使用回调来表示并发操作要求从一开始就用完全不同的方式编写程序。将一个使用正常顺序程序流的程序改写为使用回调需要大量重构,反之亦然。
基于回调的并发难以理解,程序可能变得极为复杂。JavaScript开发者熟知的“回调地狱”一词并非偶然。
由于每个子任务必须保存它稍后执行所需的所有状态,因此内存使用量会随着任务中回调的数量线性增长。
优点
- 在大多数语言中易于实现
- 无需上下文切换
- 相对较低的内存开销(在大多数情况下)
缺点
- 内存使用量随着回调数量线性增长。
- 程序逻辑难以理解。
- 这是一种非常不同的编程方式,影响几乎所有程序部分,因为所有让出操作都需要回调。
- 所有权管理难以处理。没有垃圾收集器的情况下,编写基于回调的程序会非常困难。
- 由于所有权规则复杂,任务之间共享状态非常困难。
- 调试回调较为困难。
协程:promises 和 futures
注释!
这也是一种M
线程模型,多个任务可以在一个OS线程上并发运行,每个任务被表示为状态机。
JavaScript中的promises和Rust中的futures是基于相同理念的不同实现。虽然实现之间存在差异,但我们在此不做详细讨论。值得稍微介绍一下promises,因为它们在JavaScript中广泛应用,并且与Rust的futures有很多共同之处。
首先,许多语言中都有promise的概念,在以下示例中,我将使用JavaScript中的promise。
promises是一种应对回调方法复杂性的方式。
与其这样写:
setTimer(200, () => {
setTimer(100, () => {
setTimer(50, () => {
console.log("I'm the last one");
});
});
});
我们可以这样写:
function timer(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
timer(200)
.then(() => timer(100))
.then(() => timer(50))
.then(() => console.log("I'm the last one"));
后者也被称为“持续传递风格”(continuation-passing style),每个子任务完成后调用一个新的子任务。
回调和promise之间的区别在底层更加明显。promise返回一个可以处于三种状态之一的状态机:待定(pending)、已完成(fulfilled)或已拒绝(rejected)。
在上面的例子中调用 timer(200)
时,我们得到一个处于待定状态的promise。
持续传递风格确实解决了回调的一些问题,但在复杂性和编程风格的差异上仍然保留了一些问题。然而,它们使我们能够利用编译器来解决许多这些问题,我们将在下一段中讨论这一点。
协程和 async/await
协程分为两种类型:非对称协程和对称协程。非对称协程让出执行权给调度器,这是我们将重点关注的;对称协程则让出执行权给特定的目标协程。
协程作为编程语言中的对象概念,使得这种处理并发的方式在易用性上接近于OS线程和Fiber/绿色线程。在Rust或JavaScript中编写async
时,编译器会将看似正常的函数调用重写为一个future
(在Rust中)或promise
(在JavaScript中)。另一方面,await
将控制权让给运行时调度器,任务会暂停,直到所等待的future
或promise
完成。
通过这种方式,我们可以以几乎与编写正常顺序程序相同的方式来编写处理并发操作的程序。
我们的JavaScript程序现在可以这样写:
async function run() {
await timer(200);
await timer(100);
await timer(50);
console.log("I'm the last one");
}
可以将run
函数视为一个包含多个子任务的可暂停任务。在每个“await”点上,它将控制权交给调度器(在JavaScript中是事件循环)。当一个子任务的状态变为“已完成”或“已拒绝”时,调度器会将任务安排继续到下一步。
在Rust中,可以观察到类似的转换,当你编写如下内容时:
async fn run() -> () { … }
该函数会将返回对象封装起来,而不是直接返回()
类型,而是返回一个Future
,其输出类型为()
:
fn run() -> impl Future<Output = ()>
从语法上看,Rust的futures 0.1版本与我们之前展示的promise例子非常相似,而现在使用的Rust futures与JavaScript中的async/await有许多共同点。
这种将普通函数和代码重写为其他结构的方法带来了很多好处,但也有一些缺点。
与所有无堆栈协程实现一样,完全的抢占执行可能很难甚至无法实现。这些函数必须在特定点上让出控制权,不能在栈帧的中途挂起任务。这一点与Fiber/绿色线程不同。某种程度的抢占可以通过让运行时或编译器在每个函数调用前插入“抢占点”来实现,但这与能够在执行过程中的任意位置抢占任务不同。
抢占点
可以将抢占点视为插入一些代码,以调用调度器询问是否希望抢占任务。编译器或库可以在每个新的函数调用之前插入这些点。
此外,最大限度利用这一模式需要编译器支持。具有元编程能力(如宏)的语言可以模拟类似的效果,但与编译器直接支持的特性相比,效果仍不够自然。
调试也是实现futures/promises时需特别注意的领域。由于代码被重写为状态机(或生成器),你无法获得与普通函数相同的调用栈追踪。通常情况下,你可以假设调用函数的地方会在栈中和程序流中紧接着之前的部分。但在futures和promises中,可能是运行时调用了函数以推进状态机,因此可能没有可靠的回溯信息来显示调用失败函数前的执行过程。尽管有一些解决方案可以规避此问题,但大多数会引入额外开销。
优点
- 可以按通常的方式编写代码和建模程序
- 无需上下文切换
- 可实现非常高效的内存管理
- 易于支持多种平台
缺点
- 抢占执行难以实现,任务不能在栈帧中途停止
- 需要编译器支持以充分发挥其优势
- 由于非顺序的程序流和回溯信息的限制,调试较为困难
总结
还在这里吗?太好了!恭喜你完成了所有这些背景信息的学习。我知道阅读描述抽象和代码的文本可能很费劲,但希望你能明白为什么我们在书的开头先讨论这些高层主题是有价值的。很快就会进入示例部分了,我保证!
在本章中,我们详细讨论了如何通过操作系统提供的线程以及编程语言或库的抽象来建模和处理异步操作。虽然未涵盖所有内容,但我们介绍了一些最流行和广泛使用的技术,并探讨了它们的优缺点。
我们花了不少时间深入了解线程、协程、Fiber、绿色线程和回调,因此你应该对它们的定义及相互之间的区别有了清晰的认识。
下一章将深入讲解系统调用的实现、跨平台抽象的创建,以及操作系统支持的事件队列(如Epoll、Kqueue和IOCP)的原理和重要性,这些技术是大多数异步运行时的基础。