在本章中,我们将深入探讨一种非常流行的并发处理方式。没有什么比亲自实现更能让人深入理解这个主题的了。幸运的是,尽管主题有些复杂,但最终我们只需要大约 200 行代码,就可以得到一个完全可行的示例。
使这一主题变得复杂的是它需要对 CPU、操作系统和汇编有相当多的基础理解。这种复杂性也让它变得格外有趣。如果你仔细探究并逐步实现这个示例,你会对一些可能只听说过或仅有基础理解的话题有全新的认识。此外,你还将有机会接触到 Rust 语言中一些你之前可能没有见过的内容,扩展你对 Rust 和编程的知识。
我们首先介绍一些写代码前需要的背景知识。在此基础上,我们会从一些小示例入手,详细展示和讨论示例中最技术性和最难理解的部分,以便逐步介绍相关概念。最后,我们将在所获得的知识基础上创建我们的主要示例,即一个在 Rust 中实现的 Fiber 的工作示例。
此外,仓库中有两个扩展版本的示例,以激发你进一步改进、调整和构建我们所创建内容的兴趣,使其成为你自己的作品。
我将列出主要主题,以便稍后参考:
- 如何在阅读本书时使用代码仓库
- 背景知识
- 一个可供扩展的示例
- 栈
- 实现我们自己的 Fiber
- 最后的思考
注意
在本章中,我们将用"Fiber"和"绿色线程"(green threads)来指代这种具体实现的带有栈的协程(stackful coroutines)。本章代码中使用的"线程"一词将指代我们在示例中实现的绿色线程/Fiber,而非操作系统线程。
技术要求
要运行示例代码,您需要一台使用 x86-64 指令集的 CPU 电脑。如今大多数流行的桌面、服务器和笔记本电脑的 CPU 都使用该指令集,包括 Intel 和 AMD 生产的绝大多数现代 CPU(这两家厂商过去 10--15 年的大多数 CPU 型号)。
需要注意的是,现代的 M 系列 Mac 使用的是 ARM 指令集,与本章的示例代码不兼容。不过,旧款基于 Intel 的 Mac 则可以运行这些示例代码,因此如果您没有最新款 Mac,依旧可以使用它来跟随示例。
如果您手边没有符合此指令集的电脑,仍有一些方法可以安装 Rust 并运行示例代码:
- 使用 M 系列芯片的 Mac 用户可以通过 Rosetta(随新版 macOS 一起提供)运行示例,只需四个简单步骤。相关说明可以在代码仓库的
ch05/How-to-MacOS-M.md
文件中找到。 - 您还可以使用虚拟机软件,例如 UTM,或者远程租用一个运行 x86-64 架构 Linux 的服务器。我个人有使用 Linode 的经验,但市场上还有许多其他选择。
要跟随本书中的示例,您还需要一个基于 Unix 的操作系统。只要系统运行在 x86-64 CPU 上,示例代码可在任何 Linux 和 BSD 操作系统(如 Ubuntu 或 macOS)上直接运行。
如果您使用 Windows,代码仓库中也提供了可在 Windows 上运行的版本,但要完全跟随本书的内容,我强烈推荐设置 Windows Subsystem for Linux (WSL)(安装说明),安装 Rust,并在 WSL 上运行示例代码。
我个人使用 VS Code 作为编辑器,它方便地支持在 WSL 和 Windows 之间切换操作系统。只需按下 Ctrl + Shift + P
,然后搜索 Reopen folder in WSL
即可。
如何配合本书使用代码仓库
阅读本章的推荐方式是打开本书的同时也打开代码仓库。在仓库中,你会找到与本章示例对应的三个文件夹:
ch05/a-stack swap
ch05/b-show-stack
ch05/c-fibers
此外,还有两个示例在书中提到,但你可以在代码仓库中进一步探索:
ch05/d-fibers-closure
:这是第一个示例的扩展版本,可能会激发你进行更复杂的尝试。这个示例试图模仿 Rust 标准库中使用std::thread::spawn
的 API。ch05/e-fibers-windows
:这是一个示例版本,适用于 Unix 系统和 Windows 系统。在README
文件中有详细说明,描述了使该示例在 Windows 上可用的更改。如果你想深入了解该主题,这部分内容是推荐阅读,但对理解本章的主要概念并非必不可少。
背景信息
在本章中,我们将直接干预并控制 CPU。这种做法的移植性不强,因为不同的 CPU 结构各不相同。总体实现思路虽然一致,但其中一个小却关键的部分将非常依赖于目标 CPU 架构。另一个影响代码移植性的因素是操作系统的不同 ABI(应用程序二进制接口)。为了在继续深入之前确保我们对这些概念有共同理解,先来解释一下这些术语。
指令集、硬件架构和 ABI
在开始之前,我们需要了解 ABI、CPU 架构和指令集架构(ISA)之间的区别,这有助于我们编写自己的栈并让 CPU 跳转到这个栈上。幸运的是,尽管听起来复杂,但在我们的示例中只需了解几个关键点。
ISA 描述了一个抽象的 CPU 模型,定义了 CPU 如何被运行的软件控制。我们通常称之为"指令集",它规定了 CPU 可以执行的指令、程序员可以使用的寄存器、硬件如何管理内存等。例如,x86-64、x86 和 ARM ISA(Mac M 系列芯片使用)都是常见的指令集。
ISA 大致分为两种子类:复杂指令集计算机(CISC)和精简指令集计算机(RISC),区别在于指令集的复杂度。CISC 架构提供了许多硬件需要执行的不同指令,其中一些指令较为专业,程序中很少使用。相比之下,RISC 架构支持的指令较少,一些操作需要由软件处理,而在 CISC 中硬件可以直接处理。我们关注的 x86-64 指令集便是 CISC 架构的一个例子。
进一步增加一点复杂度的是,某些指令集有不同的名称。例如,x86-64 指令集也称为 AMD64 或 Intel 64 指令集,本书中我们称其为 x86-64 指令集。
小提示
可以通过在终端中运行以下命令查看系统的架构:
- 在 Linux 和 MacOS 上:
arch
或uname -m
- 在 Windows PowerShell 上:
$env:PROCESSOR_ARCHITECTURE
- 在 Windows 命令提示符上:
echo %PROCESSOR_ARCHITECTURE%
指令集仅定义程序如何与 CPU 交互。ISA 的具体实现可能因制造商而异,这种实现称为"CPU 架构",例如 Intel Core 处理器。不过在实践中,这些术语经常互换使用,因为从程序员角度看它们执行相同的功能,通常也不需要针对 ISA 的特定实现。
ISA 规定了 CPU 必须能够执行的最小指令集。随着时间的推移,出现了指令集的扩展,如流式 SIMD 扩展(SSE),它为程序员提供了更多指令和寄存器。
本章中的示例将针对 x86-64 ISA,这是一种在大多数桌面计算机和服务器中使用的流行架构。
处理器架构为程序员提供了一个接口,操作系统实现者利用这个接口来创建操作系统。
操作系统(如 Windows 和 Linux)定义了一套 ABI 规则,程序员需遵循这些规则以确保程序在平台上正常运行。例如,Linux 使用的 System V ABI 和 Windows 使用的 Win64 都是操作系统的 ABI。ABI 指定了操作系统对栈的要求、函数调用方式、文件的加载和运行方式,以及程序加载后将被调用的函数名称等。
ABI 的一个重要组成部分是调用约定(calling convention),它定义了如何使用栈和调用函数。
以 x86-64 上 Linux 和 Windows 如何处理函数参数为例,例如 fn foo(a: i64, b: i64)
:
- x86-64 指令集定义了 16 个通用寄存器。这些寄存器供程序员自由使用。注意,这里的"程序员"包括操作系统开发者,他们可以在程序运行时为寄存器规定额外的使用限制。
- Linux 规定一个有两个参数的函数应将第一个参数放入
rdi
寄存器,第二个参数放入rsi
寄存器。 - Windows 则要求前两个参数放入
rcx
和rdx
寄存器中。
这只是导致一个平台的程序无法在另一个平台上运行的许多原因之一。通常情况下,这些细节由编译器开发者处理,编译器会在为特定平台编译时自动适应不同的调用约定。
总结一下,CPU 实现了一个指令集,指令集定义了 CPU 可以执行的指令以及它应提供的程序员接口(如寄存器)。操作系统利用这些基础设施,并制定程序必须遵循的附加规则,以便在其平台上正确运行。大多数情况下,只有操作系统或编译器开发者才需要关心这些细节。不过,当我们自己编写低级代码时,需要了解 ISA 和操作系统 ABI 以确保代码正常运行。
由于我们需要编写这种代码来实现自己的 Fiber/绿色线程,因此可能需要为每种操作系统 ABI/ISA 组合编写不同的代码。这意味着我们需要为 Windows/x86-64、Windows/ARM、MacOS/x86-64、MacOS/M 等组合分别实现代码。
可以理解,这也正是使用 Fiber/绿色线程处理并发的复杂性之一。一旦为 ISA/操作系统 ABI 组合正确实现,这种方式能带来很多优势,但要做到正确实现需要付出大量工作。
本书的示例将只专注于一种组合:x86-64 架构的 System V ABI。
注意!
在随附的代码仓库中,提供了适用于 Windows x86-64 的本章主要示例版本。我们为使示例在 Windows 上运行所做的更改在 README
中有详细说明。
x86-64 的 System V ABI
如前所述,该 CPU 架构拥有 16 个通用 64 位寄存器、16 个 128 位宽的 SSE 寄存器和 8 个 80 位宽的浮点寄存器:
有些架构基于此基础进行了扩展,例如 Intel 的高级矢量扩展(AVX),它提供了额外的 16 个 256 位宽的寄存器。让我们看看 System V ABI 规范中的一页内容:
图 5.1 显示了 x86-64 架构中通用寄存器的概览。
对我们特别重要的是标记为"被调用方保存"的寄存器。这些寄存器用于在函数调用间保存上下文,包括下一个要运行的指令、基指针、栈指针等。虽然寄存器本身由 ISA 定义,但哪些寄存器需要保存的规则由 System V ABI 规定。我们稍后会详细了解这些内容。
注意
Windows 有稍微不同的约定。在 Windows 上,寄存器 XMM6:XMM15
也是被调用方保存的寄存器,因此在使用它们的情况下,必须在调用间保存和恢复它们。我们在第一个示例中编写的代码在 Windows 上可以正常运行,因为我们还没有严格遵循任何 ABI,而是集中于如何指示 CPU 执行我们的命令。
如果希望直接向 CPU 发出一组特定的命令,我们需要用汇编语言编写一些小段代码。幸运的是,第一步任务中只需掌握一些基本的汇编指令,尤其是如何将值从寄存器之间移动:
mov rax, rsp
汇编语言快速入门
首先,汇编语言的移植性较差,因为它是可供 CPU 读取的最低级别的人类可读指令,不同架构的汇编指令会有所不同。接下来,我们将仅编写面向 x86-64 架构的汇编,因此只需学习这一特定架构的一些指令即可。
在深入具体内容之前,您需要知道汇编中有两种流行的方言:AT&T 方言和 Intel 方言。
在 Rust 中,编写内联汇编时通常使用 Intel 方言,但我们可以选择使用 AT&T 方言。Rust 对内联汇编有其独特的实现方式,乍一看对习惯 C 风格内联汇编的人来说可能较为陌生。不过,这种方式设计周到,我们将在代码中详细解释,让有 C 内联汇编经验的读者和无经验的读者都能理解。
注意
我们的示例将使用 Intel 方言。
汇编具有强大的向后兼容性保障。这就是为什么我们会看到同一个寄存器有不同的表示方式。以 rax
寄存器为例,说明如下:
rax
:64 位寄存器(8 字节)eax
:rax
寄存器的低 32 位ax
:rax
寄存器的低 16 位ah
:rax
寄存器中ax
部分的高 8 位al
:rax
寄存器中ax
部分的低 8 位
如上所示,这基本上展现了 CPU 演进的历史。由于现代 CPU 大多为 64 位,我们的代码将使用 64 位版本。
汇编中的"字大小"也有历史原因,源于当时 CPU 有 16 位数据总线,所以一个"字"是 16 位。这一点很重要,因为许多指令会带有 q
(quad word,四字节)或 l
(long word,长字节)后缀。因此,movq
表示移动 4 * 16 位,即 64 位。
在现代汇编器中,普通的 mov
会使用目标寄存器的大小。这是 AT&T 和 Intel 方言中最常用的写法,我们的代码中也会采用这种写法。
还有一点要注意的是,x86-64 架构中的栈对齐为 16 字节。记住这一点,后续会用到。
一个可扩展的示例
这是一个简单示例,我们将在其中创建自己的栈,并让 CPU 退出当前执行上下文并切换到新栈。接下来的章节中将基于这些概念进行扩展。
设置项目
首先,创建一个名为 a-stack-swap
的文件夹并进入该文件夹,运行以下命令:
csharp
cargo init
小提示
你也可以直接导航到随附代码仓库中的 ch05/a-stack-swap
文件夹,查看完整示例。
在 main.rs
文件中,先导入 asm!
宏:
arduino
use core::arch::asm;
我们将栈大小设为 48 字节,这样可以在切换上下文之前打印并查看栈内容:
ini
const SSIZE: isize = 48;
注意
在 macOS 上使用如此小的栈可能会出现问题,栈大小至少需要 624 字节。若想完全按照此示例运行,可在 Rust Playground 上尝试(不过,由于我们在结尾的循环,可能需等待约 30 秒超时)。
接着,添加一个结构体表示 CPU 状态。我们暂时只关注存储栈指针的寄存器,因为这就是我们目前所需的全部:
rust
#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
rsp: u64,
}
在后续示例中,我们会使用规范文档中标记为被调用方保存的所有寄存器。这些寄存器由 System V x86-64 ABI 定义,用于保存上下文,但现在我们只需一个寄存器来让 CPU 切换到新栈。
请注意,这里需要 #[repr(C)]
,因为在汇编代码中需要以 C 语言 ABI 的方式访问数据。Rust 没有稳定的语言 ABI,因此无法确保 rsp
是内存中的第一个 8 字节。而 C 有稳定的语言 ABI,#[repr(C)]
属性就告诉编译器遵循这一规则。当然,我们的结构体目前只有一个字段,但后面会添加更多。
为了实现这个简单的示例,我们定义一个函数来打印消息并无限循环:
rust
fn hello() -> ! {
println!("I LOVE WAKING UP ON A NEW STACK!");
loop {}
}
接下来是我们的内联汇编代码,用于切换到自定义栈:
arduino
unsafe fn gt_switch(new: *const ThreadContext) {
asm!(
"mov rsp, [{0} + 0x00]",
"ret",
in(reg) new,
);
}
乍看之下,这段代码似乎没有什么特别之处,但让我们深入理解一下其作用。
回顾图 5.1,rsp
是用于指向当前栈位置的栈指针寄存器。
若要让 CPU 切换到其他栈,我们需要将栈指针寄存器(rsp
)设置为新栈的顶部,并将指令指针(rip
)指向 hello
函数的地址。
指令指针(或在不同架构中称为程序计数器)指向下一个要执行的指令。如果能直接操控它,CPU 就会取出 rip
指向的指令并执行。然后 CPU 会在新栈中推入/弹出数据,而我们的旧栈则保持不变。
x86-64 指令集中无法直接操控 rip
,因此需要一些小技巧:
- 首先,设置新栈,并在距栈顶 16 字节处写入我们希望执行的函数的地址(ABI 规定栈对齐为 16 字节)。
- 然后,将存储此地址的内存位置传递给
rsp
(new.rsp
指向新栈中存储hello
地址的内存位置)。
ret
指令会将程序控制权转移到当前栈帧顶部的返回地址。因为我们在新栈中放入了 hello
的地址,并设置 rsp
指向新栈,CPU 会认为 rsp
指向当前运行函数的返回地址,但实际上是新栈中的位置。
当 CPU 执行 ret
指令时,会从栈中弹出第一个值(即 hello
的地址),并将该地址放入 rip
寄存器中。下一个周期,CPU 将取出此函数指针指向的指令并开始执行。因为 rsp
现在指向新栈,所以接下来将使用新栈。
注意
如果此刻感到有些困惑,这非常正常。理解和掌握这些细节需要时间。目前,我们还无法恢复被替换的栈,但栈交换的技术细节与上述描述相同。
在设置新栈之前,我们将逐行解释内联汇编宏的工作原理。
Rust 内联汇编宏简介
我们将从 gt_switch
函数的主体入手,逐步解析其中的每一部分。
如果你以前没有使用过内联汇编,这可能看起来有些陌生,但稍后我们会扩展该示例以实现上下文切换,因此有必要了解其工作原理。
unsafe
是一个关键字,用来表示 Rust 无法在我们编写的函数中保证安全性。由于我们在直接操控 CPU,这显然是不安全的。该函数接收一个指向 ThreadContext
实例的指针,只读取其中一个字段:
php
unsafe fn gt_switch(new: *const ThreadContext)
接下来的 asm!
宏是 Rust 标准库中的宏,它会检查语法并在遇到看似无效的 Intel 汇编语法(默认)时提供错误信息:
arduino
asm!(
宏的第一个输入是汇编模板:
arduino
"mov rsp, [{0} + 0x00]",
这条简单指令将 {0}
内存位置的 0x00 偏移值(十六进制的无偏移值)移动到 rsp
寄存器中。rsp
通常存储指向栈顶的指针,因此我们将 hello
的地址压入当前栈顶,以便 CPU 将返回到该地址,而不是从前一个栈帧的位置恢复。
注意
如果不需要从内存位置进行偏移,我们可以简化写法为 mov rsp, [{0}]
。不过,我在此处引入偏移的概念,因为稍后在访问 ThreadContext
结构的更多字段时会用到。
Intel 语法略显反向,mov a, b
表示"将 b 移动到 a"。这是与 AT&T 语法的基本区别之一,了解这一点非常有用。
汇编模板中的 {0}
仅仅是占位符,用于替换宏的第一个输入参数。就像 println!
中的字符串格式一样,参数从 0 开始按顺序编号。{}
也可以不带编号,但标明编号会提高可读性。
[]
表示"获取该内存位置的值",可视作指针解引用操作。
概括来说,这行代码的作用是:
将 {compiler_chosen_general_purpose_register}
指向的内存位置偏移量为 0x00 的值移动到 rsp
寄存器中。
下一行 ret
指令让 CPU 从栈中弹出一个内存位置并无条件跳转到该位置。实际上,我们让 CPU 返回到自定义栈。
接下来的 in(reg) new
是第一个非汇编参数:
scss
in(reg) new,
in(reg)
告诉编译器将 new
的值存储到一个通用寄存器中,out(reg)
表示该寄存器是输出,因此如果使用 out(reg) new
,则需要将 new
声明为 mut
。此外还有 inout
和 lateout
等其他版本。
选项
在输入和输出参数之后,通常会看到 options(att_syntax)
等选项,指定汇编语法为 AT&T 等。有关更多选项的详细说明,请参考 Rust 文档: doc.rust-lang.org/nightly/ref...
内联汇编相当复杂,接下来我们会通过示例逐步讲解其工作原理。
运行示例
最后一步是编写 main
函数运行示例。我将展示完整代码并逐步解释:
rust
fn main() {
let mut ctx = ThreadContext::default();
let mut stack = vec![0_u8; SSIZE as usize];
unsafe {
let stack_bottom = stack.as_mut_ptr().offset(SSIZE);
let sb_aligned = (stack_bottom as usize & !15) as *mut u8;
std::ptr::write(sb_aligned.offset(-16) as *mut u64, hello as u64);
ctx.rsp = sb_aligned.offset(-16) as u64;
gt_switch(&mut ctx);
}
}
在该函数中,我们实际创建了新栈。hello
是指针(函数指针),因此我们可以直接将其作为 u64
进行转换(在 64 位系统上所有指针均为 64 位)。然后,我们将该指针写入新栈。
注意
栈是向下增长的。如果 48 字节的栈起始于索引 0 并结束于索引 47,那么索引 32 是距栈基址 16 字节的第一个索引。我们在栈基址 16 字节的偏移处写入指针。
解释 let sb_aligned = (stack_bottom as usize & !15) as *mut u8;
当我们像创建 Vec<u8>
那样申请内存时,无法保证得到的内存地址是 16 字节对齐的。这行代码会将内存地址向下取整到最接近的 16 字节对齐地址。如果已对齐则无变化。这样我们可以确保在栈基址上减去 16 时得到对齐的地址。
当我们运行示例(在终端中输入 cargo run
)时,将输出:
css
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target\debug\a-stack-swap`
I LOVE WAKING UP ON A NEW STACK!
小提示
由于程序在无限循环中结束,可按 Ctrl + C
退出。
所以,发生了什么?我们并未调用 hello
函数,但它依然执行了。
实际上,我们使 CPU 切换到了自定义栈,并认为是在返回某个函数,因此会读取 hello
的地址并开始执行指令。这样我们实现了上下文切换的第一步。
在接下来的部分中,我们将更详细地讨论栈,随后实现我们的 Fiber。
栈
栈只是一个连续的内存片段。
这一点很重要。计算机只有内存,并没有专门的"栈内存"或"堆内存";它们都属于相同的内存空间。
它们的区别在于访问和使用方式。栈支持简单的推/弹操作,用于连续的内存段,因此使用速度快。而堆内存是按需由内存分配器分配的,可能会分布在不同位置。
这里不再详细讨论栈和堆的区别,因为已经有大量文章详细解释了它们的不同,包括《Rust 编程语言》一书中的一章:栈和堆。
栈是什么样的?
让我们从栈的简化视图开始。64 位 CPU 每次读取 8 字节。尽管我们通常认为栈是由许多 u8
组成的长队列(如图 5.2 所示),但 CPU 更像是将其视为由 u64
组成的长队列,因为 CPU 执行加载或存储操作时,读取的最小单位是 8 字节。
当我们传递指针时,需要确保指针指向示例中的地址 0016
、0008
或 0000
。
栈向下增长,因此从栈顶开始并逐渐向下移动。当我们在 16 字节对齐的栈上设置栈指针时,需要确保栈指针指向一个 16 的倍数的地址。在这个例子中,唯一满足这一要求的地址是 0008
(记住栈从顶部开始)。
如果在上章的示例中,在 main
函数中切换之前添加以下代码行,我们可以打印出栈内容并观察:
rust
ch05/b-show-stack
for i in 0..SSIZE {
println!("mem: {}, val: {}",
sb_aligned.offset(-i as isize) as usize,
*sb_aligned.offset(-i as isize))
}
得到的输出如下:
yaml
mem: 2643866716720, val: 0
mem: 2643866716719, val: 0
mem: 2643866716718, val: 0
mem: 2643866716717, val: 0
mem: 2643866716716, val: 0
mem: 2643866716715, val: 0
mem: 2643866716714, val: 0
mem: 2643866716713, val: 0
mem: 2643866716712, val: 0
mem: 2643866716711, val: 0
mem: 2643866716710, val: 0
mem: 2643866716709, val: 127
mem: 2643866716708, val: 247
mem: 2643866716707, val: 172
mem: 2643866716706, val: 15
mem: 2643866716705, val: 29
mem: 2643866716704, val: 240
mem: 2643866716703, val: 0
mem: 2643866716702, val: 0
mem: 2643866716701, val: 0
mem: 2643866716700, val: 0
mem: 2643866716699, val: 0
...
mem: 2643866716675, val: 0
mem: 2643866716674, val: 0
mem: 2643866716673, val: 0
I LOVE WAKING UP ON A NEW STACK!
此处将内存地址打印为 u64
,方便未熟悉十六进制的读者理解。
首先,这只是一个连续的内存片段,起始地址为 2643866716673
,结束于 2643866716720
。
地址 2643866716704
到 2643866716712
对我们尤为重要 。第一个地址是我们写入 rsp
寄存器的栈指针地址。这段区域表示我们在切换前写入栈中的值。
注意
实际地址每次运行程序时都会有所不同。
换句话说,240, 205, 252, 56, 67, 86, 0, 0
表示我们以 u8
值写入栈的 hello()
函数指针。
字节序
一个有趣的副知识点是,CPU 将 u64
写为一组 u8
字节的顺序取决于其字节序。也就是说,小端序的 CPU 会将指针地址写为 240, 205, 252, 56, 67, 86, 0, 0
,而大端序的 CPU 则可能写为 0, 0, 86, 67, 56, 252, 205, 240
。可以将其类比为阅读方向的差异,例如希伯来语、阿拉伯语从右到左,而拉丁语、希腊语从左到右。只要预先知道这种差异,结果是一致的。
x86-64 架构使用小端序,因此手动解析数据时需要记住这一点。
随着我们编写更复杂的函数,48 字节的小栈空间会很快耗尽。因为我们在 Rust 中编写的函数运行时,CPU 会在新栈上推入/弹出值以执行程序,因此需要程序员确保不会溢出栈。这引出了下一个主题:栈大小。
栈大小
我们在第 2 章简要讨论过这个话题,但现在我们已经创建了自己的栈并让 CPU 切换到这个栈上,可能会更清楚地意识到其中的挑战。创建自己的绿色线程(green thread)的一大优势在于,可以自由选择为每个栈预留多少空间。
在大多数现代操作系统中,进程的默认栈大小通常是 8 MB,但可以根据需要配置。这足以满足大多数程序的需求,但编程人员仍然需要确保不要超出栈的大小。这就是我们常见的"栈溢出"问题的来源。
然而,当我们自己控制栈时,可以选择合适的大小。例如,对于运行简单函数的 Web 服务器来说,8 MB 栈空间远超所需,因此通过减少栈的大小,我们可以在一台机器上运行数百万个 fibers(绿色线程)。使用操作系统提供的栈会更快地耗尽内存。
不过,我们还是要考虑如何处理栈大小的问题。多数生产环境的系统(如 Boost.Coroutine 或 Go 中的协程)会使用分段栈或可增长的栈。为简化起见,我们将使用固定大小的栈。
实现自己的 Fiber
开始之前,我想提醒一下,接下来的代码非常不安全,在 Rust 编程中并非"最佳实践"。我们会尽量简化,同时也优先关注代码的功能性,因此可能会有很多不安全的代码,并且在这里不会严格遵循最佳实践和安全性原则。
我们首先创建一个名为 c-fibers
的新项目,并清空 main.rs
文件以获得一个空白起点。
注意
在伴随的代码仓库的 ch05/c-fibers
文件夹中也可以找到此示例。此外,ch05/d-fibers-closure
和 ch05/e-fibers-windows
示例需要通过 nightly 编译器编译,因为使用了不稳定的功能。可以通过以下两种方式实现:
- 使用
rustup override set nightly
覆盖整个目录的默认工具链(推荐此选项)。 - 每次编译或运行程序时使用
cargo +nightly run
指定 nightly 工具链。
接下来,我们将创建一个简单的运行时和调度器。每个 fiber 将保存/恢复其状态,因此可以在执行中随时暂停并继续。每个 fiber 将代表一个我们希望并发执行的任务,每次运行一个任务就创建一个新 fiber。
在代码开头启用特定功能、导入 asm
宏并定义一些常量:
ini
#![feature(naked_functions)]
use std::arch::asm;
const DEFAULT_STACK_SIZE: usize = 1024 * 1024 * 2;
const MAX_THREADS: usize = 4;
static mut RUNTIME: usize = 0;
我们启用的 naked_functions
功能允许定义裸函数(naked function)。让我们先解释一下裸函数的概念。
裸函数(NAKED FUNCTIONS)
之前讨论操作系统 ABI 和调用约定时提到,不同架构和操作系统有不同的要求。这对于创建新的栈帧(调用函数时发生的操作)尤其重要。编译器根据各架构/操作系统的要求调整栈布局和参数的位置,并保存/恢复某些寄存器,以满足当前平台的 ABI 要求。这在函数入口和出口时发生,通常称为函数序幕和尾声。
在 Rust 中,启用裸函数功能并用 #[naked]
标记函数后,表示不希望编译器创建函数序幕和尾声,而是自己处理。由于我们要切换到新栈,并可能稍后恢复原始栈,不希望编译器管理这些栈布局。这样可以确保后续代码的栈切换能够正常工作。
DEFAULT_STACK_SIZE
设为 2 MB,足够本例使用;MAX_THREADS
设为 4。最后的 RUNTIME
是运行时的指针,虽然使用可变的全局变量不优雅,但可以简化示例的重点。
接着,我们定义一些数据结构来表示将要处理的数据:
rust
pub struct Runtime {
threads: Vec<Thread>,
current: usize,
}
#[derive(PartialEq, Eq, Debug)]
enum State {
Available,
Running,
Ready,
}
struct Thread {
stack: Vec<u8>,
ctx: ThreadContext,
state: State,
}
#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
rsp: u64,
r15: u64,
r14: u64,
r13: u64,
r12: u64,
rbx: u64,
rbp: u64,
}
Runtime
是主要入口。我们将创建一个非常小的运行时,使用简单的调度器在线程间切换。运行时包含 Thread
结构体数组和一个 current
字段,用于指示当前正在运行的线程。
Thread
表示线程数据,包括栈、ctx
(表示 CPU 恢复到栈顶部时需要的上下文数据)和 state
(表示线程状态)。
State
枚举定义线程的状态:
Available
表示线程可用,可以分配任务。Running
表示线程正在运行。Ready
表示线程已准备好,可以继续执行。
ThreadContext
保存 CPU 恢复到栈上执行所需的寄存器数据。
注意
我们在 ThreadContext
结构中保存的寄存器是 Figure 5.1 中标记为"被调用方保存"的寄存器。需要保存它们,因为 ABI 要求被调用方(从操作系统的角度看是我们的 switch
函数)在恢复调用方之前必须恢复这些寄存器。
接下来,我们为新创建的线程初始化数据:
rust
impl Thread {
fn new() -> Self {
Thread {
stack: vec![0_u8; DEFAULT_STACK_SIZE],
ctx: ThreadContext::default(),
state: State::Available,
}
}
}
这段代码相对简单。新线程以 Available
状态开始,表示它已准备好被分配任务。
这里需要指出的是,我们在此处为栈分配了内存。尽管这不是必须的,也不是资源的最佳使用方式,因为为可能不需要的线程预先分配内存并不是最佳选择。不过,这样可以降低代码复杂性,便于集中注意力于更重要的部分。
注意
一旦栈分配完成,不能移动!不要在栈的向量上调用 push()
或其他可能触发重新分配的方法。如果栈重新分配,持有的指针将会失效。
值得一提的是,Vec<T>
有一个 into_boxed_slice()
方法,可以返回一个指向分配的切片 Box<[T]>
的引用。切片无法增长,因此存储切片可以避免重新分配问题。
实现运行时
首先,我们需要将运行时初始化为基础状态。以下代码段都属于 impl Runtime
块,我会在结束时提醒你,以免在分割代码时错过关闭的括号。我们首先在 Runtime
结构体上实现一个 new
函数:
rust
impl Runtime {
pub fn new() -> Self {
let base_thread = Thread {
stack: vec![0_u8; DEFAULT_STACK_SIZE],
ctx: ThreadContext::default(),
state: State::Running,
};
let mut threads = vec![base_thread];
let mut available_threads: Vec<Thread> = (1..MAX_THREADS).map(|_| Thread::new()).collect();
threads.append(&mut available_threads);
Runtime {
threads,
current: 0,
}
}
在实例化 Runtime
时,我们设置了一个基础线程(base_thread
),将其状态设置为 Running
,以确保运行时在所有任务完成前保持运行。然后,我们实例化其余的线程,并将当前线程(基础线程)设置为索引 0。
接下来,我们做了一些在 Rust 中通常不推荐的操作。我们希望在代码中的任意位置访问 Runtime
,以便随时调用 yield
。虽然有一些更安全的方法,但为了简化示例,我们将 Runtime
的指针存储在一个全局可变变量中。在调用 Runtime
的 init
函数后,我们要确保不要执行任何会导致 self
指针无效的操作。
rust
pub fn init(&self) {
unsafe {
let r_ptr: *const Runtime = self;
RUNTIME = r_ptr as usize;
}
}
这部分代码初始化运行时,并将 Runtime
的指针保存在全局变量 RUNTIME
中。接下来,我们实现 run
函数,反复调用 t_yield
直到它返回 false
,表示没有剩余任务,可以退出进程。
rust
pub fn run(&mut self) -> ! {
while self.t_yield() {}
std::process::exit(0);
}
注意
yield
是 Rust 的保留字,因此我们用 t_yield
作为函数名。
接着,我们实现 t_return
函数,用于在线程完成时调用。return
也是保留字,因此我们用 t_return
替代。用户不会直接调用这个函数,而是当任务完成时会自动调用它。
php
fn t_return(&mut self) {
if self.current != 0 {
self.threads[self.current].state = State::Available;
self.t_yield();
}
}
如果调用线程是基础线程,我们什么也不做,因为运行时会自动调用 t_yield
。如果是新创建的线程,我们将其状态设置为 Available
,并立即调用 t_yield
来调度下一个线程。
现在,我们进入运行时的核心:t_yield
函数。该函数的前半部分是调度器。我们遍历所有线程,寻找状态为 Ready
的线程(表示有可执行的任务)。如果没有找到,则任务已全部完成。我们的调度器非常简单,采用循环调度算法。
以下是 t_yield
函数的代码:
ini
#[inline(never)]
fn t_yield(&mut self) -> bool {
let mut pos = self.current;
while self.threads[pos].state != State::Ready {
pos += 1;
if pos == self.threads.len() {
pos = 0;
}
if pos == self.current {
return false;
}
}
if self.threads[self.current].state != State::Available {
self.threads[self.current].state = State::Ready;
}
self.threads[pos].state = State::Running;
let old_pos = self.current;
self.current = pos;
unsafe {
let old: *mut ThreadContext = &mut self.threads[old_pos].ctx;
let new: *const ThreadContext = &self.threads[pos].ctx;
asm!("call switch", in("rdi") old, in("rsi") new, clobber_abi("C"));
}
self.threads.len() > 0
}
然后,我们调用 switch
函数,保存当前上下文并将新上下文加载到 CPU 中。这个新上下文要么是新任务,要么是 CPU 需要恢复的任务信息。switch
函数需要两个参数,并被标记为 #[naked]
,因此不能像普通函数那样直接调用,而是使用汇编传递参数。
#[inline(never)]
阻止编译器内联该函数。如果内联该函数,可能会导致运行时在任务完成前提前退出。
更多内联汇编
汇编中的 call switch
调用函数 switch
,其中 in("rdi") old
和 in("rsi") new
将 old
和 new
的值放入 rdi
和 rsi
寄存器。System V ABI
规定,rdi
寄存器存放第一个参数,rsi
存放第二个参数。clobber_abi("C")
通知编译器,不应假设任何寄存器在 asm!
块后仍然未被修改。
self.threads.len() > 0
防止编译器优化代码,但在本示例中实际不会执行到这行代码。
接下来是 spawn
函数。代码如下:
rust
pub fn spawn(&mut self, f: fn()) {
let available = self
.threads
.iter_mut()
.find(|t| t.state == State::Available)
.expect("no available thread.");
let size = available.stack.len();
unsafe {
let s_ptr = available.stack.as_mut_ptr().offset(size as isize);
let s_ptr = (s_ptr as usize & !15) as *mut u8;
std::ptr::write(s_ptr.offset(-16) as *mut u64, guard as u64);
std::ptr::write(s_ptr.offset(-24) as *mut u64, skip as u64);
std::ptr::write(s_ptr.offset(-32) as *mut u64, f as u64);
available.ctx.rsp = s_ptr.offset(-32) as u64;
}
available.state = State::Ready;
}
} // 这里结束 `impl Runtime` 块
我们传递的函数 f
表示需要并发执行的任务。spawn
函数设置栈的内容,以确保栈的布局符合 System V ABI
的栈布局要求。首先检查是否有可用线程。如果线程已满,我们简单地触发一个错误(但可以通过更优雅的方式处理)。
如果找到可用线程,我们获取栈的长度和指针。然后使用一些不安全函数设置新栈,将 guard、skip 和 f 函数的地址写入栈。最后,设置 rsp
寄存器指向 f
的地址,以便执行该任务。
至此,我们已完成 Runtime
的实现。理解这一部分基本就掌握了 fibers(绿色线程)的工作原理。
Guard、Skip 和 Switch 函数
在 Runtime
中,我们提到了一些关键函数,实际实现起来非常简单。其中大部分函数都很容易理解。我们从 guard
函数开始:
csharp
fn guard() {
unsafe {
let rt_ptr = RUNTIME as *mut Runtime;
(*rt_ptr).t_return();
};
}
guard
函数在传入的 f
函数完成时调用。当 f
返回时,表示任务已完成,因此我们取消引用 Runtime
并调用 t_return()
。如果线程完成后需要做额外工作,可以在这里实现,但当前 t_return()
已经满足需求,标记线程为 Available
并让出控制权,以便继续执行其他线程。
接下来是 skip
函数:
csharp
#[naked]
unsafe extern "C" fn skip() {
asm!("ret", options(noreturn))
}
skip
函数只包含 ret
指令,用于从栈中弹出下一个地址并跳转。在这里,这个地址是 guard
函数的地址。#[naked]
属性使函数不会生成额外的指令,只保留 ret
指令。
接下来是辅助函数 yield_thread
:
rust
pub fn yield_thread() {
unsafe {
let rt_ptr = RUNTIME as *mut Runtime;
(*rt_ptr).t_yield();
};
}
yield_thread
函数允许在代码的任意位置调用 Runtime
的 t_yield
,无需直接引用 Runtime
。它在运行时尚未初始化或已经被释放的情况下调用会导致未定义行为,但这里我们优先简化示例。
最后是 switch
函数,负责进行上下文切换。代码如下:
csharp
#[naked]
#[no_mangle]
unsafe extern "C" fn switch() {
asm!(
"mov [rdi + 0x00], rsp",
"mov [rdi + 0x08], r15",
"mov [rdi + 0x10], r14",
"mov [rdi + 0x18], r13",
"mov [rdi + 0x20], r12",
"mov [rdi + 0x28], rbx",
"mov [rdi + 0x30], rbp",
"mov rsp, [rsi + 0x00]",
"mov r15, [rsi + 0x08]",
"mov r14, [rsi + 0x10]",
"mov r13, [rsi + 0x18]",
"mov r12, [rsi + 0x20]",
"mov rbx, [rsi + 0x28]",
"mov rbp, [rsi + 0x30]",
"ret", options(noreturn)
);
}
switch
函数保存当前上下文(寄存器值)并将它们加载到新的线程上下文中。我们使用 #[naked]
属性避免函数生成多余的指令,并通过 options(noreturn)
告诉编译器此函数不会返回,且我们手动添加 ret
指令来确保正常退出。
主函数实现
最后,我们实现主函数以测试运行时,代码如下:
ini
fn main() {
let mut runtime = Runtime::new();
runtime.init();
runtime.spawn(|| {
println!("THREAD 1 STARTING");
let id = 1;
for i in 0..10 {
println!("thread: {} counter: {}", id, i);
yield_thread();
}
println!("THREAD 1 FINISHED");
});
runtime.spawn(|| {
println!("THREAD 2 STARTING");
let id = 2;
for i in 0..15 {
println!("thread: {} counter: {}", id, i);
yield_thread();
}
println!("THREAD 2 FINISHED");
});
runtime.run();
}
运行 cargo run
应产生以下输出:
arduino
THREAD 1 STARTING
thread: 1 counter: 0
THREAD 2 STARTING
thread: 2 counter: 0
thread: 1 counter: 1
thread: 2 counter: 1
...
thread: 2 counter: 13
thread: 2 counter: 14
THREAD 2 FINISHED.
输出显示线程在每次计数后交替执行,直到 THREAD 1
完成,THREAD 2
执行完剩余计数。
完结感想
在结束本章前,我想重温一下这种方法的优缺点。在第二章我们就讨论过这些,但现在我们有了实际经验来体会这些特点。
首先,我们在这里实现的是一种有堆栈协程的例子。每个协程(在例子中我们称之为线程)都有自己的栈。这也意味着我们可以在任意时刻中断和恢复执行。即便是在一个函数的中途(栈帧的中途)也可以保存当前状态到栈,然后切换到另一个栈,恢复所需的状态,继续执行仿佛什么都没发生过。
这同时意味着我们需要管理栈。在示例中,我们创建了一个静态栈(就像请求 OS 线程时 OS 分配的栈,但更小)。但如果要比 OS 线程更高效地运行,我们需要选择一种策略来解决栈管理的潜在问题。
如果你查看 ch05/d-fibers-closure 中稍微扩展的示例,你会发现我们可以使 API 非常易用,类似于标准库中 std::thread::spawn
的 API。当然,缺点是要在所有 ISA/ABI 组合上正确实现这类 API 是一项非常复杂的工作。尤其在 Rust 中,没有语言原生支持时,为这种有堆栈协程创建一个安全、好用的 API 是一项巨大的挑战。
将其与第三章讨论的事件队列和非阻塞调用联系起来,如果你使用纤程来处理并发,那么在非阻塞调用中设置读取兴趣后会调用 yield
。通常,运行时会提供这些非阻塞调用,用户不会直接接触到 yield
的实现,纤程会在该点被挂起。可能还需要在 State
枚举中增加一个状态,比如 Pending
,表示线程正在等待外部事件。
当操作系统信号通知数据已准备好时,我们会将线程标记为 State::Ready
,调度器会像本例中一样恢复执行。虽然需要一个更复杂的调度器和基础设施,但希望通过本章的内容,你能对这种系统如何实际工作有个初步了解。
总结
首先,祝贺你!你现在已经实现了一个简单但有效的纤程示例。你设置了自己的栈,并学到了 ISA、ABI、调用约定以及 Rust 中的内联汇编知识。
如果你走到了这里并阅读了所有内容,你应该为自己感到骄傲。这不是一件简单的事情,但你做到了。本例(以及本章)可能需要一点时间来完全消化,但不要急。你可以随时回到本例,重新阅读代码并深入理解。我强烈建议你亲自尝试改变代码,了解它的运行机制。你可以尝试更改调度算法,为创建的线程添加更多上下文,充分发挥你的想象力。
在这种低级代码中调试问题可能会非常困难,但这是学习过程的一部分,你随时可以回滚到可运行的版本。
现在我们已经覆盖了本书中最大的例子之一,接下来我们将学习另一种流行的并发处理方式------深入了解 Rust 中的 futures
和 async/await
。本书的剩余部分将专注于学习 futures
和 async/await
,而现在我们已拥有了坚实的基础知识,理解它们的工作原理将变得更加容易。到目前为止,你的表现非常出色!