Rust-异步编程-全-

Rust 异步编程(全)

原文:annas-archive.org/md5/e9b457967002d38e04727726f283d753

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书中的内容最初是为那些希望从零开始使用 Rust 学习异步编程的程序员编写的一系列较短的书籍。我发现当时我所遇到的材料在挫败感、启发性和困惑感上各占一半,所以我想要做些改变。

那些较短的书籍变得流行起来,所以当我有机会再次写一切,改进我满意的部分,并完全重写其他所有内容,将其整合成一本单一、连贯的书籍时,我不得不这么做。结果就在你面前。

人们开始编程的原因多种多样。科学家开始编程是为了建模问题和进行计算。商业专家创建程序来解决特定问题,这些问题有助于他们的业务。有些人开始编程作为一种爱好或在业余时间。这些程序员共同的特点是他们从上到下学习编程。

大多数情况下,这完全没问题,但在异步编程这一普遍话题,尤其是在 Rust 语言中,从基本原理学习这一主题具有明显的优势,本书旨在提供一种实现这一目标的方法。

异步编程是一种编写程序的方式,你将程序划分为可以在特定点停止和恢复的任务。这反过来又允许语言运行时或库驱动和调度这些任务,使它们的进度交错。

异步编程由于其本质,将影响整个程序流程,并且非常具有侵入性。它以对你作为程序员来说并不总是明显的方式重写、重新排序和调度你编写的程序。

大多数编程语言都试图使异步编程变得如此简单,以至于你不必真正了解它是如何工作的,就可以在其中高效工作。

你可以在几乎不了解异步 Rust 是如何工作的同时,相当高效地编写异步 Rust,但 Rust 比大多数其他语言更明确,并向程序员暴露了更多的复杂性。如果你对异步编程有一个深入的理解,以及当你编写异步 Rust 时真正发生了什么,你将更容易处理这种复杂性。

另一个巨大的优势是,从基本原理学习得出的知识可以应用于 Rust 之外,而且它反过来会使在其他语言中学习异步编程变得更加容易。我甚至可以说,这些知识中的大部分在日常编程中也会很有用。至少,对我来说是这样的。

我希望这本书能让你感觉像是加入了我的旅程,我们在每个知识主题上逐步构建知识,并通过创建示例和实验来学习。我不想让这本书感觉像是一位讲师简单地告诉你一切是如何工作的。

本书是为那些天生好奇的人而创作的,这类程序员希望理解他们使用的系统,并喜欢通过创建小实验和大实验来探索和学习。

本书面向的对象

本书面向那些有一定编程经验,希望从零开始学习异步编程的开发者,以便他们能够熟练掌握异步 Rust,并能够参与关于该主题的技术讨论。对于那些喜欢编写可以拆解、扩展和实验的工作示例的人来说,本书是完美的。我认为这本书对以下两种角色特别相关:

  • 来自具有垃圾收集器、解释器或运行时的高级语言(如 C#、Java、JavaScript、Python、Ruby、Swift 或 Go)的开发者。那些在这些语言中具有丰富异步编程经验,但希望从零开始学习它,以及那些没有异步编程经验的程序员,都应该发现这本书同样有用。

  • 在 C 或 C++等语言方面有经验,但对异步编程经验有限的开发者。

本书涵盖的内容

第一章并发与异步编程:详细概述,提供了关于我们今天使用的异步编程类型的历史简短介绍。我们给出了几个重要的定义,并提供了一个心理模型来解释异步编程真正解决的问题,以及并发与并行之间的区别。我们还讨论了在讨论异步程序流程时选择正确参考框架的重要性,并介绍了关于 CPU、操作系统、硬件、中断和 I/O 的几个重要和基本概念。

第二章编程语言如何模拟异步程序流程,将范围从上一章缩小,专注于编程语言处理异步编程的不同方式。它首先给出了几个重要的定义,然后解释了堆栈式和无堆栈协程、操作系统线程、绿色线程、纤程、回调、承诺、未来和 async/await。

第三章理解操作系统支持的事件队列、系统调用和跨平台抽象,解释了 epoll、kqueue 和 IOCP 是什么以及它们之间的区别。通过介绍系统调用、FFI 和跨平台抽象,为下一章做准备。

第四章创建您自己的事件队列,在这一章中,你将创建自己的事件队列,该队列模仿了mio(支撑当前异步生态系统大部分内容的流行 Rust 库)的 API。示例将围绕 epoll 展开,并详细介绍其工作原理。

第五章创建我们自己的 Fibers,通过一个示例展示了我们创建自己的堆栈式协程,称为 fibers。它们与 Go 使用的绿色线程相同,展示了目前 Rust 使用 futures 和 async/await 进行抽象的最常见和最受欢迎的替代方案之一。Rust 在达到 1.0 之前就使用了这种抽象,因此它也是 Rust 历史的一部分。本章还将涵盖许多通用编程概念,如堆栈、汇编、应用程序二进制接口ABIs)和指令集架构ISAs),这些概念在异步编程之外也很有用。

第六章Rust 中的 Futures,提供了对 futures、运行时和 Rust 中异步编程的简要介绍和概述。

第七章协程和 async/await,是一个你可以编写自己的协程的章节,这些协程是 Rust 中 async/await 创建的协程的简化版本。我们将手动编写几个,并介绍一种新的语法,允许我们将看起来像常规函数的内容程序化地重写为我们手动编写的协程。

第八章运行时、唤醒器和反应器-执行器模式,介绍了运行时和运行时设计。通过迭代我们在第七章中创建的示例,我们将为我们的协程创建一个运行时,我们将逐步改进它。完成之后,我们还会对我们的运行时进行一些实验,以更好地理解其工作原理。

第九章协程、自引用结构和 Pinning,是我们介绍 Rust 中的自引用结构和 Pinning 的章节。通过进一步改进我们的协程,我们将亲身体验为什么我们需要像Pin这样的东西,以及它是如何帮助我们解决我们遇到的问题的。

第十章创建您自己的运行时,是我们最终将所有部件组合在一起的一章。我们将进一步改进前几章中的相同示例,以便我们可以运行 Rust 的 future,这将使我们能够使用 async/await 和异步 Rust 的全部功能。我们还将进行一些实验,展示异步 Rust 的一些困难以及我们如何最好地解决这些问题。

为了充分利用这本书

你应该有一些先前的编程经验,并且最好有一些关于 Rust 的知识。阅读免费的、优秀的入门书籍《Rust 编程语言》(doc.rust-lang.org/book/)应该会给你提供足够多的关于 Rust 的知识,以便跟随,因为任何高级主题都会一步一步地解释。

阅读本书的理想方式是将书本和代码编辑器并排打开。您还应该有伴随的仓库可用,以便在遇到任何问题时可以参考。

本书涵盖的软件/硬件 操作系统要求
Rust (版本 1.51 或更高) Windows, macOS, 或 Linux

您需要安装 Rust。如果您还没有安装,请按照以下说明进行操作:www.rust-lang.org/tools/install

一些示例可能需要您在 Windows 上使用Windows Subsystem for LinuxWSL)。如果您在 Windows 机器上跟随,我建议您现在启用 WSL(learn.microsoft.com/en-us/windows/wsl/install),并按照以下安装 Rust 的说明进行安装:www.rust-lang.org/tools/install

如果您使用的是本书的数字版,我们建议您自己输入代码或从书本的 GitHub 仓库(下一节中有一个链接)访问代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

伴随的仓库按照以下方式组织:

  • 属于特定章节的代码位于该章节的文件夹中(例如,ch01)。

  • 每个示例都作为一个单独的 crate 组织。

  • 示例名称前的字母表示不同示例在书中的呈现顺序。例如,a-runtime示例在b-reactor-executor示例之前。这样,它们将按时间顺序排列(至少在大多数系统上默认是这样)。

  • 一些示例有一个后缀为-bonus的版本。这些版本将在书中提到,通常包含一个可能值得检查但不是当前主题重要性的示例的具体变体。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Asynchronous-Programming-in-Rust。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“因此,我们现在已经创建了自己的异步运行时,它使用了 Rust 的FuturesWakerContextasync/await。”

代码块设置为如下:

pub trait Future {
    type Output;
    fn poll(&mut self) -> PollState<Self::Output>;
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

struct Coroutine0 {
    stack: Stack0,
    state: State0,
}

任何命令行输入或输出都应如下编写:

$ cargo run

小贴士或重要注意事项

它看起来像这样。

联系我们

我们读者的反馈始终受到欢迎。

一般反馈:如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Rust 中的异步编程》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但又无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问链接

packt.link/free-ebook/9781805128137

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

第一部分:异步编程基础

在本部分,您将全面了解并发与异步编程。我们还将探讨编程语言采用的各种技术来模拟异步性,包括最流行的技术,并讨论与每种技术相关的优缺点。最后,我们将解释基于操作系统的事件队列的概念,例如 epoll、kqueue 和 IOCP,详细说明如何使用系统调用来与操作系统交互,并解决在创建跨平台抽象(如 mio)时遇到的挑战。本节包括以下章节:

  • 第一章, 并发与异步编程:详细概述

  • 第二章, 编程语言如何模拟异步程序流程

  • 第三章**, 理解基于操作系统的事件队列、系统调用和跨平台抽象

第一章:并发与异步编程:详细概述

异步编程是许多程序员觉得令人困惑的话题之一。当你认为自己已经掌握了它时,你可能会后来意识到这个兔子洞比你想象的要深得多。如果你参与讨论,听了足够的讲座,并在互联网上阅读有关这个主题的内容,你可能会遇到似乎相互矛盾的说法。至少,这描述了我第一次接触这个主题时的感受。

这种混淆的原因通常是缺乏上下文,或者作者在没有明确说明的情况下假设了特定的上下文,再加上围绕并发和异步编程的术语定义得相当糟糕。

本章我们将涵盖很多内容,并将内容分为以下主要主题:

  • 异步编程历史

  • 并发与并行

  • 操作系统与 CPU

  • 中断、固件和 I/O

本章内容较为通用。它并不特别关注Rust,或者任何特定的编程语言,但这是我们需要的背景信息,以便我们知道每个人在前进的道路上都在同一页面上。好处是,这将对任何编程语言都很有用。在我看来,这也使得这一章成为本书中最有趣的一章之一。

本章代码不多,所以我们从轻松的开始。这是一个泡一杯茶、放松并让自己舒适的时候,因为我们将一起开始这段旅程。

技术要求

所有示例都将使用 Rust 编写,并且你有两种运行示例的替代方案:

  • 在 Rust playground 上编写和运行我们将编写的示例

  • 在你的机器上安装 Rust 并本地运行示例(推荐)

阅读本章的理想方式是克隆随附的仓库(github.com/PacktPublishing/Asynchronous-Programming-in-Rust/tree/main/ch01/a-assembly-dereference),打开ch01文件夹,并在阅读本书时保持打开状态。在那里,你可以找到本章中我们编写的所有示例,甚至还有一些你可能觉得有趣的信息。当然,如果你现在无法访问,你当然也可以稍后回到仓库。

多任务处理的进化之旅

在最初,计算机只有一个 CPU,它会逐个执行程序员编写的指令集。没有操作系统OS),没有调度,没有线程,没有多任务处理。这就是计算机长时间以来的工作方式。我们说的是当程序在穿孔卡片堆栈上汇编的时候,如果你不幸将卡片堆叠掉到地上,那可就麻烦了。

在 20 世纪 80 年代个人计算开始增长时,操作系统如 DOS 在大多数消费级 PC 上成为标准。

这些操作系统通常将整个 CPU 的控制权交给当前正在执行的程序,程序员需要确保程序正常运行并实现任何类型的多任务处理。这没问题,但随着使用鼠标和窗口式操作系统的交互式用户界面成为常态,这种模式已经无法再继续下去了。

非抢占式多任务处理

非抢占式多任务处理是第一个能够保持用户界面交互性(并运行后台进程)的方法。

这种多任务处理将操作系统运行其他任务(如响应用户鼠标输入或运行后台任务)的责任交给了程序员。

通常,程序员会将控制权“让出”给操作系统。

除了将巨大的责任转嫁给为你的平台编写程序的每个程序员之外,这种方法自然容易出错。程序代码中的一个小错误可能导致整个系统停止或崩溃。

注意

我们所说的非抢占式多任务处理的另一个流行术语是协作式多任务处理。Windows 3.1 使用了协作式多任务处理,并要求程序员通过使用特定的系统调用来将控制权让给操作系统。一个表现不佳的应用程序可能会因此使整个系统停止。

抢占式多任务处理

虽然非抢占式多任务处理听起来是个好主意,但它实际上也带来了严重的问题。让每个程序和程序员负责在操作系统中拥有响应式的用户界面,最终可能导致糟糕的用户体验,因为任何一个错误都可能导致整个系统停止。

解决方案是将调度 CPU 资源之间程序的责任(包括操作系统本身)交给了操作系统。操作系统可以停止一个进程的执行,做其他事情,然后再切换回来。

在这样的系统中,如果你在一台单核机器上编写并运行一个具有图形用户界面的程序,操作系统会在切换回你的程序继续之前停止你的程序以更新鼠标位置。这种情况发生的频率很高,以至于我们通常无法观察到 CPU 是否有很多工作或空闲。

操作系统负责调度任务,这是通过在 CPU 上切换上下文来实现的。这个过程可以每秒发生多次,不仅为了保持用户界面的响应性,还为了给其他后台任务和 I/O 事件留出时间。

这现在已经成为设计操作系统的主流方式。

注意

在本书的后面部分,我们将编写自己的绿色线程,并涵盖许多关于上下文切换、线程、堆栈和调度等基础知识,这将使你对这个主题有更深入的了解,所以请保持关注。

超线程技术

随着 CPU 的发展,增加了更多功能,例如几个算术逻辑单元ALUs)和额外的逻辑单元,CPU 制造商意识到整个 CPU 并没有得到充分利用。例如,当一个操作只需要 CPU 的部分功能时,可以在 ALU 上同时运行一条指令。这成为了超线程的起点。

例如,你今天的电脑可能有 6 个核心和 12 个逻辑核心。这正是超线程发挥作用的地方。它通过使用 CPU 的未使用部分来驱动线程2的进度,并同时运行线程1上的代码,从而“模拟”同一核心上的两个核心。它是通过使用一些智能技巧(例如与 ALU 相关的技巧)来做到这一点的。

现在,通过使用超线程,我们实际上可以在一个线程上卸载一些工作,同时通过在第二个线程中响应事件来保持用户界面交互性,即使我们只有一个 CPU 核心,从而更好地利用我们的硬件。

你可能会对超线程的性能感到好奇

事实上,自 90 年代以来,超线程技术一直在不断改进。由于你实际上并没有运行两个 CPU,因此将会有一些操作需要等待彼此完成。与单核中的多任务处理相比,超线程的性能提升似乎接近 30%,但这在很大程度上取决于工作负载。

多核处理器

如大多数人所知,处理器的时钟频率已经很长时间没有提升了。处理器通过改进缓存分支预测推测执行,以及通过优化处理器的处理流水线来变快,但收益似乎正在减少。

另一方面,新的处理器非常小,这使我们能够在同一芯片上拥有许多处理器。现在,大多数 CPU 都有多个核心,而且通常每个核心也都有执行超线程的能力。

你真的编写了同步代码吗?

就像许多事情一样,这取决于你的视角。从你的进程和编写的代码的角度来看,一切通常都会按照你编写的顺序发生。

从操作系统的角度来看,它可能会或可能不会中断你的代码,暂停它,并在恢复你的进程之前在同时运行一些其他代码。

从 CPU 的角度来看,它通常会一次执行一条指令。*尽管它不在乎谁编写了代码,但当发生硬件中断时,它会立即停止并将控制权交给中断处理程序。这就是 CPU 处理并发的方式。

注意

*然而,现代 CPU 也可以并行执行很多事情。大多数 CPU 都是流水线化的,这意味着在当前指令执行的同时,会加载下一条指令。它可能有一个分支预测器,试图确定接下来要加载哪些指令。

如果处理器认为通过使用乱序执行可以使事情更快,而不需要“询问”或“告诉”程序员或操作系统,那么您可能无法保证 A 发生在 B 之前。

CPU 将一些工作卸载到单独的“协处理器”上,例如用于浮点计算的 FPU,这样主 CPU 就可以准备执行其他任务等等。

从高层次概述来看,将 CPU 模拟为以同步方式运行是可以的,但在此,我们只需在脑海中记下这是一个存在一些限制条件的模型,这些限制条件在讨论并行性、同步原语(如互斥锁和原子操作)以及计算机和操作系统的安全性时尤为重要。

并发与并行性

我们将立即深入探讨这个主题,首先定义并发是什么。由于很容易将并发并行混淆,我们将从一开始就尝试在这两者之间做出明确的区分。

重要

并发是关于处理很多事情。

并行性是关于同时做很多事情。

我们将同时推进多个任务的概念称为多任务处理。有两种多任务处理的方式。一种是通过并发推进任务,但不是同时进行。另一种是在并行中同时推进任务。图 1.1展示了这两种场景之间的区别:

图 1.1 – 同时处理两个任务

图 1.1 – 同时处理两个任务

首先,我们需要就一些定义达成一致:

  • 资源:这是我们能够推进任务所需要的东西。我们的资源是有限的。这可能是 CPU 时间或内存。

  • 任务:这是一组需要某种资源来推进的操作。一个任务必须由几个子操作组成。

  • 并行:这是在确切同一时间独立发生的事情。

  • 并发:这些是在同一时间进行中的任务,但不一定是同时进行的。

这是一个重要的区分。如果两个任务并发运行,但不是并行运行,它们必须能够停止并恢复其进度。我们说一个任务是可中断的,如果它允许这种类型的并发。

我使用的心理模型

我坚信,我们觉得并行和并发编程难以区分的主要原因,源于我们如何模拟日常生活中的事件。我们往往对这些术语定义得比较宽松,因此我们的直觉往往是不正确的。

注意

并没有帮助的是,并发在词典中被定义为同时进行或发生,这并没有真正帮助我们描述它与并行的区别。

对于我来说,当我开始理解为什么我们最初想要区分并行和并发时,这第一个想法就产生了!

为什么与资源利用和效率息息相关。

效率是(通常可以衡量的)避免在做事或产生期望结果时浪费材料、能源、努力、金钱和时间的能力。

并行性是增加我们用来解决任务的资源。它与效率无关。

并发性与效率和资源利用密切相关。并发性永远不能让单个任务更快地完成。它只能帮助我们更好地利用资源,从而更快地完成一系列任务。

让我们通过流程经济学来类比一下

在制造商品的企业中,我们经常谈论精益流程。这很容易与程序员为什么如此关心如果我们并发处理任务我们能实现什么进行比较。

让我们假设我们经营一家酒吧。我们只提供健力士啤酒,没有其他选择,但我们把健力士啤酒做得尽善尽美。是的,我知道,这有点小众,但请耐心听我说。

你是这个酒吧的经理,你的目标是尽可能高效地经营它。现在,你可以把每个酒保想象成一个CPU 核心,每个订单想象成一个任务。为了管理这个酒吧,你需要知道如何服务一杯完美的健力士啤酒的步骤:

  • 将健力士啤酒倒入倾斜 45 度的玻璃杯中,直到杯子的 3/4 满(15 秒)。

  • 让激增持续 100 秒。

  • 将玻璃杯完全倒满(5 秒)。

  • 上菜。

由于酒吧里只有一种东西可以点,顾客只需要用手指表示他们想要点多少,所以我们假设接收新订单是瞬时的。为了简化问题,支付也是如此。在经营这家酒吧时,你有几种选择。

选项 1 – 与一名酒保完全同步的任务执行

你开始时只有一名酒保(CPU)。酒保接收一个订单,完成它,然后继续下一个。队伍已经走出门口,沿着街道走了两块街区——太棒了!一个月后,你几乎要破产了,你不知道为什么。

好吧,即使你的酒保在接收新订单方面非常快,他们每小时也只能服务 30 名顾客。记住,他们在啤酒沉淀时要等待 100 秒,他们实际上只是在站着,他们只用了 20 秒来真正倒满玻璃杯。只有当一项订单完全完成后,他们才能继续服务下一位顾客并接收他们的订单。

结果是收入不佳,顾客愤怒,成本高昂。这是不可行的。

选项 2 – 并行和同步任务执行

因此,你雇佣了 12 名酒保,你计算你可以每小时服务大约 360 名顾客。现在队伍几乎要走出门口了,收入看起来很可观。

一个月过去了,你几乎要破产了。这怎么可能呢?

结果是,拥有 12 名酒保相当昂贵。尽管收入很高,但成本更高。向问题投入更多资源并不能真正使酒吧更有效率。

选项 3 – 使用一个酒保的异步任务执行

因此,我们又回到了起点。让我们仔细思考,找到一种更聪明的工作方式,而不是简单地投入更多资源。

您询问您的酒保是否可以在啤酒沉淀时开始接受新订单,这样他们就不会在有顾客需要服务时只是站立等待。开业之夜到来了...

哇!在一个忙碌的夜晚,酒保连续工作几个小时,您计算得出他们现在处理一个订单只需要超过 20 秒。您基本上消除了所有等待时间。您的理论吞吐量现在是每小时 240 杯啤酒。如果您再增加一个酒保,您的吞吐量将超过拥有 12 个酒保时的吞吐量。

然而,您意识到您实际上并没有达到每小时 240 杯啤酒的产量,因为订单的到达有些不规则,并且不是均匀分布的。有时,酒保忙于新订单,阻止他们补充和为几乎立即完成的啤酒服务。在现实生活中,吞吐量只有每小时 180 杯。

然而,两个酒保以这种方式每小时可以服务 360 杯啤酒,这与您雇佣 12 个酒保时的服务量相同。

这很好,但您会问自己是否还能做得更好。

选项 4 – 使用两个酒保的并行和异步任务执行

如果您雇佣两个酒保,并要求他们只做我们在选项 3 中描述的事情,但有一个变化:您允许他们互相偷取任务,这样酒保 1就可以开始倒酒并将啤酒放下让其沉淀,而酒保 2可以在酒保 1忙于倒新订单时补充啤酒并服务。这样,两个酒保同时忙碌的情况很少发生,因为其中一杯正在进行的啤酒已经准备好补充并服务。几乎所有的订单都以可能的最短时间完成并服务,让顾客更快地离开酒吧,为想要下新订单的顾客腾出空间。

现在,这样,您可以进一步提高吞吐量。您仍然无法达到理论上的最大值,但您会非常接近。在开业之夜,您意识到酒保现在每小时处理 230 个订单,总吞吐量为每小时 460 杯啤酒。

收入看起来不错,顾客很满意,成本保持在最低,您是地球上最奇怪的酒吧(尽管是一个效率极高的酒吧)的快乐的经理。

关键要点

并发是关于更聪明地工作。并行是一种向问题投入更多资源的方式。

并发及其与 I/O 的关系

如您从我所写的内容中理解的那样,编写异步代码主要在您需要聪明地充分利用资源时才有意义。

现在,如果你编写一个努力解决问题的程序,通常在并发方面没有帮助。这就是并行性发挥作用的地方,因为它给你提供了一个方法,如果你可以将问题分解成可以并行工作的部分,你可以向问题投入更多的资源。

考虑以下两个不同的并发用例:

  • 当执行 I/O 并且你需要等待某些外部事件发生时

  • 当你需要分散你的注意力,防止一个任务等待太长时间

第一个是一个经典的 I/O 示例:你必须等待网络调用、数据库查询或其他事情发生,然后你才能继续任务。然而,你有许多任务要做,所以你不必等待,你可以继续在其他地方工作,或者定期检查任务是否准备好继续,或者确保你在任务准备好继续时得到通知。

第二个例子是在有 UI 时经常发生的情况。让我们假设你只有一个核心。在执行其他 CPU 密集型任务时,你如何防止整个 UI 变得无响应?

好吧,你可以每 16 毫秒停止你正在做的任何任务,运行更新 UI任务,然后继续你之后正在做的事情。这样,你将每秒需要停止/恢复你的任务 60 次,但你也会有一个响应速度大约为 60 赫兹的完全响应式 UI。

操作系统提供的线程怎么样?

当我们谈到处理 I/O 的策略时,我们将在本书的后面部分更详细地介绍线程,但我也将在这里提及它们。使用操作系统线程理解并发的一个挑战是,它们似乎被映射到核心上。这不一定是一个正确的心理模型来使用,尽管大多数操作系统都会尝试将一个线程映射到一个核心上,直到线程数量等于核心数量。

一旦我们创建的线程数量超过了核心数量,操作系统将在这我们的线程之间切换,并使用其调度器并发地处理每个线程,以给每个线程一些运行的时间。你也必须考虑这样一个事实,你的程序并不是系统中唯一运行的程序。其他程序也可能创建多个线程,这意味着 CPU 上的线程数量将远多于核心数量。

因此,线程可以是并行执行任务的手段,但它们也可以是实现并发的手段。

这让我想到了关于并发的最后一部分。它需要在某种参考系中定义。

选择正确的参考系

当你编写的代码从你的角度来看是完美同步的,停顿一下,考虑一下从操作系统的角度来看这会是什么样子。

操作系统可能根本不会从头到尾运行你的代码。它可能会多次停止和恢复你的进程。CPU 可能会被中断并处理一些输入,而你认为它只专注于你的任务。

因此,同步执行只是一个错觉。但从程序员的角度来看,它不是,这是重要的启示:

当我们谈论并发而不提供任何其他上下文时,我们是以程序员和你的代码(你的进程)作为参考框架。如果你开始思考并发而不将这一点放在心中,它会很快变得混乱。

我之所以花这么多时间在这上面,是因为一旦你意识到拥有相同定义和相同参考框架的重要性,你就会开始发现,你所听到和学到的一些看似矛盾的事情实际上并不是。你只需首先考虑参考框架即可。

异步与并发

因此,你可能想知道,当这本书是关于异步编程时,我们为什么花这么多时间谈论多任务处理、并发和并行性。

主要原因在于,所有这些概念彼此之间都紧密相关,甚至可以根据它们被使用的上下文具有相同(或重叠)的含义。

为了使定义尽可能清晰,我们将比通常更狭窄地定义这些术语。然而,请记住,我们无法取悦每个人,我们这样做是为了使主题更容易理解。另一方面,如果你喜欢热烈的互联网辩论,这是一个很好的开始。只需声称某个人的并发定义是 100%错误的,或者你的定义是 100%正确的,然后你就可以开始了。

为了本书的目的,我们将坚持这个定义:异步编程是编程语言或库对并发操作进行抽象的方式,以及我们作为语言或库的用户如何使用这种抽象来并发执行任务。

操作系统已经有一个现有的抽象,称为线程。使用操作系统线程来处理异步操作通常被称为多线程编程。为了避免混淆,我们不会直接将使用操作系统线程称为异步编程,即使它解决了相同的问题。

由于异步编程现在被定义为在语言或库中对并发或并行操作进行抽象,因此也更容易理解,它在没有操作系统的嵌入式系统上与针对具有高级操作系统的复杂系统的程序一样相关。定义本身并不暗示任何特定的实现,尽管我们将在本书中探讨一些流行的实现方式。

如果这仍然听起来很复杂,我理解。只是坐着反思并发是困难的,但如果我们试图在处理异步代码时将这些想法放在心中,我保证它会越来越不令人困惑。

操作系统的作用

操作系统(OS)是我们作为程序员所做的一切的中心(好吧,除非你在编写操作系统或工作在嵌入式领域),因此我们无法在不稍微详细地讨论操作系统的情况下讨论任何编程基础。

从操作系统的角度来看的并发

这与我在之前提到的话题相关,当时我说并发需要在参考框架内讨论,并解释了操作系统可能会随时停止和启动你的进程。

我们所说的同步代码,在大多数情况下,是对于我们程序员来说看起来是同步的代码。操作系统和 CPU 都不生活在完全同步的世界中。

操作系统使用抢占式多任务处理,只要你在运行的操作系统是抢占式调度进程,你就无法保证你的代码会一条指令一条指令地无中断地运行。

操作系统将确保所有重要进程都能从 CPU 获得一些时间以取得进展。

注意

当我们谈论具有 4、6、8 或 12 个物理核心的现代机器时,这并不简单,因为如果系统负载非常小,你实际上可能会在一个 CPU 上不间断地执行代码。这里的关键部分是,你无法确定,也没有保证你的代码会被允许不间断地运行。

与操作系统合作

当你发起一个网络请求时,你并不是要求 CPU 或网卡为你做些什么——你是在要求操作系统代表你与网卡通信。

作为程序员,你无法在不利用操作系统的优势的情况下使你的系统达到最优效率。你基本上无法直接访问硬件。你必须记住,操作系统是对硬件的抽象

然而,这也意味着,为了从底层理解一切,你还需要了解你的操作系统如何处理这些任务。

为了能够与操作系统一起工作,你需要知道你如何与之通信,这正是我们接下来将要探讨的。

与操作系统通信

与操作系统的通信是通过我们所说的系统调用syscall)发生的。我们需要知道如何进行系统调用,并理解当我们想要与操作系统合作和通信时,为什么这对我们来说如此重要。我们还需要了解我们每天使用的这些基本抽象是如何在幕后使用系统调用的。我们将在第三章中进行详细说明,所以现在我们将简要介绍。

系统调用使用操作系统提供的公共 API,以便我们编写的“用户空间”程序可以与操作系统通信。

大多数时候,这些调用被我们使用的语言或运行时抽象化。

现在,系统调用是与你所通信的内核独特的东西的例子,但 UNIX 系列内核有很多相似之处。UNIX 系统通过 libc 暴露这些相似之处。

另一方面,Windows 使用它自己的 API,通常被称为 WinAPI,并且它可以与基于 UNIX 的系统操作截然不同。

然而,大多数情况下,都有一种方法可以达到相同的效果。从功能的角度来看,你可能不会注意到太大的差异,但正如我们稍后将会看到的,尤其是在我们深入研究 epollkqueueIOCP 的工作原理时,它们在实现这一功能方面可能会有很大的不同。

然而,系统调用并不是我们与操作系统交互的唯一方式,正如我们将在下一节中看到的。

CPU 和操作系统

CPU 与操作系统合作吗?

如果你在我认为自己最初理解了程序是如何工作时问我这个问题,我可能会很可能回答 。我们在 CPU 上运行程序,如果我们知道如何做,我们可以做任何我们想做的事情。现在,首先,我可能不会这样思考,但除非你了解 CPU 和操作系统是如何一起工作的,否则很难确定。

让我开始怀疑自己非常错误的是一段看起来像你即将看到的代码。如果你认为 Rust 中的内联汇编看起来陌生且令人困惑,请不要担心,稍后我们会在本书中详细介绍内联汇编。我会确保逐行解释以下内容,直到你对语法更加熟悉:

仓库引用:ch01/ac-assembly-dereference/src/main.rs

fn main() {
    let t = 100;
    let t_ptr: *const usize = &t;
    let x = dereference(t_ptr);
    println!("{}", x);
}
fn dereference(ptr: *const usize) -> usize {
    let mut res: usize;
    unsafe {
        asm!("mov {0}, [{1}]", out(reg) res, in(reg) ptr)
    };
    res
}

你刚才看到的是一个用汇编编写的解引用函数。

mov {0}, [{1}] 这一行需要一些解释。{0}{1} 是模板,告诉编译器我们正在引用由 out(reg)in(reg) 表示的寄存器。数字只是一个索引,所以如果我们有更多的输入或输出,它们将是 {2}{3} 等等。由于我们只指定了 reg 而没有指定特定的寄存器,我们就让编译器选择它想要使用的寄存器。

mov 指令指示 CPU 从 {1} 所指向的内存位置读取前 8 个字节(如果我们是在 64 位机器上),并将其放置在由 {0} 表示的寄存器中。[] 方括号将指示 CPU 将该寄存器中的数据视为内存地址,而不是简单地复制内存地址本身到 {0},它将获取该内存位置的内容并将其移动过来。

无论如何,我们在这里只是向 CPU 写入指令。没有标准库,没有系统调用;只有原始指令。操作系统不可能参与那个解引用 函数,对吧?

如果你运行这个程序,你会得到你预期的结果:

100

现在,如果你保留解引用函数,但用创建指向99999999999999地址的指针的函数替换main函数,我们知道这个地址是无效的,我们得到以下函数:

fn main() {
    let t_ptr = 99999999999999 as *const usize;
    let x = dereference(t_ptr);
    println!("{}", x);
}

现在,如果我们运行它,我们会得到以下结果。

这是 Linux 上的结果:

Segmentation fault (core dumped)

这是 Windows 上的结果:

error: process didn't exit successfully: `target\debug\ac-assembly-dereference.exe` (exit code: 0xc0000005, STATUS_ACCESS_VIOLATION)

我们遇到了段错误。这并不奇怪,实际上,你可能也注意到了,我们在不同平台上得到的错误是不同的。当然,操作系统在这里起到了某种作用。让我们看看这里真正发生了什么。

掉进兔子洞

结果表明,操作系统和 CPU 之间有很大的合作,但可能不是你天真地认为的那种方式。

许多现代 CPU 为操作系统提供了一些基本的基础设施。这个基础设施为我们提供了我们期望的安全性和稳定性。实际上,大多数先进的 CPU 提供的选项比 Linux、BSD 和 Windows 等操作系统实际使用的选项要多得多。

有两个特别想在这里讨论:

  • CPU 如何阻止我们访问不应该访问的内存

  • CPU 如何处理异步事件,如 I/O

我们在这里讨论第一个,第二个将在下一节中讨论。

CPU 是如何阻止我们访问不应该访问的内存的?

正如我提到的,现代 CPU 架构通过设计定义了一些基本概念。以下是一些例子:

  • 虚拟内存

  • 页表

  • 页面错误

  • 异常

  • 权限级别

具体如何工作将取决于特定的 CPU,所以我们在这里以一般术语来处理。

大多数现代 CPU 都有一个内存管理单元MMU)。这个 CPU 的部分通常甚至是在同一层上蚀刻的。MMU 的职责是将我们在程序中使用的虚拟地址转换为物理地址。

当操作系统启动一个进程(比如我们的程序)时,它会为我们的进程设置一个页表,并确保 CPU 上的一个特殊寄存器指向这个页表。

现在,当我们尝试在前面代码中解引用t_ptr时,地址会在某个时刻发送给 MMU 进行转换,MMU 会在页表中查找它,将其转换为内存中的物理地址,以便从中获取数据。

在第一种情况下,它将指向我们的栈上的一个内存地址,该地址持有值100

当我们传入99999999999999并要求它获取该地址存储的内容(这就是解引用所做的),它会查找页表中的转换,但找不到。

CPU 随后将其视为页面错误。

在启动时,操作系统向 CPU 提供了一个中断描述符表。这个表有一个预定义的格式,其中操作系统为 CPU 可能遇到预定义条件提供了处理程序。

由于操作系统提供了一个处理页面错误的函数指针,当我们尝试解引用99999999999999时,CPU 会跳转到该函数,并将控制权交给操作系统。

然后,操作系统会为我们打印一条友好的消息,让我们知道我们遇到了它所说的段错误。因此,这条消息将根据你在其上运行代码的操作系统而有所不同。

但我们能否只是更改 CPU 中的页表?

现在,这就是权限级别发挥作用的地方。大多数现代操作系统以两个ring 级别运行:ring 0,内核空间,和ring 3,用户空间。

图 1.2 – 权限环

图 1.2 – 权限环

大多数 CPU 的概念比大多数现代操作系统使用的还要多。这有历史原因,这也是为什么使用ring 0ring 3(而不是 1 和 2)的原因。

页表中的每个条目都有关于它的附加信息。其中信息包括它属于哪个环的信息。这些信息是在你的操作系统启动时设置的。

ring 0中执行的代码几乎可以无限制地访问外部设备和内存,并且可以自由更改提供硬件级别安全性的寄存器。

你在ring 3中编写的代码通常对 I/O 和某些 CPU 寄存器(以及指令)的访问权限非常有限。试图从ring 3发出指令或设置寄存器以更改页表将被 CPU 阻止。然后 CPU 会将此视为异常,并跳转到操作系统提供的异常处理程序。

这也是为什么你除了与操作系统合作并通过系统调用处理 I/O 任务外别无选择的原因。如果不是这样,系统将不会非常安全。

因此,总结一下:是的,CPU 和操作系统之间有很多合作。大多数现代桌面 CPU 都是考虑到操作系统而设计的,因此它们提供了操作系统在启动时可以抓取的钩子和基础设施。当操作系统创建一个进程时,它也会设置其权限级别,确保普通进程保持在它定义的边界内,以维护稳定性和安全性。

中断、固件和 I/O

我们即将结束这本书中的一般计算机科学主题,我们很快就会开始从兔子洞中走出来。

这一部分试图将事物联系起来,并查看整个计算机作为一个系统来处理 I/O 和并发。

让我们开始吧!

简化的概述

让我们看看一些步骤,在这些步骤中,我们想象我们从网络卡中读取:

图 1.3 – 页表

记住,我们在这里简化了很多。这是一个相当复杂的操作,但我们将关注对我们最有兴趣的部分,并跳过一些步骤。

第 1 步 – 我们的代码

我们注册一个套接字。这是通过向操作系统发出系统调用来完成的。根据操作系统,我们可能得到一个文件描述符(macOS/Linux)或一个套接字(Windows)。

下一步是我们注册对那个套接字的Read事件的兴趣。

第 2 步 – 在操作系统中注册事件

这可以通过三种方式之一来处理:

  1. 我们告诉操作系统我们感兴趣的是Read事件,但我们想通过yielding对线程的控制权给操作系统来等待它发生。然后操作系统通过存储寄存器状态并切换到其他线程来挂起我们的线程。

从我们的角度来看,这将阻塞我们的线程,直到我们有数据 可以读取。

  1. 我们告诉操作系统我们感兴趣的是Read事件,但我们只想获取一个可以poll的任务句柄来检查事件是否已准备好。

操作系统不会挂起我们的线程,所以这不会阻塞 我们的代码。

  1. 我们告诉操作系统我们可能对许多事件感兴趣,但我们只想订阅一个事件队列。当我们poll这个队列时,它将阻塞我们的线程,直到一个或多个事件发生。

这将阻塞我们的线程,直到事件 发生。

第三章和第四章将详细介绍第三种方法,因为它是现代异步框架处理并发最常用的方法。

第 3 步 – 网络卡

我们在这里跳过了某些步骤,但我不认为它们对我们理解至关重要。

在网络卡上,有一个运行专用固件的微型控制器。我们可以想象这个微型控制器正在忙循环中轮询,检查是否有数据传入。

网络卡处理其内部的方式与我这里建议的略有不同,并且可能因供应商而异。重要的是,有一个非常简单但专门的 CPU 在网络卡上运行,以检查是否有传入的事件。

一旦固件注册了传入的数据,它就会发出硬件中断

第 4 步 – 硬件中断

现代 CPU 有一组中断请求线IRQs)来处理来自外部设备发生的事件。CPU 有一组固定的中断线。

硬件中断是一个可以在任何时候发生的电信号。CPU 立即中断其正常工作流程,通过保存其寄存器的状态并查找中断处理程序来处理中断。中断处理程序定义在中断描述符 IDT)中。

第 5 步 – 中断处理程序

IDT 是一个表,其中操作系统(或驱动程序)为可能发生的不同中断注册了处理程序。每个条目都指向特定中断的处理程序函数。网络卡的处理程序通常由该卡的驱动程序注册和处理。

注意

IDT 并没有存储在 CPU 上,正如图 1**.3所示。它位于主内存中的一个固定且已知的位置。CPU 只在其一个寄存器中保存指向表的指针。

第 6 步 – 写入数据

这个步骤可能会因 CPU 和网络卡的固件而大不相同。如果网络卡和 CPU 支持直接内存访问DMA),这在今天所有现代系统中应该是标准配置,那么网络卡将直接将数据写入操作系统已在主内存中设置的一组缓冲区。

在这样的系统中,网络卡的固件可能会在数据写入内存时发出中断。DMA 非常高效,因为只有在数据已经在内存中时,CPU 才会被通知。在较旧的系统中,CPU 需要分配资源来处理从网络卡的数据传输。

直接内存访问控制器DMAC)被添加到图中,因为在这样的系统中,它将控制对内存的访问。它不是 CPU 的一部分,如前一个图所示。我们现在已经足够深入到这个兔子洞中,而现在系统不同部分的精确位置对我们来说并不真正重要,所以让我们继续前进。

步骤 7 – 驱动程序

驱动程序通常会处理操作系统和网络卡之间的通信。在某个时刻,缓冲区被填满,网络卡发出中断。然后 CPU 跳转到该中断的处理程序。对于这种特定类型的中断,处理程序是由驱动程序注册的,因此实际上是驱动程序处理这个事件,并反过来通知内核数据已准备好读取。

步骤 8 – 读取数据

根据我们选择的方法 1、2 或 3,操作系统将按以下方式操作:

  • 唤醒我们的线程

  • 在下一次轮询时返回Ready

  • 唤醒线程并为我们注册的处理程序返回一个Read事件

中断

如你所知,有两种类型的中断:

  • 硬件中断

  • 软件中断

它们在本质上非常不同。

硬件中断

硬件中断是通过发送一个通过 IRQ 的电信号来创建的。这些硬件线路直接向 CPU 发出信号。

软件中断

这些是来自软件而非硬件的中断。就像硬件中断的情况一样,CPU 会跳转到中断描述符表(IDT)并运行指定中断的处理程序。

固件

固件并没有得到我们大多数人的太多关注;然而,它是我们生活在这个世界中的关键部分。它运行在各种硬件上,并且有各种奇怪和独特的方式使我们所编程的计算机工作。

现在,固件需要一个微控制器才能工作。甚至 CPU 也有使其工作的固件。这意味着在我们的系统中,比我们编程的核心还要多出许多小的‘CPU’。

这为什么很重要呢?好吧,你还记得并发都是关于效率的,对吧?由于我们已经在系统中有很多 CPU/微控制器为我们做工作,我们关心的一点是在编写代码时不要重复或复制这些工作。

如果网卡有固件不断检查是否有新数据到达,如果我们让我们的 CPU 也不断检查是否有新数据到达,那么这将是相当浪费的。如果我们偶尔检查一次,或者更好,当数据到达时得到通知,那就好多了。

摘要

本章覆盖了大量的内容,所以做得很好,完成了所有这些前期工作。我们从历史的角度了解了一些关于 CPU 和操作系统如何演变的情况,以及非抢占式和抢占式多任务处理之间的区别。我们讨论了并发和并行之间的区别,讨论了操作系统的角色,并了解到系统调用是我们与宿主操作系统交互的主要方式。你也看到了 CPU 和操作系统如何通过作为 CPU 一部分设计的底层设施进行合作。

最后,我们通过一个图表了解了当你发起网络调用时会发生什么。你知道至少有三种不同的方式来处理 I/O 调用需要一些时间来执行的事实,我们必须决定我们想要处理那种等待时间的方式。

这涵盖了我们需要的大部分一般性背景信息,这样我们才能在继续之前拥有相同的定义和概述。随着我们在书中不断前进,我们将更详细地探讨,下一章我们将讨论的第一个主题是编程语言如何通过研究线程、协程和未来来模拟异步程序流程。

第二章:编程语言如何模拟异步程序流程

在上一章中,我们以一般性的方式介绍了异步程序流程、并发和并行性。在本章中,我们将缩小范围。具体来说,我们将探讨编程语言和库中模拟和处理并发性的不同方法。

需要记住的是,线程、未来、纤程、goroutines、承诺等都是抽象概念,它们为我们提供了一种模拟异步程序流程的方法。它们具有不同的优势和劣势,但它们的目标是给程序员提供一个易于使用(并且重要的是,难以误用)、高效且具有表现力的方式来创建一个处理任务的非顺序、通常不可预测的程序。

缺乏精确的定义在这里也很普遍;许多术语的名称源于某个时间点的具体实现,但后来它们获得了更广泛的意义,涵盖了不同实现和同一事物的不同变体。

在我们讨论每个抽象的优缺点之前,我们首先将根据它们的相似性对不同的抽象进行分组。我们还将介绍本书中会使用的重要定义,并详细讨论操作系统线程。

我们在这里讨论的主题相当抽象和复杂,所以如果你一开始不理解所有内容,请不要感到难过。随着我们继续阅读本书,并通过解决一些示例来熟悉不同的术语和技术,越来越多的部分将会变得清晰。

具体来说,以下内容将会被涵盖:

  • 定义

  • 操作系统提供的线程

  • 绿色线程/栈满协程/纤程

  • 基于回调的方法

  • 承诺、未来和 async/await

定义

我们可以将并发操作的抽象大致分为两组:

  1. Rust 和 JavaScript 中的 async/await

  2. 非协作式:不一定自愿让出的任务。在这样的系统中,调度器必须能够抢占一个正在运行的任务,这意味着调度器可以停止任务并控制 CPU,即使该任务本来还能够工作并取得进展。这类任务的例子包括操作系统线程和 Goroutines(在 GO 版本 1.14 之后)。

图 2.1 – 非协作式与协作式多任务处理

图 2.1 – 非协作式与协作式多任务处理

注意

在一个调度器可以抢占正在运行的任务的系统中,任务也可以像在协作系统中那样自愿让出,而在仅依赖于抢占的系统中的这种情况很少见。

我们可以根据它们实现的特点将这些抽象进一步分为两大类:

  1. 有堆栈:每个任务都有自己的调用堆栈。这通常实现为一个与操作系统用于其线程的堆栈相似的堆栈。有堆栈的任务可以在程序的任何位置挂起执行,因为整个堆栈都被保留。

  2. 无堆栈:每个任务没有单独的堆栈;它们都运行在共享的调用堆栈上。任务不能在堆栈帧的中间被挂起,这限制了运行时抢占任务的能力。然而,它们在任务间切换时需要存储/恢复更少的信息,因此可以更高效。

这两个类别还有更多细微之处,你将在本书后面通过实现一个堆栈式协程(纤程)和一个无堆栈协程(由async/await生成的 Rust 未来)的示例来深入理解。目前,我们只提供概述,尽量简化细节。

线程

我们将在整本书中不断提到线程,所以在我们走得太远之前,让我们停下来给“线程”一个好的定义,因为它是一个引起很多混淆的基本术语。

在最一般的意义上,线程指的是执行线程,即需要按顺序执行的一系列指令。如果我们将其与本书第一章中在并发与并行性子节下提供的几个定义联系起来,执行线程类似于我们定义的具有多个步骤且需要资源才能进展的任务

这个定义的通用性可能会引起一些混淆。对某个人来说,一个线程显然可以指操作系统线程,而对另一个人来说,它可能仅仅指代任何代表系统上执行线程的抽象。

线程通常分为两大类:

  • 操作系统线程:这些线程是由操作系统创建并由操作系统调度器管理的。在 Linux 上,这被称为内核线程

  • 用户级线程:这些线程是由我们程序员创建和管理的,操作系统并不知道它们的存在。

现在,事情变得有点棘手:大多数现代操作系统的操作系统线程有很多相似之处。其中一些相似之处是由现代 CPU 的设计决定的。一个例子是,大多数 CPU 都假设有一个它可以执行操作的堆栈,并且它有一个堆栈指针寄存器和堆栈操作指令。

在最广泛的意义上,用户级线程可以指代任何创建和调度任务的系统(运行时)的实现,你不能像对待操作系统线程那样做出相同的假设。它们可以通过为每个任务使用单独的堆栈而与操作系统线程相似,正如我们在第五章中通过我们的纤程/绿色线程示例所看到的,或者它们在本质上可以非常不同,正如我们在本书第三部分的后面部分将看到的,我们将了解 Rust 如何模拟并发操作。

无论定义如何,一组任务都需要某种东西来管理它们并决定谁可以获得哪些资源以进行进展。在计算机系统中,所有任务都需要进行进展的最明显资源是 CPU 时间。我们将决定谁可以获得 CPU 时间以进行进展的“某种东西”称为调度器

很可能,当某人提到“线程”而没有添加额外上下文时,他们指的是操作系统线程/内核线程,所以我们将继续这样做。

我还会继续将执行线程简单地称为任务。我发现当我们尽可能限制与上下文相关的不同假设所关联的术语的使用时,异步编程的主题更容易推理。

首先,让我们把这个问题解决掉,同时我们也会强调操作系统中线程的一些定义特征。

重要!

定义将根据你阅读的书籍或文章而有所不同。例如,如果你阅读关于特定操作系统如何工作的内容,你可能会看到进程或线程是代表“任务”的抽象,这似乎与我们在这里使用的定义相矛盾。正如我之前提到的,参考系的选择很重要,这就是为什么我们在整本书中遇到这些术语时,都要非常仔细地定义它们。

线程的定义也可能因操作系统而异,尽管今天大多数流行的系统共享一个类似定义。最值得注意的是,Solaris(在 2002 年发布的 Solaris 9 之前)曾经有一个两级线程系统,它区分了应用程序线程、轻量级进程和内核线程。这是我们所说的 M:N 线程的实现,我们将在本书后面的章节中了解更多。只是要注意,如果你阅读了旧材料,这种系统中线程的定义可能与今天普遍使用的定义有显著差异。

既然我们已经讨论了本章最重要的定义,现在是时候更多地讨论编程时处理并发最流行的方式了。

操作系统提供的线程

注意!

我们称之为 1:1 线程。每个任务分配一个操作系统线程。

由于本书将不会专门关注操作系统线程作为处理并发的手段,所以我们在这里更详细地讨论它们。

让我们从显而易见的事情开始。要使用操作系统提供的线程,你需要,嗯,一个操作系统。在我们讨论将线程用作处理并发的一种手段之前,我们需要清楚我们正在讨论哪种类型的操作系统,因为它们有不同的风味。

嵌入式系统现在比以往任何时候都更普遍。这种硬件可能没有操作系统的资源,如果有的话,你可能会使用一种针对你的需求定制的、根本不同的操作系统,因为系统往往不那么通用,而更多是专门化的。

他们对线程的支持以及它们调度线程的特性可能与你习惯的 Windows 或 Linux 等操作系统中的不同。

由于涵盖所有不同的设计本身就是一本书的内容,我们将范围限制在讨论线程上,因为它们在运行在流行桌面和服务器 CPU 上的基于 Windows 和 Linux 的系统中使用。

操作系统线程易于实现和使用。我们只是让操作系统为我们处理一切。我们通过为每个我们想要完成的任务创建一个新的操作系统线程,并像平常一样编写代码来实现这一点。

我们用来处理并发的运行时是操作系统本身。除了这些优点之外,你还能免费获得并行性。然而,直接管理并行性和共享资源也会带来一些缺点和复杂性。

创建新线程需要时间

创建一个新的操作系统线程涉及一些账目和初始化开销,所以虽然在同一进程内切换两个现有线程相当快,但创建新线程和丢弃不再使用的线程需要花费时间。如果系统需要创建和丢弃大量线程,所有额外的开销都会限制吞吐量。当你处理大量需要并发处理的小任务时,这可能会成为一个问题,尤其是在处理大量 I/O 时。

每个线程都有自己的栈

我们将在本书的后面详细讨论栈,但到目前为止,知道它们占据固定大小的内存就足够了。每个操作系统线程都有自己的栈,尽管许多系统允许配置这个大小,但它们仍然是固定大小的,不能增长或缩小。毕竟,它们是栈溢出的原因,如果你将它们配置得太小,以适应你正在运行的任务,这将会成为一个问题。

如果我们有很多只需要少量栈空间的小任务,但我们预留的比实际需要的多得多,我们将占用大量内存,并可能耗尽它。

上下文切换

正如你所知,线程和调度器紧密相连。上下文切换发生在 CPU 停止执行一个线程并继续执行另一个线程时。尽管这个过程高度优化,但它仍然涉及到存储和恢复寄存器状态,这需要时间。每次你向操作系统调度器让步时,它可以选择在同一个 CPU 上调度来自不同进程的线程。

你看,这些系统创建的线程属于一个进程。当你启动一个程序时,它会启动一个进程,进程至少创建一个初始线程来执行你编写的程序。每个进程可以创建多个线程,这些线程共享相同的地址空间

这意味着同一进程内的线程可以访问共享内存,并且可以访问相同的资源,例如文件和文件句柄。这一结果之一是,当操作系统通过停止同一进程中的一个线程并恢复另一个线程来切换上下文时,它不需要保存和恢复与该进程相关的所有状态,只需保存和恢复特定于该线程的状态。

另一方面,当操作系统从一个与一个进程关联的线程切换到与另一个进程关联的线程时,新进程将使用不同的地址空间,操作系统需要采取措施确保进程“A”不会访问属于进程“B”的数据或资源。如果不是这样,系统将不会安全。

结果是,可能需要刷新缓存,并且可能需要保存和恢复更多的状态。在高并发系统负载下,这些上下文切换可能会花费额外的时间,如果它们频繁发生,可能会以某种不可预测的方式限制吞吐量。

调度

操作系统可以以你意想不到的方式调度任务,并且每次你向操作系统让步时,你都会被放入与系统上所有其他线程和进程相同的队列中。

此外,由于无法保证线程将在离开时相同的 CPU 核心上恢复执行,或者两个任务不会并行运行并尝试访问相同的数据,因此你需要同步数据访问以防止数据竞争和其他与多核编程相关的陷阱。

Rust 作为一种语言将帮助你防止许多这些陷阱,但同步数据访问将需要额外的工作,并增加此类程序复杂性。我们经常说,使用操作系统线程处理并发给我们带来了免费的可并行性,但从增加的复杂性和需要适当的数据访问同步的角度来看,这并不是免费的。

将异步操作与操作系统线程解耦的优势

将异步操作与线程概念解耦有很多好处。

首先,使用操作系统线程作为处理并发的手段要求我们使用本质上是一种操作系统抽象来表示我们的任务。

拥有一个单独的抽象层来表示并发任务,这给了我们选择如何处理并发操作的自由。如果我们创建了一个表示并发操作(如 Rust 中的 future、JavaScript 中的 promise 或 GO 中的 goroutine)的抽象,那么这些并发任务的处理方式将由运行时实现者来决定。

运行时可以将每个并发操作映射到一个操作系统线程,它们可以使用纤程/绿色线程或状态机来表示任务。编写异步代码的程序员在底层实现发生变化时,不一定需要在他们的代码中进行任何更改。理论上,如果只是有一个运行时,相同的异步代码就可以用来处理没有操作系统的情况下在微控制器上的并发操作。

总结一下,使用操作系统提供的线程来处理并发有以下优点:

  • 容易理解

  • 易于使用

  • 在任务之间切换是相对快速的

  • 你可以免费获得并行性

然而,它们也有一些缺点:

  • 操作系统级别的线程带有相当大的堆栈。如果你有多个任务同时等待(就像在重负载下的 Web 服务器中那样),你很快就会耗尽内存。

  • 上下文切换可能会造成成本增加,并且由于你让操作系统进行所有调度,你可能会得到不可预测的性能。

  • 操作系统有许多需要处理的事情。它可能不会像你希望的那样快速切换回你的线程。

  • 它与操作系统抽象紧密耦合。在某些系统上,这可能不是一个选项。

示例

由于我们不会在这本书中花费更多时间讨论操作系统线程,我们将通过一个简短的示例来展示它们是如何使用的:

ch02/aa-os-threads

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();
}

在这个示例中,我们简单地创建了几个操作系统线程并将它们放入休眠状态。休眠本质上等同于向操作系统调度器让步,请求在经过一定时间后被重新调度运行。为了确保我们的主线程在子线程有时间运行之前不会完成并退出(这将退出进程),我们在main函数的末尾join它们。

如果我们运行示例,我们会看到操作顺序的不同是基于我们让每个线程向调度器让步时间的长短:

So, we start the program here!
The tasks run concurrently!
We can chain callbacks...
...like this!
The long-running tasks finish last!

因此,虽然使用操作系统线程对于许多任务来说很棒,但我们通过讨论它们的限制和缺点,也概述了查看替代方案的好理由。我们将首先查看的是我们称之为纤维和绿色线程的替代方案。

纤维和绿色线程

注意!

这是一个M:N 线程的例子。许多任务可以在一个操作系统线程上并发运行。纤维和绿色线程通常被称为堆栈协程。

“绿色线程”这个名字最初来源于 Java 中早期使用的 M:N 线程模型的一个实现,并且自那时起就与 M:N 线程的不同实现相关联。你将遇到这个术语的不同变体,例如“绿色进程”(在 Erlang 中使用),这与我们在这里讨论的不同。你也会看到一些定义绿色线程比我们在这里更广泛的例子。

在这本书中,我们定义的绿色线程与纤维同义,因此这两个术语在以后都指同一件事。

纤维和绿色线程的实现意味着存在一个运行时和一个调度器,该调度器负责调度哪个任务(M)在操作系统线程(N)上运行。任务的数量远多于操作系统线程的数量,这样的系统仅使用一个操作系统线程就可以运行得很好。后者通常被称为M:1 线程

Goroutines 是堆栈满协程的特定实现的一个例子,但它有一些细微差别。术语“协程”通常意味着它们本质上是合作的,但 Goroutines 可以被调度器抢占(至少从版本 1.14 开始),因此它们在我们提出的类别中处于某种灰色区域。

绿色线程和纤维使用与操作系统相同的机制,为每个任务设置一个堆栈,保存 CPU 的状态,并通过上下文切换从一个任务(线程)跳转到另一个任务。

我们将控制权交给调度器(在这样一个系统的运行时中,调度器是一个核心部分),然后它继续运行不同的任务。

执行状态存储在每个堆栈中,因此在这种解决方案中,不需要 asyncawaitFuturePin。在许多方面,绿色线程模仿了操作系统如何促进并发,实现它们是一个很好的学习经历。

使用纤维/绿色线程进行并发任务运行的运行时可以具有高度的灵活性。例如,任务可以在任何时间、任何执行点被抢占和上下文切换,因此理论上,一个长时间运行的、占用 CPU 的任务可以被运行时抢占,作为防止任务由于边缘情况或程序员错误而阻塞整个系统的安全措施。

这使得运行时调度器几乎具有与操作系统调度器相同的性能,这是使用纤维/绿色线程的系统最大的优点之一。

典型的流程如下:

  • 你运行一些非阻塞代码

  • 你向某个外部资源发起一个阻塞调用

  • CPU 跳转到主线程,调度另一个线程运行,并跳转到那个堆栈

  • 你在新线程上运行一些非阻塞代码,直到新的阻塞调用或任务完成

  • CPU 跳回到主线程,调度一个准备好进行进度的新线程,并跳转到那个线程

图 2.2 – 使用纤维/绿色线程的程序流程

图 2.2 – 使用纤维/绿色线程的程序流程

每个堆栈都有固定空间

由于纤维和绿色线程类似于操作系统线程,它们确实有一些相同的缺点。每个任务都设置了一个固定大小的堆栈,所以你仍然需要预留比你实际使用的更多空间。然而,这些堆栈可以是可增长的,这意味着一旦堆栈满了,运行时可以增长堆栈。虽然这听起来很简单,但解决这个问题相当复杂。

我们不能像生长一棵树那样简单地生长一个堆栈。实际上需要发生的是以下两种情况之一:

  1. 你分配一块新的连续内存,并处理你的堆栈分布在两个不连续内存段的事实

  2. 你分配一个新的更大的堆栈(例如,是之前堆栈的两倍大小),将所有数据移动到新的堆栈上,并从这里继续

第一个解决方案听起来相当简单,因为你可以将原始栈保持原样,并在需要时基本切换到新栈,并从那里继续。然而,由于缓存和它们预测你接下来要处理的数据的能力,现代 CPU 可以在连续的内存块上工作得非常快。将栈分散到两块不连续的内存中将会阻碍性能。这在你有一个恰好位于栈边界处的循环时尤为明显,因此你可能会为循环的每次迭代进行多达两次的上下文切换。

第二个解决方案通过将栈作为连续的内存块来解决第一个解决方案的问题,但它也带来了一些问题。

首先,你需要分配一个新的栈并将所有数据移动到新栈上。但是,当一切移动到新位置时,所有指向栈上位置的指针和引用怎么办?你已经猜到了:指向栈上任何内容的指针和引用都需要更新,以便它们指向新位置。这是复杂且耗时的,但如果你的运行时已经包括垃圾回收器,你已经在跟踪所有指针和引用方面有了开销,所以这可能比非垃圾回收程序的问题要小。然而,它确实需要在垃圾回收器和运行时之间进行大量的集成,以便每次栈增长时都执行此操作,因此实现这种运行时可能会变得非常复杂。

其次,你必须考虑如果你有很多长时间运行的任务,这些任务在短时间内只需要大量的栈空间(例如,如果任务开始时涉及大量的递归)但大部分时间都是 I/O 密集型的情况。你最终会只为任务的一个特定部分增长栈多次,你必须决定你是否会接受任务占用比它需要的更多空间,或者在某些时候将其移回较小的栈。这种影响当然会根据你所做的工作类型而有很大差异,但这仍然是一件需要注意的事情。

上下文切换

即使这些纤程/绿色线程与操作系统线程相比很轻量级,你仍然需要在每次上下文切换时保存和恢复寄存器。这很可能不会成为问题,但与不需要上下文切换的替代方案相比,它可能效率较低。

上下文切换也可能非常复杂,特别是如果你打算支持许多不同的平台。

调度

当一个纤维/绿色线程向运行时调度器让步时,调度器可以简单地在新任务上恢复执行。这意味着每次你向调度器让步时,你都不会被放入与系统中每个其他任务相同的运行队列。从操作系统的角度来看,你的线程一直在忙于工作,所以如果可能,它会尽量避免抢占它们。

这个方法的一个意想不到的缺点是,大多数操作系统调度器确保所有线程都能得到一些运行时间,通过为每个操作系统线程分配一个时间片,在该时间片内它可以运行,然后操作系统抢占线程并在此 CPU 上调度新的线程。使用许多操作系统线程的程序可能会被分配比使用较少操作系统线程的程序更多的时片。使用 M:N 线程的程序很可能只会使用少数操作系统线程(在大多数系统上,每个 CPU 核心似乎是一个起点)。因此,根据系统上运行的其他内容,你的程序可能总共被分配的时片比使用许多操作系统线程时更少。然而,考虑到大多数现代 CPU 上可用的核心数量和并发系统的典型工作负载,这种影响应该是微不足道的。

FFI

由于你创建了自己的栈,这些栈在特定条件下会增长/缩小,并且可能有一个假设可以在任何时间抢占运行中的任务的调度器,因此在使用 FFI 时,你必须采取额外措施。大多数 FFI 函数将假设一个正常的操作系统提供的 C 栈,因此从纤维/绿色线程调用 FFI 函数可能会出现问题。你需要通知运行时调度器,切换到不同的操作系统线程,并有一种方式通知调度器你已经完成,纤维/绿色线程可以继续。这自然为运行时实现者和进行 FFI 调用的用户都增加了开销和复杂性。

优点

  • 对于用户来说,使用起来很简单。代码看起来就像使用操作系统线程时一样。

  • 上下文切换相对较快。

  • 与操作系统线程相比,内存使用量过多的问题较小。

  • 你可以完全控制任务的调度方式,如果你愿意,可以根据自己的需求优先级排序。

  • 很容易整合抢占,这可以是一个强大的特性。

缺点

  • 栈需要在空间不足时有一种增长的方式,这会创建额外的工作和复杂性。

  • 你仍然需要在每次上下文切换时保存 CPU 状态。

  • 如果你打算支持许多平台和/或 CPU 架构,正确实现会比较复杂。

  • FFI 可能会有很多开销并增加意外的复杂性。

基于回调的方法

注意!

这是 M:N 线程的另一个例子。许多任务可以在一个操作系统线程上并发运行。每个任务由一系列回调组成。

你可能已经知道接下来几段将要讨论的内容,这来自于 JavaScript,我假设大多数人知道。

基于回调的方法背后的整个想法是保存一组我们想要稍后一起运行的指令的指针,以及所需的任何状态。在 Rust 中,这将是一个闭包。

在大多数语言中实现回调相对容易。它们不需要为每个任务进行上下文切换或预分配内存。

然而,使用回调表示并发操作需要你从开始就以一种截然不同的方式编写程序。将使用正常顺序程序流的程序重写为使用回调的程序,这代表了一次重大的重写,反之亦然。

基于回调的并发可能很难理解,也可能变得非常复杂。因此,“回调地狱”这个术语是大多数 JavaScript 开发者都熟悉的一点,这并非巧合。

由于每个子任务必须保存它稍后需要的所有状态,内存使用量将与任务中回调的数量线性增长。

优点

  • 在大多数语言中易于实现

  • 没有上下文切换

  • 相对较低的内存开销(在大多数情况下)

缺点

  • 内存使用量与回调的数量线性增长。

  • 程序和代码可能很难理解。

  • 这是一种完全不同的编写程序的方式,它将影响程序的几乎所有方面,因为所有让步操作都需要一个回调。

  • 所有权可能很难理解。结果是,在没有垃圾收集器的情况下编写基于回调的程序可能变得非常困难。

  • 由于所有权规则的复杂性,任务之间的状态共享很困难。

  • 调试回调可能很困难。

协程:承诺和未来

注意!

这又是 M:N 线程的一个例子。许多任务可以在一个操作系统线程上并发运行。每个任务都表示为一个状态机。

JavaScript 中的承诺Rust 中的未来是基于相同理念的不同实现。

不同的实现之间有一些差异,但在这里我们不会关注这些。由于它们在 JavaScript 中的使用而广为人知,解释承诺是值得的。承诺也与 Rust 的 future 有很多共同之处。

首先,许多语言都有一个承诺的概念,但以下示例中我将使用 JavaScript 中的承诺。

承诺是处理基于回调方法带来的复杂性的一个方法。

而不是:

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"));

后者方法也被称为延续传递风格。每个子任务一旦完成就调用一个新的子任务。

回调和承诺之间的区别在底层更为显著。你看,承诺返回一个可以处于三种状态之一的状态机:pendingfulfilledrejected

当我们在前面的例子中调用timer(200)时,我们得到一个处于pending状态的承诺。

现在,传递风格的确解决了与回调相关的一些问题,但在复杂性和编写程序的不同方式方面,它仍然保留了很多。然而,它们使我们能够利用编译器来解决这些问题,我们将在下一段中讨论。

协程和 async/await

协程有两种类型:非对称对称。非对称协程将控制权交给调度器,我们将关注这些。对称协程将控制权交给特定的目的地;例如,另一个协程。

虽然协程在一般情况下是一个相当广泛的概念,但将协程作为编程语言中的对象引入,才是真正使这种处理并发的方式与操作系统线程和纤程/绿色线程所知的易于使用性相媲美的原因。

当你在 Rust 或 JavaScript 中写入async时,编译器会将看起来像正常函数调用的代码重写为未来(在 Rust 的情况下)或承诺(在 JavaScript 的情况下)。另一方面,await将控制权交给运行时调度器,任务将在你等待的未来/承诺完成之前挂起。

这样,我们可以编写几乎以与编写正常顺序程序相同的方式处理并发操作的程序。

我们现在可以将 JavaScript 程序编写如下:

async function run() {
    await timer(200);
    await timer(100);
    await timer(50);
    console.log("I'm the last one");
}

你可以将run函数视为一个由多个子任务组成的可暂停任务。在每个“await”点上,它将控制权交给调度器(在这种情况下,它是众所周知的 JavaScript 事件循环)。

一旦子任务的状态变为fulfilledrejected之一,任务将被安排继续到下一步。

当使用 Rust 时,当你写下类似以下内容时,你可以看到函数签名发生了相同的转换:

async fn run() -> () { … }

该函数包装了返回对象,而不是返回类型(),而是返回一个输出类型为()Future

Fn run() -> impl Future<Output = ()>

语法上,Rust 的 futures 0.1 与我们所展示的承诺示例非常相似,我们今天使用的 Rust futures 与 JavaScript 中的async/await的工作方式有很多共同之处。

这种将看起来像正常函数和代码重写为其他方式的方法有很多好处,但并非没有缺点。

就像任何无栈协程实现一样,完全预占可能很难实现,或者根本不可能实现。这些函数必须在特定点让出,与纤程/绿色线程不同,在栈帧的中间无法挂起执行。通过在每次函数调用处插入预占点,例如,运行时或编译器可以实现一定程度的预占,但这并不等同于能够在执行过程中任何时刻预占任务。

预占点

预先中断点可以被视为插入调用调度器的代码,并询问它是否希望中断任务。这些点可以通过编译器或你使用的库在每次新的函数调用之前插入,例如。

此外,你需要编译器的支持来充分利用它。具有元编程能力(如宏)的语言可以模拟很多相同的功能,但这仍然不会像编译器知道这些特殊异步任务时那样无缝。

调试是另一个在实现未来/承诺时必须小心处理的问题领域。由于代码被重写为状态机(或生成器),你将不会像在正常函数中那样拥有相同的堆栈跟踪。通常,你可以假设函数的调用者既在堆栈中也在程序流程中先于它。对于未来和承诺,可能是运行时调用函数来推进状态机,因此可能没有好的回溯可以用来查看在调用失败的函数之前发生了什么。有方法可以绕过这个问题,但大多数方法都会带来一些开销。

优点

  • 你可以像平时一样编写代码和模拟程序

  • 没有上下文切换

  • 它可以以非常内存高效的方式实现

  • 它很容易在各种平台上实现

缺点

  • 预先中断可能很难或不可能完全实现,因为任务不能在堆栈帧的中间停止

  • 它需要编译器的支持来发挥其全部优势

  • 调试可能很困难,这既是因为程序的流程非顺序性,也是因为从回溯中获取的信息有限。

摘要

你还在这里吗?这太棒了!你做得很好,已经通过了所有这些背景信息。我知道阅读描述抽象和代码的文本可能相当令人畏惧,但我希望你能看到为什么我们现在在书的开始部分研究这些高级主题是如此有价值。我们很快就会看到例子。我保证!

在本章中,我们讨论了如何通过使用操作系统提供的线程和编程语言或库提供的抽象来模拟和处理编程语言中的异步操作。虽然这不是一个详尽的列表,但我们讨论了一些最流行和广泛使用的技术,同时讨论了它们的优缺点。

我们花了很多时间深入探讨了线程、协程、纤程、绿色线程和回调,所以你应该对它们是什么以及它们之间有何不同有一个相当好的了解。

下一章将详细介绍我们如何进行系统调用和创建跨平台抽象,以及像 Epoll、Kqueue 和 IOCP 这样的操作系统支持的事件队列究竟是什么,以及为什么它们对于你将在野外遇到的几乎所有异步运行时都是基本的。

第三章:理解基于操作系统的事件队列、系统调用和跨平台抽象

在本章中,我们将探讨基于操作系统的事件队列的工作方式以及三个不同的操作系统如何以不同的方式处理这项任务。我们这样做的原因是,我所知道的大多数异步运行时都将此类基于操作系统的事件队列作为实现高性能 I/O 的基本部分。你很可能在阅读有关异步代码真正是如何工作的内容时经常听到对这些的引用。

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

  • mio (github.com/tokio-rs/mio),Tokio 等流行运行时的关键部分

  • polling (github.com/smol-rs/polling),Smol 和 async-std 中使用的事件队列

  • libuv (libuv.org/),用于创建 Node.js(一种 JavaScript 运行时)和 Julia 编程语言中使用的事件队列的库

  • C#用于其异步网络调用

  • Boost.Asio,一个用于 C++的异步网络 I/O 库

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

除了知道如何使用 FFI 和进行系统调用外,我们还需要涵盖跨平台抽象。在创建事件队列时,无论你是自己创建还是使用库,如果你只有对例如 Windows 上 IOCP 工作方式的高级概述,你可能会发现这些抽象似乎有点不直观。这是因为这些抽象需要提供一个 API,涵盖不同操作系统以不同方式处理相同任务的事实。这个过程通常涉及在平台之间识别共同分母,并在其上构建新的抽象。

为了解释 FFI、系统调用和跨平台抽象,我们不会使用一个相当复杂且冗长的示例,而是通过一个简单的示例来逐步介绍这个主题。当我们后来遇到这些概念时,我们已经对这些主题有了足够的了解,因此我们为以下章节中更有趣的示例做好了充分的准备。

在本章中,我们将探讨以下主要主题:

  • 为什么使用基于操作系统的事件队列?

  • 基于就绪状态的事件队列

  • 基于完成的事件队列

  • epoll

  • kqueue

  • IOCP

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

注意

尽管我们在这里没有涵盖它们,但有一些流行的、尽管使用较少的替代方案,你应该了解:

wepoll:这使用 Windows 上的特定 API,并封装 IOCP,使其在 Linux 上与 epoll 的工作方式非常相似,而不是常规的 IOCP。这使得在两种不同技术之上创建具有相同 API 的抽象层变得更容易。它被libuvmio都使用。

io_uring:这是 Linux 上一个相对较新的 API,与 Windows 上的 IOCP 有很多相似之处。

我非常自信,在你阅读完接下来的两个章节后,如果你想要了解更多关于它们的信息,你会很容易阅读。

技术要求

本章不需要你设置任何新的东西,但由于我们将为三个不同的平台编写一些底层代码,如果你想运行所有示例,你需要访问这些平台。

跟随的最佳方式是在你的计算机上打开配套的存储库,并导航到 ch03 文件夹。

本章有些特别,因为我们从最基本的概念开始构建一些基本理解,这意味着其中一些内容相当底层,需要特定的操作系统和 CPU 系列。不用担心;我已经选择了最常用和最受欢迎的 CPU,所以这不应该是个问题,但这是你需要注意的事情。

在 Windows 和 Linux 上,机器必须使用 x86-64 指令集的 CPU。Intel 和 AMD 的台式机 CPU 使用这种架构,但如果你在基于 ARM 处理器的机器上运行 Linux(或 WSL),你可能会遇到一些使用内联汇编的示例的问题。在 macOS 上,本书中的示例针对的是较新的 M 系列芯片,但存储库中也包含针对较旧的基于 Intel 的 Mac 的示例。

不幸的是,一些针对特定平台的示例需要特定的操作系统才能运行。然而,这将是唯一一个你需要访问三个不同平台才能运行所有示例的章节。展望未来,我们将创建可以在所有平台上本地运行或使用 Windows Subsystem for Linux (WSL) 运行的示例,但为了理解跨平台抽象的基础,我们需要实际创建针对这些不同平台的示例。

运行 Linux 示例

如果你没有设置 Linux 机器,你可以在 Rust Playground 上运行 Linux 示例,或者如果你在 Windows 系统上,我的建议是设置 WSL 并在那里运行代码。你可以在 learn.microsoft.com/en-us/windows/wsl/install 上找到如何操作的说明。记住,你还需要在 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

当我们请求操作系统执行阻塞操作时,它将挂起发起调用的操作系统线程。然后,它会存储我们在调用点所拥有的 CPU 状态,继续执行其他任务。当通过网络到达我们的数据时,它会再次唤醒我们的线程,恢复 CPU 状态,并让我们像什么都没发生一样继续执行。

对于我们程序员来说,阻塞操作是最不灵活的,因为我们在每次调用时都会将控制权交给操作系统。它的一个重大优势是,一旦我们等待的事件准备好,我们的线程就会被唤醒,我们可以继续执行。如果我们考虑整个在操作系统上运行的系统,这是一个相当高效的解决方案,因为操作系统会为有工作要做的线程在 CPU 上分配时间来推进。然而,如果我们缩小范围,只关注我们的进程,我们会发现每次我们进行阻塞调用时,我们都会让一个线程进入休眠状态,即使我们还有进程可以完成的工作。这让我们面临选择:是生成新的线程来执行工作,还是接受我们必须等待阻塞调用返回。我们稍后会对此进行更详细的讨论。

非阻塞 I/O

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

我们称查询状态的这个过程为轮询

非阻塞 I/O 操作给我们程序员更多的自由,但,像往常一样,这也伴随着责任。如果我们轮询过于频繁,比如在一个循环中,我们只会占用大量的 CPU 时间来请求更新状态,这是非常浪费的。如果我们轮询过于不频繁,那么事件就绪和我们对它采取行动之间会有显著的延迟,从而限制我们的吞吐量。

通过 epoll/kqueue 和 IOCP 进行事件排队

这是一种先前方法的混合体。在网络调用的情况下,调用本身将是非阻塞的。然而,我们不必定期轮询句柄,我们可以将句柄添加到事件队列中,我们可以用很少的开销处理数千个句柄。

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

这允许我们在没有更多工作要做且所有任务都在等待事件发生才能继续之前,只向操作系统交出控制权。我们可以决定何时自己发起这样的阻塞调用。

注意

我们将不会介绍pollselect这样的方法。大多数操作系统都有一些较老的方法,在今天的现代异步运行时中并不广泛使用。只需知道我们还可以调用其他方法,这些方法本质上旨在提供与我们刚才讨论的事件队列相同的灵活性。

基于就绪状态的事件队列

epollkqueue被称为基于就绪状态的事件队列,这意味着它们会在动作准备好执行时通知你。一个例子是准备好读取的套接字。

为了了解这种做法在实际中的工作原理,我们可以看看当我们使用 epoll/kqueue 从套接字读取数据时会发生什么:

  1. 我们通过调用系统调用epoll_createkqueue来创建一个事件队列。

  2. 我们向操作系统请求一个表示网络套接字的文件描述符。

  3. 通过另一个系统调用,我们在该套接字上注册对Read事件的兴趣。重要的是我们也要通知操作系统,我们将期待在我们在步骤 1中创建的事件队列中事件就绪时收到通知。

  4. 接下来,我们调用epoll_waitkevent以等待事件。这将阻塞(挂起)被调用的线程。

  5. 当事件准备就绪时,我们的线程将被解除阻塞(恢复)并从wait调用返回,返回有关已发生事件的数据。

  6. 我们在步骤 2中创建的套接字上调用read

图 3.1 – epoll 和 kqueue 流程的简化视图

图 3.1 – epoll 和 kqueue 流程的简化视图

基于完成的的事件队列

IOCP 代表输入/输出完成端口。这是一种基于完成的的事件队列。此类队列在事件完成时通知您。一个例子是将数据读入缓冲区。

以下是对此类事件队列中发生的基本情况的分解:

  1. 我们通过调用系统调用CreateIoCompletionPort创建一个事件队列。

  2. 我们创建一个缓冲区并请求操作系统给我们一个套接字的句柄。

  3. 我们使用另一个系统调用在套接字上注册对Read事件的兴趣,但这次我们还传递了在(步骤 2)中创建的缓冲区,数据将被读入该缓冲区。

  4. 接下来,我们调用GetQueuedCompletionStatusEx,它将阻塞,直到事件完成。

  5. 我们的线程被解除阻塞,并且我们的缓冲区现在充满了我们感兴趣的数据。

图 3.2 – IOCP 流程的简化视图

图 3.2 – IOCP 流程的简化视图

epoll、kqueue 和 IOCP

epoll是 Linux 实现事件队列的方式。在功能方面,它与 kqueue 有很多共同之处。使用 epoll 而不是 Linux 上其他类似方法(如 select 或 poll)的优势在于,epoll 被设计成能够与大量事件非常高效地工作。

kqueue是 macOS 在操作系统(如 FreeBSD 和 OpenBSD)中实现事件队列(起源于 BSD)的方式。在高级功能方面,它与 epoll 在概念上相似,但在实际使用中有所不同。

IOCP是 Windows 处理此类事件队列的方式。在 Windows 中,一个完成端口会在事件完成时通知您。现在,这听起来可能是一个微小的差异,但事实并非如此。当您想要编写库时,这一点尤其明显,因为抽象这两种方法意味着您必须将 IOCP 建模为基于准备状态的,或者将 epoll/kqueue 建模为基于完成的。

或者,将缓冲区借给操作系统也带来了一些挑战,因为当等待操作返回时,这个缓冲区保持不变非常重要。

Windows Linux macOS
IOCP epoll kqueue
基于完成 基于准备状态 基于准备状态

表 3.1 – 不同平台和事件队列

跨平台事件队列

当创建跨平台事件队列时,您必须处理这样一个事实:您必须创建一个统一的 API,无论是在 Windows(IOCP)、macOS(kqueue)还是 Linux(epoll)上使用,都是相同的。最明显的区别是 IOCP 是基于完成的,而 kqueue 和 epoll 是基于准备状态的。

这种基本差异意味着您必须做出选择:

  • 您可以创建一个抽象,将 kqueue 和 epoll 视为基于完成的事件队列,

  • 您可以创建一个抽象,将 IOCP 视为基于准备状态的事件队列

根据我的个人经验,创建一个模仿基于完成的队列的抽象,并处理 kqueue 和 epoll 在幕后是基于准备状态的这一事实,比反过来要容易得多。正如我之前提到的,使用 wepoll 是创建 Windows 上基于准备状态的队列的一种方法。这将极大地简化创建这样的 API,但我们现在不讨论这一点,因为它不太为人所知,并且不是微软官方文档化的方法。

由于 IOCP 是基于完成的,它需要一个缓冲区来读取数据,因为它在数据被读取到该缓冲区时返回。另一方面,Kqueue 和 epoll 不需要。它们只会在你可以不阻塞地将数据读取到缓冲区时返回。

通过要求用户为我们 API 提供他们偏好的缓冲区大小,我们让用户控制他们想要如何管理他们的内存。用户定义缓冲区的大小,并控制所有将被传递给操作系统的内存方面,当使用 IOCP 时。

在这种 API 的情况下,对于 epoll 和 kqueue,你可以简单地调用 read 为用户服务,并填充相同的缓冲区,这样用户就会觉得 API 是基于完成的。

如果你想要展示一个基于准备状态的 API,那么在 Windows 上进行 I/O 操作时,你必须创建一个有两个独立操作的错觉。首先,当数据准备好在套接字上读取时请求一个通知,然后实际读取数据。虽然可以做到,但你很可能会发现自己不得不创建一个非常复杂的 API 或在 Windows 平台上接受一些由于中间缓冲区而导致的效率低下,以保持基于准备状态的 API 的错觉。

我们将把事件队列的话题留到我们创建一个简单示例来展示它们是如何工作的具体时刻。在我们这样做之前,我们需要真正熟悉 FFI 和系统调用,我们将通过编写一个在三个不同平台上实现系统调用的示例来实现这一点。

我们还将利用这个机会来讨论抽象级别以及我们如何创建一个在三个不同平台上都能工作的统一 API。

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

我们将为三种架构实现一个非常基本的系统调用:BSD/macOSLinuxWindows。我们还将看到这是如何在三个抽象级别上实现的。

我们将实现的系统调用是在我们将某些内容写入标准输出stdout)时使用的,因为这是一个非常常见的操作,而且了解它是如何真正工作的很有趣。

我们将从查看我们可以用来进行系统调用和从底层构建我们对它们的理解的最低抽象级别开始。

最低的抽象级别

最低层的抽象是编写通常被称为“原始”的系统调用。原始系统调用是绕过操作系统提供的系统调用库,而是依赖于操作系统有一个稳定的系统调用 ABI。稳定的系统调用 ABI 意味着它保证如果你在特定的寄存器中放入正确的数据并调用一个将控制权传递给操作系统的特定 CPU 指令,它总是会做同样的事情。

要进行原始系统调用,我们需要编写一点内联汇编,但别担心。尽管我们在这里突然引入了它,但我们会逐行分析它,并且在第五章中,我们将更详细地介绍内联汇编,以便你熟悉它。

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

Linux 上的原始系统调用

在 Linux 和 macOS 上,我们想要调用的系统调用称为write。这两个系统都是基于当你启动一个进程时stdout已经存在的概念。

如果你不在你的机器上运行 Linux,有一些选项可以运行这个示例。你可以将代码复制粘贴到 Rust Playground 中,或者你可以在 Windows 中使用 WSL 运行它。

如介绍中所述,我将在每个示例的开始列出你需要去的地方的示例,你可以通过编写cargo run在那里运行示例。源代码始终位于src/main.rs示例文件夹中。

我们首先引入的是标准库模块,它给我们提供了访问asm!宏的权限。

仓库引用:ch03/a-raw-syscall

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") _
        );
    }
}

我们将逐行分析这一部分。接下来的内容将非常相似,所以我们只需要详细说明一次。

首先,我们有一个名为#[inline(never)]的属性,它告诉编译器我们永远不会希望在这个函数优化期间将其内联。内联是编译器省略函数调用并简单地复制函数体而不是调用它的情况。在这种情况下,我们不希望这种情况发生。

接下来,我们有我们的函数调用。函数中的前两行只是简单地获取存储我们的文本的内存位置的原始指针和文本缓冲区的长度。

下一个行是一个不安全的块,因为在 Rust 中无法安全地调用这样的汇编。

汇编的第一行将值1放入rax寄存器。当 CPU 稍后陷阱我们的调用并将控制权传递给操作系统时,内核知道rax中的值为一意味着我们想要进行write

第二行将值1放入rdi寄存器。这告诉内核我们想要写入的位置,而一表示我们想要写入stdout

第三行调用syscall指令。这个指令发出一个软件中断,CPU 将控制权传递给操作系统。

Rust 的内联汇编语法一开始可能会显得有些令人生畏,但请耐心等待。我们将在本书稍后详细讲解,以便你能够熟悉它。现在,我只会简要地解释它做了什么。

第四行将地址写入缓冲区,其中我们的文本存储在 rsi 寄存器中。

第五行将我们的文本缓冲区的长度(以字节为单位)写入 rdx 寄存器。

接下来的四行不是对 CPU 的指令;它们的目的是告诉编译器它不能将这些寄存器中的任何内容存储,并且假设我们在退出内联汇编块时数据未被修改。我们通过告诉编译器将有一些未指定数据(由下划线表示)写入这些寄存器来实现这一点。

最后,是时候调用我们的原始系统调用了:

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

这个函数只是创建一个 String 并调用我们的 syscall 函数,将其作为参数传递。

如果你在这台 Linux 机器上运行它,你现在应该在控制台看到以下消息:

Hello world from raw syscall!

macOS 上的原始系统调用

现在,由于我们使用的是特定于 CPU 架构的指令,所以我们需要根据你运行的是带有英特尔 CPU 的较老 Mac 还是带有基于 Arm 64 架构的较新 Mac 来使用不同的函数。我们只展示了适用于使用 ARM 64 架构的新 M 系列芯片的函数,但不用担心,如果你已经克隆了 Github 仓库,你会在那里找到适用于两种版本 Mac 的代码。

由于只有细微的变化,我将在这里展示整个示例,并仅说明差异。

记住,你需要在一台带有 macOS 和 M 系列芯片的机器上运行此代码。你不能在 Rust 演示场地上尝试此操作。

ch03/a-raw-syscall

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") _
        );
    }
}

除了不同的寄存器命名外,与我们在 Linux 上编写的那一个相比,并没有太大的区别,除了在 macOS 上,write 操作的代码是 4,而不是 Linux 上的 1。此外,引发软件中断的 CPU 指令是 svc 0,而不是 syscall

再次强调,如果你在 macOS 上运行此代码,你将在控制台看到以下输出:

Hello world from raw syscall!

那么在 Windows 上原始的系统调用呢?

这是一个很好的机会来解释为什么如果你想让你的程序或库跨平台工作,像我们刚才那样编写原始的系统调用是一个坏主意。

你看,如果你想让你的代码在未来很长时间内都能工作,你必须担心操作系统提供的保证。Linux 保证,例如,写入 rax 寄存器的值 1 总是指向 write,但 Linux 在许多平台上运行,并不是每个人都使用相同的 CPU 架构。我们与 macOS 面临着相同的问题,它最近刚刚从基于英特尔 x86_64 架构转变为基于 ARM 64 架构。

当涉及到像这样的低级内部结构时,Windows 根本不提供任何保证。Windows 已经多次更改其内部结构,并且没有提供关于此问题的官方文档。我们唯一拥有的只是可以在互联网上找到的反汇编表,但这些不是稳健的解决方案,因为下一次运行 Windows 更新时,原本是write的系统调用可能会变成delete的系统调用。即使这种情况不太可能发生,你也没有任何保证,这反过来又使得你无法向你的程序用户保证它将来会工作。

因此,虽然原始的系统调用在理论上确实有效,并且熟悉它们是有好处的,但它们主要作为我们为什么更愿意在系统调用时链接到不同操作系统为我们提供的库的例子。下一部分将展示我们是如何做到这一点的。

抽象的下一层次

抽象的下一层次是使用 API,这是三个操作系统都为我们提供的。

我们很快就会看到这个抽象有助于我们移除一些代码。在这个特定例子中,Linux 和 macOS 上的系统调用是相同的,所以我们只需要担心我们是否在 Windows 上。我们可以通过使用#[cfg(target_family = "windows")]#[cfg(target_family = "unix")]条件编译标志来区分平台。你将在仓库中的示例中看到这些标志的使用。

我们的主要功能将和之前看起来一样:

ch03/b-normal-syscall

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

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

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

你可以直接在 Rust playground 上运行这段代码,因为它在 Linux 上运行,或者你可以在使用 WSL 的 Linux 机器上本地运行它,或者在 macOS 上运行:

ch03/b-normal-syscall

#[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(())
}

让我们一步一步地了解不同的步骤。了解如何进行适当的系统调用将在本书后面的内容中对我们非常有用。

#[link(name = "c")]

每个 Linux(和 macOS)安装都附带了一个libc版本,这是一个用于与操作系统通信的 C 库。有了libc和一致的 API,我们可以以相同的方式进行编程,而不用担心底层平台架构。内核开发者也可以在不破坏每个人程序的情况下更改底层的 ABI。这个标志告诉编译器链接到系统上的"c"库。

接下来是定义我们想要在链接的库中调用的函数:

extern "C" {
 fn write(fd: u32, buf: *const u8, count: usize);
}

extern "C"(有时可以不带"C",因为如果未指定,则假定使用"C")意味着我们想要在链接到的"C"库中使用"C"write函数。这个函数需要有与链接到的库中函数完全相同的名称。参数名称不必相同,但它们必须以相同的顺序排列。将它们命名为与链接到的库中相同的名称是一种良好的做法。

在这里,我们使用 Rust 的 FFI(Foreign Function Interface,外部函数接口),所以当你读到使用 FFI 调用外部函数时,这正是我们在做的事情。

write函数接受一个文件描述符fd,在这个例子中,它是stdout的句柄。此外,它期望我们提供一个指向 u8 数组buf值的指针和该缓冲区的长度count

调用约定

这是我们第一次遇到这个术语,所以即使我们稍后会更深入地探讨这个主题,我仍会简要解释一下。

调用约定定义了函数调用是如何执行的,并将指定如下:

  • 函数参数是如何传递给函数的

  • 函数在开始时预期存储哪些寄存器,并在返回前恢复

  • 函数如何返回其结果

  • 如何设置堆栈(我们稍后会回到这个问题)

因此,在调用外部函数之前,你需要指定要使用的调用约定,因为编译器不知道的话,就没有办法知道了。C 调用约定是最常见的一种。

接下来,我们将对链接的函数的调用包装在一个正常的 Rust 函数中。

ch03/b-normal-syscall

#[cfg(target_family = "unix")]
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(())
}

你现在可能已经熟悉前两行,因为它们与我们为原始系统调用示例编写的相同。我们获取存储文本的缓冲区的指针和该缓冲区的长度。

接下来是libc中的write函数的调用,由于 Rust 在调用外部函数时不能保证安全性,所以需要包装在unsafe块中。

你可能会想知道我们是如何知道值1指的是stdout的文件句柄。

当你从 Rust 编写系统调用时,你会经常遇到这种情况。通常,常量是在C头文件中定义的,因此我们需要手动搜索它们并查找这些定义。1在 UNIX 系统中始终是stdout的文件句柄,所以很容易记住。

注意

包装libc函数并提供这些常量正是 create libc (github.com/rust-lang/libc)为我们提供的。大多数时候,你可以使用它来代替我们在这里所做的所有手动工作,比如链接和定义函数。

最后,我们有错误处理,当使用 FFI 时,你会经常看到这一点。C函数通常使用一个特定的整数来指示函数调用是否成功。在这个write调用的例子中,函数将返回写入的字节数,或者如果发生错误,它将返回值-1。你可以通过阅读 Linux 的man-pagesman7.org/linux/man-pages/index.html)来轻松找到这些信息。

如果发生错误,我们使用 Rust 标准库中的内置函数查询 OS 为该进程报告的最后错误,并将其转换为 rust io::Error类型。

如果你使用cargo run运行这个函数,你会看到以下输出:

Hello world from syscall!

使用 Windows API

在 Windows 上,事情有点不同。虽然 UNIX 模型几乎将你交互的每一件事都视为“文件”,但 Windows 使用其他抽象。在 Windows 上,你得到一个句柄,它代表你可以以特定方式与之交互的对象。句柄的具体类型决定了交互方式。

我们将使用之前相同的main函数,但我们需要链接到 Windows API 中的不同函数,并对我们的syscall函数进行修改。

ch03/b-normal-syscall

#[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;
}

你首先注意到的是,我们不再链接到"C"库。相反,我们链接到kernel32库。下一个变化是使用系统调用约定。这个约定有点特别。你看,Windows 根据你为 32 位 x86 Windows 版本还是 64 位 x86_64 Windows 版本编写代码而使用不同的调用约定。运行在 x86_64 上的较新版本的 Windows 使用"C"调用约定,所以如果你有一个较新的系统,你可以尝试将其更改并查看它是否仍然工作。“指定系统”让编译器根据系统确定正确的调用约定。

在 Windows 中,我们链接到两个不同的系统调用:

  • GetStdHandle: 这个函数用于获取对标准设备(如stdout)的引用

  • WriteConsoleW: WriteConsole有两种类型。WriteConsoleW接受 Unicode 文本,而WriteConsoleA接受 ANSI 编码的文本。在我们的程序中,我们使用接受 Unicode 文本的那个版本。

现在,如果你只写英文文本,ANSI 编码的文本可以正常工作,但一旦你开始写其他语言的文本,你可能需要使用 ANSI 无法表示但在Unicode中可以表示的特殊字符。如果你混合使用它们,你的程序可能不会按预期工作。

接下来是我们的新syscall函数:

ch03/b-normal-syscall

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(())
}

我们首先做的事情是将文本转换为 Windows 使用的utf-16编码的文本。幸运的是,Rust 有一个内置函数可以将我们的utf-8编码文本转换为utf-16代码点。encode_utf16返回一个u16代码点的迭代器,我们可以将其收集到一个Vec中。

接下来的两行现在应该很熟悉了。我们获取文本存储位置的指针和文本的字节长度。

接下来,我们调用GetStdHandle并传入值-11。我们需要为不同的标准设备传入的值与GetStdHandle的文档一起描述,在learn.microsoft.com/en-us/windows/console/getstdhandle。这很方便,因为我们不需要在 C 头文件中挖掘以找到我们需要的所有常量值。

所有函数的返回码都有详细的文档说明,所以我们在这里以与 Linux/macOS 系统调用相同的方式处理潜在的错误。

最后,我们有调用WriteConsoleW函数的例子。这没有什么太复杂的,你会注意到它与我们在 Linux 中使用的write系统调用的相似之处。一个不同之处在于,输出不是从函数返回,而是写入我们以指针形式传递的输出变量地址位置。

注意

现在你已经看到了我们如何创建跨平台的系统调用,你可能也会理解为什么我们不包含使本书中每个示例都跨平台的代码。简单来说,如果这样做,这本书会非常长,而且并不明显这些额外的信息实际上会帮助我们理解关键概念。

最高级别的抽象

这很简单,但我只是想为了完整性而添加这一点。Rust 标准库为我们封装了对底层操作系统 API 的调用,所以我们不需要关心要调用哪些系统调用。

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

恭喜!你现在已经用三个层次抽象写出了相同的系统调用。你现在知道了 FFI 的样子,你看到了一些内联汇编(我们将在稍后更详细地介绍),并且你已经正确地进行了系统调用,将内容打印到控制台。你还看到了我们标准库通过封装这些针对不同平台的调用来尝试解决的问题,这样我们就不需要知道这些系统调用来打印内容到控制台。

概述

在本章中,我们介绍了基于操作系统的事件队列是什么,并对其工作原理进行了高级概述。我们还讨论了 epoll、kqueue 和 IOCP 的定义特征,并重点介绍了它们之间的差异。

在本章的后半部分,我们介绍了一些系统调用的例子。我们讨论了原始系统调用和“正常”系统调用,这样你知道它们是什么,并看到了两者的示例。我们还利用这个机会讨论了抽象层次,以及当我们能够利用好的抽象时,依赖它们的优点。

作为系统调用的一个部分,你也了解了 Rust 的 FFI(Foreign Function Interface)。

最后,我们创建了一个跨平台的抽象。你也看到了创建一个在多个操作系统上工作的统一 API 所面临的挑战。

下一章将带你通过一个使用 epoll 创建简单事件队列的例子,这样你可以看到它在实际中是如何工作的。在仓库中,你还可以找到 Windows 和 macOS 的相同示例,所以如果你想要为这些平台中的任何一个实现事件队列,这些示例都是可用的。

第二部分:事件队列和绿色线程

在这部分,我们将展示两个示例。第一个示例演示了使用 epoll 创建事件队列的过程。我们将设计 API,使其与 mio 使用的 API 非常相似,这样我们就可以掌握 mio 和 epoll 的基本原理。第二个示例说明了使用 fibers/green threads 的方法,这与 Go 使用的方法类似。这种方法是 Rust 使用 futures 和 async/await 进行异步编程的流行替代方案之一。在 Rust 达到 1.0 版本之前,它也使用了 green threads,这使得它成为了 Rust 异步编程历史的一部分。在整个探索过程中,我们将深入研究诸如 ISAs、ABIs、调用约定、栈等基本编程概念,并简要涉及汇编编程。本节包括以下章节:

  • 第四章**,创建你自己的事件队列

  • 第五章**,创建我们自己的纤维

第四章:创建您自己的事件队列

在本章中,我们将使用 epoll 创建事件队列的简单版本。我们将从 mio 中汲取灵感,这也有助于如果你想要探索一个真正的生产就绪库是如何工作的,更容易地深入研究它们的代码库。

到本章结束时,你应该能够理解以下内容:

  • 阻塞和非阻塞 I/O 之间的区别

  • 如何使用 epoll 创建自己的事件队列

  • 跨平台事件队列库(如 mio)的源代码

  • 如果我们想让程序或库在不同的平台上工作,为什么需要在 epoll、kqueue 和 IOCP 之上添加抽象层

我们将本章分为以下几部分:

  • epoll 的设计和介绍

  • ffi 模块

  • Poll 模块

  • main 程序

技术要求

本章重点介绍 epoll,它是 Linux 特有的。不幸的是,epoll 不是 可移植操作系统接口 (POSIX) 标准的一部分,因此这个示例将需要你在 Linux 上运行,不会与 macOS、BSD 或 Windows 操作系统兼容。

如果你在一台运行 Linux 的机器上,你已经设置好了,可以运行示例而无需进一步操作。

如果你使用的是 Windows,我的建议是如果你还没有设置,请设置 WSL (learn.microsoft.com/en-us/windows/wsl/install) 并在 WSL 上运行的 Linux 操作系统中安装 Rust。

如果你使用的是 Mac,你可以通过使用基于 QEMU 的 UTM 应用程序 (mac.getutm.app/) 或其他任何在 Mac 上管理虚拟机 (VM) 的解决方案来创建一个运行 Linux 的 虚拟机 (VM),例如。

最后一个选择是租用一个 Linux 服务器(甚至有些提供商提供免费层),安装 Rust,然后在控制台中使用 Vim 或 Emacs 等编辑器,或者在远程机器上通过 SSH 使用 VS Code 进行开发 (code.visualstudio.com/docs/remote/ssh)。我个人对 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 自称是一个“针对 Rust 的快速、低级 I/O 库,专注于非阻塞 API 和事件通知,以尽可能少的开销在 操作系统抽象之上 *构建性能 I/O 应用。””

mio 驱动 Tokio 中的事件队列,Tokio 是 Rust 中最受欢迎和广泛使用的异步运行时之一。这意味着 mio 正在驱动像 Actix Web(actix.rs/)、Warp(github.com/seanmonstar/warp)和 Rocket(rocket.rs/)这样的流行框架的 I/O。

在本例中,我们将使用 mio 的0.8.8版本作为设计灵感的来源。API 在过去已经改变,并且未来可能会改变,但我们在本处覆盖的 API 部分自 2019 年以来一直保持稳定,因此可以合理地预测,在不久的将来它不会发生重大变化。

正如所有跨平台抽象一样,通常有必要走选择最小公倍数的路线。一些选择可能会限制一个或多个平台上的灵活性和效率,以追求一个与所有这些平台都兼容的统一 API。我们将在本章中讨论一些这些选择。

在我们继续之前,让我们创建一个空白项目并给它起一个名字。我们将从现在开始称它为a-epoll,但当然你需要用你选择的名称来替换它。

进入文件夹并输入cargo init命令。

在这个例子中,我们将项目分成几个模块,并将代码拆分到以下文件中:

src
 |-- ffi.rs
 |-- main.rs
 |-- poll.rs

它们的描述如下:

  • ffi.rs:此模块将包含与我们需要与宿主操作系统通信的 syscalls 相关的代码

  • main.rs:这是示例程序本身

  • poll.rs:此模块包含主要抽象,它是在 epoll 之上的一个薄层

接下来,在src文件夹中创建前面提到的四个文件。

main.rs中,我们还需要声明模块:

a-epoll/src/main.rs

mod ffi;
mod poll;

现在我们已经设置了项目,我们可以开始讨论我们将如何设计我们将使用的 API。主要抽象在poll.rs文件中,所以请打开该文件。

让我们先构建出我们需要的结构和函数。当我们把它们放在面前时,讨论它们会更简单:

a-epoll/src/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 是一个表示事件队列本身的 struct。它有几个方法:

  • new: 创建一个新的事件队列

  • registry: 返回一个引用,我们可以用它来注册对新事件的兴趣

  • poll: 阻塞调用该方法的线程,直到有事件准备好或超时,以先发生者为准

Registry 是等式的另一半。虽然 Poll 代表事件队列,但 Registry 是一个句柄,它允许我们注册对新事件的兴趣。

Registry 只有一个方法:register。再次强调,我们模仿 mio 使用的 API (docs.rs/mio/0.8.8/mio/struct.Registry.html),并且不是接受一个预定义的方法列表来注册不同的兴趣,而是接受一个 interests 参数,它将指示我们希望我们的事件队列跟踪哪种类型的事件。

另一点需要注意的是,我们不会为所有源使用泛型类型。我们只会为 TcpStream 实现,尽管我们可以用事件队列跟踪许多潜在的东西。

这尤其适用于我们想要实现跨平台时,因为根据你想要支持的平台,可能有许多类型的事件源我们想要跟踪。

mio 通过让 Registry::register 接受一个实现了 mio 定义的 Source 特性的对象来解决此问题。只要为源实现这个特性,你就可以使用事件队列来跟踪它的事件。

在下面的伪代码中,你会了解我们计划如何使用这个 API:

let queue = Poll::new().unwrap();
let id = 1;
// register interest in events on a TcpStream
queue.registry().register(&stream, id, ...).unwrap();
let mut events = Vec::with_capacity(1);
// This will block the curren thread
queue.poll(&mut events, None).unwrap();
//...data is ready on one of the tracked streams

你可能会想知道为什么我们真的需要 Registry 结构。

要回答这个问题,我们需要记住 mio 抽象了 epollkqueueIOCP。它是通过使 Registry 包围一个 Selector 对象来做到这一点的。Selector 对象是条件编译的,以便每个平台都有其自己的 Selector 实现,对应于执行 IOCP、kqueueepoll 的相关系统调用。

Registry 实现了一个我们不会在示例中实现的重要方法,称为 try_clone。我们不实现这个方法的原因是我们不需要它来理解这种事件循环是如何工作的,我们希望保持示例简单易懂。然而,这个方法对于理解为什么注册事件和队列本身的责任是分开的是很重要的。

重要提示

通过将注册兴趣的关注点移动到这样一个单独的结构中,用户可以通过调用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,转换为 IP 地址,如216.58.207.228。操作系统维护一个本地地址和之前在缓存中查找的地址的映射,并且几乎可以立即解析它们。然而,第一次查找未知地址时,它可能需要调用 DNS 服务器,这需要很长时间,如果未以非阻塞方式处理,操作系统将挂起调用线程等待响应。

文件输入/输出

本地文件系统上的文件是另一个操作系统执行大量缓存操作的区域。经常被读取的小文件通常会被缓存在内存中,因此请求该文件可能根本不会阻塞。如果你有一个提供静态文件的 Web 服务器,你很可能只提供一小部分小文件。这些文件很可能被缓存在内存中。然而,无法确定这一点——如果操作系统内存不足,它可能不得不将内存页面映射到硬盘上,这使得通常非常快的内存查找变得极其缓慢。同样,如果随机访问大量小文件,或者如果你提供非常大的文件(因为操作系统只会缓存有限的信息),情况也是如此。如果你在同一操作系统上运行许多无关的过程,你也可能会遇到这种不可预测性,因为它可能不会缓存对你重要的信息。

处理这些情况的一个流行方法是忘记非阻塞 I/O,实际上进行阻塞调用。你不想在运行Poll实例的同一个线程中执行这些调用(因为任何小的延迟都会阻塞所有任务),但你可能会将这个任务委派给线程池。在线程池中,你有一有限数量的线程,它们负责为诸如 DNS 查找或文件 I/O 之类的操作进行常规的阻塞调用。

一个运行时示例,它正好做这件事的是 libuv (docs.libuv.org/en/v1.x/threadpool.html#threadpool)。libuv 是 Node.js 构建在之上的异步 I/O 库。

虽然 libuv 的范围比 mio 更广(mio 只关心非阻塞 I/O),但 libuv 在 JavaScript 中的地位相当于 mio 在 Rust 中的地位。

注意

在线程池中执行文件 I/O 的原因在于,历史上非阻塞文件 I/O 的跨平台 API 一直很差。虽然许多运行时选择将这个任务委托给线程池,并通过操作系统进行阻塞调用,但这可能不会在未来成为事实,因为操作系统 API 随着时间的推移而演变。

创建一个线程池来处理这些情况超出了这个示例的范围(即使是 mio 也认为这超出了它的范围,为了明确起见)。我们将专注于展示 epoll 的工作原理,并在文本中提及这些主题,尽管我们不会在这个示例中实际实现解决方案。

现在我们已经介绍了关于 epoll、mio 以及我们示例设计的很多基本信息,是时候编写一些代码,亲自看看这一切在实际中是如何工作的了。

ffi 模块

让我们从那些不依赖于其他模块的模块开始,逐步深入。ffi 模块包含了我们与操作系统通信所需的系统调用和数据结构的映射。一旦我们介绍了系统调用,我们也会详细解释 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_ADDEPOLLINEPOLLET 的常量。

我稍后会解释这些常量的含义。首先,让我们看看我们需要执行的系统调用。幸运的是,我们已经详细介绍了系统调用,所以你已经知道了 ffi 的基础知识以及为什么在前面的代码中我们要链接到 C:

  • epoll_create 是我们用来创建 epoll 队列的系统调用。你可以在这里找到它的文档 man7.org/linux/man-pages/man2/epoll_create.2.html。这个方法接受一个名为 size 的参数,但 size 只是为了历史原因而存在的。这个参数将被忽略,但必须有一个大于 0 的值。

  • close 是我们需要在创建 epoll 实例时关闭文件描述符的系统调用,这样我们就可以正确地释放资源。你可以在 man7.org/linux/man-pages/man2/close.2.html 阅读这个系统调用的文档。

  • epoll_ctl是我们用来对 epoll 实例执行操作的控件接口。这是我们用来在源上注册对事件兴趣的调用。它支持三种主要操作:添加修改删除。第一个参数epfd是我们想要执行操作的 epoll 文件描述符。第二个参数op是我们指定是否想要执行一个添加修改删除操作的参数。

  • 在我们的情况下,我们只对为事件添加兴趣感兴趣,因此我们只会传递EPOLL_CTL_ADD,这是表示我们想要执行一个添加操作的值。epoll_event稍微复杂一些,所以我们将在更多细节中讨论它。它为我们做了两件重要的事情:首先,events字段指示我们想要被通知的事件类型,并且它还可以修改我们如何何时被通知的行为。其次,data字段将一块数据传递给内核,当发生事件时内核会将其返回给我们。后者很重要,因为我们需要这些数据来精确识别发生了什么事件,因为这是我们唯一会收到的可以识别我们收到通知来源的信息。您可以在以下链接中找到此系统调用的文档:man7.org/linux/man-pages/man2/epoll_ctl.2.html

  • epoll_wait是会阻塞当前线程并等待以下两种情况之一的调用:我们收到一个事件已发生的通知,或者它超时了。epfd是标识我们使用epoll_create创建的队列的 epoll 文件描述符。events是我们用于epoll_ctl的相同Event结构体的数组。区别在于,events字段现在提供了关于发生了什么事件的信息,并且重要的是,data字段包含我们在注册兴趣时传递的相同数据。

  • 例如,data字段让我们能够识别哪个文件描述符有准备读取的数据。maxevents参数告诉内核我们在我们的数组中为多少事件预留了空间。最后,timeout参数告诉内核在它再次唤醒我们之前我们将等待事件多长时间,这样我们就不可能永远阻塞。您可以在以下链接中阅读epoll_wait的文档:man7.org/linux/man-pages/man2/epoll_wait.2.html

该文件中的代码最后一部分是Event结构体:

ch04/a-epoll/src/ffi.rs

#[derive(Debug)]
#[repr(C, packed)]
pub struct Event {
    pub(crate) events: u32,
    // Token to identify event
    pub(crate) epoll_data: usize,
}
impl Event {
    pub fn token(&self) -> usize {
        self.epoll_data
    }
}

这个结构体用于在epoll_ctl中与操作系统通信,操作系统使用相同的结构体在epoll_wait中与我们通信。

事件被定义为u32,但它不仅仅是数字。这个字段就是我们所说的位掩码。我会在稍后的部分花时间解释位掩码,因为它在大多数系统调用中都很常见,并不是每个人都遇到过。简单来说,它是一种使用位表示作为一组是/否标志的方法,以指示是否选择了某个选项。

不同的选项在我提供的epoll_ctl系统调用链接中有描述。在这里,我不会详细解释所有选项,但只介绍我们将要使用的:

  • EPOLLIN代表一个位标志,表示我们对文件句柄上的读取操作感兴趣。

  • EPOLLET代表一个位标志,表示我们感兴趣的是通过将 epoll 设置为边沿触发模式来通知事件。

我们稍后会回到解释 bitflags、bitmasks 以及边沿触发模式真正意味着什么,但让我们先完成代码部分。

Event结构体的最后一个字段是epoll_data。这个字段在文档中被定义为联合体。联合体很像枚举,但与 Rust 的枚举不同,它不携带任何关于它是什么类型的信息,因此我们得确保我们知道它持有的数据类型。

我们使用这个字段简单地保存一个usize,这样我们就可以在注册兴趣时使用epoll_ctl传递一个标识每个事件的整数。传递一个指针也是完全可以接受的——只要我们确保在epoll_wait返回时指针仍然是有效的。

我们可以把这个字段看作是一个令牌,这正是mio所做的事情,为了使 API 尽可能相似,我们复制了mio,并在结构体上提供了一个token方法来获取这个值。

#[repr(packed)]的作用是什么?

#[repr(packed)]这个注解对我们来说是新的。通常,一个结构体会在字段之间或结构体末尾有填充。即使我们指定了#[repr(C)],这种情况也会发生。

原因在于,通过不需要多次获取结构体字段中存储的数据,我们可以更有效地访问结构体中存储的数据。在Event结构体的例子中,通常的填充会在events字段末尾添加 4 个字节的填充。当操作系统期望Event结构体是打包的,而我们提供了一个填充的结构体时,它会在字段之间的填充中写入event_data的部分。当你稍后尝试读取event_data时,你将只读取event_data的最后部分,这恰好与填充重叠并获取了错误的数据。

操作系统期望Event结构体是打包的,这一点通过阅读 Linux 的 man 手册页并不明显,因此你必须阅读适当的 C 头文件才能确定。当然,你可以简单地依赖libc包(github.com/rust-lang/libc),如果我们不是在这里自己学习这类知识,我们也会这样做。

因此,现在我们已经走过了代码,有几个主题我们承诺要回过头来讨论。

位标志和位掩码

当你进行系统调用时(实际上,位掩码的概念在底层编程中相当常见),你经常会遇到这种情况。位掩码是一种将每个位视为开关或标志的方法,以指示一个选项是启用还是禁用。

一个整数,例如i32,可以表示为 32 位。EPOLLIN的十六进制值为0x1(这在十进制中就是 1)。以二进制表示,这将看起来像00000000000000000000000000000001

另一方面,EPOLLET的值为1 << 31。这仅仅意味着十进制数 1 的位表示向左移动了 31 位。巧合的是,十进制数 1 与EPOLLIN相同,所以通过查看这个表示并将位向左移动 31 次,我们得到一个位表示为10000000000000000000000000000000的数字。

我们使用位标志的方式是使用 OR 运算符|,通过将值组合在一起,我们得到一个掩码,其中每个 OR 过的标志都设置为 1。在我们的例子中,掩码将看起来像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

在本章中,我们将介绍下一个主题,即边沿触发事件的概念,这可能需要一些解释。

电平触发与边沿触发事件

在一个完美的世界里,我们不需要讨论这个问题,但当我们与 epoll 一起工作时,几乎不可避免地需要了解这些差异。通过阅读文档,这一点并不明显,尤其是如果你之前没有接触过这些术语。有趣的是,这允许我们创建一个并行,即 epoll 中事件的处理方式与硬件级别事件的处理方式。

epoll 可以在Event结构体的events掩码上通知事件,我们设置EPOLLET标志以在边沿触发模式下接收通知(如果没有指定,默认为电平触发)。

这种对事件通知和事件处理的方式进行建模,与计算机处理中断的方式有很多相似之处。

电平触发意味着只要中断线上报告的电信号为高,问题的答案“事件是否发生”就是真的。如果我们把这一点应用到我们的例子中,只要与文件句柄关联的缓冲区中有数据,读取事件就已经发生。

在处理中断时,你会通过服务引起中断的任何硬件来清除中断,或者你可以屏蔽中断,这只是在稍后显式取消屏蔽之前禁用该线路上的中断。

在我们的例子中,我们通过读取缓冲区中的所有数据来清除 中断。当缓冲区被清空时,我们问题的答案变为 false

当使用默认模式的 epoll 时,它是电平触发的,我们可能会遇到在相同事件上收到多个通知的情况,因为我们还没有时间清空缓冲区(记住,只要缓冲区中有数据,epoll 就会不断地通知你)。这在我们有一个线程报告事件然后将处理事件(从流中读取)的任务委托给其他工作线程时尤为明显,因为 epoll 会愉快地报告事件已准备好,即使我们正在处理它。

为了解决这个问题,epoll 有一个名为 EPOLLONESHOT 的标志。

EPOLLONESHOT 告诉 epoll,一旦我们在这个文件描述符上收到一个事件,它应该在该兴趣列表中禁用文件描述符。它不会移除它,但除非我们通过调用带有 EPOLL_CTL_MOD 参数和新的掩码的新 epoll_ctl 来显式重新激活它,否则我们不会在该文件描述符上收到任何更多通知。

如果我们没有添加这个标志,可能会发生以下情况:如果 线程 1 是我们调用 epoll_wait 的线程,那么一旦它收到关于读取事件的通告,它就会在 线程 2 中启动一个任务来读取该文件描述符,然后再次调用 epoll_wait 来获取新事件的通告。在这种情况下,epoll_wait 的调用将再次返回并告诉我们数据已在该文件描述符上准备好,因为我们还没有时间清空该文件描述符上的缓冲区。我们知道任务已经被 thread 2 处理,但我们仍然收到通知。如果没有额外的同步和逻辑,我们可能会将读取同一文件描述符的任务分配给 线程 3,这可能会引起难以调试的问题。

使用 EPOLLONESHOT 解决了这个问题,因为 线程 2 在完成其任务后必须重新激活事件队列中的文件描述符,从而通知我们的 epoll 队列它已经处理完毕,并且我们再次对该文件描述符上的通知感兴趣。

要回到我们最初的硬件中断的类比,EPOLLONESHOT 可以被看作是屏蔽中断。你实际上还没有清除事件通知的来源,但你不想在完成并显式取消屏蔽之前收到更多通知。在 epoll 中,EPOLLONESHOT 标志将禁用文件描述符上的通知,直到你通过调用 epoll_ctl 并将 op 参数设置为 EPOLL_CTL_MOD 来显式启用它。

边沿触发意味着对于“是否发生了事件”这个问题的答案只有在电信号从低到高改变时才是真的。如果我们把这个翻译到我们的例子中:当缓冲区从没有数据变为有数据时,就会发生读取事件。只要缓冲区中有数据,就不会报告新的事件。你仍然通过从套接字中清除所有数据来处理事件,但直到缓冲区完全清除并重新填充新数据,你都不会收到新的通知。

边沿触发模式也有一些陷阱。最大的一个问题是,如果你没有正确清除缓冲区,你将永远不会在那个文件句柄上再次收到通知。

图 4.1 – 边沿触发与电平触发事件

图 4.1 – 边沿触发与电平触发事件

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 },
        })
    }

The ffi module部分对 epoll 的详细介绍之后,这应该相当直接。我们用参数 1 调用ffi::epoll_create(记住,参数被忽略,但必须是非零值)。如果出现任何错误,我们要求操作系统为我们进程报告最后一个错误并返回它。如果调用成功,我们返回一个新的Poll实例,它简单地封装了我们持有的 epoll 文件描述符的注册表。

接下来是我们的注册方法,它只是简单地返回内部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。如果它是Some,我们就展开那个值;如果是None,我们就将其设置为-1,这个值告诉操作系统我们想要阻塞,直到发生事件,即使这种情况可能永远不会发生。

在文件顶部,我们将Events定义为Vec<ffi::Event>的类型别名,所以接下来我们要做的是获取那个Vec的容量。我们依赖Vec::capacity而不是Vec::len是很重要的,因为Vec::len报告Vec中的项目数量,而Vec::capacity报告我们分配的空间,这是我们想要的。

接下来是调用ffi::epoll_wait。如果返回值为 0 或更大,表示成功,这告诉我们发生了多少事件。

注意

如果在事件发生之前超时,我们会得到一个值为 0。

我们最后要做的事情是调用events.set_len(res as usize),这是一个不安全的调用。因为这个函数可能会设置一个长度,导致我们在安全的 Rust 代码中访问尚未初始化的内存。我们知道操作系统给我们的保证是它返回的事件数量指向我们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(())
    }
}

注册函数接受一个&TcpStream作为源,一个类型为usize的令牌,以及一个名为interests的掩码,其类型为i32

注意

这就是 mio 以不同的方式做事的地方。源参数对每个平台都是特定的。在注册函数在Registry上实现的地方,它以接收到的源参数的特定平台方式处理。

我们首先创建一个ffi::Event对象。events字段简单地设置为接收到的掩码,并命名为interests,而epoll_data被设置为在token参数中传递的值。

我们想要在 epoll 队列上执行的操作是在新的文件描述符上添加对事件的兴趣。因此,我们将op参数设置为ffi::EPOLL_CTL_ADD常量值。

接下来是调用ffi::epoll_ctl。我们首先传入 epoll 实例的文件描述符,然后传入op参数来指示我们想要执行的操作。最后两个参数是我们想要队列跟踪的文件描述符和我们创建的Event对象,以指示我们感兴趣的事件类型。

函数体的最后一部分仅仅是错误处理,现在应该已经很熟悉了。

poll.rs的最后一部分是RegistryDrop实现:

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实现简单地调用 epoll 文件描述符上的ffi::close。在drop中添加 panic 很少是一个好主意,因为drop可以在 panic 中调用,这会导致进程简单地中止。如果 mio 的 Drop 实现中发生错误,它将记录错误,但不会以任何其他方式处理它们。对于我们的简单示例,我们只是打印错误,这样我们就可以看到是否有什么出错,因为我们没有实现任何类型的日志记录。

最后一部分是运行我们的示例的代码,这把我们带到了main.rs

主程序

让我们看看这一切在实际中是如何工作的。确保delayserver正在运行,因为我们需要它来使这些例子正常工作。

目标是向delayserver发送一组具有不同延迟的请求,然后使用 epoll 等待响应。因此,在这个例子中,我们只会使用 epoll 来跟踪read事件。目前程序所做的并不多。

我们首先确保我们的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 和标准库中导入了一些类型,这些类型是我们接下来需要用到的,以及声明我们的两个模块。

在这个例子中,我们将直接与TcpStreams一起工作,这意味着我们必须自己格式化发送给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"
    )
}

上述代码只是接受一个路径作为输入参数,并使用它格式化一个有效的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 = std::net::TcpStream::connect(addr)?;
        stream.set_nonblocking(true)?;
        stream.write_all(request.as_bytes())?;
        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设置为非阻塞,通过调用TcpStream::set_nonblocking(true)

然后,我们在注册对读事件感兴趣之前将请求写入服务器,通过在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的变量来跟踪我们处理了多少事件。

接下来是我们的事件循环。只要处理的事件少于我们期望的事件数,我们就继续循环。一旦所有事件都得到处理,我们就退出循环。

在循环内部,我们创建一个容量为 10 个事件的Vec<Event>。我们使用Vec::with_capacity来创建这一点很重要,因为操作系统会假设我们传递给它的是我们已分配的内存。我们在这里可以选择任何数量的事件,它都会正常工作,但设置得太低会限制操作系统在每次唤醒时通知我们的事件数量。

接下来是我们的阻塞调用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");
                }
                // Not ready to read in a non-blocking manner. This could
                // happen even if the event was reported as ready
                Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
                Err(e) => return Err(e),
            }
        }
    }
    Ok(handled_events)
}

我们首先创建一个变量handled_events来跟踪我们在每次唤醒时认为处理了多少事件。下一步是遍历我们接收的事件。

在循环中,我们检索标识我们收到事件的TcpStreamtoken。正如我们在这段示例中之前解释的,这个tokenstreams集合中该特定流的索引相同,因此我们可以简单地用它来索引我们的streams集合并检索正确的TcpStream

在我们开始读取数据之前,我们创建一个大小为 4,096 字节的缓冲区(当然,如果你想的话,可以分配一个更大或更小的缓冲区)。

我们创建一个循环,因为我们可能需要多次调用read以确保我们实际上已经清空了缓冲区。记住在使用边缘触发模式下的 epoll 时,完全清空缓冲区是多么重要

我们根据调用TcpStream::read的结果进行匹配,因为我们想根据结果采取不同的行动:

  • 如果我们得到Ok(n)并且值是 0,我们已清空了缓冲区;我们考虑该事件已被处理并跳出循环。

  • 如果我们得到Ok(n)并且值大于 0,我们将数据读取到String中,并使用一些格式化打印出来。我们还没有跳出循环,因为我们必须调用read直到返回 0(或错误)以确保我们已经完全清空了缓冲区。

  • 如果我们得到Err并且错误是io::ErrorKind::WouldBlock类型,我们只需跳出循环。由于WouldBlock表示数据传输尚未完成,但我们现在没有准备好数据,所以我们还不认为事件已被处理。

  • 如果我们得到任何其他错误,我们只需返回该错误并认为它是一个失败。

注意

你通常还想覆盖的一个更多错误条件是io::ErrorKind::Interrupted。从流中读取可能会被操作系统的信号中断。这应该是预期的,可能不会被视为失败。处理这种错误的方式与我们得到WouldBlock类型错误时的方式相同。

如果read操作成功,我们返回处理的事件数量。

使用 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的流上的事件:

  1. send_requests中,我们将 ID4分配给了我们创建的最后一个流。

  2. 套接字 4 向delayserver发送一个请求,设置延迟为 1,000 毫秒和消息为request-4,这样我们就可以在服务器端识别它。

  3. 我们将套接字 4 注册到事件队列中,确保将epoll_data字段设置为4,这样我们就可以识别事件发生在哪个流上。

  4. delayserver接收到那个请求,在发送HTTP/1.1 200 OK响应和原始发送的消息之前,延迟响应 1,000 毫秒。

  5. epoll_wait唤醒,通知我们有一个事件准备好了。在Event结构体的epoll_data字段中,我们得到了我们在注册事件时传递的相同数据。这告诉我们,这是一个发生在流 4 上的事件。

  6. 我们然后从流 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 请求。

  • 你看到了非阻塞套接字的行为以及操作系统报告的错误代码可以是一种传达你预期要处理某些条件的方式。

  • 通过观察 DNS 解析和文件 I/O,你了解到并非所有的 I/O 都是同等“阻塞”的。

我认为这对于一个章节来说已经相当不错了!

如果你进一步深入研究我们在这里讨论的主题,你很快就会意识到到处都是陷阱和死胡同——特别是如果你将这个示例扩展到抽象 epoll、kqueue 和 IOCP。你可能很快就会开始阅读林纳斯·托瓦兹关于边缘触发模式应该在管道上如何工作的电子邮件。

至少你现在有了进一步探索的良好基础。你可以在我们的简单示例基础上进行扩展,创建一个适当的事件循环来处理连接、写入、超时和调度;你可以通过查看mio如何解决这个问题来深入了解 kqueue 和 IOCP;或者你可以高兴地发现你不必再次直接处理它,并欣赏像miopollinglibuv这样的库所付出的努力。

到目前为止,我们已经对异步编程的基本构建块有了很多了解,因此是时候开始探索不同的编程语言是如何在异步操作上创建抽象,并使用这些构建块为我们程序员提供高效、表达性和生产性的异步程序编写方式了。

首先是一个我最喜欢的例子,我们将通过自己实现它们来探究纤程(或绿色线程)是如何工作的。

现在你应该休息一下了。是的,继续吧,下一章可以稍后再说。泡一杯茶或咖啡,让自己放松一下,以便以清醒的头脑开始下一章。我保证这将既有趣又引人入胜。

第五章:创建我们自己的纤程

在本章中,我们将深入探讨一种非常流行的处理并发的方式。没有比亲自实践更好的方式来获得对这一主题的根本理解了。幸运的是,尽管这个主题有点复杂,我们只需要大约 200 行代码就能最终得到一个完全工作的示例。

使这个主题复杂的是,它需要相当多的对 CPU、操作系统和汇编工作方式的基本理解。这种复杂性也是这个主题如此有趣的原因。如果你详细地探索并完成这个示例,你将获得一个令人耳目一新的对那些你可能只听说过或只有初步了解的主题的理解。你还将有机会了解一些你之前没有见过的 Rust 语言的方面,这将扩大你对 Rust 以及一般编程的知识。

我们首先介绍一些在开始编写代码之前我们需要的一些基础知识。一旦我们有了这些知识,我们就会从一些小例子开始,这些例子将允许我们详细展示和讨论我们示例中最技术性和困难的部分,这样我们就可以逐步介绍这些主题。最后,我们将基于我们获得的知识来创建我们的主要示例,这是一个在 Rust 中实现的纤程的工作示例。

作为额外奖励,你将在仓库中获得两个扩展的示例版本,以激发你继续前进,改变、适应并基于我们所创建的内容来使其成为你自己的。

我在这里列出主要主题,以便你以后可以参考:

  • 如何在书中使用仓库

  • 背景信息

  • 一个我们可以构建的示例

  • 实现我们自己的纤程

  • 最后的想法

注意

在本章中,我们将使用“纤程”和“绿色线程”这两个术语来指代这种具体的堆栈纤程实现。本章中使用的“线程”术语,在我们在代码中使用的,将指代我们在示例中实现的绿色线程/纤程,而不是操作系统线程。

技术要求

要运行示例,你需要一台运行在 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找到说明。

  • mac.getutm.app/(有些甚至有免费层)是一个运行在 x86-64 上的 Linux 远程服务器。我有使用 Linode 提供的服务的经验(/),但还有更多选择。

为了与书中的示例同步,你还需要一个基于 Unix 的操作系统。只要运行在 x86-64 CPU 上,示例代码将原生适用于任何 Linux 和 BSD 操作系统(如 Ubuntu 或 macOS)。

如果你使用的是 Windows,仓库中有一个与 Windows 原生兼容的示例版本,但为了与书中内容同步,我明确的建议是设置Windows Subsystem for LinuxWSL)(learn.microsoft.com/en-us/windows/wsl/install),安装 Rust,并在 WSL 上使用 Rust 进行操作。

我个人使用 VS Code 作为我的编辑器,因为它使得在 WSL 和 Windows 之间切换使用 Linux 版本变得非常容易——只需按下Ctrl + Shift + P并搜索在 WSL 中重新打开文件夹

如何将仓库与书籍一起使用

阅读本章的推荐方法是同时打开仓库和书籍。在仓库中,你会发现三个不同的文件夹,对应于本章中我们讨论的示例:

  • ch05/a-stack swap

  • ch05/b-show-stack

  • ch05/c-fibers

此外,你还将获得两个我在书中提到的例子,但这些例子应该在仓库中进一步探索:

  • ch05/d-fibers-closure:这是第一个示例的扩展版本,可能会激发你做更复杂的事情。示例尝试使用std::thread::spawn模仿 Rust 标准库中使用的 API。

  • ch05/e-fibers-windows:这是本书中我们讨论的示例的版本,可以在基于 Unix 的系统上和 Windows 上运行。在 README 中有一个相当详细的解释,说明了我们为使示例在 Windows 上工作所做的更改。如果你想要深入了解这个主题,我将其视为推荐阅读,但它对于理解本章中我们讨论的主要概念并不重要。

背景信息

我们将直接干扰和控制 CPU。由于存在许多不同类型的 CPU,这并不非常便携。虽然整体实现将是相同的,但实现中有一个小但重要的部分将非常具体于我们为它编程的 CPU 架构。另一个限制我们代码便携性的方面是操作系统有不同的 ABIs,我们需要遵守,并且相同的代码片段将根据不同的 ABIs 而改变。在我们进一步讨论之前,让我们具体解释一下我们的意思,以确保我们处于同一页面上。

指令集、硬件架构和 ABI

好的,在我们开始之前,我们需要了解 应用程序二进制接口ABI)、CPU 架构指令集架构ISA)之间的区别。我们需要这些信息来编写自己的栈,并让 CPU 跳转到它。幸运的是,虽然这听起来可能很复杂,但为了我们的示例运行,我们只需要了解一些具体的事情。这里提供的信息在许多情况下都很有用,而不仅仅是我们的示例,所以详细地介绍它是值得的。

ISA 描述了一个 CPU 的抽象模型,它定义了软件如何控制 CPU。我们通常简单地称其为 指令集,它定义了 CPU 可以执行哪些指令,程序员可以使用哪些寄存器,硬件如何管理内存等。ISA 的例子包括 x86-64x86ARM ISA(用于 Mac M 系列芯片)。

ISA 可以广泛分为两个子组,复杂指令集计算机CISC)和 精简指令集计算机RISC),根据它们的复杂性。CISC 架构提供了大量的不同指令,硬件必须知道如何执行这些指令,导致一些指令非常专业且很少被程序使用。RISC 架构接受较少的指令,但要求一些操作由软件处理,而在 CISC 架构中这些操作可以直接由硬件处理。我们将关注的 x86-64 指令集是一个 CISC 架构的例子。

为了增加一点复杂性(你知道,如果太简单就不好玩了),不同的名称可能指的是相同的 ISA。例如,x86-64 指令集也被称为 AMD64 指令集和 Intel 64 指令集,所以无论你遇到哪个,只需知道它们指的是同一件事。在我们的书中,我们将简单地称之为 x86-64 指令集。

小贴士

要找到您当前系统的架构,请在您的终端中运行以下命令之一:

在 Linux 和 MacOS 上:archuname -m

在 Windows PowerShell 中:$env:PROCESSOR_ARCHITECTURE

在 Windows 命令提示符中:echo %PROCESSOR_ARCHITECTURE%

指令集仅定义了程序如何与 CPU 交互。ISA 的具体实现可能因不同制造商而异,特定的实现被称为 CPU 架构,例如英特尔酷睿处理器。然而,在实践中,这些术语通常可以互换使用,因为从程序员的视角来看,它们都执行相同的功能,而且很少需要针对 ISA 的特定实现进行目标定位。

ISA 指定了 CPU 必须能够执行的最小指令集。随着时间的推移,这个指令集已经扩展,例如 Streaming SIMD ExtensionsSSE),它添加了更多的指令和寄存器,程序员可以利用这些指令和寄存器。

对于本章的示例,我们将针对 x86-64 ISA(指令集架构),这是今天大多数桌面计算机和服务器上使用的一种流行架构。

因此,我们知道处理器架构提供了一个程序员可以使用的接口。操作系统实现者使用这个基础设施来创建操作系统。

操作系统如 Windows 和 Linux 定义了一个 ABI(应用程序二进制接口),它指定了一组规则,程序员必须遵守这些规则,以确保他们的程序在该平台上正确运行。操作系统 ABI 的例子包括System V ABI(Linux)和Win64(Windows)。ABI 指定了操作系统期望如何设置堆栈,如何调用函数,如何创建一个可以加载并作为程序运行的文件,程序加载后将被调用的函数名称等。

操作系统必须指定的 ABI 的一个重要部分是其调用约定。调用约定定义了堆栈的使用方式和函数的调用方式。

让我们用一个例子来说明 Linux 和 Windows 在 x86-64 架构上如何处理函数的参数;例如,一个具有如下签名的函数:fn foo(a: i64, b: i64)

x86-64 ISA 定义了 16 个通用寄存器。这些是 CPU 提供给程序员使用的寄存器,程序员可以根据需要使用它们。请注意,这里的程序员包括编写操作系统的那些人,他们可以在创建在他们的操作系统上运行的程序时对可用的寄存器施加额外的限制。在我们的具体例子中,Windows 和基于 Unix 的系统对函数参数的放置位置有不同的要求:

  • Linux 指定,一个接受两个参数的函数应将第一个参数放置在rdi寄存器中,第二个参数放置在rsi寄存器中

  • Windows 要求前两个参数必须通过寄存器rcxrdx传递

这只是许多情况下,为某个平台编写的程序在另一个平台上无法工作的一种方式。通常,这些细节是编译器开发者的关注点,当为特定平台编译时,编译器将处理不同的调用约定。

所以总结一下,CPU 实现了指令集。指令集定义了 CPU 可以执行哪些指令以及它应该为程序员提供哪些基础设施(例如寄存器)。操作系统以不同的方式使用这个基础设施,并为程序员提供额外的规则,程序员必须遵守这些规则才能在他们的平台上正确运行程序。大多数时候,唯一需要关注这些细节的程序员是编写操作系统或编译器的人。然而,当我们自己编写底层代码时,我们需要了解 ISA 和 OS ABI,以确保我们的代码在平台上正确运行。

由于我们需要编写这种代码来实现自己的纤维/绿色线程,我们必须可能为每个存在的 OS ABI/ISA 组合编写不同的代码。这意味着一个用于 Windows/x86-64,一个用于 Windows/ARM,一个用于 MacOS/x86-64,一个用于 Macos/M 等等。

如你所理解,这也是使用纤维/绿色线程处理并发复杂性的一个主要贡献者。一旦为 ISA/OS ABI 组合正确实现,它就有很多优点,但要做到正确需要大量的工作。

为了本书中的示例,我们将只关注一种这样的组合:x86-64 的 System V ABI。

注意!

在随附的仓库中,你可以找到本章主要示例的 Windows x86-64 版本。在 README 中解释了我们需要做的更改以使其在 Windows 上工作。

x86-64 的 System V ABI

如前所述,这种 CPU 架构具有一组 16 个 64 位通用寄存器,16 个 128 位宽度的 SSE 寄存器,以及 8 个 80 位宽度的浮点寄存器:

图 5.1 – x86-64 CPU 寄存器

图 5.1 – x86-64 CPU 寄存器

有一些架构建立在基础之上并对其进行扩展,例如 Intel 的高级向量扩展AVX),它提供了额外的 16 个 256 位宽度的寄存器。让我们看看 System V ABI 规范中的一页:

图 5.2 – 寄存器使用

图 5.2 – 寄存器使用

图 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 语法

Intel 语法是编写 Rust 中内联汇编的标准,但在 Rust 中,我们可以指定我们想要使用 AT&T 语法,如果我们想的话。Rust 对内联汇编有自己的看法,对于习惯于 C 中内联汇编的人来说,一开始看起来可能很陌生。但它经过深思熟虑,随着我们通过代码进行详细解释,我将花一些时间来解释它,以便有经验的 C 类内联汇编读者和没有经验的读者都能够跟上。

注意

在我们的示例中,我们将使用 Intel 语法。

汇编有强大的向后兼容性保证。这就是为什么您会看到以不同方式引用相同的寄存器。让我们以我们用作示例的 rax 寄存器为例进行解释:

rax    # 64 bit register (8 bytes)
eax    # 32 low bits of the "rax" register
ax     # 16 low bits of the "rax" register
ah     # 8 high bits of the "ax" part of the "rax" register
al     # 8 low bits of the "ax" part of the "rax" register

如您所见,这基本上就像在我们面前观看 CPU 的发展历史。由于今天的大多数 CPU 都是 64 位,因此我们将使用代码中的 64 位版本。

在汇编中,字大小也有历史原因。它源于 CPU 有 16 位数据总线的时候,所以一个字是 16 位。这很重要,因为您会看到许多带有 q(四倍字)或 l(长字)后缀的指令。所以,movq 就意味着移动 4 * 16 位,即 64 位。

在大多数现代汇编器中,一个普通的 mov 会使用您目标寄存器的大小。这是您在编写内联汇编时在 AT&T 和 Intel 语法中看到的最常用的一个,我们也会在我们的代码中使用它。

另一点需要注意的是,x86-64 上的 栈对齐 是 16 字节。只需记住这一点以备后用。

我们可以构建的示例

这是一个简短的示例,我们将创建自己的栈,并让我们的 CPU 从当前的执行上下文返回到我们刚刚创建的栈。我们将在接下来的章节中在此基础上构建这些概念。

设置我们的项目

首先,让我们通过创建一个名为 a-stack-swap 的新文件夹来开始一个新的项目。进入新文件夹并运行以下命令:

cargo init

小贴士

您也可以导航到附带的仓库中名为 ch05/a-stack-swap 的文件夹,并在那里查看整个示例。

在我们的 main.rs 中,我们首先导入 asm! 宏:

ch05/a-stack-swap/src/main.rs

use core::arch::asm;

让我们在这里设置一个小的栈大小,仅为 48 字节,这样我们就可以在切换上下文之前打印栈并查看它,以便在第一个示例工作后:

const SSIZE: isize = 48;

注意

在 macOS 上使用如此小的栈似乎存在问题。此代码运行的最小栈大小为 624 字节。如果你想要跟随这个精确的示例,可以在Rust Playground上运行此代码(然而,由于我们最后的循环,你可能需要等待大约 30 秒才能超时)。

然后,让我们添加一个表示 CPU 状态的struct。我们现在只关注存储栈指针的寄存器,因为这是我们目前需要的所有:

#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
    rsp: u64,
}

在后面的示例中,我们将使用我在链接的规范文档中标记为调用者保存的所有寄存器。这些是在 System V x86-64 ABI 中描述的寄存器,我们需要保存我们的上下文,但目前为止,我们只需要一个寄存器来让 CPU 跳转到我们的栈。

注意,这需要是#[repr(C)],因为我们在汇编中访问数据的方式。Rust 没有稳定的语言 ABI,所以我们无法确定这将以rsp作为前 8 个字节在内存中表示。C 有一个稳定的语言 ABI,这正是这个属性告诉编译器要使用的。当然,我们的结构体目前只有一个字段,但我们会稍后添加更多。

对于这个非常简单的示例,我们将定义一个只打印消息然后无限循环的函数:

fn hello() -> ! {
    println!("I LOVE WAKING UP ON A NEW STACK!");
    loop {}
}

接下来是我们的内联汇编,我们将切换到自己的栈:

unsafe fn gt_switch(new: *const ThreadContext) {
    asm!(
        "mov rsp, [{0} + 0x00]",
        "ret",
        in(reg) new,
    );
}

乍一看,你可能认为这段代码没有什么特别之处,但让我们停下来,暂时考虑一下这里发生了什么。

如果我们参考*图 5.1,我们会看到rsp是存储 CPU 用于确定栈当前位置的栈指针**的寄存器。

现在,如果我们想让 CPU 切换到不同的栈,我们实际上想要做的是将栈指针寄存器(rsp)设置为新的栈顶,并将 CPU 上的指令指针(rip)设置为指向hello的地址。

指令指针,有时在不同的架构上被称为程序计数器,它指向要运行的下一个指令。如果我们能直接操作它,CPU 就会获取由rip寄存器指向的指令并执行我们在hello函数中编写的第一个指令。然后,CPU 将使用栈指针指向的地址在新的栈上推送/弹出数据,而简单地保留我们的旧栈不变。

现在,这里会变得有点困难。在 x86-64 指令集中,我们无法直接操作rip,所以我们必须使用一个小技巧。

我们首先设置新的栈,并将我们想要运行的函数的地址写入栈顶 16 字节偏移处(ABI 规定栈对齐为 16 字节,因此我们的栈帧顶部必须从 16 字节偏移开始)。我们稍后会看到如何创建连续的内存块,但这是一个相当直接的过程。

接下来,我们将存储在我们新创建的栈中这个地址的第一个字节的地址传递给 rsp 寄存器(我们设置的 new.rsp 地址将指向我们自己的栈上的一个地址,而这个地址又指向 hello 函数)。明白了吗?

ret 关键字将程序控制权转移到当前栈帧顶部通常的返回地址。由于我们将 hello 的地址放置在了我们新的栈上,并将 rsp 寄存器设置为指向我们的新栈,CPU 会认为 rsp 现在指向了它正在运行的函数的返回地址,但实际上,它指向的是我们新栈上的一个位置。

当 CPU 执行 ret 指令时,它将从栈中弹出第一个值(恰好是我们 hello 函数的地址)并将该地址放入 rip 寄存器。在下个周期,CPU 将从该函数指针处获取指令并开始执行这些指令。由于 rsp 现在指向我们的新栈,它将从此栈开始使用。

注意

如果你现在感到有些困惑,这是完全可以理解的。这些细节很难理解并正确执行,而且需要时间来熟悉其工作方式。正如我们将在本章后面看到的那样,我们需要保存和恢复更多的数据(目前,我们没有方法来恢复我们刚刚交换的栈),但栈交换的技术细节与之前描述的是相同的。

在我们解释如何设置新栈之前,我们将利用这个机会逐行解释内联汇编宏是如何工作的。

Rust 内联汇编宏简介

我们将通过逐步分析 gt_switch 函数的主体来使用它作为起点。

如果你之前没有使用过内联汇编,这可能会看起来有些陌生,但稍后我们会使用示例的扩展版本来切换上下文,因此我们需要理解正在发生的事情。

unsafe 是一个关键字,表示 Rust 无法在所写的函数中强制执行安全性保证。由于我们正在直接操作 CPU,这绝对是不安全的。该函数还将从一个 ThreadContext 实例的指针中读取一个字段:

unsafe gt_switch(new: *const ThreadContext)

下一条是 Rust 标准库中的 asm! 宏。它将检查我们的语法,如果遇到看起来不像有效的 Intel(默认)汇编语法的部分,将提供错误信息。

asm!(

宏首先接受的是汇编模板:

"mov rsp, [{0} + 0x00]",

这是一条简单的指令,它将存储在 {0} 内存位置偏移 0x00 处的值(这意味着在十六进制中没有偏移)移动到 rsp 寄存器。由于 rsp 寄存器通常存储指向最近推入栈的值的指针,我们实际上将 hello 的地址推到当前栈的顶部,这样 CPU 将返回到该地址而不是从上一个栈帧的断点恢复。

注意

注意,当我们不想从内存位置偏移时,不需要写 [{0} + 0x00]。写入 mov rsp, [{0}] 就完全可以。然而,我选择介绍如何在这里进行偏移,因为我们稍后需要访问 ThreadContext 结构体中的更多字段。

注意,mov a, b 的意思是“将 a 中的内容移动到 b”,但 Intel 语法通常规定目标寄存器在前,源寄存器在后。

为了使事情更复杂,这与通常的“从 ab”的做法相反。这是两种语法之间的一个基本区别,了解这一点是有用的。

在正常的汇编代码中,你不会看到 {0} 被这样使用。这是汇编模板的一部分,是作为宏的第一个参数传递的值的占位符。你会注意到这非常接近 Rust 中使用 println! 或类似方式格式化的字符串模板。参数按升序编号,从 0 开始。这里我们只有一个输入参数,对应于 {0}

你实际上并不需要像这样索引你的参数;正确地写入 {} 就足够了(就像使用 println! 宏那样)。然而,使用索引可以提高可读性,我强烈建议这样做。

[] 基本上意味着“获取这个内存位置的内容”,你可以把它想象成与解引用指针相同。

让我们用词来总结一下我们在这里所做的工作:

{compiler_chosen_general_purpose_register} 指向的内存位置偏移 + 0x00 处的内容移动到 rsp 寄存器。

下一行是 ret 关键字,它指示 CPU 从栈中弹出一个内存位置,然后无条件跳转到该位置。实际上,我们已经劫持了 CPU,使其返回到我们的栈。

接下来是 asm! 宏的第一个非汇编参数,即我们的输入参数:

in(reg) new,

当我们写 in(reg) 时,我们让编译器决定一个通用寄存器来存储 new 的值。out(reg) 意味着该寄存器是输出,所以如果我们写 out(reg) new,则需要 newmut,这样我们才能向它写入值。你还会发现其他版本,如 inoutlateout

选项

为了现在对 Rust 的内联汇编有一个最小限度的理解,我们需要介绍的最后一件事情是options关键字。在输入和输出参数之后,您通常会看到类似options(att_syntax)的东西,这指定了汇编是用 AT&T 语法而不是 Intel 语法编写的。其他选项包括purenostack和几个其他选项。

我将引导您查阅文档来了解它们,因为它们在那里有详细的解释:

doc.rust-lang.org/nightly/reference/inline-assembly.html#options

内联汇编相当复杂,所以我们将一步一步地进行,并在我们的示例中逐步介绍其工作原理的更多细节。

运行我们的示例

我们需要的最后一个部分是运行我们示例的主函数。我将展示整个函数,然后我们一步一步地分析它:

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,我们最终会到达一个 16 字节对齐的地址。

我们将地址hello作为一个指向u64的指针,而不是指向u8的指针。我们想要写入位置“32, 33, 34, 35, 36, 37, 38, 39”,这是我们存储u64所需的 8 字节空间。如果我们不进行这种转换,我们尝试只将u64写入位置 32,这并不是我们想要的。

当我们在终端中通过写入cargo run来运行示例时,我们得到:

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的地址并开始执行它指向的指令。我们迈出了实现上下文切换的第一步。

在我们实现我们的纤程之前,我们将在下一节中更详细地讨论栈。由于我们已经涵盖了这么多基础知识,现在会更容易。

栈不过是一块连续的内存。

这是很重要的。计算机只有内存,它没有特殊的栈内存和堆内存;它们都是同一内存的一部分。

差别在于如何访问和使用这块内存。栈支持在连续内存部分上的简单 push/pop 指令,这就是它使用快速的原因。堆内存由内存分配器按需分配,并且可以分散在不同的位置。

我们在这里不会讲解栈和堆之间的区别,因为已经有许多文章详细解释了它们,包括 《Rust 编程语言》 中的一个章节,在 doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html#the-stack-and-the-heap

栈看起来是什么样子?

让我们从对栈的简化视图开始。64 位 CPU 会一次读取 8 个字节。尽管我们看栈的自然方式是像 图 5.2 所示的长串 u8,但 CPU 会更像是长串的 u64,因为它在执行加载或存储时无法读取少于 8 个字节。

图 5.3 – 栈

图 5.3 – 栈

当我们传递指针时,我们需要确保传递的是指向示例中地址 001600080000 的指针。

栈向下增长,所以我们从顶部开始,向下工作。

当我们设置 0008(记住栈是从顶部开始的)。

如果我们在上一章的例子中在 main 函数的切换之前添加以下几行代码,我们就可以有效地打印出我们的栈并查看它:

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))
}

我们得到的结果如下:

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 结束。

地址 26438667167042643866716712 对我们来说特别有趣。第一个地址是栈指针的地址,我们将其写入 CPU 的 rsp 寄存器。这个范围代表了我们切换之前写入栈的值。

注意

每次运行程序时,你得到的实际地址都会不同。

换句话说,值 240, 205, 252, 56, 67, 86, 0, 0 代表了我们的 hello() 函数的指针,这些值被写成 u8

大小端序

这里有一个有趣的旁注:CPU 将u64作为一个由 8 个u8字节组成的集合写入的顺序取决于其端序。换句话说,如果它是小端序,CPU 可以将我们的指针地址写入240, 205, 252, 56, 67, 86, 0, 0,如果是大端序,则写入0, 0, 86, 67, 56, 252, 205, 240。想象一下,像希伯来语、阿拉伯语和波斯语这样的语言是从右到左阅读和书写的,而拉丁语、希腊语和印度语系的语言是从左到右阅读和书写的。只要您事先知道这一点,结果就会相同。

x86-64 架构使用小端格式,所以如果您尝试手动解析数据,您必须记住这一点。

随着我们编写更复杂的函数,我们那极小的 48 字节栈空间很快就会耗尽。您可以看到,当我们运行在 Rust 中编写的函数时,CPU 现在会在我们的新栈上推入和弹出值以执行我们的程序,而确保它们不会溢出栈空间的责任就留给了程序员。这引出了我们的下一个主题:栈大小。

栈大小

我们在第二章中提到了这个主题,但现在我们已经创建了我们的栈,并让 CPU 跳转到它,您可能会对这个问题有更好的理解。创建我们自己的绿色线程的一个优点是,我们可以自由地选择为每个栈预留多少空间。

当您在大多数现代操作系统中启动一个进程时,标准栈大小通常是 8MB,但可以配置为不同的值。这对大多数程序来说已经足够了,但程序员必须确保我们不会使用超过我们拥有的。这就是我们大多数人都有过经验的可怕的栈溢出的原因。

然而,当我们能够自己控制栈时,我们可以选择我们想要的尺寸。例如,在运行 Web 服务器中的简单函数时,每个任务 8MB 的空间远远超过了我们的需求,因此通过减少栈大小,我们可以在一台机器上运行数百万个纤程/绿色线程。使用操作系统提供的栈,我们很快就会耗尽内存。

总之,我们需要考虑如何处理栈大小,而大多数生产系统,如Boost.Coroutine或您在Go中找到的系统,将使用分段栈或可增长栈。我们将使这个过程变得简单,并从现在开始使用固定大小的栈。

实现我们自己的纤程

在我们开始之前,我想确保您明白我们编写的代码相当不安全,并且不是编写 Rust 时的“最佳实践”。我想尽量使代码尽可能安全,而不引入过多的不必要复杂性,但在这个例子中不可避免地会有很多不安全代码。我们还将优先关注如何实现这一点,并尽可能简单地解释它,这本身就是一个很大的挑战,因此在这个问题上,最佳实践和安全性的关注将不得不退居次要位置。

让我们从创建一个全新的项目c-fibers并从main.rs中移除代码开始,这样我们就从一个空白页开始。

注意

你也可以在ch05/c-fibers文件夹下的存储库中找到这个例子。这个例子,以及ch05/d-fibers-closurech05/e-fibers-windows,需要使用 nightly 编译器编译,因为我们使用了不稳定的功能。你可以通过以下两种方式之一来完成:

• 通过编写rustup override set nightly覆盖你所在目录的默认工具链(我个人更喜欢这个选项)。

• 使用cargo +nightly run告诉 cargo 每次编译或运行程序时使用 nightly 工具链。

我们将创建一个简单的运行时,具有一个非常简单的调度器。我们的纤程将保存/恢复它们的状态,以便在执行过程中的任何时刻停止和恢复。每个纤程将代表我们想要并发推进的任务,我们只需为每个想要运行的任务创建一个新的纤程。

我们从启用一个特定的功能开始,导入asm宏,并定义一些常量:

ch05/c-fibers/main.rs

#![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功能。让我们立即解释一下什么是裸函数。

裸函数

如果你还记得我们之前讨论操作系统 ABI 和调用约定的时候,你可能还记得每个架构和操作系统都有不同的要求。这在创建新的栈帧时尤为重要,当你调用一个函数时就会发生这种情况。因此,编译器知道每个架构/操作系统需要什么,并调整布局和栈上的参数位置,保存/恢复某些寄存器,以确保我们在当前平台上满足 ABI。这发生在我们进入和退出函数时,通常被称为函数的序言尾声

在 Rust 中,我们可以启用此功能并将函数标记为#[naked]。裸函数告诉编译器我们不想让它创建函数序言和尾声,而想自己处理这些。由于我们在返回到新栈并希望在稍后某个时刻恢复旧栈时做了这个技巧,我们不希望编译器认为它在这些点管理栈布局。在我们的第一个例子中它有效,因为我们从未切换回原始栈,但向前推进时它将不起作用。

我们的DEFAULT_STACK_SIZE设置为 2 MB,这对于我们的使用来说已经足够多了。我们还把MAX_THREADS设置为4,因为我们不需要更多的线程来运行我们的例子。

最后一个静态常量RUNTIME是指向我们的运行时的指针(是的,我知道,使用可变的全局变量看起来并不漂亮,但它有助于我们专注于示例的重要部分)。

接下来,我们设置一些数据结构来表示我们将要处理的数据:

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 结构体中保存的寄存器是标记为 callee saved图 5**.1 中的寄存器。我们需要保存这些寄存器,因为 ABI 规定在 caller(从操作系统的角度来看,将是我们的 switch 函数)恢复之前,callee(即我们的 switch 函数)需要恢复它们。

接下来是如何初始化新创建的线程的数据:

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 函数:

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 时,我们设置一个基本线程。这个线程将被设置为 Running 状态,并确保我们保持运行时运行,直到所有任务完成。

然后,我们实例化其余的线程,并将当前线程(基本线程)设置为 0

我们接下来要做的事情在某种程度上是有点黑客式的,因为我们做了一些在 Rust 中通常不被推荐的事情。正如我提到过的,当我们查看常量时,我们希望从代码的任何地方访问我们的运行时结构体,这样我们就可以在任何代码点调用 yield。有安全地做到这一点的方法,但当前的话题已经很复杂,所以尽管我们在玩刀子,我会尽我所能使所有不是本例主要焦点的部分尽可能简单。

在我们对运行时调用 initialize 之后,我们必须确保我们不做任何可能使我们对 self 指针的引用无效的事情。

    pub fn init(&self) {
        unsafe {
            let r_ptr: *const Runtime = self;
            RUNTIME = r_ptr as usize;
        }
    }

这是我们开始运行运行时的地方。它将不断调用 t_yield(),直到它返回 false,这意味着没有更多的工作要做,我们可以退出进程:

    pub fn run(&mut self) -> ! {
        while self.t_yield() {}
        std::process::exit(0);
    }

注意

yield 是 Rust 中的一个保留字,所以我们不能将我们的函数命名为这个名字。如果不是这样,那将是我对它稍显晦涩的 t_yield 的首选名称。

这是我们在线程完成时调用的返回函数。return 是 Rust 中的另一个保留关键字,所以我们将其命名为 t_return()。请注意,我们的线程用户不会调用这个函数;我们设置我们的堆栈,以便在任务完成时调用这个函数:

    fn t_return(&mut self) {
        if self.current != 0 {
            self.threads[self.current].state = State::Available;
            self.t_yield();
        }
    }

如果调用线程是 base_thread,我们不会做任何事情。我们的运行时会在基础线程上为我们调用 t_yield。如果是从一个派生线程中调用的,我们知道它已经完成,因为所有线程的堆栈顶部都有一个 guard 函数(我们将在下面展示),而这个函数被调用的唯一地方就是我们的 guard 函数。

我们将其状态设置为 Available,让运行时知道它已准备好分配新的任务,然后立即调用 t_yield,这将安排一个新的线程运行。

所以,最终,我们来到了运行时的核心:t_yield 函数。

这个函数的前一部分是我们的调度器。我们只是遍历所有线程,看看是否有任何线程处于 Ready 状态,这表明它有一个准备进行进展的任务。这可能是现实世界应用程序中返回的数据库调用。

如果没有线程处于 Ready 状态,我们就完成了。这是一个极其简单的调度器,仅使用轮询算法。一个真正的调度器可能会有一种更复杂的方式来决定下一个要运行的任务。

如果我们找到一个可以运行的线程,我们将当前线程的状态从 Running 改为 Ready

在我们解释这个函数的最后部分之前,让我们先展示这个函数:

    #[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]。裸函数与普通函数不同。它们不接受形式参数,例如,所以我们不能像在 Rust 中调用普通函数一样简单地调用它,如switch(old, new)

你看,通常,当我们用两个参数调用一个函数时,编译器会将每个参数放置在平台调用约定描述的寄存器中。然而,当我们调用一个#[naked]函数时,我们需要自己处理这个问题。因此,我们通过汇编传递我们旧的和新ThreadContext的地址。rdi是 System V ABI 调用约定中第一个参数的寄存器,而rsi是用于第二个参数的寄存器。

#[inline(never)]属性阻止编译器简单地用函数内容的副本替换我们的函数调用(这就是内联的含义)。在调试构建中这几乎永远不会成为问题,但在这个案例中,如果编译器将Runtime作为静态usize处理,然后将其转换为*mut指针(这几乎肯定会引起未定义行为),那么它最有可能是由于编译器在将此函数内联并调用时,在将要概述的辅助方法中通过强制转换和间接引用RUNTIME时做出了错误的假设。请注意,如果我们改变我们的设计,这可能是有避免的;这不是在这个特定情况下值得长时间深思的问题。

更多内联汇编

我们需要解释我们在这里引入的新概念。汇编调用函数switch(该函数被标记为#[no_mangle],因此我们可以通过名称调用它)。in("rdi") oldin("rsi") new参数将oldnew的值分别放置到rdirsi寄存器中。x86-64 的 System V ABI 指出,rdi寄存器持有函数的第一个参数,而rsi持有第二个参数。

clobber_abi("C")参数告诉编译器它不能假设任何通用寄存器在asm!块执行期间被保留。编译器将发出指令将使用的寄存器推送到栈上,并在asm!块执行后恢复它们。

如果你再仔细看看图 5**.1中的列表,我们已经知道我们需要特别小心标记为调用者保留的寄存器。在调用普通函数时,编译器会在调用函数之前插入代码*保存/恢复所有非调用者保留或调用者保留的寄存器,以便在函数返回时能够以正确的状态恢复。由于我们将要调用的函数标记为#[naked],我们明确告诉编译器不要插入此代码,因此最安全的事情是确保编译器在执行我们的asm!块中的调用后恢复时不会假设任何寄存器未被触及。

*在某些情况下,编译器会知道一个寄存器在函数调用中没有被修改,因为它控制了调用者和被调用者的寄存器使用,并且它不会发出任何特殊的指令来保存/恢复它们知道在函数返回时不会被修改的寄存器

最后的 self.threads.len() > 0 行只是我们防止编译器优化我们的代码的一种方式。这种情况在 Windows 上会发生,但在 Linux 上不会,这是运行基准测试时常见的常见问题。还有其他防止编译器优化这段代码的方法,但我选择了我能找到的最简单的方法。只要它被注释掉,做这个应该没问题。代码永远不会到达这个点。

接下来是我们的 spawn 函数。我先展示这个函数,然后再引导你了解它:

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;
}
} // We close the `impl Runtime` block here

注意

我承诺指出我们关闭 impl Runtime 块的位置,我们是在 spawn 函数之后做的。接下来的函数是“免费”函数,不属于任何结构体。

虽然我认为 t_yield 是这个例子中逻辑上有趣的功能,但我认为 spawn 在技术上是最有趣的。

首先要注意的是,这个函数接受一个参数:f: fn()。这只是一个指向我们作为参数传递的函数的函数指针。这个函数是我们想要与其他任务并发运行的作业。如果这是一个库,那么这个函数就是用户实际传递给我们并希望我们的运行时并发处理的函数。

在这个例子中,我们以一个简单的函数作为参数,但如果我们稍微修改一下代码,我们也可以接受一个闭包。

小贴士

ch05/d-fibers-closure 示例中,你可以看到一个稍微修改过的例子,它接受一个闭包而不是函数,这使得它比我们这里展示的更灵活。我真心建议你在完成这个例子后去查看一下那个例子。

函数的其余部分是我们根据前一章讨论的内容设置我们的栈,并确保我们的栈看起来像 System V ABI 栈布局中指定的那样。

当我们启动一个新的纤程(或用户空间线程)时,我们首先检查是否有任何可用的用户空间线程(处于 Available 状态的线程)。如果我们用完了线程,在这个场景下我们会恐慌,但还有几种(更好的)处理方式。我们现在会保持简单。

当我们找到一个可用的线程时,我们获取栈长度以及指向我们的 u8 字节数组的指针。

在下一个部分,我们必须使用一些不安全的功能。我们稍后会解释我们在这里提到的函数,但这是我们在新的栈中设置它们,以便它们以正确的顺序被我们的运行时调用的地方。

首先,我们确保我们将要使用的内存段是 16 字节对齐的。然后,我们将地址写入我们的 guard 函数,该函数将在我们提供的任务完成并返回时被调用。

第二,我们将地址写入 skip 函数,这个函数只是用来处理从 f 返回时的间隙,这样 guard 就会在 16 字节边界上被调用。我们接下来写入栈的下一个值是 f 的地址。

我们为什么需要 skip 函数?

记得我们是如何解释栈的工作原理的吗?我们希望 f 函数首先运行,因此我们将基指针设置为 f 并确保它是 16 字节对齐的。然后我们推送 skip 函数的地址,最后推送 guard 函数。由于 skip 只有一个指令 ret,这样做确保我们的 guard 调用是 16 字节对齐的,这样我们就遵守了 ABI 要求。

在我们将函数指针写入栈后,我们设置 rsp 的值,即栈指针,指向我们提供的函数地址,这样当我们被调度运行时,我们首先执行该函数。

最后,我们将状态设置为 Ready,这意味着我们有工作要做,并且我们已经准备好去做。记住,启动这个线程实际上是由我们的调度器来完成的。

现在我们已经完成了 Runtime 的实现,如果你理解了这一切,你基本上就明白了 fibers/green threads 的工作原理。然而,还有一些细节需要完善才能使整个系统工作。

守护、跳过和切换函数

有几个我们提到的重要函数对于我们的运行时真正工作至关重要。幸运的是,除了一个之外,它们都非常简单易懂。我们将从 guard 函数开始:

fn guard() {
    unsafe {
        let rt_ptr = RUNTIME as *mut Runtime;
        (*rt_ptr).t_return();
    };
}

当我们传递的函数 f 返回时,会调用 guard 函数。当 f 返回时,意味着我们的任务已经完成,因此我们取消引用 Runtime 并调用 t_return()。我们本可以创建一个函数,在线程完成时执行一些额外的工作,但当前我们的 t_return() 函数已经足够满足需求。它将我们的线程标记为 Available(如果不是基本线程)并释放,这样我们就可以在不同的线程上继续工作。

接下来是我们的 skip 函数:

#[naked]
unsafe extern "C" fn skip() {
    asm!("ret", options(noreturn))
}

skip 函数中并没有发生太多事情。我们使用 #[naked] 属性,使得这个函数实际上编译成只有一个 ret 指令。ret 将从栈中弹出下一个值,并跳转到该地址指向的指令。在我们的例子中,这是 guard 函数。

接下来是一个名为 yield_thread 的小型辅助函数:

pub fn yield_thread() {
    unsafe {
        let rt_ptr = RUNTIME as *mut Runtime;
        (*rt_ptr).t_yield();
    };
}

这个辅助函数允许我们从代码的任意位置调用 Runtime 上的 t_yield,而不需要任何对该函数的引用。这个函数非常不安全,并且是我们为了使示例稍微简单易懂而做出的大胆简化的地方之一。如果我们调用这个函数,而我们的 Runtime 尚未初始化或者运行时被丢弃,这将导致未定义的行为。然而,为了使这个函数更安全,对我们来说并不是一个优先事项,只是为了让我们的示例能够运行起来。

我们已经非常接近终点;只需再完成一个函数。我们最后需要的函数是switch函数,你已经知道了它的最重要的部分。让我们看看它的样子,并解释一下它与我们的第一个栈交换函数有何不同:

#[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)
    );
}

因此,这是我们完整的栈交换函数。你可能还记得,从我们的第一个例子中,这只是一个更详细的过程。我们首先读取所有需要的寄存器的值,然后将所有寄存器值设置为我们在新线程上挂起执行时保存的寄存器值。

这基本上就是我们保存和恢复执行所需做的所有事情。

在这里,我们看到再次使用了#[naked]属性。通常,每个函数都有一个前缀和后缀,我们在这里不希望有这些,因为这里全是汇编,我们希望自行处理所有事情。如果我们不包括这个,我们将在第二次切换回我们的栈时失败。

你也可以看到我们在实践中使用我们之前引入的偏移量:

0x00[rdi] # 0
0x08[rdi] # 8
0x10[rdi] # 16
0x18[rdi] # 24

这些是表示从内存指针到我们想要读取/写入的偏移量的十六进制数字。我在注释中写下了十进制数字,所以正如你所见,我们只以 8 字节步长偏移指针,这与我们的ThreadContext结构体上的u64字段大小相同。

这也是为什么用#[repr(C)]注释ThreadContext很重要的原因;它告诉我们数据将以这种方式在内存中表示,因此我们写入正确的字段。Rust ABI 不保证它们在内存中以相同的顺序表示;然而,C-ABI 确实如此。

最后,我们在asm!块中添加了一个新的选项。在编写裸函数时,option(noreturn)是一个必需的要求,如果我们不添加它,将会收到编译错误。通常,编译器会假设函数调用将会返回,但裸函数与我们习惯的函数完全不同。它们更像是我们可以调用的汇编标签容器,因此我们不希望编译器在函数末尾发出ret指令或做出任何关于我们返回到上一个栈帧的假设。通过使用这个选项,我们告诉编译器将汇编块视为永远不会返回,并通过添加一个ret指令来确保我们永远不会通过汇编块。

接下来是main函数,它相当直接,所以我将在这里简单地展示代码:

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();
}

正如你所见,我们初始化我们的运行时并创建了两个线程:一个从 1 数到 10 并在每个计数之间交出控制权,另一个从 1 数到 15。当我们cargo run我们的项目时,我们应该得到以下输出:

Finished dev [unoptimized + debuginfo] target(s) in 2.17s
Running `target/debug/green_threads`
THREAD 1 STARTING
thread: 1 counter: 0
THREAD 2 STARTING
thread: 2 counter: 0
thread: 1 counter: 1
thread: 2 counter: 1
thread: 1 counter: 2
thread: 2 counter: 2
thread: 1 counter: 3
thread: 2 counter: 3
thread: 1 counter: 4
thread: 2 counter: 4
thread: 1 counter: 5
thread: 2 counter: 5
thread: 1 counter: 6
thread: 2 counter: 6
thread: 1 counter: 7
thread: 2 counter: 7
thread: 1 counter: 8
thread: 2 counter: 8
thread: 1 counter: 9
thread: 2 counter: 9
THREAD 1 FINISHED.
thread: 2 counter: 10
thread: 2 counter: 11
thread: 2 counter: 12
thread: 2 counter: 13
thread: 2 counter: 14
THREAD 2 FINISHED.

精美!我们的线程交替,因为它们在每个计数之间交出控制权,直到THREAD 1完成,然后THREAD 2在它完成任务之前数最后几个数字。

结束语

我想通过指出这种方法的一些优缺点来结束本章,这些优缺点我们在第二章中讨论过,因为我们现在对这个主题有了第一手经验。

首先,我们在这里实现的例子是我们所说的栈满协程的例子。每个协程(或线程,如我们在示例实现中称呼它)都有自己的栈。这也意味着我们可以在任何时间点中断和恢复执行。我们是否在栈帧的中间(在执行函数的中间)无关紧要;我们只需告诉 CPU 将我们需要的状态保存到栈上,返回到不同的栈并恢复那里的状态,然后像什么都没发生一样继续执行。

你也可以看到,我们必须要以某种方式管理我们的栈。在我们的例子中,我们只是创建了一个静态栈(就像操作系统在请求线程时所做的,但更小),但为了使其比使用操作系统线程更有效率,我们需要选择一种策略来解决这个潜在的问题。

如果你查看ch05/d-fibers-closure中稍微扩展的例子,你会注意到我们可以使 API 非常易于使用,就像标准库中用于std::thread::spawn的 API 一样。当然,另一方面是实现这个 API 的正确性在所有我们想要支持的 ISA/ABIs 组合上的复杂性,虽然这特定于 Rust,但没有原生语言的支持,在这些类型的栈满协程上创建一个优秀且安全的 API 是具有挑战性的。

为了将其与第三章联系起来,在那里我们讨论了事件队列和非阻塞调用,我想指出,如果你使用纤维来处理并发,你会在你的非阻塞调用中做出读取兴趣之后调用 yield。通常,运行时会提供这些非阻塞调用,而我们 yield 的事实对用户来说是透明的,但纤维在那个点被挂起。我们可能会在我们的State枚举中添加一个名为Pending或其它表示线程正在等待某些外部事件的额外状态。

当操作系统指示数据已准备好时,我们会将线程标记为State::Ready以恢复,调度器会像在这个例子中一样继续执行。

虽然它需要一个更复杂的调度器和基础设施,但我希望你已经对这种系统在实际中的工作方式有了很好的了解。

摘要

首先,恭喜!你现在已经实现了一个超级简单但有效的纤维示例。你已经设置了自己的栈,并了解了 Rust 中的 ISAs、ABIs、调用约定和内联汇编。

我们经历了一段相当刺激的旅程,但如果你已经走到这一步并阅读了所有内容,你应该给自己一个大大的掌声。这不是给胆小的人准备的,但你做到了。

这个例子(和章节)可能需要一些时间来完全消化,但不必急于一时。你总是可以回过头来再次阅读这段代码,以完全理解它。我真心建议你自己动手操作代码,去熟悉它。改变调度算法,为创建的线程添加更多上下文,并发挥你的想象力。

你可能会发现,在像这样低级别的代码中调试问题可能相当困难,但这正是学习过程的一部分,你总是可以回退到一个可工作的版本。

现在我们已经覆盖了这本书中最大、最困难的例子之一,我们将继续学习另一种处理并发的方法,即通过研究 Rust 中 Futures 和 async/await 的工作原理。实际上,本书的其余部分完全致力于学习 Rust 中的 Futures 和 async/await,由于我们在此阶段已经获得了大量的基础知识,因此我们将更容易获得对它们如何工作的良好和深入的理解。你到目前为止已经做得很好了!

第三部分:Rust 中的 Futures 和 async/await

这一部分将从基础开始解释 Rust 中的 Futures 和 async/await。在迄今为止获得的知识基础上,我们将构建一个中心示例,这个示例将在后续章节中反复出现,最终将导致一个能够在 Rust 中执行 Futures 的运行时的创建。在整个探索过程中,我们将深入研究诸如协程、运行时、反应器、执行器、唤醒器等概念。

本部分包括以下章节:

  • 第六章**,Rust 中的 Futures

  • 第七章**,协程和 async/await

  • 第八章**,运行时,唤醒器以及反应器-执行器模式

  • 第九章**,协程,自引用结构体和固定

  • 第十章**,创建你自己的运行时

第六章:Rust 中的 future

第五章中,我们介绍了一种在编程语言中建模并发最流行的方式:纤程/绿色线程。纤程/绿色线程是堆栈式协程的一个例子。建模异步程序流的另一种流行方式是使用我们所说的无堆栈协程,将 Rust 的 future 与async/await结合就是一个例子。我们将在下一章中详细讨论这一点。

这第一章将向您介绍 Rust 的 future,本章的主要目标是完成以下内容:

  • 给您一个 Rust 中并发的概述

  • 解释 Rust 在处理异步代码时提供的内容以及语言和标准库中不提供的内容

  • 了解为什么我们需要在 Rust 中使用运行时库

  • 理解叶未来和非叶未来的区别

  • 了解如何处理 CPU 密集型任务

为了完成这个目标,我们将把本章分为以下部分:

  • 什么是 future?

  • 叶未来

  • 非叶未来

  • 运行时

  • 异步运行时的心理模型

  • Rust 语言和标准库负责的内容

  • I/O 与 CPU 密集型任务

  • Rust 异步模型的优缺点

什么是 future?

future 是对未来完成的某个操作的表示。

Rust 中的异步使用基于 poll 的方法,其中异步任务将经历三个阶段:

  1. 轮询阶段:对 future 进行轮询,这会导致任务进展到它不能再进一步进展的点。我们通常将轮询 future 的运行时部分称为 executor。

  2. 等待阶段:事件源,通常被称为 reactor,注册了一个 future 正在等待某个事件发生,并确保当该事件准备好时唤醒 future。

  3. 唤醒阶段:事件发生,future 被唤醒。现在取决于在步骤 1中轮询 future 的 executor 来安排 future 再次被轮询并进一步进展,直到它完成或达到它不能再进一步进展的新点,然后循环重复。

现在,当我们谈论未来时,我认为一开始就区分非叶未来和未来是有用的,因为在实践中,它们彼此之间相当不同。

叶未来

运行时创建叶 future,它代表一个资源,如套接字。

这是一个叶未来示例:

let mut stream = tokio::net::TcpStream::connect("127.0.0.1:3000");

对这些资源进行的操作,例如从套接字读取,将是非阻塞的,并返回一个 future,我们称之为叶 future,因为它是我们实际等待的 future。

您不太可能自己实现叶 future,除非您正在编写运行时,但我们将在这本书中介绍它们是如何构建的。

您也不太可能将叶 future 传递给运行时并单独运行到完成,正如您将在下一段中阅读到的。

非叶未来

非叶未来是我们作为运行时用户使用 async 关键字自己编写的,可以在执行器上运行的任务。

异步程序的大部分将包括非叶未来,这是一种可暂停的计算。这是一个重要的区别,因为这些未来代表了一组操作。通常,这样的任务会将叶未来作为许多操作之一 await,以完成任务。

这是一个非叶未来示例:

let non_leaf = async {
    let mut stream = TcpStream::connect("127.0.0.1:3000").await.unwrap();
    println!("connected!");
    let result = stream.write(b"hello world\n").await;
    println!("message sent!");
    ...
};

两个突出显示的行表示我们暂停执行、将控制权交给运行时,并最终恢复的点。与叶未来相比,这类未来本身并不代表 I/O 资源。当我们轮询它们时,它们会一直运行,直到到达返回 Pending 的叶未来,然后才会将控制权交给调度器(这是我们所说的运行时的一部分)。

运行时

C#、JavaScript、Java、Go 以及许多其他语言都自带用于处理并发的运行时。因此,如果你习惯了这些语言之一,这对你来说可能有点奇怪。与这些语言不同,Rust 并没有自带用于处理并发的运行时,所以你需要使用一个提供此功能的库。

分配给未来的相当一部分复杂性实际上源于运行时;创建一个高效的运行时是困难的。

学习如何正确使用它也需要相当多的努力,但你会发现这类运行时之间有几种相似之处,因此学习一个会使学习下一个变得容易得多。

Rust 与其他语言的区别在于,在选择运行时时要做出一个积极的选择。在其他语言中,你通常会使用为你提供的那个。

异步运行时的心理模型

我发现通过创建一个我们可以使用的高级心理模型来推理未来的工作方式更容易。为了做到这一点,我必须引入一个概念,即运行时将推动我们的未来完成。

注意

我在这里创建的心理模型并不是驱动未来完成的唯一方式,Rust 的未来也不会对如何实际完成这项任务施加任何限制。

一个完全工作的 Rust 异步系统可以分为三个部分:

  • 反应器(负责通知 I/O 事件)

  • 执行器(调度器)

  • 未来(可以在特定点停止和恢复的任务)

那么,这三个部分是如何协同工作的呢?

让我们看看一个展示异步运行时简化概述的图表:

图 6.1 – 反应器、执行器和唤醒器

图 6.1 – 反应器、执行器和唤醒器

在图象的步骤 1中,执行者持有一个未来列表。它将通过轮询(轮询阶段)尝试运行未来,当它这样做时,它会将一个Waker传递给它。未来要么返回Poll:Ready(这意味着它已完成)或Poll::Pending(这意味着它尚未完成,但此刻无法进一步执行)。当执行者收到这些结果之一时,它知道它可以开始轮询另一个未来。我们将这些控制权返回给执行者的点称为yield 点

步骤 2中,反应器存储了执行者在轮询未来时传递给它的Waker的副本。反应器跟踪该 I/O 源上的事件,通常是通过我们在第四章中学到的相同类型的事件队列。

步骤 3中,当反应器收到通知,表明跟踪的源之一发生了事件时,它会定位与该源关联的Waker,并在其上调用Waker::wake。这将反过来通知执行者,未来已准备好继续前进,因此它可以再次轮询它。

如果我们使用伪代码编写一个简短的异步程序,它将看起来像这样:

async fn foo() {
    println!("Start!");
    let txt = io::read_to_string().await.unwrap();
    println!("{txt}");
}

我们写入await的行是返回控制权给调度器的行。这通常被称为yield 点,因为它将返回Poll::PendingPoll::Ready(最可能的情况是,第一次轮询未来时将返回Poll::Pending)。

由于Waker在所有执行者中都是相同的,因此反应器在理论上可以完全忽略执行者的类型,反之亦然。执行者和反应器无需直接相互通信

这种设计赋予了未来框架其力量和灵活性,并允许 Rust 标准库为我们提供一个易于使用、零成本的抽象。

注意

我在这里介绍了反应器和执行者的概念,就像它是每个人都了解的东西一样。我知道情况并非如此,不用担心,我们将在下一章中详细讲解。

Rust 语言和标准库负责处理的事项

Rust 只为在语言中建模异步操作提供必要的功能。基本上,它提供了以下功能:

  • 一个表示操作的通用接口,该操作将通过Future特质在未来完成

  • 通过asyncawait关键字创建可以挂起和恢复的任务(确切地说,是无栈协程)的便捷方式

  • 通过Waker类型定义的唤醒挂起任务的方式

这正是 Rust 标准库所做的事情。正如你所看到的,没有非阻塞 I/O 的定义,也没有说明这些任务是如何创建或运行的。没有标准库的非阻塞版本,因此要实际运行异步程序,你必须创建或选择一个运行时来使用。

I/O 任务与 CPU 密集型任务

如您现在所知,您通常编写的被称为非叶子 future。让我们以伪 Rust 为例,看看这个async块:

let non_leaf = async {
    let mut stream = TcpStream::connect("127.0.0.1:3000").await.unwrap();
    // request a large dataset
    let result = stream.write(get_dataset_request).await.unwrap();
    // wait for the dataset
    let mut response = vec![];
    stream.read(&mut response).await.unwrap();
    // do some CPU-intensive analysis on the dataset
    let report = analyzer::analyze_data(response).unwrap();
    // send the results back
    stream.write(report).await.unwrap();
};

我已经突出显示了我们将控制权交给运行时执行器的地方。重要的是要意识到,我们在 yield 点之间编写的代码与我们的执行器在同一线程上运行。

这意味着当我们的analyzer正在处理数据集时,执行器正忙于进行计算,而不是处理新的请求。

幸运的是,有几种处理这种问题的方法,而且并不困难,但这是您必须注意的事情:

  1. 我们可以创建一个新的叶子 future,将我们的任务发送到另一个线程,并在任务完成时解决。我们可以像其他 future 一样await这个叶子 future。

  2. 运行时可能有一种监督器,可以监控不同任务花费的时间,并将执行器本身移动到不同的线程,这样即使我们的analyzer任务阻塞了原始执行器线程,它也可以继续运行。

  3. 您可以创建一个与运行时兼容的反应器,以任何您认为合适的方式进行分析,并返回一个可以被await的 future。

现在,第一种方法是处理这种情况的常规方法,但一些执行器也实现了第二种方法。问题在于,如果您切换运行时,您需要确保它也支持这种类型的监督,否则您最终会阻塞执行器。

第三个方法在理论上有更多的重要性;通常,您会乐意将任务发送到大多数运行时提供的线程池。

大多数执行器都有一种方法,可以使用如spawn_blocking之类的函数来实现#1。

这些方法将任务发送到由运行时创建的线程池中,您可以在其中执行 CPU 密集型任务或运行时不支持的阻塞任务。

摘要

因此,在本章中,我们向您介绍了 Rust 的 futures。现在,您应该对 Rust 的异步设计有一个基本的了解,语言为您提供了什么,以及您需要在哪里获取什么。您也应该对叶子 future 和非叶子 future 有一个概念。

这些方面很重要,因为它们是内置到语言中的设计决策。您现在知道 Rust 使用无栈协程来模拟异步操作,但既然协程本身不做什么,了解如何调度和运行这些协程的选择留给了您。

随着我们开始详细解释这一切是如何工作的,我们将对它有一个更深入的理解。

现在我们已经看到了 Rust 的 futures 的高级概述,我们将从底层开始解释它们是如何工作的。下一章将涵盖 futures 的概念以及它们与 Rust 中的协程和 async/await 关键字的关系。我们将亲自看到它们如何表示可以暂停和恢复执行的任务,这是多个任务能够同时 进行中 的先决条件,以及它们与我们实现为 fibers/green threads 的可暂停/可恢复任务有何不同。第五章。

第七章:协程和 async/await

既然你已经对 Rust 的异步模型有了简要的了解,是时候看看它如何与我们在这本书中迄今为止所涵盖的其他内容相契合了。

Rust 的未来是一个基于无栈协程的异步模型示例,在本章中,我们将探讨这究竟意味着什么,以及它与有栈协程(纤程/绿色线程)有何不同。

我们将以基于简化模型的未来和async/await的示例为中心,看看我们如何使用它来创建可挂起和可恢复的任务,就像我们创建自己的纤程时做的那样。

好消息是,这比实现我们自己的纤程/绿色线程容易得多,因为我们可以留在 Rust 中,这更安全。不利的一面是,它稍微抽象一些,并且与编程语言理论以及计算机科学紧密相关。

在本章中,我们将涵盖以下内容:

  • 无栈协程简介

  • 手写协程的例子

  • async/await

技术要求

本章中的所有示例都将跨平台,所以你唯一需要的是安装 Rust 以及下载属于本书的本地存储库。本章中的所有代码都将位于ch07文件夹中。

在这个例子中,我们也将使用delayserver,所以你需要打开一个终端,进入存储库根目录下的delayserver文件夹,并运行cargo run,以便它为后续的示例准备好并可用。

如果因为某种原因你需要更改delayserver监听的端口号,请记住更改代码中的端口号。

无栈协程简介

因此,我们终于到达了介绍本书中建模异步操作最后一种方法的点。你可能还记得,我们在第二章中给出了关于有栈和无栈协程的高级概述。在第五章中,我们在编写自己的纤程/绿色线程时实现了一个有栈协程的例子,所以现在我们该更深入地看看无栈协程是如何实现和使用的。

如果你还记得,在第一章中我们提到,如果我们想让任务并发运行(同时进行),但不一定并行,我们需要能够暂停和恢复任务。

在其最简单的形式中,协程只是一个可以通过将控制权交还给其调用者、另一个协程或调度器来暂停和恢复的任务。

许多语言都将有一个协程实现,它还提供了一个运行时,为你处理调度和非阻塞 I/O,但区分协程是什么以及创建异步系统所涉及的其余机制是有帮助的。

这在 Rust 中尤其如此,因为 Rust 没有提供运行时,只提供了创建具有语言原生支持的协程所需的基础设施。Rust 确保所有使用 Rust 编程的人使用相同的抽象来处理可以暂停和恢复的任务,但它将所有其他使异步系统启动和运行的具体细节留给程序员。

无栈协程或仅仅是协程?

最常见的情况是,你会看到无栈协程简单地被称为协程。为了保持一致性(你还记得我不喜欢根据上下文引入具有不同含义的术语),我一直将协程称为无栈有栈,但今后,我只需简单地称无栈协程为协程。这也是你在其他来源阅读关于它们时可以期待的内容。

纤维/绿色线程以与操作系统非常相似的方式表示这种可恢复的任务。一个任务有一个栈,其中存储/恢复其当前执行状态,使其能够暂停和恢复任务。

状态机在其最简单的形式中是一个具有预定义状态集的数据结构。在协程的情况下,每个状态代表一个可能的暂停/恢复点。我们不将暂停/恢复任务的所需状态存储在单独的栈中,而是将其保存在数据结构中。

这有一些优点,我之前已经介绍过,但最突出的是它们非常高效和灵活。缺点是,你永远不会想手动编写这些状态机(你将在本章中看到原因),因此你需要来自编译器或其他机制的支持来重写你的代码,使其成为状态机而不是正常函数调用。

结果是,你得到的是一个看起来非常简单的东西。它看起来像是一个函数/子程序,你可以很容易地将其映射到可以使用简单的汇编call指令运行的东西,但实际上你得到的是一个相当复杂且与预期不同的东西,它看起来也不像你期望的那样。

生成器与协程的比较

生成器也是状态机,正是我们将在本章中介绍的那种。它们通常在一种语言中实现,以创建向调用函数产生值的州机。

从理论上讲,你可以根据它们产生的结果来区分协程和生成器。生成器通常仅限于向调用函数产生结果。协程可以产生结果给另一个协程、调度器或简单地给调用者,在这种情况下,它们就像生成器一样。

在我看来,在它们之间做出区分实际上没有意义。它们代表了创建可以暂停和恢复执行的任务的相同底层机制,因此在这本书中,我们将它们视为基本上是同一件事。

现在我们已经用文字描述了什么是协程,我们可以开始看看它们在代码中的样子。

手写协程的例子

我们接下来要使用的例子是 Rust 异步模型的简化版本。我们将创建和实现以下内容:

  • 我们自己的简化版 Future trait

  • 一个只能执行 GET 请求的简单 HTTP 客户端

  • 我们可以暂停和恢复的任务,实现为一个状态机

  • 我们自己的简化版 async/await 语法称为 coroutine/wait

  • 一个自制的预处理器,将我们的 coroutine/wait 函数转换成状态机,就像 async/await 被转换一样

为了真正揭开协程、未来和 async/await 的神秘面纱,我们不得不做一些妥协。如果我们不这样做,我们最终会重新实现今天 Rust 中所有的 async/await 和未来,这对于仅仅理解底层技术和概念来说太多了。

因此,我们的例子将做以下事情:

  • 避免错误处理。如果发生任何失败,我们将引发恐慌。

  • 要具体,而不是泛化。创建泛型解决方案会引入很多复杂性,并使得底层概念更难推理,因为我们随后不得不创建额外的抽象层。尽管如此,我们的解决方案在需要的地方将有一些泛型方面。

  • 在它能做什么方面有限制。你当然可以自由地扩展、更改和玩转所有这些例子(我鼓励你这样做),但在例子中,我们只涵盖我们需要的内容,而不是更多。

  • 避免宏。

因此,在解决完这些问题后,让我们开始我们的例子。

你需要做的第一件事是创建一个新的文件夹。这个第一个例子可以在仓库中的 ch07/a-coroutine 目录下找到,所以我建议你也将其命名为 a-coroutine

然后,进入文件夹并运行 cargo init 来初始化一个新的 crate。

现在我们有一个新项目正在运行,我们可以创建我们需要的模块和文件夹:

首先,在 main.rs 中,声明两个模块如下:

ch07/a-coroutine/src/main.rs

mod http;
mod future;

接下来,在 src 文件夹中创建两个新文件:

  • future.rs,将包含我们的未来相关代码

  • http.rs,它将包含我们 HTTP 客户端相关的代码

我们最后需要做的一件事是添加对 mio 的依赖。我们将使用 mio 中的 TcpStream,因为我们将在接下来的章节中构建这个例子,并使用 mio 作为我们的非阻塞 I/O 库,因为我们已经熟悉它:

ch07/a-coroutine/Cargo.toml

[dependencies]
mio = { version = "0.8", features = ["net", "os-poll"] }

让我们从 future.rs 开始,并首先实现我们的未来相关代码。

Futures 模块

futures.rs 中,我们首先将定义一个 Future trait。它看起来如下:

ch07/a-coroutine/src/future.rs

pub trait Future {
    type Output;
    fn poll(&mut self) -> PollState<Self::Output>;
}

如果我们将它与 Rust 标准库中的 Future trait 进行对比,你会发现它们非常相似,除了我们不取 cx: &mut Context<'_> 作为参数,并且我们返回一个具有不同名称的 enum,只是为了区分它们,以免混淆:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

我们接下来要做的是定义一个 PollState<T> enum

ch07/a-coroutine/src/future.rs

pub enum PollState<T> {
    Ready(T),
    NotReady,
}

再次,如果我们将其与 Rust 标准库中的 Poll 枚举进行比较,我们会发现它们实际上是一样的:

pub enum Poll<T> {
    Ready(T),
    Pending,
}

现在,为了使我们的示例的第一个迭代能够运行,我们只需要这些。让我们继续到下一个文件:http.rs

HTTP 模块

在这个模块中,我们将实现一个非常简单的 HTTP 客户端。这个客户端只能向我们的 delayserver 发送 GET 请求,因为我们只是用它来表示典型的 I/O 操作,并不关心能否做更多我们不需要的事情。

我们首先将一些类型和特质从标准库以及我们的 Futures 模块导入:

ch07/a-coroutine/src/http.rs

use crate::future::{Future, PollState};
use std::io::{ErrorKind, Read, Write};

接下来,我们创建了一个小的辅助函数来编写我们的 HTTP 请求。我们之前在这本书中已经使用过这段代码,所以在这里我不会再次解释它:

ch07/a-coroutine/src/http.rs

fn get_req(path: &str) -> String {
    format!(
        "GET {path} HTTP/1.1\r\n\
             Host: localhost\r\n\
             Connection: close\r\n\
             \r\n"
    )
}

因此,现在我们可以开始编写我们的 HTTP 客户端了。实现非常简短且简单:

pub struct Http;
impl Http {
    pub fn get(path: &str) -> impl Future<Output = String> {
        HttpGetFuture::new(path)
    }
}

我们在这里实际上并不需要一个结构体,但我们添加了一个,因为我们可能在以后某个时刻想要添加一些状态。这也是将属于 HTTP 客户端的功能分组在一起的好方法。

我们的 HTTP 客户端只有一个函数,即 get,它最终会向我们的 delayserver 发送一个带有指定路径的 GET 请求(记住,在这个示例 URL 中,路径是所有加粗的内容:http://127.0.0.1:8080/1000/HelloWorld),

在函数体中,你首先会注意到这里并没有发生太多事情。我们只返回 HttpGetFuture,就这么多。

在函数签名中,你可以看到它返回一个实现 Future 特质的对象,当它解析时输出一个 String。从这个函数返回的字符串将是来自服务器的响应。

现在,我们本可以直接在 Http 结构体上实现 future 特质,但我认为更好的设计是允许一个 Http 实例提供多个 Futures,而不是让 Http 本身实现 Future

让我们更仔细地看看 HttpGetFuture,因为那里发生的事情更多。

只是为了指出,以免将来有疑问,HttpGetFuture 是一个叶子未来的例子,并且它将是我们在本例中使用的唯一叶子未来。

让我们在文件中添加结构体声明:

ch07/a-coroutine/src/http.rs

struct HttpGetFuture {
    stream: Option<mio::net::TcpStream>,
    buffer: Vec<u8>,
    path: String,
}

这个数据结构将为我们保存一些数据:

  • stream:这保存了一个 Option<mio::net::TcpStream>。这将是 Option,因为我们不会在创建此结构的同时连接到流。

  • buffer:我们将从 TcpStream 读取数据并将其全部放入这个缓冲区,直到我们读取了服务器返回的所有数据。

  • path:这个简单地存储了我们的 GET 请求的路径,以便我们以后可以使用它。

我们接下来要查看的是 HttpGetFutureimpl 块:

ch07/a-coroutine/src/http.rs

impl HttpGetFuture {
    fn new(path: &'static str) -> Self {
        Self {
            stream: None,
            buffer: vec![],
            Path: path.to_string(),
        }
    }
    fn write_request(&mut self) {
        let stream = std::net::TcpStream::connect("127.0.0.1:8080").unwrap();
        stream.set_nonblocking(true).unwrap();
        let mut stream = mio::net::TcpStream::from_std(stream);
        stream.write_all(get_req(&self.path).as_bytes()).unwrap();
        self.stream = Some(stream);
    }
}

impl 块定义了两个函数。第一个是 new,它只是设置初始状态。

下一个函数是write_requst,它将 GET 请求发送到服务器。您在第四章的示例中已经看到过这段代码,所以这应该看起来很熟悉。

注意

创建 HttpGetFuture时,我们实际上并没有做任何与 GET 请求相关的事情,这意味着对Http::get的调用会立即返回,只带有一个简单的数据结构。

与早期示例相比,我们传递了localhostIP 地址而不是 DNS 名称。我们采取与之前相同的捷径,让connect是阻塞的,而其他一切都是非阻塞的。

下一步是向服务器发送 GET 请求。这将是非阻塞的,我们不需要等待它完成,因为我们无论如何都会等待响应。

文件的最后一部分是最重要的——我们定义的Future特质的实现:

ch07/a-coroutine/src/http.rs

impl Future for HttpGetFuture {
    type Output = String;
    fn poll(&mut self) -> PollState<Self::Output> {
        if self.stream.is_none() {
            println!("FIRST POLL - START OPERATION");
            self.write_request();
            return PollState::NotReady;
        }
        let mut buff = vec![0u8; 4096];
        loop {
            match self.stream.as_mut().unwrap().read(&mut buff) {
                Ok(0) => {
                    let s = String::from_utf8_lossy(&self.buffer);
                    break PollState::Ready(s.to_string());
                }
                Ok(n) => {
                    self.buffer.extend(&buff[0..n]);
                    continue;
                }
                Err(e) if e.kind() == ErrorKind::WouldBlock => {
                    break PollState::NotReady;
                }
                Err(e) if e.kind() == ErrorKind::Interrupted => {
                    continue;
                }
                Err(e) => panic!("{e:?}"),
            }
        }
    }
}

好吧,所以这里就是一切发生的地方。我们首先做的事情是将关联类型Output设置为String

我们接下来要做的就是检查这是否是第一次调用poll。我们通过检查self.stream是否为None来完成这个操作。

如果这是我们第一次调用poll,我们会打印一条消息(只是为了看到第一次这个 future 被轮询的情况),然后我们将 GET 请求写入服务器。

在第一次轮询时,我们返回PollState::NotReady,因此HttpGetFuture至少还需要被轮询一次才能返回任何结果。

函数的下一部分尝试从我们的TcpStream读取数据。

我们之前已经讨论过这个问题,所以我会简要说明,但基本上有五件事情可能发生:

  1. 调用成功返回,读取了0个字节。我们已经从流中读取了所有数据,并收到了整个 GET 响应。我们在返回之前,从读取的数据中创建一个String并将其包装在PollState::Ready中。

  2. 调用成功返回,读取了n > 0个字节。如果是这种情况,我们将数据读取到我们的缓冲区中,将数据追加到self.buffer中,并立即尝试从流中读取更多数据。

  3. 我们得到一个WouldBlock类型的错误。如果是这种情况,我们知道由于我们将流设置为非阻塞,数据尚未准备好或者有更多数据但我们尚未收到。在这种情况下,我们返回PollState::NotReady以表明需要更多轮询调用来完成操作。

  4. 我们得到一个Interrupted类型的错误。这是一个特殊情况,因为读取可以被信号中断。如果发生这种情况,处理错误的通常方式是简单地再次尝试读取。

  5. 我们得到一个我们无法处理的错误,并且由于我们的示例没有进行错误处理,我们简单地panic!

有一个微妙的地方我想指出。我们可以将其视为一个非常简单的具有三个状态的状态机:

  • 未开始,由self.streamNone表示

  • 等待中,由self.streamSome且对stream.read的读取返回WouldBlock表示

  • 已解决,通过self.streamSome以及调用stream.read返回0字节来指示

如你所见,这个模型很好地映射到了操作系统在尝试读取我们的TcpStream时报告的状态。

大多数像这样的叶子未来将会非常简单,尽管我们没有在这里明确状态,但它仍然适合我们基于协程构建的状态机模型。

所有未来都必须是懒加载的吗?

懒加载的未来是在它第一次被轮询之前不执行任何工作。

如果你阅读关于 Rust 中的未来的内容,这会经常出现,并且由于我们的Future特例正是基于这个模型,同样的问题也会在这里出现。对这个问题的简单回答是:不!

没有什么强制叶子未来,比如我们在这里写的,必须是懒加载的。如果我们想在调用 Http::get 函数时发送 HTTP 请求,我们可以这样做。如果你这么想,如果我们只是这样做,这可能会引起一个可能很大的变化,从而影响我们在程序中实现并发的方式。

现在的工作方式是,必须有人至少调用一次 poll 来实际发送请求。结果是,调用这个未来的 poll 的人将不得不对许多未来调用 poll,如果他们想让它们并发运行的话。

如果我们在创建未来时立即启动操作,你可以创建许多未来,即使你逐个轮询它们以完成,它们也会并发运行。如果你在当前设计中逐个轮询它们以完成,未来将不会并发地前进。请稍作思考。

类似 JavaScript 这样的语言在协程创建时就开始执行操作,因此没有“一种方式”来做这件事。每次遇到协程实现时,你应该找出它们是懒加载的还是急加载的,因为这将影响你如何使用它们编程。

尽管在这种情况下我们可以使我们的未来变得急加载,但我们实际上不应该这样做。由于 Rust 中的程序员期望未来是懒加载的,他们可能会依赖于在你对它们调用 poll 之前不发生任何事情,如果你写的未来行为不同,可能会有意外的副作用。

现在,当你读到 Rust 的未来总是懒加载的,这是一个我经常看到的说法,它指的是使用 async/await 生成的编译器生成的状态机。正如我们稍后将会看到的,当你的异步函数被编译器重写时,它们是以一种方式构建的,这样你在一个 async 函数体中写的任何内容都不会在第一次调用 Future::poll 之前执行。

好的,所以我们已经涵盖了Future特性和我们命名为HttpGetFuture的叶子未来。下一步是创建一个可以在预定义点停止和恢复的任务。

创建协程

我们将从零开始构建我们的知识和理解。我们首先要做的是创建一个可以通过将其建模为手动状态机来停止和恢复的任务。

一旦我们完成,我们将看看这种建模暂停任务的方式如何使我们能够编写类似于async/await的语法,并依赖于代码转换来创建这些状态机,而不是手动编写它们。

我们将创建一个简单的程序,它将执行以下操作:

  1. 当我们的暂停任务开始时打印一条消息。

  2. 向我们的delayserver发起 GET 请求。

  3. 等待 GET 请求。

  4. 打印来自服务器的响应。

  5. 向我们的delayserver发起第二次 GET 请求。

  6. 等待来自服务器的第二次响应。

  7. 打印来自服务器的响应。

  8. 退出程序。

此外,我们将通过在自定义协程上多次调用Future::poll来执行我们的程序,直到运行完成。目前还没有运行时、反应器或执行器,因为我们将这些内容留到下一章介绍。

如果我们将程序编写为一个async函数,它将如下所示:

async fn async_main() {
    println!("Program starting")
    let txt = Http::get("/1000/HelloWorld").await;
    println!("{txt}");
    let txt2 = Http::("500/HelloWorld2").await;
    println!("{txt2}");
}

main.rs中,首先进行必要的导入和模块声明:

ch07/a-coroutine/src/main.rs

use std::time::Instant;
mod future;
mod http;
use crate::http::Http;
use future::{Future, PollState};

我们接下来要写的是我们的可停止/可恢复任务,称为Coroutine

ch07/a-coroutine/src/main.rs

struct Coroutine {
    state: State,
}

一旦完成,我们将编写这个任务可能处于的不同状态:

ch07/a-coroutine/src/main.rs

enum State {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Resolved,
}

这个特定的协程可以处于四种状态:

  • Coroutine已创建,但尚未被轮询。

  • Http::get,我们得到一个存储在State enum中的HttpGetFuture返回值。在此点,我们将控制权交回调用函数,以便它可以在需要时执行其他操作。我们选择使其对所有输出StringFuture函数是通用的,但由于我们目前只有一种类型的未来,我们也可以简单地使其仅持有HttpGetFuture,它将以相同的方式工作。

  • Http::get是我们将控制权交回调用函数的第二个地方。

  • 已解决:未来已解决,没有更多的工作要做。

注意

我们本可以直接将Coroutine定义为enum,因为它只持有表示其状态的enum。但我们将设置这个示例,以便我们可以在本书的后面部分添加一些状态到Coroutine

接下来是Coroutine的实现:

ch07/a-coroutine/src/main.rs

impl Coroutine {
    fn new() -> Self {
        Self {
            state: State::Start,
        }
    }
}

到目前为止,这相当简单。当创建一个新的Coroutine时,我们只需将其设置为State::Start即可。

现在我们来到了实际工作在CoroutineFuture实现部分。我将带您浏览代码:

ch07/a-coroutine/src/main.rs

impl Future for Coroutine {
    type Output = ();
    fn poll(&mut self) -> PollState<Self::Output> {
        loop {
            match self.state {
                State::Start => {
                    println!("Program starting");
                    let fut = Box::new(Http::get("/600/HelloWorld1"));
                    self.state = State::Wait1(fut);
                }
                State::Wait1(ref mut fut) => match fut.poll() {
                    PollState::Ready(txt) => {
                        println!("{txt}");
                        let fut2 = Box::new(Http::get("/400/HelloWorld2"));
                        self.state = State::Wait2(fut2);
                    }
                    PollState::NotReady => break PollState::NotReady,
                },
                State::Wait2(ref mut fut2) => match fut2.poll() {
                    PollState::Ready(txt2) => {
                        println!("{txt2}");
                        self.state = State::Resolved;
                        break PollState::Ready(());
                    }
                    PollState::NotReady => break PollState::NotReady,
                },
                State::Resolved => panic!("Polled a resolved future"),
            }
        }
    }
}

让我们从顶部开始:

  1. 我们首先将Output类型设置为()。由于我们不会返回任何内容,这仅仅使我们的示例更简单。

  2. 接下来是poll方法的实现。首先您会注意到我们写了一个匹配self.stateloop实例。我们这样做是为了推动状态机向前发展,直到我们达到一个点,没有从我们的子未来中获得PollState::NotReady我们就无法进一步进展。

  3. 如果状态是State::Start,我们知道这是第一次被轮询,所以我们运行我们需要运行的任何指令,直到我们到达需要解决的新未来的点。

  4. 当我们调用Http::get时,我们返回一个需要在我们进一步进展之前完成轮询的未来。

  5. 在这一点上,我们将状态更改为State::Wait1,并存储我们想要解析的未来,以便在下一个状态中访问它。

  6. 我们的状态机现在已从Start变为Wait1。由于我们在match语句上循环,我们立即进入下一个状态,并在下一次迭代中到达State::Wait1的匹配分支。

  7. Wait1中,我们首先对等待的Future实例调用poll

  8. 如果未来返回PollState::NotReady,我们只需将其冒泡到调用者处,通过跳出循环并返回NotReady

  9. 如果未来返回PollState::Ready并附带我们的数据,我们知道我们可以执行依赖于第一个未来数据的指令,并进入下一个状态。在我们的情况下,我们只打印出返回的数据,所以这只有一行代码。

  10. 接下来,我们通过调用Http::get获得一个新的未来。我们将状态设置为Wait2,就像我们从State::StartState::Wait1所做的那样。

  11. 就像第一次我们得到一个需要在继续之前解决的未来一样,我们将其保存起来,以便在State::Wait2中访问它。

  12. 由于我们处于循环中,接下来发生的事情是我们到达Wait2的匹配分支,在这里,我们重复与State::Wait1相同的步骤,但针对不同的未来。

  13. 如果它返回带有我们的数据的Ready,我们就采取行动,并将我们的Coroutine的最终状态设置为State::Resolved。还有一个重要的变化:这次,我们希望通知调用者这个未来已经完成,所以我们跳出循环并返回PollState::Ready

如果有人试图再次在我们的Coroutine上调用poll,我们将引发恐慌,因此调用者必须确保跟踪未来何时返回PollState::Ready,并确保永远不再调用它。在我们到达main函数之前做的最后一件事是在我们称为async_main的函数中创建一个新的Coroutine。这样,当我们在本章的最后部分讨论async/await时,我们可以将更改保持在最小:

ch07/a-coroutine/src/main.rs

fn async_main() -> impl Future<Output = ()> {
    Coroutine::new()
}

因此,在这个点上,我们已经完成了协程的编写,剩下要做的就是编写一些逻辑来通过main函数的不同阶段驱动状态机。

这里要注意的一点是,我们的主函数只是一个普通的主函数。我们主函数中的循环驱动异步操作完成:

ch07/a-coroutine/src/main.rs

fn main() {
    let mut future = async_main();
    loop {
        match future.poll() {
            PollState::NotReady => {
                println!("Schedule other tasks");
            },
            PollState::Ready(_) => break,
        }
        thread::sleep(Duration::from_millis(100));
    }
}

这个函数非常简单。我们首先获取async_main返回的未来,然后在一个循环中对其调用poll,直到它返回PollState::Ready

每当我们收到 PollState::NotReady 的返回值时,控制权就交回到了我们这里。如果我们想的话,我们在这里可以做一些其他的工作,比如安排另一个任务,但在这个例子中,我们只是打印 安排 其他任务

我们还通过在每次调用时暂停 100 毫秒来限制循环的运行频率。这样我们不会因为打印输出而感到不知所措,并且我们可以假设每次我们在控制台看到 "安排其他任务" 打印出来之间大约有 100 毫秒的时间间隔。

如果我们运行这个例子,我们会得到以下输出:

Program starting
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, 24 Oct 2023 20:39:13 GMT
HelloWorld1
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, 24 Oct 2023 20:39:13 GMT
HelloWorld2

通过查看打印输出,你可以了解程序流程。

  1. 首先,我们看到 程序开始,这是在协程开始时执行的。

  2. 我们接着看到,我们立即跳转到 第一次轮询 – 开始操作 的消息,我们只在从我们的 HTTP 客户端返回的未来对象第一次轮询时打印这个消息。

  3. 接下来,我们可以看到我们又回到了 main 函数中,在这个时候,如果我们有其他任务,理论上我们可以继续运行其他任务

  4. 每 100 毫秒,我们检查任务是否完成,并得到同样的消息,告诉我们可以安排其他任务

  5. 然后,大约 600 毫秒后,我们收到一个打印出来的响应

  6. 我们重复这个过程,直到我们收到并打印出从服务器返回的第二响应

恭喜你,你现在创建了一个可以在不同点暂停和恢复的任务,允许它在进行中。

谁会想写这样的代码来完成一个简单的任务呢?

答案是没有一个人!

是的,这听起来有点夸张,但我敢猜测,与编写 55 行状态机相比,很少有程序员更喜欢编写 7 行正常的顺序代码来完成相同的事情。

如果我们回顾一下大多数用户空间并发操作的抽象目标,我们会发现这种做法只检查了我们想要达到的三个目标中的一个:

  • 高效

  • 表达性

  • 易于使用且难以误用

我们的状态机将会是高效的,但这基本上就是全部了。

然而,你也可能注意到,这种疯狂中其实有一定的规律。这可能不会让你感到惊讶,但如果我们在每个函数的开始和每个我们想要将控制权交还给调用者的点使用几个关键字进行标记,并且由系统为我们生成状态机,那么我们编写的代码可能会简单得多。这正是 async/await 的基本理念。

让我们去看看在我们的例子中这会如何工作。

async/await

之前的例子可以简单地用 async/await 关键字写成以下形式:

async fn async_main() {
    println!("Program starting")
    let txt = Http::get("/1000/HelloWorld").await;
    println!("{txt}");
    let txt2 = Http::("500/HelloWorld2").await;
    println!("{txt2}");
}

这只有七行代码,看起来非常熟悉,就像你在一个普通的子例程/函数中编写的代码一样。

结果表明,我们可以让编译器为我们编写这些状态机,而不是自己编写。不仅如此,我们只需使用简单的宏来帮助我们,这正是当前的 async/await 语法在成为语言一部分之前的原型设计方式。您可以在github.com/alexcrichton/futures-await中看到一个例子。

当然,缺点是这些函数看起来像普通的子程序,但实际上在本质上非常不同。在像 Rust 这样的强类型语言中,它使用借用语义而不是垃圾回收器,不可能隐藏这些函数是不同的这一事实。这可能会让程序员感到困惑,因为他们期望一切都能以相同的方式表现。

Coroutine bonus example

为了展示我们的例子与使用 Rust 中的 std::future:::Future 特性和 async/await 所获得的行为有多接近,我创建了一个与我们在 a-coroutines 中所做的完全相同的例子,使用“正确”的未来和 async/await 语法。您首先会注意到,这只需要对代码进行非常小的修改。其次,您可以亲自看到输出显示了与我们在自己编写状态机的例子中完全相同的程序流程。您将在存储库的 ch07/a-coroutines-bonus 文件夹中找到这个例子。

因此,让我们更进一步。为了避免混淆,并且由于我们的协程目前只向调用函数让步(还没有调度器、事件循环或类似的东西),我们使用一种稍微不同的语法,称为 coroutine/wait,并创建一种让我们自己生成这些状态机的方法。

coroutine/wait

coroutine/wait 语法将与 async/await 语法有明显的相似之处,尽管它要有限得多。

基本规则如下:

  • 每个以 coroutine 前缀的函数都将被重写为类似于我们编写的状态机。

  • 函数上标记为 coroutine 的返回类型将被重写,以便它们返回 -> impl Future<Output = String>(是的,我们的语法将仅处理输出为 String 的未来)。

  • 只有实现了 Future 特性的对象才能后缀 .wait。这些点将在我们的状态机中表示为单独的阶段。

  • coroutine 前缀的函数可以调用普通函数,但普通函数不能调用 coroutine 函数并期望有任何动作发生,除非它们反复调用 poll 直到返回 PollState::Ready

我们的实现将确保如果我们编写以下代码,它将编译为我们在本章开头编写的相同状态机(除了所有协程都将返回一个 String):

coroutine fn async_main() {
    println!("Program starting")
    let txt = Http::get("/1000/HelloWorld").wait;
    println!("{txt}");
    let txt2 = Http::("500/HelloWorld2").wait;
    println!("{txt2}");
}

但等等。coroutine/wait 在 Rust 中不是有效的关键字。如果我那样写,我会得到编译错误!

你是对的。所以,我创建了一个名为corofy的小程序,它将coroutine/wait函数重写为我们这些状态机。让我们快速解释一下。

corofy——协程预处理器

在 Rust 中重写代码的最佳方式是使用宏系统。缺点是它并不清楚它最终编译成什么样子,而且对于我们的使用场景来说,展开宏并不是最优的,因为我们的主要目标之一是查看我们编写的代码与它转换成的内容之间的差异。此外,除非你经常使用宏,否则宏可能会变得相当复杂,难以阅读和理解。

相反,corofy 是你可以从仓库中的ch07/corofy下找到的正常 Rust 程序。

如果你进入那个文件夹,你可以通过写下以下内容来全局安装该工具:

cargo install --path .

现在你可以从任何地方使用这个工具了。它通过提供一个包含coroutine/wait语法的输入文件来工作,例如corofy ./src/main.rs [可选输出文件]。如果你没有指定输出文件,它将在同一文件夹中创建一个以_corofied后缀命名的文件。

注意

工具的功能极其有限。诚实的理由是我想在我们到达 2300 年之前完成这个示例,而且我重新从头开始重写了整个 Rust 编译器,只是为了提供一个使用coroutine/wait关键字时的稳健体验。

结果表明,在没有访问 Rust 的类型系统的情况下编写这样的转换是非常困难的。这个工具的主要用途将是转换我们在这里编写的示例,但它可能也适用于相同示例的微小变化(比如添加更多的等待点或在每个等待点之间执行更有趣的任务)。有关corofy的限制,请参阅 README。

还有一件事:我假设你指定了没有明确的输出文件,所以输出文件将与输入文件同名,后缀为_corofied

程序读取你给出的文件,并搜索coroutine关键字的用法。它将这些函数注释掉(这样它们仍然在文件中),将它们放在文件末尾,并在wait点下面直接写出状态机实现,指出状态机的哪些部分是你实际上在wait点之间编写的代码。

现在我已经介绍了我们的新工具,是时候开始使用了。

b-async-await——一个协程/wait 转换的示例

让我们先稍微扩展一下我们的示例。现在我们有一个程序可以输出我们的状态机,这样我们更容易创建一些示例并涵盖我们协程实现的一些更复杂的部分。

我们下面的示例将基于与第一个示例完全相同的代码。在仓库中,你可以在ch07/b-async-await下找到这个示例。

如果你从书中编写每个示例并且不依赖于仓库中的现有代码,你可以做两件事之一:

  • 不断更改第一个示例中的代码

  • 创建一个新的 cargo 项目,命名为b-async-await,并将上一个示例中的src文件夹和Cargo.toml中的dependencies部分的所有内容复制到新的项目中。

无论你选择什么,你都应该在你面前有相同的代码。

让我们简单地将main.rs中的代码更改为以下内容:

ch07/b-async-await/src/main.rs

use std::time::Instant;
mod http;
mod future;
use future::*;
use crate::http::Http;
fn get_path(i: usize) -> String {
    format!("/{}/HelloWorld{i}", i * 1000)
}
coroutine fn async_main() {
    println!("Program starting");
    let txt = Http::get(&get_path(0)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(1)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(2)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(3)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(4)).wait;
    println!("{txt}");
}
fn main() {
    let start = Instant::now();
    let mut future = async_main();
    loop {
        match future.poll() {
            PollState::NotReady => (),
            PollState::Ready(_) => break,
        }
    }
    println!("\nELAPSED TIME: {}", start.elapsed().as_secs_f32());
}

这段代码包含了一些更改。首先,我们添加了一个方便的函数get_path,用于创建新的路径,以便我们可以在 GET 请求中使用它,并基于我们传递的整数添加延迟和消息。

接下来,在我们的async_main函数中,我们创建了五个具有从04秒不同延迟的请求。

我们所做的最后一个更改是在我们的main函数中。我们不再在每次调用poll时打印消息,因此,我们不再使用thread::sleep来限制调用次数。相反,我们测量从我们进入main函数到退出它的时间,因为我们可以用这个作为证明我们的代码是否并发运行的方法。

现在由于我们的main.rs看起来与前面的示例相同,我们可以使用corofy将其重写为一个状态机,所以假设我们处于ch07/b-async-await的根目录中,我们可以编写以下内容:

corofy ./src/main.rs

这应该在src文件夹中输出一个名为main_corofied.rs的文件,你可以打开并检查它。

现在,你可以复制这个文件中main_corofied.rs的所有内容,并将其粘贴到main.rs中。

注意

为了方便,项目根目录中有一个名为original_main.rs的文件,其中包含我们之前展示的main.rs的代码,所以你不需要保存main.rs的原始内容。如果你通过从书中的项目复制它来自己编写每个示例,在你覆盖它之前将main.rs的原始内容存储在某个地方是明智的。

我不会在这里展示整个状态机,因为使用coroutine/wait编写的 39 行代码在作为状态机编写时变成了 170 行代码,但我们的State enum现在看起来像这样:

enum State0 {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Wait3(Box<dyn Future<Output = String>>),
    Wait4(Box<dyn Future<Output = String>>),
    Wait5(Box<dyn Future<Output = String>>),
    Resolved,
}

如果你使用cargo run运行程序,你现在会得到以下输出:

Program starting
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:55 GMT
HelloWorld0
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:56 GMT
HelloWorld1
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:58 GMT
HelloWorld2
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:06:01 GMT
HelloWorld3
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:06:05 GMT
HelloWorld4
ELAPSED TIME: 10.043025

所以,你看,我们的代码按预期运行。

由于我们在每次调用Http::get时都调用了wait,代码是顺序执行的,当我们查看 10 秒的经过时间时,这一点很明显。

这是有意义的,因为我们请求的延迟是0 + 1 + 2 + 3 + 4,等于 10 秒。

如果我们想让我们的未来运行并发呢?

你还记得我们讨论过这些未来是懒的吗?很好。所以,你知道仅仅创建一个未来并不能获得并发性。我们需要轮询它们以启动操作。

为了解决这个问题,我们借鉴了join_all的一些灵感。它接受一组未来,并将它们并发地驱动到完成。

让我们为这一章创建最后一个示例,其中我们只做这件事。

c-async-await—并发未来

好的,我们将基于上一个示例继续进行,并做同样的事情。创建一个名为 c-async-await 的新项目,并将 Cargo.tomlsrc 文件夹中的所有内容复制过来。

我们首先要做的事情是去 future.rs 并在我们的现有代码下方添加一个 join_all 函数:

ch07/c-async-await/src/future.rs

pub fn join_all<F: Future>(futures: Vec<F>) -> JoinAll<F> {
    let futures = futures.into_iter().map(|f| (false, f)).collect();
    JoinAll {
        futures,
        finished_count: 0,
    }
}

这个函数接受一个未来集合作为参数,并返回一个 JoinAll<F> 未来。

这个函数只是创建一个新的集合。在这个集合中,我们将有由我们收到的原始未来和一个表示未来是否解决的 bool 值组成的元组。

接下来,我们有我们 JoinAll 结构体的定义:

ch07/c-async-await/src/future.rs

pub struct JoinAll<F: Future> {
    futures: Vec<(bool, F)>,
    finished_count: usize,
}

这个结构体将简单地存储我们创建的集合和一个 finished_count。最后一个字段将使跟踪有多少未来被解决变得稍微容易一些。

如我们所习惯的,大多数有趣的部分都发生在 JoinAllFuture 实现中:

impl<F: Future> Future for JoinAll<F> {
    type Output = String;
    fn poll(&mut self) -> PollState<Self::Output> {
        for (finished, fut) in self.futures.iter_mut() {
            if *finished {
                continue;
            }
            match fut.poll() {
                PollState::Ready(_) => {
                    *finished = true;
                    self.finished_count += 1;
                }
                PollState::NotReady => continue,
            }
        }
        if self.finished_count == self.futures.len() {
            PollState::Ready(String::new())
        } else {
            PollState::NotReady
        }
    }
}

我们将 Output 设置为 String。这可能会让你感到奇怪,因为我们实际上并没有从这个实现中返回任何东西。原因是 corofy 只与返回 String 的未来一起工作(这是它许多缺点之一),所以我们只是接受这一点,并在完成时返回一个空字符串。

接下来是 poll 实现的下一步。我们首先对每个(标志,未来)元组进行循环:

for (finished, fut) in self.futures.iter_mut()

在循环内部,我们首先检查这个未来的标志是否设置为 finished。如果是,我们只需转到集合中的下一个项目。

如果它还没有完成,我们 poll 这个未来。

如果我们得到 PollState::Ready,我们将这个未来的标志设置为 true,这样我们就不会再次轮询它,并增加完成计数。

注意

值得注意的是,我们在这里创建的 join_all 实现不会以任何有意义的方式与返回值的未来一起工作。在我们的例子中,我们只是扔掉了这个值,但请记住,我们现在试图尽可能保持简单,我们只想展示调用 join_all 的并发方面。

Tokio 的 join_all 实现将所有返回的值放入一个 Vec<T> 中,并在 JoinAll 未来解决时返回它们。

如果我们得到 PollState::NotReady,我们只需继续到集合中的下一个未来。

在遍历整个集合之后,我们检查我们是否已经解决了最初收到的所有未来,在 if self.finished_count == self.futures.len()

如果我们所有的未来都已经被解决,我们将返回 PollState::Ready 并带一个空字符串(为了使 corofy 满意)。如果还有未解决的未来,我们将返回 PollState::NotReady

重要

这里有一个需要注意的微妙之处。第一次调用JoinAll::poll时,它将对集合中的每个 future 调用poll。对每个 future 进行轮询将启动它们所代表的任何操作,并允许它们并发地进步。这是通过懒协程实现并发的一种方式,就像我们在这里处理的那样。

接下来,我们将对main.rs进行的更改。

main函数将保持不变,以及文件开头的导入和声明,所以我只会展示我们更改的coroutine/await函数:

coroutine fn request(i: usize) {
    let path = format!("/{}/HelloWorld{i}", i * 1000);
    let txt = Http::get(&path).wait;
    println!("{txt}");
}
coroutine fn async_main() {
    println!("Program starting");
    let mut futures = vec![];
    for i in 0..5 {
        futures.push(request(i));
    }
    future::join_all(futures).wait;
}

注意

在仓库中,如果你在所有复制粘贴的过程中丢失了跟踪,你可以在ch07/c-async-await/original_main.rs中找到放入main.rs的正确代码。

现在我们有两个coroutine/wait函数。async_mainread_request创建的一组协程存储在Vec<T: Future>中。

然后它创建了一个JoinAll future,并对其调用wait

下一个coroutine/wait函数是read_requests,它接受一个整数作为输入,并使用该整数创建 GET 请求。这个协程将等待响应,并在响应到达时打印出结果。

由于我们创建的请求延迟为0, 1, 2, 3, 4秒,因此我们预计整个程序将在四秒多一点的时间内完成,因为所有任务都将并发地进行。那些延迟短的将在四秒延迟的任务完成时完成。

现在我们可以通过确保我们位于ch07/c-async-await文件夹中,并编写corofy ./src/main.rs,将我们的coroutine/await函数转换为状态机。

你现在应该在src文件夹中看到一个名为main_corofied.rs的文件。复制其内容,并用它替换main.rs中的内容。

如果你通过编写cargo run来运行程序,你应该会得到以下输出:

Program starting
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:36 GMT
HelloWorld0
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:37 GMT
HelloWorld1
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:38 GMT
HelloWorld2
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:39 GMT
HelloWorld3
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:40 GMT
HelloWorld4
ELAPSED TIME: 4.0084987

这里要注意的是经过的时间。现在正好超过四秒,就像我们预期的那样,当我们的 future 并发运行时。

如果我们看看coroutine/await是如何从程序员的视角改变编写协程的体验的,我们会看到我们现在离目标更近了:

  • 高效:状态机不需要上下文切换,只需保存/恢复与该特定任务相关的数据。我们没有增长与分段栈的问题,因为它们都使用相同的操作系统提供的栈。

  • 表达性:我们可以像在“正常”Rust 中一样编写代码,并且有了编译器的支持,我们可以得到相同的错误消息并使用相同的工具。

  • 从一个普通函数到async函数,并期望发生任何有意义的事情;你必须以某种方式主动轮询它以完成,随着我们开始添加运行时,这会变得更加复杂。然而,就大部分而言,我们可以像我们习惯的那样编写程序。

最后的想法

在我们结束本章之前,我想指出,现在应该对我们来说已经很清楚为什么协程实际上并不是可抢占的。如果你还记得在第二章中,我们提到一个堆栈型协程(例如我们纤维/绿色线程的例子)可以被抢占,并且可以在任何点上暂停其执行。这是因为它们有一个堆栈,暂停一个任务就像将当前执行状态存储到堆栈中并跳转到另一个任务一样简单。

这在这里是不可能的。我们能停止和恢复执行的地方只有预先定义的挂起点,这些挂起点是我们手动用wait标记的。

理论上,如果你有一个紧密集成的系统,你控制编译器、协程定义、调度器和 I/O 原语,你可以向状态机添加额外的状态,并创建额外的挂起/恢复点。这些挂起点对用户来说是透明的,并且与正常的等待/挂起点不同对待。

例如,每次你遇到一个正常的函数调用时,你可以在我们的状态机中添加一个挂起点(一个新的状态),在那里你检查当前任务是否已经用完了其时间预算或类似的事情。如果是这样,你可以安排另一个任务运行,并在稍后某个时间点恢复任务,尽管这并没有以协作的方式进行。

然而,尽管这对用户来说是不可见的,但这并不等同于能够在代码的任何点停止/恢复执行。这也会违反协程通常隐含的协作性质。

摘要

干得好!在本章中,我们介绍了很多代码,并设置了一个示例,我们将在接下来的章节中继续使用。

到目前为止,我们一直专注于使用futuresasync/await来模拟和创建可以在特定点暂停和恢复的任务。我们知道这是同时拥有正在执行的任务的先决条件。我们通过引入我们自己的简化版Future特性和我们自己的coroutine/wait语法来实现这一点,这些语法比 Rust 的futuresasync/await语法要有限得多,但更容易理解,并且更容易在心理上理解这与纤维/绿色线程(至少我希望是这样)是如何工作的。

我们还讨论了急切协程和懒协程之间的区别以及它们如何影响你实现并发的方式。我们从 Tokio 的join_all函数中汲取了灵感,并实现了我们自己的版本。

在本章中,我们只是创建了可以暂停和恢复的任务。目前还没有事件循环、调度或其他类似的东西,但不用担心。它们正是我们将在下一章中探讨的内容。好消息是,像本章这样清晰地理解协程是非常困难的事情之一。

第八章:运行时、Wakers 和 Reactor-Executor 模式

在上一章中,我们通过将它们编写为状态机来创建了我们的可暂停任务(协程)。我们通过要求它们实现Future特质为这些任务创建了一个通用的 API。我们还展示了如何使用一些关键字创建这些协程,并通过编程重写它们,这样我们就不必手动实现这些状态机,而是可以像平时一样编写我们的程序。

如果我们停下来,从宏观的角度审视到目前为止我们所取得的成果,从概念上讲是非常简单的:我们有一个可暂停任务的接口(Future特质),并且我们有两个关键字(coroutine/wait)来指示我们希望重写的代码段,作为将我们的代码分割成可以暂停的段的状态机。

然而,我们还没有事件循环,也没有调度器。在本章中,我们将扩展我们的示例,并添加一个运行时,使我们能够高效地运行我们的程序,并使我们能够比现在更高效地并发地调度任务。

本章将带你在两个阶段实现我们的运行时,逐步使其更有用、更高效和更强大。我们将从简要概述运行时是什么以及为什么我们想要了解它们的一些特性开始。我们将基于我们在第七章中学到的知识,并展示我们如何利用在第四章中获得的知识,使其变得更加高效,并避免不断轮询未来以使其进展。

接下来,我们将展示如何通过将运行时分为两部分:一个executor和一个reactor,来获得更灵活和松散耦合的设计。

在本章中,你将了解基本的运行时设计、Reactor、Executor、Waker 和创建,我们将基于本书中我们学到的很多知识。

这将是本书中的一大章节,不是因为主题过于复杂或困难,而是因为我们有很多代码要编写。除此之外,我还试图通过提供很多图表并详细解释一切,给你一个良好的心理模型来了解正在发生的事情。尽管这不是你通常在睡前快速浏览的章节,但我确实承诺最终它绝对值得。

本章将分为以下部分:

  • 运行时简介及其必要性

  • 改进我们的基础示例

  • 创建一个合适的运行时

  • 第一步 - 通过添加一个 Reactor 和一个 Waker 来改进我们的运行时设计

  • 第二步 - 实现一个合适的 Executor

  • 第三步 - 实现一个合适的 Reactor

  • 尝试我们的新运行时

因此,让我们直接进入正题!

技术要求

本章中的示例将基于我们上一章的代码,因此要求相同。所有示例都将跨平台,并在 Rust(doc.rust-lang.org/beta/rustc/platform-support.html#tier-1-with-host-tools)和mio(github.com/tokio-rs/mio#platforms)支持的平台上运行。你需要做的只是安装 Rust 并将属于本书的存储库下载到本地。本章中的所有代码都将位于ch08文件夹中。

要逐步跟随示例,你还需要在你的机器上安装corofy。如果你在第七章中没有安装它,现在就通过进入存储库中的ch08/corofy文件夹并运行以下命令来安装它:

cargo install --force --path .

或者,当我们在使用corofy重写coroutine/wait语法时,你也可以直接复制存储库中的相关文件。这两种版本都会在那里供你使用。

在这个例子中,我们还将使用delayserver,因此你需要打开一个单独的终端,进入存储库根目录下的delayserver文件夹,并运行cargo run,以便它为后续的示例准备就绪并可用。

如果由于某种原因你需要更改delayserver监听的端口号,请记住在代码中更改端口号。

运行时介绍及其必要性

如你所知,你需要自己提供运行时来驱动和调度 Rust 中的异步任务。

运行时有多种风味,从流行的Embassy嵌入式运行时(github.com/embassy-rs/embassy),它更多地关注通用多任务处理,可以在许多平台上替代实时操作系统RTOS)的需求,到Tokio(github.com/tokio-rs/tokio),它专注于在流行的服务器和桌面操作系统上的非阻塞 I/O。

Rust 中的所有运行时都需要至少完成两件事:调度和驱动实现 Rust 的Future特质的对象以完成。在本章的后续内容中,我们将主要关注在流行的桌面和服务器操作系统(如 Windows、Linux 和 macOS)上执行非阻塞 I/O 的运行时。这也是程序员在 Rust 中最常见的运行时类型。

掌控任务调度的方式非常侵入性,几乎是一条单行道。如果你依赖用户空间的调度器来运行你的任务,那么你同时就不能使用操作系统调度器(除非跳过几个步骤),因为将它们混合在你的代码中将会造成混乱,并可能最终抵消编写异步程序的全部目的。

下面的图示说明了不同的调度器:

图 8.1 – 单线程异步系统中的任务调度

图 8.1 – 单线程异步系统中的任务调度

向操作系统调度器让步的一个例子是使用默认的 std::net::TcpStreamstd::thread::sleep 方法进行阻塞调用。甚至使用标准库提供的原语(如 Mutex)进行的 可能 阻塞调用也可能让步给操作系统调度器。

这也是为什么你经常会发现异步编程往往会影响它所触及的一切,并且仅使用 async/await 运行程序的一部分是非常困难的。

结果是,运行时必须使用标准库的非阻塞版本。从理论上讲,你可以制作一个所有运行时都使用的非阻塞版本的标准库,这也是 async_std 初始化(book.async.rs/introduction)的一个目标。然而,让社区就解决这个任务达成一致意见是一项艰巨的任务,而且至今尚未实现。

在我们开始实现示例之前,我们将讨论 Rust 中典型异步运行时的整体设计。大多数运行时,如 Tokio、Smol 或 async-std,都会将它们的运行时分为两部分。

跟踪我们等待的事件并确保以高效方式等待来自操作系统的通知的部分通常被称为 reactordriver

调度任务并轮询它们直到完成的部分被称为 executor

让我们从这个设计的宏观角度看看,这样我们就会知道我们将在示例中实现什么。

Reactors 和 executors

当我们查看 Rust 如何建模异步任务时,将运行时分为两个不同的部分是非常有意义的。如果你阅读了 Future (doc.rust-lang.org/std/future/trait.Future.html) 和 Waker (doc.rust-lang.org/std/task/struct.Waker.html) 的文档,你会发现 Rust 不仅定义了一个 Future 特征和一个 Waker 类型,而且还提供了关于它们应该如何使用的重要信息。

这的一个例子是 Future 特征是 惰性的,正如我们在 第六章 中所讨论的。另一个例子是,对 Waker::wake 的调用将保证 至少一次 对相应任务上的 Future::poll 的调用。

因此,仅通过阅读文档,你就会看到至少有一些关于运行时应该如何表现的想法。

学习这种模式的原因是它几乎与 Rust 的异步模型完美契合。

由于许多读者,包括我,英语不是第一语言,所以我会在一开始就解释这些名称,因为,嗯,它们似乎很容易被误解。

如果在 TcpStream 上有名为 READABLE 的事件。

你可以在同一个运行时中运行几种不同类型的 reactor。

如果“executor”这个名字让你联想到执行者(中世纪的那种)或可执行文件,那么也请摒弃这种想法。如果你查阅 executor 的定义,它是一个人,通常是律师,负责管理一个人的遗嘱。通常情况下,由于那个人已经去世。这也正是任何命名所暗示的心理模型崩溃的点,因为异步运行时中,没有任何东西,也没有任何人需要受到伤害,executor 才能有工作可做,但我跑题了。

重要的是,executor 只是决定谁能在 CPU 上获得时间以推进,以及何时获得。executor 还必须调用Future::poll并推进状态机到下一个状态。它是一种调度器。

由于主题本身已经足够复杂,无需考虑核反应堆和执行者如何在整体画面中发挥作用,因此一开始就产生错误的想法可能会令人沮丧。

由于反应器将对事件做出响应,它们需要与事件的来源进行一些集成。如果我们继续以TcpStream为例,某个东西将对其调用readwrite,此时,反应器需要知道它应该跟踪该来源上的某些事件。

因此,非阻塞 I/O 原语和反应器需要紧密集成,具体取决于你如何看待,I/O 原语将不得不自带反应器,或者你将有一个提供 I/O 原语(如套接字、端口和流)的反应器。

现在我们已经讨论了一些总体设计,我们可以开始编写一些代码了。

运行时通常会很快变得复杂,因此为了尽可能保持简单,我们将在代码中避免任何错误处理,并使用unwrapexpect来处理所有事情。我们还将尽我们所能选择简单而不是巧妙,可读性而不是效率。

我们的第一项任务将是改进我们在第七章中编写的第一个示例,避免需要主动轮询它以取得进展。相反,我们将依靠我们在前面章节中学到的非阻塞 I/O 和epoll的知识。

改进我们的基础示例

我们将创建第七章中第一个示例的版本,因为它是最简单的一个开始。我们唯一的重点是展示如何更有效地调度和驱动运行时。

我们将从以下步骤开始:

  1. 创建一个新的项目,并将其命名为a-runtime(或者,导航到书籍仓库中的ch08/a-runtime)。

  2. src文件夹中的future.rshttp.rs文件从我们在第七章中创建的第一个项目a-coroutine(或者,从书籍仓库中的ch07/a-coroutine复制文件)复制到我们新项目的src文件夹中。

  3. 确保通过在Cargo.toml中添加以下内容将mio添加为依赖项:

    [dependencies]
    mio = { version = "0.8", features = ["net", "os-poll"] }
    
  4. src文件夹中创建一个名为runtime.rs的新文件。

我们将使用corofy将以下coroutine/wait程序转换为我们可以运行的其状态机表示形式。

src/main.rs中添加以下代码:

ch08/a-runtime/src/main.rs

mod future;
mod http;
mod runtime;
use future::{Future, PollState};
use runtime::Runtime;
fn main() {
    let future = async_main();
    let mut runtime = Runtime::new();
    runtime.block_on(future);
}
coroutine fn async_main() {
    println!("Program starting");
    let txt = http::Http::get("/600/HelloAsyncAwait").wait;
    println!("{txt}");
    let txt = http::Http::get("/400/HelloAsyncAwait").wait;
    println!("{txt}");
}

这个程序基本上与我们创建在第七章中相同,只是这次我们是从coroutine/wait语法创建它,而不是手动编写状态机。接下来,我们需要使用corofy将其转换为代码,因为编译器不识别我们自己的coroutine/wait语法。

  1. 如果你处于a-runtime的根目录,运行corofy ./src/main.rs

  2. 你现在应该有一个名为main_corofied.rs的文件。

  3. 删除main.rs中的代码,并将main_corofied.rs的内容复制到main.rs中。

  4. 你现在可以删除main_corofied.rs,因为我们以后不再需要它。

如果一切按计划进行,项目结构现在应该看起来像这样:

src
 |-- future.rs
 |-- http.rs
 |-- main.rs
 |-- runtime.rs

小贴士

你可以随时参考书籍的仓库以确保一切正确。正确的示例位于ch08/a-runtime文件夹中。在仓库中,你还可以在根目录找到一个名为main_orig.rs的文件,其中包含coroutine/wait程序,如果你想要重新运行它或遇到问题使一切正常工作。

设计

在我们继续之前,让我们通过考虑由coroutine/wait创建的两个未来和两次对Http::get的调用来可视化我们的系统当前是如何工作的。在main函数中轮询我们的Future特遇到完成的循环在我们的可视化中扮演执行器的角色,正如你所看到的,我们有一个由以下组成的一连串未来:

  1. async/await(或我们示例中的coroutine/wait)创建的非叶子未来,它们简单地调用下一个未来的poll,直到它达到一个叶子未来

  2. 轮询实际源(无论是Ready还是NotReady)的叶子未来

以下图表展示了我们当前设计的简化概述:

图 8.2 – 执行器和未来链:当前设计

图 8.2 – 执行器和未来链:当前设计

如果我们更仔细地查看未来链,我们可以看到,当一个未来被轮询时,它会轮询所有子未来,直到它达到一个代表我们实际等待的某个事物的叶子未来。如果该未来返回NotReady,它将立即将此状态向上传播。然而,如果它返回Ready,状态机将一直前进,直到下一个未来返回NotReady。顶级未来将不会解决,直到所有子未来都返回Ready

下一个图表更详细地查看未来链,并给出了其工作原理的简化概述:

图 8.3 – 未来链:详细视图

图 8.3 – 未来链:详细视图

我们将要进行的第一个改进是避免对顶级未来的连续轮询以推动其前进。

我们将改变我们的设计,使其看起来更像这样:

图 8.4 – 执行器和 Future 链:设计 2

图 8.4 – 执行器和 Future 链:设计 2

在这个设计中,我们使用了我们在第四章中获得的知识,但我们不是简单地依赖于epoll,而是使用mio的跨平台抽象。由于我们之前已经实现了一个简化的版本,现在我们应该对它的工作方式很熟悉。

我们不再连续循环并轮询我们的顶级 Future,而是向Poll实例注册兴趣,当我们得到一个NotReady的结果返回时,我们等待在Poll上。这将使线程休眠,直到操作系统再次唤醒我们,通知我们我们等待的事件已准备好。

此设计将更加高效和可扩展。

修改当前实现

现在我们已经对我们的设计有了概述,并且知道要做什么,我们可以继续进行,对程序进行必要的更改。让我们逐一查看我们需要更改的每个文件。我们将从main.rs开始。

main.rs

当我们在更新的coroutine/wait示例上运行corofy时,我们已经对main.rs做了一些更改。我只想在这里指出这个更改,以免你错过,因为这里实际上没有更多需要更改的内容。

我们不再在main函数中轮询 Future,而是创建了一个新的Runtime结构体,并将 Future 作为参数传递给Runtime::block_on方法。在这个文件中我们不再需要做任何更改。我们的main函数变成了这样:

ch08/a-runtime/src/main.rs

 fn main() {
    let future = async_main();
    let mut runtime = Runtime::new();
    runtime.block_on(future);
}

我们在main函数中的逻辑现在已移动到runtime模块中,这也是我们需要更改代码以从我们之前的状态中轮询 Future 完成的地方。

因此,下一步将是打开runtime.rs

runtime.rs

runtime.rs中,我们首先做的是引入我们需要的依赖项:

ch08/a-runtime/src/runtime.rs

use crate::future::{Future, PollState};
use mio::{Events, Poll, Registry};
use std::sync::OnceLock;

下一步是创建一个名为REGISTRY的静态变量。如果你还记得,Registry是我们向Poll实例注册事件兴趣的方式。当我们实际进行 HTTP GET请求时,我们希望在TcpStream上注册对事件的兴趣。我们本可以将Registry结构体作为参数传递给Http::get,以便稍后使用,但我们希望保持 API 的简洁性,因此我们希望在HttpGetFuture内部访问Registry,而不必将其作为引用传递:

ch08/a-runtime/src/runtime.rs

static REGISTRY: OnceLock<Registry> = OnceLock::new();
pub fn registry() -> &'static Registry {
    REGISTRY.get().expect("Called outside a runtime context")
}

我们使用std::sync::OnceLock,这样我们就可以在运行时启动时初始化REGISTRY,从而防止任何人(包括我们自己)在没有运行Runtime实例的情况下调用Http::get。如果我们没有初始化运行时就调用Http::get,它将引发 panic,因为访问它的唯一公共方式是通过runtime模块外的pub fn registry(){…}函数,而这个调用将失败。

注意

我们本可以使用标准库中的 thread_local! 宏来使用线程局部静态变量,但当我们在这个章节的后面扩展示例时,我们需要从多个线程访问它,所以我们从这一点开始设计。

我们接下来添加的是 Runtime 结构体:

ch08/a-runtime/src/runtime.rs

pub struct Runtime {
    poll: Poll,
}

目前,我们的运行时将只存储一个 Poll 实例。有趣的部分在于 Runtime 的实现。由于它不是很长,我将在这里展示整个实现,并在接下来解释它:

ch08/a-runtime/src/runtime.rs

impl Runtime {
    pub fn new() -> Self {
        let poll = Poll::new().unwrap();
        let registry = poll.registry().try_clone().unwrap();
        REGISTRY.set(registry).unwrap();
        Self { poll }
    }
    pub fn block_on<F>(&mut self, future: F)
    where
        F: Future<Output = String>,
    {
        let mut future = future;
        loop {
            match future.poll() {
                PollState::NotReady => {
                    println!("Schedule other tasks\n");
                    let mut events = Events::with_capacity(100);
                    self.poll.poll(&mut events, None).unwrap();
                }
                PollState::Ready(_) => break,
            }
        }
    }
}

我们首先创建一个 new 函数。这将初始化我们的运行时并设置我们所需的一切。我们创建一个新的 Poll 实例,然后从 Poll 实例中获取 Registry 的所有者版本。如果你还记得 第四章,这是我们提到但没有在示例中实现的方法之一。然而,在这里,我们利用了将这两部分分开的能力。

我们将 Registry 存储在 REGISTRY 全局变量中,这样我们就可以在稍后从 http 模块中访问它,而不需要运行时的引用。

下一个函数是 block_on 函数。我会一步一步地解释它:

  1. 首先,这个函数接受一个泛型参数,并将阻塞在实现了我们的 Future 特质且 Output 类型为 String 的任何事物上(记住,这是我们目前唯一支持的 Future 特质类型,所以如果没有数据返回,我们将只返回一个空字符串)。

  2. 我们不需要将 mut future 作为参数传递,我们可以在函数体中定义一个变量,并将其声明为 mut。这样做只是为了使 API 稍微干净一些,并避免我们以后需要做出小的修改。

  3. 接下来,我们创建一个循环。我们将循环,直到我们接收到的顶级 future 返回 Ready

    如果 future 返回 NotReady,我们将输出一条消息,让我们知道在这个时候我们可以做其他事情,例如处理与 future 无关的事情,或者更有可能的是,如果我们的运行时支持多个顶级 future,则轮询另一个顶级 future(别担心——稍后会解释)。

    注意,我们需要将 Events 集合传递给 mioPoll::poll 方法,但由于只有一个顶级 future 需要运行,我们并不关心发生了哪个事件;我们只关心发生了某些事情,并且这很可能意味着数据已准备好(记住——我们总是必须考虑假唤醒)。

目前我们只需要对 runtime 模块做出这些更改。

在我们的 http 模块中将请求写入服务器后,我们需要注册对 读取 事件的 兴趣

让我们打开 http.rs 并做一些修改。

http.rs

首先,让我们调整我们的依赖关系,以便拉入我们需要的所有内容:

ch08/a-runtime/src/http.rs

use crate::{future::PollState, runtime, Future};
use mio::{Interest, Token};
use std::io::{ErrorKind, Read, Write};

我们需要添加对我们 runtime 模块的依赖,以及来自 mio 的几个类型。

我们只需要在这个文件中做一项更改,那就是在我们的Future::poll实现中,所以让我们继续定位它:

我们在这里做了一项重要的更改,我已经为你突出显示了。实现方式完全相同,只有一个重要的区别:

ch08/a-runtime/src/http.rs

impl Future for HttpGetFuture {
  type Output = String;
  fn poll(&mut self) -> PollState<Self::Output> {
    if self.stream.is_none() {
      println!("FIRST POLL - START OPERATION");
      self.write_request();
      runtime::registry()
        .register(self.stream.as_mut().unwrap(), Token(0), Interest::READABLE)
                .unwrap();
        }
        let mut buff = vec![0u8; 4096];
        loop {
            match self.stream.as_mut().unwrap().read(&mut buff) {
                Ok(0) => {
                    let s = String::from_utf8_lossy(&self.buffer);
                    break PollState::Ready(s.to_string());
                }
                Ok(n) => {
                    self.buffer.extend(&buff[0..n]);
                    continue;
                }
                Err(e) if e.kind() == ErrorKind::WouldBlock => {
                    break PollState::NotReady;
                }
                Err(e) => panic!("{e:?}"),
            }
        }
    }
}

在第一次轮询后,在我们写入请求后,我们注册了对这个TcpStreamREADABLE事件的兴趣。我们还删除了以下行:

return PollState::NotReady;

通过删除这一行,我们将立即轮询TcpStream,这是有道理的,因为我们不希望立即将控制权返回给我们的调度器。你也不会出错,因为我们已经将TcpStream注册为我们的 reactor 的事件源,并且无论如何都会得到唤醒。这些更改是我们需要的最后一块拼图,以使我们的示例恢复运行。

如果你记得第七章的版本,我们得到了以下输出:

Program starting
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloWorld1
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloWorld2

在我们的新改进版本中,如果我们用cargo run运行它,我们会得到以下输出:

Program starting
FIRST POLL - START OPERATION
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloAsyncAwait
FIRST POLL - START OPERATION
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloAsyncAwait

注意

如果你将示例在 Windows 上运行,你会看到每次都会连续出现两个“调度其他任务”的消息。这是因为 Windows 在服务器端TcpStream被丢弃时会发出一个额外的事件。在 Linux 上不会发生这种情况。过滤掉这些事件相当简单,但我们在示例中不会关注这一点,因为它更多的是一种优化,而我们实际上并不需要这种优化来使示例工作。

这里需要注意的一点是我们打印了“调度其他任务”的次数。每次我们在轮询并得到NotReady时,都会打印这条消息。在第一个版本中,我们每 100 毫秒打印一次,但这仅仅是因为我们不得不在每个睡眠周期中延迟,以避免打印输出过多。如果没有它,我们的 CPU 将会 100%地用于轮询未来。

如果我们添加延迟,即使我们使延迟远短于 100 毫秒,我们也会增加延迟,因为我们无法立即响应事件。

我们的新设计确保我们一旦准备好就响应事件,并且我们不进行任何不必要的操作。

因此,通过这些小的改动,我们已经创建了一个比之前更好的、更可扩展的版本。

这个版本是完全单线程的,这使得事情变得简单,避免了复杂性和同步开销。当你使用 Tokio 的current-thread调度器时,你会得到一个基于我们在这里展示的相同理念的调度器。

然而,我们的当前实现也有一些缺点,最明显的一个是它需要在以Poll为中心的运行时的reactor 部分executor 部分之间进行非常紧密的集成。

我们希望在没有任何工作要做的时候让出操作系统调度器,并在有事件发生时让操作系统唤醒我们,以便我们可以继续前进。在我们的当前设计中,这是通过在Poll::poll上阻塞来完成的。

因此,执行器(调度器)和反应器都必须了解Poll。那么,如果你创建了一个非常适合特定用例的执行器,并希望允许用户使用一个不依赖于Poll的不同反应器,你就不能这样做。

更重要的是,你可能需要运行多个不同的反应器,这些反应器出于不同的原因唤醒执行器。 你可能会发现有些东西mio不支持,因此你为这些任务创建了一个不同的反应器。当它阻塞在mio::Poll::poll(...)时,它们应该如何唤醒执行器呢?

为了给你一些例子,你可以使用一个单独的反应器来处理定时器(例如,当你想让任务睡眠一段时间时),或者你可能想实现一个线程池来处理 CPU 密集型或阻塞任务,作为当任务准备好时唤醒相应未来的反应器。

为了解决这些问题,我们需要通过一种方式唤醒执行器,这种方式不是紧密耦合到单个反应器实现,从而在运行时的反应器和执行器部分之间实现松散的耦合。

让我们看看我们如何通过创建更好的运行时设计来解决这个问题的。

创建合适的运行时

因此,如果我们可视化我们运行时不同部分之间的依赖程度,我们的当前设计可以这样描述:

图 8.5 – 反应器和执行器之间的紧密耦合

图 8.5 – 反应器和执行器之间的紧密耦合

如果我们希望在反应器和执行器之间有松散的耦合,我们需要提供一个接口来通知执行器,当发生允许未来进展的事件时,它应该唤醒。在 Rust 的标准库中,这种类型被称为Waker(doc.rust-lang.org/stable/std/task/struct.Waker.html)并非巧合。如果我们改变我们的可视化来反映这一点,它看起来可能就像这样:

图 8.6 – 松散耦合的反应器和执行器

图 8.6 – 松散耦合的反应器和执行器

我们最终采用与 Rust 今天所拥有的相同的设计并非巧合。从 Rust 的角度来看,这是一个最小化的设计,但它允许有各种各样的运行时设计,而不对未来的设计施加过多的限制。

注意

尽管从语言的角度来看,当前的设计相当最小化,但未来有计划稳定更多与异步相关的特性和接口。

Rust 有一个工作组负责将广泛使用的特性和接口纳入标准库,你可以在以下链接中找到更多信息:rust-lang.github.io/wg-async/welcome.html。你还可以在这里获得他们工作的概述并跟踪他们的进度:github.com/orgs/rust-lang/projects/28/views/1

也许你甚至想在阅读这本书后参与进来 (rust-lang.github.io/wg-async/welcome.html#-getting-involved),让异步 Rust 对每个人来说都变得更好?

如果我们将我们的系统图改为反映我们对运行时未来所需进行的更改,它将看起来像这样:

图 8.7 – 执行器和反应器:最终设计

图 8.7 – 执行器和反应器:最终设计

我们有两个部分,它们之间没有直接的依赖关系。我们有一个Executor,它安排任务,并在轮询最终将被Reactor捕获并存储的Future时传递一个Waker。当Reactor收到一个事件准备就绪的通知时,它会找到与该任务关联的Waker,并在其上调用Wake::wake

这使我们能够:

  • 运行几个操作系统线程,每个线程都有自己的执行器,但共享同一个反应器

  • 拥有多个反应器,处理不同类型的叶子期货,并确保在可以进步时唤醒正确的执行器

因此,现在我们已经有了要做什么的想法,是时候开始用代码实现了。

第 1 步 – 通过添加反应器和唤醒器来改进我们的运行时设计

在这一步中,我们将进行以下更改:

  1. 改变项目结构,使其反映我们新的设计。

  2. 找到一种让执行器睡眠和唤醒的方法,不直接依赖于Poll,并基于此创建一个Waker,允许我们唤醒执行器并识别哪个任务准备好进步。

  3. 更改Future的特质定义,使poll接受一个&Waker作为参数。

小贴士

你可以在ch08/b-reactor-executor文件夹中找到这个示例。如果你按照书中的例子编写,我建议你按照以下步骤创建一个名为b-reactor-executor的新项目来使用这个示例:

  1. 创建一个名为b-reactor-executor的新文件夹。

  2. 进入新创建的文件夹,并运行cargo init

  3. 将上一个示例中的src文件夹中的所有内容,a-runtime,复制到新项目的src文件夹中。

  4. Cargo.toml文件中的dependencies部分复制到新项目的Cargo.toml文件中。

让我们先对我们的项目结构做一些更改,以便我们可以在此基础上构建。我们首先做的事情是将我们的runtime模块分为两个子模块,reactorexecutor

  1. src文件夹中创建一个名为runtime的新子文件夹。

  2. runtime文件夹中创建两个新文件,分别命名为reactor.rsexecutor.rs

  3. runtime.rs中的导入下面,通过添加以下行声明两个新模块:

    mod executor;
    mod reactor;
    

现在,你应该有一个看起来像这样的文件夹结构:

src
 |-- runtime
        |-- executor.rs
        |-- reactor.rs
 |-- future.rs
 |-- http.rs
 |-- main.rs
 |-- runtime.rs

为了设置一切,我们首先删除runtime.rs中的所有内容,并用以下代码行替换它:

ch08/b-reactor-executor/src/runtime.rs

pub use executor::{spawn, Executor, Waker};
pub use reactor::reactor;
mod executor;
mod reactor;
pub fn init() -> Executor {
    reactor::start();
    Executor::new()
}

runtime.rs的新内容首先声明了两个子模块,称为executorreactor。然后我们声明了一个名为init的函数,它启动我们的Reactor并创建一个新的Executor,然后将其返回给调用者。

我们列表上的下一个要点是找到一种方法,让我们的Executor在需要时能够睡眠和唤醒,而不依赖于Poll

创建一个 Waker

因此,我们需要找到一种不同的方式,让我们的执行器能够睡眠和被唤醒,而不直接依赖于Poll

结果证明这相当简单。标准库为我们提供了所需的一切来让某事开始工作。通过调用std::thread::current(),我们可以获取一个Thread对象。这个对象是当前线程的引用,它为我们提供了一些方法,其中之一是unpark

标准库还提供了一个名为std::thread::park()的方法,它简单地请求操作系统调度器将我们的线程挂起,直到我们稍后请求它被unpark

结果表明,如果我们结合这些方法,我们就有了一种既能park又能unpark执行器的方式,这正是我们所需要的。

让我们基于这个创建一个Waker类型。在我们的示例中,我们将在executor模块内部定义Waker,因为这是我们创建这种特定类型Waker的地方,但你可以争论它应该属于future模块,因为它是Future特质的一部分。

重要提示

我们的Waker依赖于在标准库中对Thread类型调用park/unpark。对于我们的示例来说,这是可以接受的,因为它很容易理解,但考虑到代码的任何部分(包括你使用的任何库)都可以通过调用std::thread::current()来获取对同一线程的引用,并对其调用park/unpark,这并不是一个健壮的解决方案。如果代码的不相关部分在同一线程上调用park/unpark,我们可能会错过唤醒或陷入死锁。大多数生产库都会创建自己的Parker类型或依赖于像crossbeam::sync::Parker这样的东西(docs.rs/crossbeam/latest/crossbeam/sync/struct.Parker.html)。

我们不会将Waker实现为一个特质,因为传递特质对象会显著增加我们示例的复杂性,而且这也不符合 Rust 中FutureWaker的当前设计。

打开位于runtime文件夹内的executor.rs文件,并从开始就添加我们需要的所有导入:

ch08/b-reactor-executor/src/runtime/executor.rs

use crate::future::{Future, PollState};
use std::{
    cell::{Cell, RefCell},
    collections::HashMap,
    sync::{Arc, Mutex},
    thread::{self, Thread},
};

接下来,我们添加我们的Waker

ch08/b-reactor-executor/src/runtime/executor.rs

#[derive(Clone)]
pub struct Waker {
    thread: Thread,
    id: usize,
    ready_queue: Arc<Mutex<Vec<usize>>>,
}

Waker将为我们保留三件事:

  • thread – 对我们之前提到的Thread对象的引用。

  • id – 一个usize,用于标识这个Waker关联的任务。

  • ready_queue – 这是一个可以在线程之间共享的引用,指向一个 Vec<usize>,其中 usize 代表就绪队列中任务的 ID。我们与执行器共享这个对象,以便当任务就绪时,我们可以将 Waker 相关的任务 ID 推送到该队列。

我们 Waker 的实现将会相当简单:

ch08/b-reactor-executor/src/runtime/executor.rs

impl Waker {
    pub fn wake(&self) {
        self.ready_queue
            .lock()
            .map(|mut q| q.push(self.id))
            .unwrap();
        self.thread.unpark();
    }
}

当调用 Waker::wake 时,我们首先锁定与执行器共享的 Mutex,该 Mutex 保护着就绪队列。然后,我们将与这个 Waker 相关的任务的 id 值推送到就绪队列。

完成这些后,我们在执行器线程上调用 unpark 并唤醒它。现在它将在就绪队列中找到与这个 Waker 相关的任务,并对其调用 poll

值得注意的是,许多设计都采用对 future/task 本身共享引用(例如,一个 Arc<…>),并将其推送到队列中。通过这样做,它们跳过了一个间接层,我们在这里通过将任务表示为 usize 而不是传递其引用来实现这一点。

然而,我个人认为这种方式更容易理解和推理,并且最终结果将是一样的。

这个 Waker 与标准库中的 Waker 有何不同?

我们在这里创建的 Waker 将承担与标准库中的 Waker 类型相同的角色。最大的区别是 std::task::Waker 方法被封装在一个 Context 结构体中,并且在我们自己创建它时需要跳过几个步骤。不用担心——我们将在本书的末尾完成所有这些,但这两个差异对于理解它所扮演的角色并不重要,因此我们现在坚持使用自己的简化版异步 Rust。

我们需要做的最后一件事是更改 Future 特质的定义,使其接受 &Waker 作为参数。

修改 Future 定义

由于我们的 Future 定义在 future.rs 文件中,我们首先打开该文件。

我们需要做的第一件事是引入 Waker,以便我们可以使用它。在文件顶部添加以下代码:

ch08/b-reactor-executor/src/future.rs

use crate::runtime::Waker;

接下来,我们更改我们的 Future 特质,使其接受 &Waker 作为参数:

ch08/b-reactor-executor/src/future.rs

pub trait Future {
    type Output;
    fn poll(&mut self, waker: &Waker) -> PollState<Self::Output>;
}

在这一点上,你有一个选择。我们不会继续使用 join_all 函数或 JoinAll<F: Future> 结构体。

如果你不想保留它们,只需删除与 join_all 相关的所有内容,这就是你在 future.rs 中需要做的所有事情。

如果你想要保留它们以进行进一步的实验,你需要更改 JoinAllFuture 实现以接受 waker: &Waker 参数,并记得在 match fut.poll(waker) 中传递 Waker

步骤 1 中剩余要做的事情是在实现 Future 特质的地方做一些小的修改。

让我们从 http.rs 开始。我们首先要做的是稍微调整我们的依赖关系,以反映我们对 runtime 模块所做的更改,并添加对我们新的 Waker 的依赖。将文件顶部的 dependencies 部分替换为以下内容:

ch08/b-reactor-executor/src/http.rs

use crate::{future::PollState, runtime::{self, reactor, Waker}, Future};
use mio::Interest;
use std::io::{ErrorKind, Read, Write};

编译器会抱怨找不到反应器,但我们会很快解决这个问题。

接下来,我们必须导航到 impl Future for HttpGetFuture 块,我们需要更改 poll 方法,使其接受一个 &Waker 参数:

ch08/b-reactor-executor/src/http.rs

impl Future for HttpGetFuture {
    type Output = String;
    fn poll(&mut self, waker: &Waker) -> PollState<Self::Output> {
…

我们需要更改的最后一个文件是 main.rs。由于 corofy 不了解 Waker 类型,我们需要更改它为我们生成的 main.rs 中的协程中的几行。

首先,我们必须在我们的新 Waker 上添加一个依赖项,所以请将以下内容添加到文件的开头:

ch08/b-reactor-executor/src/main.rs

use runtime::Waker;

impl Future for Coroutine 块中,更改以下三行代码,我已经突出显示:

ch08/b-reactor-executor/src/main.rs

fn poll(&mut self, waker: &Waker)
match f1.poll(waker)
match f2.poll(Waker.
			The next step will be to create a proper `Executor`.
			Step 2 – Implementing a proper Executor
			In this step, we’ll create an executor that will:

				*   Hold many top-level futures and switch between them
				*   Enable us to spawn new top-level futures from anywhere in our asynchronous program
				*   Hand out `Waker` types so that they can sleep when there is nothing to do and wake up when one of the top-level futures can progress
				*   Enable us to run several executors by having each run on its dedicated OS thread

			Note
			It’s worth mentioning that our executor won’t be fully multithreaded in the sense that tasks/futures can’t be sent from one thread to another, and the different `Executor` instances will not know of each other. Therefore, executors can’t steal work from each other (no work-stealing), and we can’t rely on executors picking tasks from a global task queue.
			The reason is that the `Executor` design will be much more complex if we go down that route, not only because of the added logic but also because we have to add constraints, such as requiring everything to be `Send +` `Sync`.
			Some of the complexity in asynchronous Rust today can be attributed to the fact that many runtimes in Rust are multithreaded by default, which makes asynchronous Rust deviate more from “normal” Rust than it actually needs to.
			It’s worth mentioning that since most production runtimes in Rust are multithreaded by default, most of them also have a work-stealing executor. This will be similar to the last version of our bartender example in *Chapter 1*, where we achieved a slightly increased efficiency by letting the bartenders “steal” tasks that are *in progress* from each other.
			However, this example should still give you an idea of how we can leverage all the cores on a machine to run asynchronous tasks, giving us both concurrency and parallelism, even though it will have limited capabilities.
			Let’s start by opening up `executor.rs` located in the `runtime` subfolder.
			This file should already contain our `Waker` and the dependencies we need, so let’s start by adding the following lines of code just below our dependencies:
			ch08/b-reactor-executor/src/runtime/executor.rs

type Task = Box<dyn Future<Output = String>>;

thread_local! {

static CURRENT_EXEC: ExecutorCore = ExecutorCore::default();

}


			The first line is a *type alias*; it simply lets us create an alias called `Task` that refers to the type: `Box<dyn Future<Output = String>>`. This will help keep our code a little bit cleaner.
			The next line might be new to some readers. We define a thread-local static variable by using the `thread_local!` macro.
			The `thread_local!` macro lets us define a static variable that’s unique to the thread it’s first called from. This means that all threads we create will have their own instance, and it’s impossible for one thread to access another thread’s `CURRENT_EXEC` variable.
			We call the variable `CURRENT_EXEC` since it holds the `Executor` that’s currently running on this thread.
			The next lines we add to this file is the definition of `ExecutorCore`:
			ch08/b-reactor-executor/src/runtime/executor.rs

[derive(Default)]

struct ExecutorCore {

tasks: RefCell<HashMap<usize, Task>>,

ready_queue: Arc<Mutex<Vec>>,

next_id: Cell,

}


			`ExecutorCore` holds all the state for our `Executor`:

				*   `tasks` – This is a `HashMap` with a `usize` as the *key* and a `Task` (remember the alias we created previously) as *data*. This will hold all the top-level futures associated with the executor on this thread and allow us to give each an `id` property to identify them. We can’t simply mutate a static variable, so we need internal mutability here. Since this will only be callable from one thread, a `RefCell` will do so since there is no need for synchronization.
				*   `ready_queue` – This is a simple `Vec<usize>` that stores the IDs of tasks that should be polled by the executor. If we refer back to *Figure 8**.7*, you’ll see how this fits into the design we outlined there. As mentioned earlier, we could store something such as an `Arc<dyn Future<…>>` here instead, but that adds quite a bit of complexity to our example. The only downside with the current design is that instead of getting a reference to the task directly, we have to look it up in our `tasks` collection, which takes time. An `Arc<…>` (shared reference) to this collection will be given to each `Waker` that this executor creates. Since the `Waker` can (and will) be sent to a different thread and signal that a specific task is ready by adding the task’s ID to `ready_queue`, we need to wrap it in an `Arc<Mutex<…>>`.
				*   `next_id` – This is a counter that gives out the next available I, which means that it should never hand out the same ID twice for this executor instance. We’ll use this to give each top-level future a unique ID. Since the executor instance will only be accessible on the same thread it was created, a simple `Cell` will suffice in giving us the internal mutability we need.

			`ExecutorCore` derives the `Default` trait since there is no special initial state we need here, and it keeps the code short and concise.
			The next function is an important one. The `spawn` function allows us to register new top-level futures with our executor from anywhere in our program:
			ch08/b-reactor-executor/src/runtime/executor.rs

pub fn spawn(future: F)

where

F: Future<Output = String> + 'static,

{

CURRENT_EXEC.with(|e| {

let id = e.next_id.get();

e.tasks.borrow_mut().insert(id, Box::new(future));

e.ready_queue.lock().map(|mut q| q.push(id)).unwrap();

e.next_id.set(id + 1);

});

}


			The `spawn` function does a few things:

				*   It gets the next available ID.
				*   It assigns the ID to the future it receives and stores it in a `HashMap`.
				*   It adds the ID that represents this task to `ready_queue` so that it’s polled at least once (remember that `Future` traits in Rust don’t do anything unless they’re polled at least once).
				*   It increases the ID counter by one.

			The unfamiliar syntax accessing `CURRENT_EXEC` by calling `with` and passing in a closure is just a consequence of how thread local statics is implemented in Rust. You’ll also notice that we must use a few special methods because we use `RefCell` and `Cell` for internal mutability for `tasks` and `next_id`, but there is really nothing inherently complex about this except being a bit unfamiliar.
			A quick note about static lifetimes
			When a `'static` lifetime is used as a trait bound as we do here, it doesn’t actually mean that the lifetime of the `Future` trait we pass in *must be* static (meaning it will have to live until the end of the program). It means that it *must be able to* last until the end of the program, or, put another way, the lifetime can’t be constrained in any way.
			Most often, when you encounter something that requires a `'static` bound, it simply means that you’ll have to give ownership over the thing you pass in. If you pass in any references, they need to have a `'static` lifetime. It’s less difficult to satisfy this constraint than you might expect.
			The final part of *step 2* will be to define and implement the `Executor` struct itself.
			The `Executor` struct is very simple, and there is only one line of code to add:
			ch08/b-reactor-executor/src/runtime/executor.rs

pub struct Executor;


			Since all the state we need for our example is held in `ExecutorCore`, which is a static thread-local variable, our `Executor` struct doesn’t need any state. This also means that we don’t strictly need a struct at all, but to keep the API somewhat familiar, we do it anyway.
			Most of the executor implementation is a handful of simple helper methods that end up in a `block_on` function, which is where the interesting parts really happen.
			Since these helper methods are short and easy to understand, I’ll present them all here and just briefly go over what they do:
			Note
			We open the `impl Executor` block here but will not close it until we’ve finished implementing the `block_on` function.
			ch08/b-reactor-executor/src/runtime/executor.rs

impl Executor {

pub fn new() -> Self {

Self {}

}

fn pop_ready(&self) -> Option {

CURRENT_EXEC.with(|q| q.ready_queue.lock().map(|mut q| q.pop()).unwrap())

}

fn get_future(&self, id: usize) -> Option {

CURRENT_EXEC.with(|q| q.tasks.borrow_mut().remove(&id))

}

fn get_waker(&self, id: usize) -> Waker {

Waker {

id,

线程:thread::current(),

ready_queue: CURRENT_EXEC.with(|q| q.ready_queue.clone()),

}

}

fn insert_task(&self, id: usize, task: Task) {

CURRENT_EXEC.with(|q| q.tasks.borrow_mut().insert(id, task));

}

fn task_count(&self) -> usize {

CURRENT_EXEC.with(|q| q.tasks.borrow().len())

}


			So, we have six methods here:

				*   `new` – Creates a new `Executor` instance. For simplicity, we have no initialization here, and everything is done lazily by design in the `thread_local!` macro.
				*   `pop_ready` – This function takes a lock on `read_queue` and pops off an ID that’s ready from the back of `Vec`. Calling `pop` here means that we also remove the item from the collection. As a side note, since `Waker` pushes its ID to the *back* of `ready_queue` and we pop off from the *back* as well, we essentially get a `VecDeque` from the standard library would easily allow us to choose the order in which we remove items from the queue if we wish to change that behavior.
				*   `get_future` – This function takes the ID of a top-level future as an argument, removes the future from the `tasks` collection, and returns it (if the task is found). This means that if the task returns `NotReady` (signaling that we’re not done with it), we need to remember to add it back to the collection again.
				*   `get_waker` – This function creates a new `Waker` instance.
				*   `insert_task` – This function takes an `id` property and a `Task` property and inserts them into our `tasks` collection.
				*   `task_count` – This function simply returns a count of how many tasks we have in the queue.

			The final and last part of the `Executor` implementation is the `block_on` function. This is also where we close the `impl` `Executor` block:
			ch08/b-reactor-executor/src/runtime/executor.rs

pub fn block_on(&mut self, future: F)

where

F: Future<Output = String> + 'static,

{

spawn(future);

loop {

while let Some(id) = self.pop_ready() {

let mut future = match self.get_future(id) {

Some(f) => f,

// 防止假唤醒

None => continue,

};

let waker = self.get_waker(id);

match future.poll(&waker) {

PollState::NotReady => self.insert_task(id, future),

PollState::Ready(_) => continue,

}

}

let task_count = self.task_count();

let name = thread::current().name().unwrap_or_default().to_string();

if task_count > 0 {

println!("{name}: {task_count} pending tasks. Sleep until notified.");

thread::park();

} else {

println!("{name}: All tasks are finished");

break;

}

}

}

}


			`block_on` will be the entry point to our `Executor`. Often, you will pass in one top-level future first, and when the top-level future progresses, it will spawn new top-level futures onto our executor. Each new future can, of course, spawn new futures onto the `Executor` too, and that’s how an asynchronous program basically works.
			In many ways, you can view this first top-level future in the same way you view the `main` function in a normal Rust program. `spawn` is similar to `thread::spawn`, with the exception that the tasks stay on the same OS thread in this example. This means the tasks won’t be able to run in parallel, which in turn allows us to avoid any need for synchronization between tasks to avoid data races.
			Let’s go through the function step by step:

				1.  The first thing we do is spawn the future we received onto ourselves. There are many ways this could be implemented, but this is the easiest way to do it.
				2.  Then, we have a loop that will run as long as our asynchronous program is running.
				3.  Every time we loop, we create an inner `while let Some(…)` loop that runs as long as there are tasks in `ready_queue`.
				4.  If there is a task in `ready_queue`, we take ownership of the `Future` object by removing it from the collection. We guard against false wakeups by just continuing if there is no future there anymore (meaning that we’re done with it but still get a wakeup). This will, for example, happen on Windows since we get a `READABLE` event when the connection closes, but even though we could filter those events out, `mio` doesn’t guarantee that false wakeups won’t happen, so we have to handle that possibility anyway.
				5.  Next, we create a new `Waker` instance that we can pass into `Future::poll()`. Remember that this `Waker` instance now holds the `id` property that identifies this specific `Future` trait and a handle to the thread we’re currently running on.
				6.  The next step is to call `Future::poll`.
				7.  If we get `NotReady` in return, we insert the task back into our `tasks` collection. I want to emphasize that when a `Future` trait returns `NotReady`, we know it will arrange it so that `Waker::wake` is called at a later point in time. It’s not the executor’s responsibility to track the readiness of this future.
				8.  If the `Future` trait returns `Ready`, we simply continue to the next item in the ready queue. Since we took ownership over the `Future` trait, this will drop the object before we enter the next iteration of the `while` `let` loop.
				9.  Now that we’ve polled all the tasks in our ready queue, the first thing we do is get a task count to see how many tasks we have left.
				10.  We also get the name of the current thread for future logging purposes (it has nothing to do with how our executor works).
				11.  If the task count is larger than `0`, we print a message to the terminal and call `thread::park()`. Parking the thread will yield control to the OS scheduler, and our `Executor` does nothing until it’s woken up again.
				12.  If the task count is `0`, we’re done with our asynchronous program and exit the main loop.

			That’s pretty much all there is to it. By this point, we’ve covered all our goals for *step 2*, so we can continue to the last and final step and implement a `Reactor` for our runtime that will wake up our executor when something happens.
			Step 3 – Implementing a proper Reactor
			The final part of our example is the `Reactor`. Our `Reactor` will:

				*   Efficiently wait and handle events that our runtime is interested in
				*   Store a collection of `Waker` types and make sure to wake the correct `Waker` when it gets a notification on a source it’s tracking
				*   Provide the necessary mechanisms for leaf futures such as `HttpGetFuture`, to register and deregister interests in events
				*   Provide a way for leaf futures to store the last received `Waker`

			When we’re done with this step, we should have everything we need for our runtime, so let’s get to it.
			Start by opening the `reactor.rs` file.
			The first thing we do is add the dependencies we need:
			ch08/b-reactor-executor/src/runtime/reactor.rs

use crate::runtime::Waker;

use mio::{net::TcpStream, Events, Interest, Poll, Registry, Token};

use std::{

collections::HashMap,

sync::{

atomic::{AtomicUsize, Ordering},

Arc, Mutex, OnceLock,

},

thread,

};


			After we’ve added our dependencies, we create a *type alias* called `Wakers` that aliases the type for our `wakers` collection:
			ch08/b-reactor-executor/src/runtime/reactor.rs

type Wakers = Arc<Mutex<HashMap<usize, Waker>>>;


			The next line will declare a static variable called `REACTOR`:
			ch08/b-reactor-executor/src/runtime/reactor.rs

static REACTOR: OnceLock = OnceLock::new();


			This variable will hold a `OnceLock<Reactor>`. In contrast to our `CURRENT_EXEC` static variable, this will be possible to access from different threads. `OnceLock` allows us to define a static variable that we can write to once so that we can initialize it when we start our `Reactor`. By doing so, we also make sure that there can only be a single instance of this specific reactor running in our program.
			The variable will be private to this module, so we create a public function allowing other parts of our program to access it:
			ch08/b-reactor-executor/src/runtime/reactor.rs

pub fn reactor() -> &'static Reactor {

REACTOR.get().expect("Called outside an runtime context")

}


			The next thing we do is define our `Reactor` struct:
			ch08/b-reactor-executor/src/runtime/reactor.rs

pub struct Reactor {

wakers: Wakers,

registry: Registry,

next_id: AtomicUsize,

}


			This will be all the state our `Reactor` struct needs to hold:

				*   `wakers` – A `HashMap` of `Waker` objects, each identified by an integer
				*   `registry` – Holds a `Registry` instance so that we can interact with the event queue in `mio`
				*   `next_id` – Stores the next available ID so that we can track which event occurred and which `Waker` should be woken

			The implementation of `Reactor` is actually quite simple. It’s only four short methods for interacting with the `Reactor` instance, so I’ll present them all here and give a brief explanation next:
			ch08/b-reactor-executor/src/runtime/reactor.rs

impl Reactor {

pub fn register(&self, stream: &mut TcpStream, interest: Interest, id: usize) {

self.registry.register(stream, Token(id), interest).unwrap();

}

pub fn set_waker(&self, waker: &Waker, id: usize) {

let _ = self

.wakers

.lock()

.map(|mut w| w.insert(id, waker.clone()).is_none())

.unwrap();

}

pub fn deregister(&self, stream: &mut TcpStream, id: usize) {

self.wakers.lock().map(|mut w| w.remove(&id)).unwrap();

self.registry.deregister(stream).unwrap();

}

pub fn next_id(&self) -> usize {

self.next_id.fetch_add(1, Ordering::Relaxed)

}

}


			Let’s briefly explain what these four methods do:

				*   `register` – This method is a thin wrapper around `Registry::register`, which we know from *Chapter 4*. The one thing to make a note of here is that we pass in an `id` property so that we can identify which event has occurred when we receive a notification later on.
				*   `set_waker` – This method adds a `Waker` to our `HashMap` using the provided `id` property as a key to identify it. If there is a `Waker` there already, we replace it and drop the old one. An important point to remember is that `Waker` associated with the `TcpStream`.
				*   `deregister` – This function does two things. First, it removes the `Waker` from our `wakers` collection. Then, it deregisters the `TcpStream` from our `Poll` instance.
				*   I want to remind you at this point that while we only work with `TcpStream` in our examples, this could, in theory, be done with anything that implements `mio`’s `Source` trait, so the same thought process is valid in a much broader context than what we deal with here.
				*   `next_id` – This simply gets the current `next_id` value and increments the counter atomically. We don’t care about any happens before/after relationships happening here; we only care about not handing out the same value twice, so `Ordering::Relaxed` will suffice here. Memory ordering in atomic operations is a complex topic that we won’t be able to dive into in this book, but if you want to know more about the different memory orderings in Rust and what they mean, the official documentation is the right place to start: [`doc.rust-lang.org/stable/std/sync/atomic/enum.Ordering.html`](https://doc.rust-lang.org/stable/std/sync/atomic/enum.Ordering.html).

			Now that our `Reactor` is set up, we only have two short functions left. The first one is `event_loop`, which will hold the logic for our event loop that waits and reacts to new events:
			ch08/b-reactor-executor/src/runtime/reactor.rs

fn event_loop(mut poll: Poll, wakers: Wakers) {

let mut events = Events::with_capacity(100);

loop {

poll.poll(&mut events, None).unwrap();

for e in events.iter() {

let Token(id) = e.token();

let wakers = wakers.lock().unwrap();

if let Some(waker) = wakers.get(&id) {

waker.wake();

}

}

}

}


			This function takes a `Poll` instance and a `Wakers` collection as arguments. Let’s go through it step by step:

				*   The first thing we do is create an `events` collection. This should be familiar since we did the exact same thing in *Chapter 4*.
				*   The next thing we do is create a `loop` that in our case will continue to loop for eternity. This makes our example short and simple, but it has the downside that we have no way of shutting our event loop down once it’s started. Fixing that is not especially difficult, but since it won’t be necessary for our example, we don’t cover this here.
				*   Inside the loop, we call `Poll::poll` with a timeout of `None`, which means it will never time out and block until it receives an event notification.
				*   When the call returns, we loop through every event we receive.
				*   If we receive an event, it means that something we registered interest in happened, so we get the `id` we passed in when we first registered an interest in events on this `TcpStream`.
				*   Lastly, we try to get the associated `Waker` and call `Waker::wake` on it. We guard ourselves from the fact that the `Waker` may have been removed from our collection already, in which case we do nothing.

			It’s worth noting that we can filter events if we want to here. Tokio provides some methods on the `Event` object to check several things about the event it reported. For our use in this example, we don’t need to filter events.
			Finally, the last function is the second public function in this module and the one that initializes and starts the runtime:
			ch08/b-reactor-executor/src/runtime/runtime.rs

pub fn start() {

use thread::spawn;

let wakers = Arc::new(Mutex::new(HashMap::new()));

let poll = Poll::new().unwrap();

let registry = poll.registry().try_clone().unwrap();

let next_id = AtomicUsize::new(1);

let reactor = Reactor {

wakers: wakers.clone(),

registry,

next_id,

};

REACTOR.set(reactor).ok().expect("Reactor already running");

spawn(move || event_loop(poll, wakers));

}


			The `start` method should be fairly easy to understand. The first thing we do is create our `Wakers` collection and our `Poll` instance. From the `Poll` instance, we get an owned version of `Registry`. We initialize `next_id` to `1` (for debugging purposes, I wanted to initialize it to a different start value than our `Executor`) and create our `Reactor` object.
			Then, we set the static variable we named `REACTOR` by giving it our `Reactor` instance.
			The last thing is probably the *most important one to pay attention to*. We spawn a new OS thread and start our `event_loop` function on that one. This also means that we pass on our `Poll` instance to the event loop thread for good.
			Now, the best practice would be to store the `JoinHandle` returned from `spawn` so that we can join the thread later on, but our thread has no way to shut down the event loop anyway, so joining it later makes little sense, and we simply discard the handle.
			I don’t know if you agree with me, but the logic here is not that complex when we break it down into smaller pieces. Since we know how `epoll` and `mio` work already, the rest is pretty easy to understand.
			Now, we’re not done yet. We still have some small changes to make to our `HttpGetFuture` leaf future since it doesn’t register with the reactor at the moment. Let’s fix that.
			Start by opening the `http.rs` file.
			Since we already added the correct imports when we opened the file to adapt everything to the new `Future` interface, there are only a few places we need to change that so this leaf future integrates nicely with our reactor.
			The first thing we do is give `HttpGetFuture` an identity. It’s the source of events we want to track with our `Reactor`, so we want it to have the same ID until we’re done with it:
			ch08/b-reactor-executor/src/http.rs

struct HttpGetFuture {

stream: Optionmio::net::TcpStream,

buffer: Vec,

path: String,

id: usize,

}


			We also need to retrieve a new ID from the reactor when the future is created:
			ch08/b-reactor-executor/src/http.rs

impl HttpGetFuture {

fn new(path: String) -> Self {

let id = reactor().next_id();

Self {

stream: None,

buffer: vec![],

path,

id,

}

}


			Next, we have to locate the `poll` implementation for `HttpGetFuture`.
			The first thing we need to do is make sure that we register interest with our `Poll` instance and register the `Waker` we receive with the `Reactor` the first time the future gets polled. Since we don’t register directly with `Registry` anymore, we remove that line of code and add these new lines instead:
			ch08/b-reactor-executor/src/http.rs

if self.stream.is_none() {

println!("FIRST POLL - START OPERATION");

self.write_request();

let stream = self.stream.as_mut().unwrap();

runtime::reactor().register(stream, Interest::READABLE, self.id);

runtime::reactor().set_waker(waker, self.id);

}


			Lastly, we need to make some minor changes to how we handle the different conditions when reading from `TcpStream`:
			ch08/b-reactor-executor/src/http.rs

match self.stream.as_mut().unwrap().read(&mut buff) {

Ok(0) => {

let s = String::from_utf8_lossy(&self.buffer);

runtime::reactor().deregister(self.stream.as_mut().unwrap(), self.id);

break PollState::Ready(s.to_string());

}

Ok(n) => {

self.buffer.extend(&buff[0..n]);

continue;

}

Err(e) if e.kind() == ErrorKind::WouldBlock => {

runtime::reactor().set_waker(waker, self.id);

break PollState::NotReady;

}

Err(e) => panic!("{e:?}"),

}


			The first change is to deregister the stream from our `Poll` instance when we’re done.
			The second change is a little more subtle. If you read the documentation for `Future::poll` in Rust ([`doc.rust-lang.org/stable/std/future/trait.Future.html#tymethod.poll`](https://doc.rust-lang.org/stable/std/future/trait.Future.html#tymethod.poll)) carefully, you’ll see that it’s expected that the `Waker` from the *most recent call* should be scheduled to wake up. That means that every time we get a `WouldBlock` error, we need to make sure we store the most recent `Waker`.
			The reason is that the future could have moved to a different executor in between calls, and we need to wake up the correct one (it won’t be possible to move futures like those in our example, but let’s play by the same rules).
			And that’s it!
			Congratulations! You’ve now created a fully working runtime based on the reactor-executor pattern. Well done!
			Now, it’s time to test it and run a few experiments with it.
			Let’s go back to `main.rs` and change the `main` function so that we get our program running correctly with our new runtime.
			First of all, let’s remove the dependency on the `Runtime` struct and make sure our imports look like this:
			ch08/b-reactor-executor/src/main.rs

mod future;

mod http;

mod runtime;

use future::{Future, PollState};

use runtime::Waker;


			Next, we need to make sure that we initialize our runtime and pass in our future to `executor.block_on`. Our `main` function should look like this:
			ch08/b-reactor-executor/src/main.rs

fn main() {

let mut executor = runtime::init();

executor.block_on(async_main());

}


			And finally, let’s try it out by running it:

cargo run.


			You should get the following output:

Program starting

FIRST POLL - START OPERATION

main: 1 pending tasks. Sleep until notified.

HTTP/1.1 200 OK

content-length: 15

connection: close

content-type: text/plain; charset=utf-8

date: Thu, xx xxx xxxx 15:38:08 GMT

HelloAsyncAwait

FIRST POLL - START OPERATION

main: 1 pending tasks. Sleep until notified.

HTTP/1.1 200 OK

内容长度: 15

连接: 关闭

内容类型: text/plain; 字符集=utf-8

日期: Thu, xx xxx xxxx 15:38:08 GMT

HelloAsyncAwait

main: 所有任务已完成


			Great – it’s working just as expected!!!
			However, we’re not really using any of the new capabilities of our runtime yet so before we leave this chapter, let’s have some fun and see what it can do.
			Experimenting with our new runtime
			If you remember from *Chapter 7*, we implemented a `join_all` method to get our futures running concurrently. In libraries such as Tokio, you’ll find a `join_all` function too, and the slightly more versatile `FuturesUnordered` API that allows you to join a set of predefined futures and run them concurrently.
			These are convenient methods to have, but it does force you to know which futures you want to run concurrently in advance. If the futures you run using `join_all` want to spawn new futures that run concurrently with their “parent” future, there is no way to do that using only these methods.
			However, our newly created spawn functionality does exactly this. Let’s put it to the test!
			An example using concurrency
			Note
			The exact same version of this program can be found in the `ch08/c-runtime-executor` folder.
			Let’s try a new program that looks like this:

fn main() {

let mut executor = runtime::init();

executor.block_on(async_main());

}

协程 fn request(i: usize) {

let path = format!("/{}/HelloWorld{i}", i * 1000);

let txt = Http::get(&path).wait;

println!("{txt}");

}

协程 fn async_main() {

println!("程序开始");

for i in 0..5 {

let future = request(i);

runtime::spawn(future);

}

}


			This is pretty much the same example we used to show how `join_all` works in *Chapter 7*, only this time, we spawn them as top-level futures instead.
			To run this example, follow these steps:

				1.  Replace everything *below the imports* in `main.rs` with the preceding code.
				2.  Run `corofy ./src/main.rs`.
				3.  Copy everything from `main_corofied.rs` to `main.rs` and delete `main_corofied.rs`.
				4.  Fix the fact that `corofy` doesn’t know we changed our futures to take `waker: &Waker` as an argument. The easiest way is to simply run `cargo check` and let the compiler guide you to the places we need to change.

			Now, you can run the example and see that the tasks run concurrently, just as they did using `join_all` in *Chapter 7*. If you measured the time it takes to run the tasks, you’d find that it all takes around 4 seconds, which makes sense if you consider that you just spawned 5 futures, and ran them concurrently. The longest wait time for a single future was 4 seconds.
			Now, let’s finish off this chapter with another interesting example.
			Running multiple futures concurrently and in parallel
			This time, we spawn multiple threads and give each thread its own `Executor` so that we can run the previous example simultaneously in parallel using the same `Reactor` for all `Executor` instances.
			We’ll also make a small adjustment to the printout so that we don’t get overwhelmed with data.
			Our new program will look like this:

模块 future;

模块 http;

模块 runtime;

use crate::http::Http;

use future::{Future, PollState};

use runtime::{Executor, Waker};

use std:🧵:Builder;

fn main() {

let mut executor = runtime::init();

let mut handles = vec![];

for i in 1..12 {

let name = format!("exec-{i}");

let h = Builder::new().name(name).spawn(move || {

let mut executor = Executor::new();

executor.block_on(async_main());

}).unwrap();

handles.push(h);

}

executor.block_on(async_main());

handles.into_iter().for_each(|h| h.join().unwrap());

}

协程 fn request(i: usize) {

let path = format!("/{}/HelloWorld{i}", i * 1000);

let txt = Http::get(&path).wait;

let txt = txt.lines().last().unwrap_or_default();

println!(«{txt}»);

}

协程 fn async_main() {

println!("程序开始");

for i in 0..5 {

let future = request(i);

runtime::spawn(future);

}

}


			The machine I’m currently running has 12 cores, so when I create 11 new threads to run the same asynchronous tasks, I’ll use all the cores on my machine. As you’ll notice, we also give each thread a unique name that we’ll use when logging so that it’s easier to track what happens behind the scenes.
			Note
			While I use 12 cores, you should use the number of cores on your machine. If we increase this number too much, our OS will not be able to give us more cores to run our program in parallel on and instead start pausing/resuming the threads we create, which adds no value to us since we handle the concurrency aspect ourselves in an `a^tsync` runtime.
			You’ll have to do the same steps as we did in the last example:

				1.  Replace the code that’s currently in `main.rs` with the preceding code.
				2.  Run `corofy ./src/main.rs`.
				3.  Copy everything from `main_corofied.rs` to `main.rs` and delete `main_corofied.rs`.
				4.  Fix the fact that `corofy` doesn’t know we changed our futures to take `waker: &Waker` as an argument. The easiest way is to simply run `cargo check` and let the compiler guide you to the places we need to change.

			Now, if you run the program, you’ll see that it still only takes around 4 seconds to run, but this time we made **60 GET requests instead of 5**. This time, we ran our futures both concurrently and in parallel.
			At this point, you can continue experimenting with shorter delays or more requests and see how many concurrent tasks you can have before the system breaks down.
			Pretty quickly, printouts to `stdout` will be a bottleneck, but you can disable those. Create a blocking version using OS threads and see how many threads you can run concurrently before the system breaks down compared to this version.
			Only imagination sets the limit, but do take the time to have some fun with what you’ve created before we continue with the next chapter.
			The only thing to be careful about is testing the concurrency limit of your system by sending these kinds of requests to a random server you don’t control yourself since you can potentially overwhelm it and cause problems for others.
			Summary
			So, what a ride! As I said in the introduction for this chapter, this is one of the biggest ones in this book, but even though you might not realize it, you’ve already got a better grasp of how asynchronous Rust works than most people do. **Great work!**
			In this chapter, you learned a lot about runtimes and why Rust designed the `Future` trait and the `Waker` the way it did. You also learned about reactors and executors, `Waker` types, `Futures` traits, and different ways of achieving concurrency through the `join_all` function and spawning new top-level futures on the executor.
			By now, you also have an idea of how we can achieve both concurrency and parallelism by combining our own runtime with OS threads.
			Now, we’ve created our own async universe consisting of `coro/wait`, our own `Future` trait, our own `Waker` definition, and our own runtime. I’ve made sure that we don’t stray away from the core ideas behind asynchronous programming in Rust so that everything is directly applicable to `async/await`, `Future` traits, `Waker` types, and runtimes in day-to-day programming.
			By now, we’re in the final stretch of this book. The last chapter will finally convert our example to use the real `Future` trait, `Waker`, `async/await`, and so on instead of our own versions of it. In that chapter, we’ll also reserve some space to talk about the state of asynchronous Rust today, including some of the most popular runtimes, but before we get that far, there is one more topic I want to cover: pinning.
			One of the topics that seems hardest to understand and most different from all other languages is the concept of pinning. When writing asynchronous Rust, you will at some point have to deal with the fact that `Future` traits in Rust must be pinned before they’re polled.
			So, the next chapter will explain pinning in Rust in a practical way so that you understand why we need it, what it does, and how to do it.
			However, you absolutely deserve a break after this chapter, so take some fresh air, sleep, clear your mind, and grab some coffee before we enter the last parts of this book.

第九章:协程、自引用结构体和Pinning

在本章中,我们将首先通过添加在状态变化之间存储变量的能力来改进我们的协程。我们将看到这如何导致我们的协程需要引用自身,以及由此产生的问题。将整个章节专门用于这个主题的原因是,它是 Rust 中实现async/await的一个关键部分,也是一个相对难以良好理解的课题。

原因在于,对于许多开发者来说,pinning的概念是陌生的,就像 Rust 的所有权系统一样,它需要一些时间来建立一个良好且有效的心理模型。

幸运的是,pinning的概念并不难理解,但它在语言中的实现以及它与 Rust 的类型系统的交互是抽象的,难以掌握。

尽管我们不会在本章中涵盖关于pinning的方方面面,但我们将努力获得对它的良好和稳固的理解。这里的重大目标是对此主题感到自信,并理解为什么我们需要它以及如何使用它。

如前所述,本章不仅关于 Rust 中的pinning,因此我们首先将做一些重要的改进,从我们停止的地方开始,通过改进第八章中的最终示例。

然后,我们将在解释如何使用pinning解决我们的问题之前,先解释什么是自引用结构体以及它们与futures之间的联系。

本章将涵盖以下主要主题

  • 改进我们的示例 1——变量

  • 改进我们的示例 2——引用

  • 改进我们的示例 3——这……不是……好的……

  • 发现自引用结构体

  • Rust 中的Pinning

  • 改进我们的示例 4——pinning拯救了我们

技术要求

本章中的示例将基于前一章的代码,因此要求相同。所有示例都将跨平台,并在 Rust(doc.rust-lang.org/stable/rustc/platform-support.html)和 mio(github.com/tokio-rs/mio#platforms)支持的所有平台上运行。您唯一需要的是安装 Rust 并下载本书的 GitHub 存储库到本地。本章中的所有代码都可以在ch09文件夹中找到。

要逐步跟随示例,您还需要在您的机器上安装corofy。如果您在第七章中没有安装它,现在请进入存储库中的ch07/corofy文件夹,并运行以下命令来安装:

cargo install --force --path .

我们还将在这个示例中使用delayserver,因此您需要打开一个单独的终端,进入存储库根目录下的delayserver文件夹,并运行cargo run,以便它为后续的示例做好准备和可用。

如果您需要更改delayserver监听的端口号,请记住在代码中更改端口号。

改进我们的示例 1 – 变量

因此,让我们回顾一下到目前为止我们所拥有的内容,继续我们在上一章中留下的地方。我们有以下内容:

  • 一个Future特质

  • 使用协程/await 语法和预处理器实现的协程实现

  • 基于mio::Poll的反应器

  • 一个允许我们创建尽可能多的顶级任务并调度准备运行的任务的执行器

  • 一个只向我们的本地延迟服务器实例发送 HTTP GET 请求的 HTTP 客户端

这并不糟糕——我们可能会争论我们的 HTTP 客户端有点儿限制,但这不是本书的重点,所以我们可以忍受这一点。然而,我们的协程实现却非常有限。让我们看看我们如何使我们的协程稍微更有用。

我们当前实现的最大缺点是没有任何东西——我是指没有任何东西——可以跨越等待点。首先解决这个问题是有意义的。

让我们从设置我们的示例开始。

我们将使用d-multiple-threads 示例中的“库”代码( 8 (我们上一个版本的示例)),但我们将通过添加一个更短、更简单的示例来修改main.rs文件。

让我们设置我们将在此章节中迭代和改进的基本示例。

设置基本示例

注意

你可以在本书的 GitHub 仓库的ch09/a-coroutines-variables下找到这个示例。

执行以下步骤:

  1. 创建一个名为a-coroutines-variables的文件夹。

  2. 进入文件夹并运行cargo init

  3. 删除默认的main.rs文件,并将ch08/d-multiple-threads/src文件夹中的所有内容复制到ch10/a-coroutines-variables/src文件夹中。

  4. 打开Cargo.toml并在依赖项部分添加对mio的依赖:

    mio = {version = "0.8", features = ["net", "os-poll"]}
    

你现在应该有一个看起来像这样的文件夹结构:

src
  |-- runtime
       |-- executor.rs
       |-- reactor.rs
  |-- future.rs
  |-- http.rs
  |-- main.rs
  |-- runtime.rs

我们将最后一次使用corofy为我们生成样板状态机。将以下内容复制到main.rs中:

ch09/a-coroutines-variables/src/main.rs

mod future;
mod http;
mod runtime;
use crate::http::Http;
use future::{Future, PollState};
use runtime::Waker;
fn main() {
    let mut executor = runtime::init();
    executor.block_on(async_main());
}
coroutine fn async_main() {
    println!("Program starting");
    let txt = Http::get("/600/HelloAsyncAwait").wait;
    println!("{txt}");
    let txt = Http::get("/400/HelloAsyncAwait").wait;
    println!("{txt}");
}

这次,让我们走捷径,直接将我们的corofied文件写回main.rs,因为我们已经足够多次地并排比较了文件。假设你处于基本文件夹,a-coroutine-variables,写下以下内容:

corofy ./src/main.rs ./src/main.rs

最后一步是修复corofy不知道Waker的事实。你可以通过编写cargo check让编译器引导你到需要做出更改的地方,但为了帮助你,这里有三处小的修改要做(注意行号是重写我们之前写的相同代码时报告的):

64: fn poll(&mut self, waker: &Waker)
82: match f1.poll(waker)
102: match f2.poll(cargo run.
			You should see the following output (the output has been abbreviated to save a little bit of space):

程序启动

第一次轮询 - 开始操作

main: 1 个待处理任务。等待通知。

HTTP/1.1 200 OK

[==== 简化版 ====]

HelloAsyncAwait

main: 所有任务已完成


			Note
			Remember that we need `delayserver` running in a terminal window so that we get a response to our HTTP GET requests. See the *Technical requirements* section for more information.
			Now that we’ve got the boilerplate out of the way, it’s time to start making the improvements we talked about.
			Improving our base example
			We want to see how we can improve our state machine so that it allows us to hold variables across wait points. To do that, we need to store them somewhere and restore the variables that are needed when we enter each state in our state machine.
			Tip
			Pretend that these rewrites are done by `corofy` (or the compiler). Even though `corofy` can’t do these rewrites, it’s possible to automate this process as well.
			Or coroutine/wait program looks like this:

coroutine fn async_main() {

println!("Program starting");

let txt = Http::get("/600/HelloAsyncAwait").wait;

println!("{txt}");

let txt = Http::get("/400/HelloAsyncAwait").wait;

println!("{txt}");

}


			We want to change it so that it looks like this:

coroutine fn async_main() {

let mut counter = 0;

println!("程序开始");

let txt = http::Http::get("/600/HelloAsyncAwait").wait;

println!("{txt}");

counter += 1;

let txt = http::Http::get("/400/HelloAsyncAwait").wait;

println!("{txt}");

counter += 1;

println!("Received {} responses.", counter);

}


			In this version, we simply create a `counter` variable at the top of our `async_main` function and increase the counter for each response we receive from the server. At the end, we print out how many responses we received.
			Note
			For brevity, I won’t present the entire code base going forward; instead, I will only present the relevant additions and changes. Remember that you can always refer to the same example in this book’s GitHub repository.
			The way we implement this is to add a new field called `stack` to our `Coroutine0` struct:
			ch09/a-coroutines-variables/src/main.rs

struct Coroutine0 {

stack: Stack0,

state: State0,

}


			The stack fields hold a `Stack0` struct that we also need to define:
			ch09/a-coroutines-variables/src/main.rs

[derive(Default)]

struct Stack0 {

counter: Option,

}


			This struct will only hold one field since we only have one variable. The field will be of the `Option<usize>` type. We also derive the `Default` trait for this struct so that we can initialize it easily.
			Note
			Futures created by async/await in Rust store this data in a slightly more efficient manner. In our example, we store every variable in a separate struct since I think it’s easier to reason about, but it also means that the more variables we need to store, the more space our coroutine will need. It will grow linearly with the number of different variables that need to be stored/restored between state changes. This could be a lot of data. For example, if we have 100 state changes that each need one distinct `i64`-sized variable to be stored to the next state, that would require a struct that takes up 100 * 8b = 800 bytes in memory.
			Rust optimizes this by implementing coroutines as enums, where each state only holds the data it needs to restore in the *next* state. This way, the size of a coroutine is not dependent on the *total number of variables*; it’s only dependent on the *size of the largest state that needs to be saved/restored*. In the preceding example, the size would be reduced to 8 bytes since the largest space any single state change needed is enough to hold one `i64`-sized variable. The same space will be reused over and over.
			The fact that this design allows for this optimization is significant and it’s an advantage that stackless coroutines have over stackful coroutines when it comes to memory efficiency.
			The next thing we need to change is the `new` method on `Coroutine0`:
			ch09/a-coroutines-variables/src/main.rs

impl Coroutine0 {

fn new() -> Self {

Self {

state: State0::Start,

stack: Stack0::default(),

}

}

}


			The default value for `stack` is not relevant to us since we’ll overwrite it anyway.
			The next few steps are the ones of most interest to us. In the `Future` implementation for `Coroutine0`, we’ll pretend that `corofy` added the following code to initialize, store, and restore the stack variables for us. Let’s take a look at what happens on the first call to `poll` now:
			ch09/a-coroutines-variables/src/main.rs

State0::Start => {

// 初始化栈(提升变量)

self.stack.counter = Some(0);

// ---- 实际编写的代码 ----

println!("程序开始");

// ---------------------------------

let fut1 = Box::new( http::Http::get("/600/HelloAsyncAwait"));

self.state = State0::Wait1(fut1);

// 保存栈

}


			Okay, so there are some important changes here that I’ve highlighted. Let’s go through them:

				*   The first thing we do when we’re in the `Start` state is add a segment at the top where we initialize our stack. One of the things we do is *hoist* all variable declarations for the relevant code section (in this case, before the first `wait` point) to the top of the function.
				*   In our example, we also initialize the variables to their initial value, which in this case is `0`.
				*   We also added a comment stating that we should save the stack, but since all that happens before the first wait point is the initialization of `counter`, there is nothing to store here.

			Let’s take a look at what happens after the first wait point:
			ch09/a-coroutines-variables/src/main.rs

State0::Wait1(ref mut f1) => {

match f1.poll(waker) {

PollState::Ready(txt) => {

// 恢复栈

let mut counter = self.stack.counter.take().unwrap();

// ---- 实际编写的代码 ----

println!("{txt}");

counter += 1;

// ---------------------------------

let fut2 = Box::new( http::Http::get("/400/HelloAsyncAwait"));

self.state = State0::Wait2(fut2);

// 保存栈

self.stack.counter = Some(counter);

}

PollState::NotReady => break PollState::NotReady,

}

}


			Hmm, this is interesting. I’ve highlighted the changes we need to make.
			 The first thing we do is to *restore* the stack by taking ownership over the counter (`take()`replaces the value currently stored in `self.stack.counter` with `None` in this case) and writing it to a variable with the same name that we used in the code segment (`counter`). Taking ownership and placing the value back in later is not an issue in this case and it mimics the code we wrote in our coroutine/wait example.
			The next change is simply the segment that takes all the code after the first wait point and pastes it in. In this case, the only change is that the `counter` variable is increased by `1`.
			Lastly, we save the stack state back so that we hold onto its updated state between the wait points.
			Note
			In *Chapter 5*, we saw how we needed to store/restore the register state in our fibers. Since *Chapter 5* showed an example of a *stackful coroutine* implementation, we didn’t have to care about stack state at all since all the needed state was stored in the stacks we created.
			Since our coroutines are *stackless*, we don’t store the entire call stack for each coroutine, but we do need to store/restore the parts of the stack that will be used *across wait points*. Stackless coroutines still need to save some information from the stack, as we’ve done here.
			When we enter the `State0::Wait2` state, we start the same way:
			ch09/a-coroutines-variables/src/main.rs

State0::Wait2(ref mut f2) => {

match f2.poll(waker) {

PollState::Ready(txt) => {

// 恢复栈

let mut counter = self.stack.counter.take().unwrap();

// ---- 实际编写的代码 ----

println!("{txt}");

counter += 1;

println!(«Received {} responses.», counter);

// ---------------------------------

self.state = State0::Resolved;

// 保存栈(所有变量已设置为 None)

break PollState::Ready(String::new());

}

PollState::NotReady => break PollState::NotReady,

}

}


			Since there are no more wait points in our program, the rest of the code goes into this segment and since we’re done with `counter` at this point, we can simply `drop` it by letting it go out of scope. If our variable held onto any resources, they would be released here as well.
			With that, we’ve given our coroutines the power of saving variables across wait points. Let’s try to run it by writing `cargo run`.
			You should see the following output (I’ve removed the parts of the output that remain unchanged):

HelloAsyncAwait

Received 2 responses.

main: 所有任务已完成


			Okay, so our program works and does what’s expected. Great!
			Now, let’s take a look at an example that needs to store *references* across wait points since that’s an important aspect of having our coroutine/wait functions behave like “normal” functions.
			Improving our example 2 – references
			Let’s set everything up for our next version of this example:

				*   Create a new folder called `b-coroutines-references` and copy everything from `a-coroutines-variables` over to it
				*   You can change the name of the project so that it corresponds with the folder by changing the `name` attribute in the `package` section in `Cargo.toml`, but it’s not something you need to do for the example to work

			Note
			You can find this example in this book’s GitHub repository in the `ch10/b-coroutines-references` folder.
			This time, we’ll learn how to store references to variables in our coroutines by using the following coroutine/wait example program:

use std::fmt::Write;

coroutine fn async_main() {

let mut buffer = String::from("\nBUFFER:\n----\n");

let writer = &mut buffer;

println!("程序开始");

let txt = http::Http::get("/600/HelloAsyncAwait").wait;

writeln!(writer, "{txt}").unwrap();

let txt = http::Http::get("/400/HelloAsyncAwait").wait;

writeln!(writer, "{txt}").unwrap();

println!("{}", buffer);

}


			So, in this example, we create a `buffer` variable of the `String` type that we initialize with some text, and we take a `&mut` reference to that and store it in a `writer` variable.
			Every time we receive a response, we write the response to the buffer through the `&mut` reference we hold in `writer` before we print the buffer to the terminal at the end of the program.
			Let’s take a look at what we need to do to get this working.
			The first thing we do is pull in the `fmt::Write` trait so that we can write to our buffer using the `writeln!` macro.
			Add this to the top of `main.rs`:
			ch09/b-coroutines-references/src/main.rs

use std::fmt::Write;


			Next, we need to change our `Stack0` struct so that it represents what we must store across wait points in our updated example:
			 ch09/b-coroutines-references/src/main.rs

[derive(Default)]

struct Stack0 {

buffer: Option,

writer: Option<*mut String>,

}


			An important thing to note here is that `writer` can’t be `Option<&mut String>` since we know it will be referencing the buffer field in the same struct. A struct where a field takes a reference on `&self` is called a **self-referential** struct and there is no way to represent that in Rust since the lifetime of the self-reference is impossible to express.
			The solution is to cast the `&mut` self-reference to a pointer instead and ensure that we manage the lifetimes correctly ourselves.
			The only other thing we need to change is the `Future::poll` implementation:
			ch09/b-coroutines-references/src/main.rs

State0::Start => {

// 初始化栈(提升变量)

self.stack.buffer = Some(String::from("\nBUFFER:\n----\n"));

self.stack.writer = Some(self.stack.buffer.as_mut().unwrap());

// ---- 实际编写的代码 ----

println!("程序开始");

// ---------------------------------

let fut1 = Box::new(http::Http::get("/600/HelloAsyncAwait"));

self.state = State0::Wait1(fut1);

// 保存栈

}


			Okay, so this looks a bit odd. The first line we change is pretty straightforward. We initialize our `buffer` variable to a new `String` type, just like we did at the top of our coroutine/wait program.
			The next line, however, looks a bit dangerous.
			We cast the `&mut` reference to our `buffer` to a `*``mut` pointer.
			Important
			Yes, I know we could have chosen another way of doing this since we can take a reference to buffer everywhere we need to instead of storing it in its variable, but that’s only because our example is very simple. Imagine that we use a library that needs to borrow data that’s local to the async function and we somehow have to manage the lifetimes manually like we do here but in a much more complex scenario.
			The `self.stack.buffer.as_mut().unwrap()` line returns a `&mut` reference to the `buffer` field. Since `self.stack.writer` is of the `Option<*mut String>` type, the reference will be *coerced* to a pointer (meaning that Rust does this cast implicitly by inferring it from the context).
			Note
			We take `*mut String` here since we deliberately don’t want a *string slice* (`&str`), which is often what we get (and want) when using a reference to a `String` type in Rust.
			Let’s take a look at what happens after the first wait point:
			ch09/b-coroutines-references/src/main.rs

State0::Wait1(ref mut f1) => {

match f1.poll(waker) {

PollState::Ready(txt) => {

// 恢复栈

let writer = unsafe { &mut *self.stack.writer.take().unwrap() };

// ---- 实际编写的代码 ----

writeln!(writer, «{txt}»).unwrap();

// ---------------------------------

let fut2 = Box::new(http::Http::get("/400/HelloAsyncAwait"));

self.state = State0::Wait2(fut2);

// 保存栈

self.stack.writer = Some(writer);

}

PollState::NotReady => break PollState::NotReady,

}

}


			The first change we make is regarding how we restore our stack. We need to restore our `writer` variable so that it holds a `&mut String` type that points to our buffer. To do this, we have to write some `unsafe` code that dereferences our pointer and lets us take a `&mut` reference to our `buffer`.
			Note
			Casting a reference to a pointer is safe. The unsafe part is dereferencing the pointer.
			Next, we add the line of code that writes the response. We can keep this the same as how we wrote it in our coroutine/wait function.
			Lastly, we save the stack state back since we need both variables to live across the wait point.
			Note
			We don’t have to take ownership over the pointer stored in the `writer` field to use it since we can simply copy it, but to be somewhat consistent, we take ownership over it, just like we did in the first example. It also makes sense since if there is no need to store the pointer for the next await point, we can simply let it go out of scope by not storing it back.
			The last part is when we’ve reached `Wait2` and our future returns `PollState::Ready`:

State0::Wait2(ref mut f2) => {

match f2.poll(waker) {

PollState::Ready(txt) => {

// 恢复栈

let buffer = self.stack.buffer.as_ref().take().unwrap();

let writer = unsafe { &mut *self.stack.writer.take().unwrap() };

// ---- 你实际编写的代码 ----

writeln!(writer, «{txt}»).unwrap();

println!("{}", buffer);

// ---------------------------------

self.state = State0::Resolved;

// 保存栈 / 释放资源

let _ = self.stack.buffer.take();

break PollState::Ready(String::new());

}

PollState::NotReady => break PollState::NotReady,

}

}


			In this segment, we restore both variables since we write the last response through our writer variable, and then print everything that’s stored in our `buffer` to the terminal.
			I want to point out that the `println!("{}", buffer);` line takes a reference in the original coroutine/wait example, even though it might look like we pass in `an` `owned` `String`. Therefore, it makes sense that we restore the buffer to a `&String` type, and not the owned version. Transferring ownership would also invalidate the pointer in our `writer` variable.
			The last thing we do is `drop` the data we don’t need anymore. Our `self.stack.writer` field is already set to `None` since we took ownership over it when we restored the stack at the start, but we need to take ownership over the `String` type that `self.stack.buffer` holds as well so that it gets dropped at the end of this scope too. If we didn’t do that, we would hold on to the memory that’s been allocated to our `String` until the entire coroutine is dropped (which could be much later).
			Now, we’ve made all our changes. If the rewrites we did previously were implemented in `corofy`, our coroutine/wait implementation could, in theory, support much more complex use cases.
			Let’s take a look at what happens when we run our program by writing `cargo run`:

程序启动

第一次轮询 - 开始操作

main: 1 个挂起任务。等待通知。

第一次轮询 - 开始操作

main: 1 个挂起任务。等待通知。

缓冲区:


HTTP/1.1 200 OK

内容长度:15

连接:关闭

内容类型:text/plain; charset=utf-8

日期:Thu, 30 Nov 2023 22:48:11 GMT

HelloAsyncAwait

HTTP/1.1 200 OK

内容长度:15

连接:关闭

内容类型:text/plain; charset=utf-8

日期:Thu, 30 Nov 2023 22:48:11 GMT

HelloAsyncAwait

main: 所有任务已完成


			Puh, great. All that dangerous `unsafe` turned out to work just fine, didn’t it? Good job. Let’s make one small improvement before we finish.
			Improving our example 3 – this is… not… good…
			Pretend you haven’t read this section title and enjoy the fact that our previous example compiled and showed the correct result.
			I think our coroutine implementation is so good now that we can look at some optimizations instead. There is one optimization in our executor in particular that I want to do immediately.
			Before we get ahead of ourselves, let’s set everything up:

				*   Create a new folder called `c-coroutines-problem` and copy everything from `b-coroutines-references` over to it
				*   You can change the name of the project so that it corresponds with the folder by changing the `name` attribute in the `package` section in `Cargo.toml`, but it’s not something you need to do for the example to work

			Tip
			This example is located in this book’s GitHub repository in the `ch``09``/c-coroutines-problem` folder.
			With that, everything has been set up.
			Back to the optimization. You see, new insights into the workload our runtime will handle in real life indicate that most futures will return `Ready` on the first poll. So, in theory, we can just poll the future we receive in `block_on` once and it will resolve immediately most of the time.
			Let’s navigate to `src/runtime/executor.rs` and take a look at how we can take advantage of this by adding a few lines of code.
			If you navigate to our `Executor::block_on` function, you’ll see that the first thing we do is `spawn` the future before we poll it. Spawning the future means that we allocate space for it in the heap and store the pointer to its location in a `HashMap` variable.
			Since the future will most likely return `Ready` on the first `poll`, this is unnecessary work that could be avoided. Let’s add this little optimization at the start of the `block_on` function to take advantage of this:

pub fn block_on(&mut self, future: F)

where

F: Future<Output = String> + 'static,

{

// ===== 优化,假设已就绪

let waker = self.get_waker(usize::MAX);

let mut future = future;

match future.poll(&waker) {

PollState::NotReady => (),

PollState::Ready(_) => return,

}

// ===== 结束

spawn(future);

loop {


			Now, we simply poll the future immediately, and if the future resolves on the first poll, we return since we’re all done. This way, we only spawn the future if it’s something we need to wait on.
			Yes, this assumes we never reach `usize::MAX` for our IDs, but let’s pretend this is only a proof of concept. Our `Waker` will be discarded and replaced by a new one if the future is spawned and polled again anyway, so that shouldn’t be a problem.
			Let’s try to run our program and see what we get:

程序启动

第一次轮询 - 开始操作

main: 1 个挂起任务。等待通知。

第一次轮询 - 开始操作

main: 1 个挂起任务。等待通知。

/400/HelloAsyn

free(): 在 tcache 2 中检测到双重释放

已终止


			Wait, what?!?
			That doesn’t sound good! Okay, that’s probably a kernel bug in Linux, so let’s try it on Windows instead:

错误:进程未成功退出:`target\release\c-coroutines-

problem.exe` (退出代码:0xc0000374, 状态:STATUS_HEAP_CORRUPTION)


			That sounds even worse!! What happened here?
			Let’s take a closer look at exactly what happened with our async system when we made our small optimization.
			Discovering self-referential structs
			What happened is that we created a self-referential struct, initialized it so that it took a pointer to itself, and then moved it. Let’s take a closer look:

				1.  First, we received a future object as an argument to `block_on`. This is not a problem since the future isn’t self-referential yet, so we can move it around wherever we want to without issues (this is also why moving futures before they’re polled is perfectly fine using proper async/await).
				2.  Then, we polled the future once. The optimization we did made one essential change. The future was located on the stack (inside the stack frame of our `block_on` function) when we polled it the first time.
				3.  When we polled the future the first time, we initialized the variables to their initial state. Our `writer` variable took a pointer to our `buffer` variable (stored as a part of our coroutine) and made it *self-referential* at this point.
				4.  The first time we polled the future, it returned `NotReady`
				5.  Since it returned `NotReady`, we spawned the future, which moves it into the tasks collection with the `HashMap<usize, Box<dyn Future<Output = String>>>` type in our `Executor`. The future is now placed in `Box`, which moves it to the heap.
				6.  The next time we poll the future, we restore the stack by dereferencing the pointer we hold for our `writer` variable. However, there’s a big problem: the pointer is now pointing to the old location on the stack where the future was located at the first poll.
				7.  That can’t end well, and it doesn’t in our case.

			You’ve now seen firsthand the problem with self-referential structs, how this applies to futures, and why we need something that prevents this from happening.
			A **self-referential struct** is a struct that takes a reference to *self* and stores it in a field. Now, the term *reference* here is a little bit unprecise since there is no way to take a reference to *self* in Rust and store that reference in *self*. To do this in safe Rust, you have to cast the reference to a *pointer* (remember that references are just pointers with a special meaning in the programming language).
			Note
			When we create visualizations in this chapter, we’ll disregard *padding*, even though we know structs will likely have some padding between fields, as we discussed in *Chapter 4*.
			When this value is moved to another location in memory, the pointer is not updated and points to the “old” location.
			If we take a look at a move from one location on the stack to another one, it looks something like this:
			![Figure 9.1 – Moving a self-referential struct](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/async-prog-rs/img/B20892_09_11.jpg)

			Figure 9.1 – Moving a self-referential struct
			In the preceding figure, we can see the memory addresses to the left with a representation of the stack next to it. Since the pointer was not updated when the value was moved, it now points to the old location, which can cause serious problems.
			Note
			It can be very hard to detect these issues, and creating simple examples where a move like this causes serious issues is surprisingly difficult. The reason for this is that even though we move everything, the old values are not zeroed or overwritten immediately. Often, they’re still there, so dereferencing the preceding pointer would *probably* produce the correct value. The problem only arises when you change the value of `x` in the new location, and expect `y` to point to it. Dereferencing `y` still produces a valid value in this case, but it’s the *wrong* value.
			Optimized builds often optimize away needless moves, which can make bugs even harder to detect since most of the program will seem to work just fine, even though it contains a serious bug.
			What is a move?
			A *move* in Rust is one of those concepts that’s unfamiliar to many programmers coming from C#, Javascript, and similar garbage-collected languages, and different from what you’re used to for C and C++ programmers. The definition of *move* in Rust is closely related to its ownership system.
			Moving means transferring ownership. In Rust, a *move* is the default way of passing values around and it happens every time you change ownership over an object. If the object you move only consists of copy types (types that implement the `Copy` trait), this is as simple as copying the data over to a new location on the stack.
			For non-copy types, a move will copy all copy types that it contains over just like in the first example, but now, it will also copy **pointers** to resources such as heap allocations. The moved-from object is left **inaccessible** to us (for example, if you try to use the moved-from object, the compilation will fail and let you know that the object has moved), so there is only one owner over the allocation at any point in time.
			In contrast to *cloning*, it does not recreate any resources and make a clone of them.
			One more important thing is that the compiler makes sure that `drop` is never called on the moved-from object so that the only thing that can free the resources is the new object that took ownership over everything.
			*Figure 9**.2* provides a simplified visual overview of the difference between move, clone, and copy (we’ve excluded any internal padding of the struct in this visualization). Here, we assume that we have a struct that holds two fields – a copy type, `a`, which is an `i64` type, and a non-copy type, `b`, which is a `Vec<u8>` type:
			![Figure 9.2 – Move, clone, and copy](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/async-prog-rs/img/B20892_10_2.jpg)

			Figure 9.2 – Move, clone, and copy
			A move will in many ways be like a deep copy of everything in our struct that’s located on the stack. This is problematic when you have a pointer that points to `self`, like we have with self-referential structs, since `self` will start at a new memory address after the move but the pointer to `self` won’t be adjusted to reflect that change.
			Most of the time, when programming Rust, you probably won’t think a lot about moves since it’s part of the language you never explicitly use, but it’s important to know what it is and what it does.
			Now that we’ve got a good understanding of what the problem is, let’s take a closer look at how Rust solves this by using its type system to prevent us from moving structs that rely on a stable place in memory to function correctly.
			Pinning in Rust
			The following diagram shows a slightly more complex self-referential struct so that we have something visual to help us understand:
			![Figure 9.3 – Moving a self-referential struct with three fields](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/async-prog-rs/img/B20892_10_3.jpg)

			Figure 9.3 – Moving a self-referential struct with three fields
			At a very high level, pinning makes it possible to rely on data that has a stable memory address by disallowing any operation that might move it:
			![Figure 9.4 – Moving a pinned struct](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/async-prog-rs/img/B20892_09_41.jpg)

			Figure 9.4 – Moving a pinned struct
			The concept of pinning is pretty simple. The complex part is how it’s implemented in the language and how it’s used.
			Pinning in theory
			Pinning is a part of Rust’s standard library and consists of two parts: the type, **Pin**, and the marker-trait, **Unpin**. Pinning is only a language construct. There is no special kind of location or memory that you move values to so they get pinned. There is no syscall to ask the operating system to ensure a value stays the same place in memory. It’s only a part of the type system that’s designed to prevent us from being able to move a value.
			`Pin` does not remove the need for `unsafe` – it just gives the user of `unsafe` a guarantee that the value has a stable location in memory, so long as the user that pinned the value only uses *safe* Rust. This allows us to write self-referential types that are safe. It makes sure that all operations that can lead to problems must use `unsafe`.
			Back to our coroutine example, if we were to move the struct, we’d have to write `unsafe` Rust. That is how Rust upholds its safety guarantee. If you somehow know that the future you created never takes a self-reference, you could choose to move it using `unsafe`, but the blame now falls on you if you get it wrong.
			Before we dive a bit deeper into pinning, we need to define several terms that we’ll need going forward.
			Definitions
			Here are the definitions we must understand:

				*   `std::pin` module. `Pin` wrap types that implement the `Deref` trait, which in practical terms means that it wraps *references and* *smart pointers*.
				*   `Unpin`, *pinning will have no effect on that type*. You read that right – no effect. The type will still be wrapped in `Pin` but you can simply take it out again.

    The impressive thing is that almost everything implements `Unpin` by default, and if you manually want to mark a type as `!Unpin`, you have to add a marker trait called `PhantomPinned` to your type. Having a type, `T`, implement `!Unpin` is the only way for something such as `Pin<&mut T>` to have any effect.

				*   **Pinning a type that’s !Unpin** will guarantee that the value remains at the same location in memory until it gets dropped, so long as you stay in safe Rust.
				*   `self`. For example, they often look like `fn foo(self:` `Pin<&mut self>)`.
				*   `Pin<&mut T>` where `T` has one field, `a`, that can be moved freely and one that can’t be moved, `b`, you can do the following:
    *   Write a *pin projection* for `a` with the `fn a(self: Pin<&mut self>) -> &A` signature. In this case, we say that pinning is *not structural*.
    *   Write a projection for `b` that looks like `fn b(self: Pin<&mut self>) -> Pin<&mut B>`, in which case we say that pinning is *structural* for `b` since it’s pinned when the struct, `T`, is pinned.

			With the most important definitions out of the way, let’s look at the two ways we can pin a value.
			Pinning to the heap
			Note
			The small code snippets we’ll present here can be found in this book’s GitHub repository in the `ch``09``/d-pin` folder. The different examples are implemented as different methods that you comment/uncomment in the `main` function.
			Let’s write a small example to illustrate the different ways of pinning a value:
			ch09/d-pin/src/main.rs

use std::{marker::PhantomPinned, pin::Pin};

[derive(Default)]

struct Foo {

a: MaybeSelfRef,

b: String,

}


			So, we want to be able to create an instance using `MaybeSelfRef::default()` that we can move around as we wish, but then at some point *initialize* it to a state where it references itself; moving it would cause problems.
			This is very much like futures that are not self-referential until they’re polled, as we saw in our previous example. Let's write the `impl` block for `MaybeSelfRef` and take a look at the code::
			ch09/d-pin/src/main.rs

impl MaybeSelfRef {

fn init(self: Pin<&mut Self>) {

unsafe {

let Self { a, b, .. } = self.get_unchecked_mut();

*b = Some(a);

}

}

fn b(self: Pin<&mut Self>) -> Option<&mut usize> {

unsafe { self.get_unchecked_mut().b.map(|b| &mut *b) }

}

}


			As you can see, `MaybeStelfRef` will only be self-referential after we call `init` on it.
			We also define one more method that casts the pointer stored in `b` to `Option<&mut usize>`, which is a mutable reference to `a`.
			One thing to note is that both our functions require `unsafe`. Without `Pin`, the only method requiring unsafe would be `b` since we dereference a pointer there. Acquiring a mutable reference to a pinned value always require `unsafe`, since there is nothing preventing us from moving the pinned value at that point.
			Pinning to the heap is usually done by pinning a `Box`. There is even a convenient method on `Box` that allows us to get `Pin<Box<...>>`. Let’s look at a short example:
			ch09/d-pin/src/main.rs

fn main() {

let mut x = Box::pin(MaybeSelfRef::default());

x.as_mut().init();

println!("{}", x.as_ref().a);

*x.as_mut().b().unwrap() = 2;

println!("{}", x.as_ref().a);

}


			Here, we pin `MaybeSelfRef` to the heap and initialize it. We print out the value of `a` and then mutate the data through the self-reference in `b`, and set its value to `2`. If we look at the output, we’ll see that everything looks as expected:

完成开发 [未优化 + 调试信息] 目标(s) 在 0.56s

运行 target\debug\x-pin-experiments.exe

0

2


			The pinned value can never move and as *users* of `MaybeSelfRef`, we didn’t have to write any `unsafe` code. Rust can guarantee that we never (in safe Rust) get a mutable reference to `MaybeSelfRef` since `Box` took ownership of it.
			Heap pinning being safe is not so surprising since, in contrast to the stack, a heap allocation will be stable throughout the program, regardless of where we create it.
			Important
			This is the preferred way to pin values in Rust. Stack pinning is for those cases where you don’t have a heap to work with or can’t accept the cost of that extra allocation.
			Let’s take a look at stack pinning while we’re at it.
			Pinning to the stack
			Pinning to the stack can be somewhat difficult. In *Chapter 5*, we saw how the stack worked and we know that it grows and shrinks as values are popped and pushed to the stack.
			So, if we’re going to pin to the stack, we have to pin it somewhere “high” on the stack. This means that if we pin a value to the stack inside a function call, we can’t return from that function, and expect the value to still be pinned there. That would be impossible.
			Pinning to the stack is hard since we pin by taking `&mut T`, and we have to guarantee that we won’t move `T` until it’s dropped. If we’re not careful, this is easy to get wrong. Rust can’t help us here, so it’s up to us to uphold that guarantee. This is why stack pinning is `unsafe`.
			Let’s look at the same example using stack pinning:
			ch09/d-pin/src/main.rs

fn stack_pinning_manual() {

let mut x = MaybeSelfRef::default();

let mut x = unsafe { Pin::new_unchecked(&mut x) };

x.as_mut().init();

println!("{}", x.as_ref().a);

*x.as_mut().b().unwrap() = 2;

println!("{}", x.as_ref().a);

}


			The noticeable difference here is that it’s `unsafe` to pin to the stack, so now, we need `unsafe` both as users of `MaybeSelfRef` and as implementors.
			If we run the example with `cargo run`, the output will be the same as in our first example:

完成开发 [未优化 + 调试信息] 目标(s) 在 0.58s

运行 target\debug\x-pin-experiments.exe

0

2


			The reason stack pinning requires `unsafe` is that it’s rather easy to accidentally break the guarantees that `Pin` is supposed to provide. Let’s take a look at this example:
			ch09/d-pin/src/main.rs

use std::mem::swap;

fn stack_pinning_manual_problem() {

let mut x = MaybeSelfRef::default();

let mut y = MaybeSelfRef::default();

{

let mut x = unsafe { Pin::new_unchecked(&mut x) };

x.as_mut().init();

*x.as_mut().b().unwrap() = 2;

}

swap(&mut x, &mut y);

println!("

x: {{

+----->a: {:p},

|      b: {:?},

|  }}

|

|  y: {{

|      a: {:p},

+-----|b: {:?},

}}",

&x.a,

x.b,

&y.a,

y.b,

);

}


			In this example, we create two instances of `MaybeSelfRef` called `x` and `y`. Then, we create a scope where we pin `x` and set the value of `x.a` to `2` by dereferencing the self-reference in `b`, as we did previously.
			Now, when we exit the scope, `x` isn’t pinned anymore, which means we can take a mutable reference to it without needing `unsafe`.
			Since this is safe Rust and we should be able to do what we want, we swap `x` and `y`.
			The output prints out the pointer address of the `a` field of both structs and the value of the pointer stored in `b`.
			When we look at the output, we should see the problem immediately:

完成 dev [未优化 + 调试信息] 目标(s) 在 0.58s

运行target\debug\x-pin-experiments.exe

x: {

+----->a: 0xe45fcff558,

|      b: None,

|  }

|

|  y: {

|      a: 0xe45fcff570,

+-----|b: Some(0xe45fcff558),

}


			Although the pointer values will differ from run to run, it’s pretty evident that `y` doesn’t hold a pointer to `self` anymore.
			Right now, it points somewhere in `x`. This is very bad and will cause the exact memory safety issues Rust is supposed to prevent.
			Note
			For this reason, the standard library has a `pin!` macro that helps us with safe stack pinning. The macro uses `unsafe` under the hood but makes it impossible for us to reach the pinned value again.
			Now that we’ve seen all the pitfalls of stack pinning, my clear recommendation is to avoid it unless you need to use it. If you have to use it, then use the `pin!` macro so that you avoid the issues we’ve described here.
			Tip
			In this book’s GitHub repository, you’ll find a function called `stack_pinning_macro()` in the `ch``09``/d-pin/src/main.rs` file. This function shows the preceding example but using Rust’s `pin!` macro.
			Pin projections and structural pinning
			Before we leave the topic of pinning, we’ll quickly explain what pin projections and structural pinning are. Both sound complex, but they are very simple in practice. The following diagram shows how these terms are connected:
			![Figure 9.5 – Pin projection and structural pinning](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/async-prog-rs/img/B20892_09_51.jpg)

			Figure 9.5 – Pin projection and structural pinning
			Structural pinning means that if a struct is pinned, so is the field. We expose this through pin projections, as we’ll see in the following code example.
			If we continue with our example and create a struct called `Foo` that holds both `MaybeSelfRef` (field `a`) and a `String` type (field `b`), we could write two projections that return a pinned version of `a` and a regular mutable reference to `b`:
			ch09/d-pin/src/main.rs

[derive(Default)]

struct Foo {

a: MaybeSelfRef,

b: String,

}

实现 Foo {

fn a(self: Pin<&mut Self>) -> Pin<&mut MaybeSelfRef> {

unsafe {

self.map_unchecked_mut(|s| &mut s.a)

}

}

fn b(self: Pin<&mut Self>) -> &mut String {

unsafe {

&mut self.get_unchecked_mut().b

}

}

}


			Note that these methods will only be callable when `Foo` is pinned. You won’t be able to call either of these methods on a regular instance of `Foo`.
			Pin projections do have a few subtleties that you should be aware of, but they’re explained in quite some detail in the official documentation (https://doc.rust-lang.org/stable/std/pin/index.html), so I’ll refer you there for more information about the precautions you must take when writing projections.
			Note
			Since pin projections can be a bit error-prone to create yourself, there is a popular create for making pin projections called **pin_project** (https://docs.rs/pin-project/latest/pin_project/). If you ever end up having to make pin projections, it’s worth checking out.
			With that, we’ve pretty much covered all the advanced topics in async Rust. However, before we go on to our last chapter, let’s see how pinning will prevent us from making the big mistake we made in the last iteration of our coroutine example.
			Improving our example 4 – pinning to the rescue
			Fortunately, the changes we need to make are small, but before we continue and make the changes, let’s create a new folder and copy everything we had in our previous example over to that folder:

				*   Copy the entire `c-coroutines-problem` folder and name the new copy `e-coroutines-pin`
				*   Open `Cargo.toml` and rename the name of the package `e-coroutines-pin`

			Tip
			You’ll find the example code we’ll go through here in this book’s GitHub repository under the `ch``09``/e-coroutines-pin` folder.
			Now that we have a new folder set up, let’s start making the necessary changes. The logical place to start is our `Future` definition in `future.rs`.
			future.rs
			The first thing we’ll do is pull in `Pin` from the standard library at the very top:
			ch09/e-coroutines-pin/src/future.rs

use std::pin::Pin;


			The only other change we need to make is in the definition of `poll` in our `Future` trait:

fn poll(self: Pin<&mut Self>, waker: &Waker) -> PollStateSelf::Output;


			That’s pretty much it.
			However, the implications of this change are noticeable pretty much everywhere poll is called, so we need to fix that as well.
			Let’s start with `http.rs`.
			http.rs
			The first thing we need to do is pull in `Pin` from the standard library. The start of the file should look like this:
			ch09/e-coroutines-pin/src/http.rs

use crate::{future::PollState, runtime::{self, reactor, Waker}, Future};

use mio::Interest;

use std::{io::{ErrorKind, Read, Write}, pin::Pin};


			The only other place we need to make some changes is in the `Future` implementation for `HttpGetFuture`, so let’s locate that. We’ll start by changing the arguments in `poll`:
			ch09/e-coroutines-pin/src/http.rs

fn poll(self is now Pin<&mut Self>, there are several small changes we need to make so that the borrow checker stays happy. Let’s start from the top:

        ch09/e-coroutines-pin/src/http.rs
let id = self.id;
        if self.stream.is_none() {
            println!("FIRST POLL - START OPERATION");
            self.write_request();
            let stream = (&mut self).stream.as_mut().unwrap();
            runtime::reactor().register(stream, Interest::READABLE, id);
            runtime::reactor().set_waker(waker, self.id);
        }
        在顶部将`id`分配给变量的原因是,当尝试将`&mut self`和`&self`作为参数传递给 register/deregister 函数时,借用检查器给我们带来了一些小麻烦,所以我们只是在顶部将`id`分配给一个变量,这样大家就都高兴了。

        只有两行需要更改,那就是我们从一个内部缓冲区创建`String`类型并从 reactor 注销兴趣的地方:

        ch09/e-coroutines-pin/src/http.rs
let s = String::from_utf8_lossy(&self.buffer).to_string();
runtime::reactor().deregister(self.stream.as_mut().unwrap(), id);
break PollState::Ready(s);
        重要

        注意,这个 future 是`Unpin`。没有东西让它移动`HttpGetFuture`变得`不安全`,对于大多数这样的 future 来说,情况确实如此。只有由 async/await 创建的 future 是按设计自我引用的。这意味着这里不需要任何`不安全`的操作。

        接下来,让我们继续到`main.rs`,因为那里有一些重要的更改需要我们进行。

        Main.rs

        让我们从顶部开始,确保我们有正确的导入:

        ch09/e-coroutines-pin/src/main.rs
mod future;
mod http;
mod runtime;
use future::{Future, PollState};
use runtime::Waker;
use std::{fmt::Write, PhantomPinned marker and Pin.
			The next thing we need to change is in our `State0` enum. The futures we hold between states are now pinned:
			ch09/e-coroutines-pin/src/main.rs

Wait1(Pin<Box<dyn Future<Output = String>>>),

Wait2(Pin<Box<dyn Future<Output = String>>>),


			Next up is an important change. We need to make our coroutines `!Unpin` so that they can’t be moved once they have been pinned. We can do this by adding a marker trait to our `Coroutine0` struct:
			ch09/e-coroutines-pin/src/main.rs

struct Coroutine0 {

栈: Stack0,

状态: State0,

_pin: PhantomPinned,

}


			We also need to add the `PhantomPinned` marker to our new function:
			ch09/e-coroutines-pin/src/main.rs

实现 Coroutine0 {

fn new() -> Self {

Self {

状态: State0::Start,

栈: Stack0::default(),

_pin: PhantomPinned,

}

}

}


			The last thing we need to change is the `poll` method. Let’s start with the function signature:
			ch09/e-coroutines-pin/src/main.rs

fn poll(this, which replaces self everywhere in the function body.

        我不会逐行解释,因为变化如此微不足道,但第一行之后,我们只需在函数体中所有使用`self`的地方进行简单的搜索和替换,将其更改为`this`:

        ch09/e-coroutines-pin/src/main.rs
let this = unsafe { self.get_unchecked_mut() };
        loop {
            match this.state {
                State0::Start => {
                    // initialize stack (hoist declarations - no stack yet)
                    this.stack.buffer = Some(String::from("\nBUFFER:\n----\n"));
                    this.stack.writer = Some(this.stack.buffer.as_mut().unwrap());
                    // ---- Code you actually wrote ----
                    println!("Program starting");
...
        这里重要的行是`let this = unsafe { self.get_unchecked_mut() };`。在这里,我们必须使用`unsafe`,因为由于我们添加的标记 trait,固定值是`!Unpin`。

        获取到固定值是`不安全的`,因为 Rust 无法保证我们不会移动固定值。

        这个好处是,如果我们以后遇到任何这样的问题,我们知道我们可以搜索我们使用 `unsafe` 的地方,问题肯定在那里。

        我们接下来需要更改的是将我们存储在等待状态中的未来对象固定。我们可以通过调用 `Box::pin` 而不是 `Box::new` 来实现这一点:

        ch09/e-coroutines-pin/src/main.rs
let fut1 = Box::pin(http::Http::get("/600/HelloAsyncAwait"));
let fut2 = Box::main.rs where we need to make changes is in the locations where we poll our child futures since we now have to go through the Pin type to get a mutable reference:
			ch09/e-coroutines-pin/src/main.rs

match f1.as_mut().poll(waker)

由于这些未来对象是 !Unpin,这里使用 f2unsafe

        我们需要更改几行代码的最后一个地方是在 `executor.rs` 中,所以让我们前往那里作为我们的最后一站。

        executor.rs

        我们必须做的第一件事是确保我们的依赖关系是正确的。我们在这里所做的唯一更改是添加来自标准库的 `Pin`:

        ch09/e-coroutines-pin/src/runtime/executor.rs
...
    thread::{self, Thread}, pin::Pin,
};
        我们将要更改的下一行是我们的 `Task` 类型别名,使其现在指向 `Pin<Box<...>>`:
type Task = Pin<Box<dyn Future<Output = String>>>;
        我们现在将要更改的最后一行是在我们的 spawn 函数中。我们必须将未来对象固定在堆上:
e.tasks.borrow_mut().insert(id, Box::pin(future));
        如果我们现在尝试运行我们的示例,它甚至无法编译并给出以下错误:
error[E0599]: no method named `poll` found for struct `Pin<Box<dyn future::Future<Output = String>>>` in the current scope
  --> src\runtime\executor.rs:89:30
        它甚至不允许我们在不先固定它的情况下轮询未来对象,因为 `poll` 只能对 `Pin<&mut Self>` 类型调用,而不能对 `&mut self` 调用。

        因此,在我们甚至尝试轮询之前,我们必须决定是将值固定在栈上还是堆上。在我们的情况下,我们的整个执行器通过堆分配未来对象,所以这是唯一合理的事情去做。

        让我们完全移除优化,并更改一行代码以使我们的执行器再次工作:

        ch09/e-coroutines-pin/src/runtime/executor.rs
match future.cargo run, you should get the expected output back and not have to worry about the coroutine/wait generated futures being moved again (the output has been abbreviated slightly):

完成开发 [未优化 + 调试信息] 目标(s) 在 0.02 秒

运行 target\debug\e-coroutines-pin.exe

程序开始

第一次轮询 - 开始操作

main: 1 个待处理任务。等待通知。

第一次轮询 - 开始操作

main: 1 个待处理任务。等待通知。

缓冲区:


HTTP/1.1 200 OK

content-length: 15

[=== 简化版 ===]

日期:Sun, 03 Dec 2023 23:18:12 GMT

HelloAsyncAwait

main: 所有任务已完成


			You now have self-referential coroutines that can safely store both data and references across wait points. Congratulations!
			Even though making these changes took up quite a few pages, the changes themselves were part pretty trivial for the most part. Most of the changes were due to `Pin` having a different API than what we had when using references before.
			The good thing is that this sets us up nicely for migrating our whole runtime over to futures created by async/await instead of our own futures created by coroutine/wait with very few changes.
			Summary
			What a ride, huh? If you’ve got to the end of this chapter, you’ve done a fantastic job, and I have good news for you: you pretty much know everything about how Rust’s futures work and what makes them special already. All the complicated topics are covered.
			In the next, and last, chapter, we’ll switch over from our hand-made coroutines to proper async/await. This will seem like a breeze compared to what you’ve gone through so far.
			Before we continue, let’s stop for a moment and take a look at what we’ve learned in this chapter.
			First, we expanded our coroutine implementation so that we could store variables across wait points. This is pretty important if our coroutine/wait syntax is going to rival regular synchronous code in readability and ergonomics.
			After that, we learned how we could store and restore variables that held references, which is just as important as being able to store data.
			Next, we saw firsthand something that we’ll *never* see in Rust unless we implement an asynchronous system, as we did in this chapter (which is quite the task just to prove a single point). We saw how moving coroutines that hold self-references caused serious memory safety issues, and exactly why we need something to prevent them.
			That brought us to pinning and self-referential structs, and if you didn’t know about these things already, you do now. In addition to that, you should at least know what a pin projection is and what we mean by structural pinning.
			Then, we looked at the differences between pinning a value to the stack and pinning a value to the heap. You even saw how easy it was to break the `Pin` guarantee when pinning something to the stack and why you should be very careful when doing just that.
			You also know about some tools that are widely used to tackle both pin projections and stack pinning and make both much safer and easier to use.
			Next, we got firsthand experience with how we could use pinning to prevent the issues we had with our coroutine implementation.
			If we take a look at what we’ve built so far, that’s pretty impressive as well. We have the following:

				*   A coroutine implementation we’ve created ourselves
				*   Coroutine/wait syntax and a preprocessor that helps us with the boilerplate for our coroutines
				*   Coroutines that can safely store both data and references across wait points
				*   An efficient runtime that stores, schedules, and polls the tasks to completion
				*   The ability to spawn new tasks onto the runtime so that one task can spawn hundreds of new tasks that will run concurrently
				*   A reactor that uses `epoll`/`kqueue`/IOCP under the hood to efficiently wait for and respond to new events reported by the operating system

			I think this is pretty cool.
			We’re not quite done with this book yet. In the next chapter, you’ll see how we can have our runtime run futures created by async/await instead of our own coroutine implementation with just a few changes. This enables us to leverage all the advantages of async Rust. We’ll also take some time to discuss the state of async Rust today, the different runtimes you’ll encounter, and what we might expect in the future.
			All the heavy lifting is done now. Well done!




第十章:创建您的自己的运行时

在最后几章中,我们涵盖了与 Rust 的异步编程相关的许多方面,但我们是通过实现比 Rust 当前的抽象更替代和更简单的抽象来做到这一点的。

这最后一章将专注于通过改变我们的运行时,使其与 Rust 的 futures 和 async/await 一起工作,而不是我们自己的 futures 和 coroutine/wait 来弥合这个差距。由于我们已经几乎涵盖了关于协程、状态机、futures、wakers、运行时和 pinning 的所有知识,因此调整我们现在所拥有的将是一个相对简单的任务。

当一切正常工作时,我们将对我们的运行时进行一些实验,以展示和讨论一些使异步 Rust 对新用户来说有些困难的方面。

在总结本书中我们所做和所学的内容之前,我们将花一些时间讨论在异步 Rust 中我们可能期待的未来。

我们将涵盖以下主要主题:

  • 使用 futures 和 async/await 创建我们的运行时

  • 对我们的运行时进行实验

  • 异步 Rust 的挑战

  • 异步 Rust 的未来

技术要求

本章中的示例将基于上一章的代码,因此要求相同。示例是跨平台的,将在所有 Rust (doc.rust-lang.org/beta/rustc/platform-support.html#tier-1-with-host-tools) 和 mio (https://github.com/tokio-rs/mio#platforms) 支持的平台上运行。

您唯一需要的是安装 Rust 并下载本书的存储库。本章中的所有代码都可以在 ch10 文件夹中找到。

在这个例子中,我们也将使用 delayserver,因此您需要打开一个单独的终端,进入存储库根目录下的 delayserver 文件夹,并输入 cargo run 以确保它已准备好并可供后续示例使用。

如果出于某种原因您需要更改 delayserver 监听的端口,请记住在代码中更改端口。

Creating our own runtime with futures and async/await

好的,所以我们已经到了最后阶段;我们最后要做的就是改变我们的运行时,使其使用 Rust 的 Future 特性、Wakerasync/await。现在我们已经通过自己构建一切来覆盖了 Rust 异步编程的几乎所有复杂方面,这将对我们来说是一个相对简单的任务。我们甚至对 Rust 在此过程中不得不做出的设计决策进行了相当详细的探讨。

Rust 当前的异步编程模型是经过演化过程的结果。Rust 在其早期阶段开始使用绿色线程,但那时它还没有达到 1.0 版本。在达到 1.0 版本时,Rust 的标准库中根本就没有 futures 或异步操作的概念。这个空间在 futures-rs crate (github.com/rust-lang/futures-rs) 中得到了探索,这个 crate 仍然是异步抽象的摇篮。然而,不久 Rust 就围绕一个类似于我们今天的 Future 特征的版本稳定下来,通常被称为 futures 0.1。支持由 async/await 创建的协程已经在进行中,但设计达到最终阶段并进入标准库的稳定版本还需要几年时间。

因此,我们在异步实现中做出的许多选择都是 Rust 在实现过程中必须做出的真实选择。然而,这一切都把我们带到了这个点,所以让我们开始适应我们的运行时,使其与 Rust 的 futures 兼容。

在我们进入示例之前,让我们了解一下与我们的当前实现不同的地方:

  • Rust 使用的 Future 特征与我们现在的有所不同。最大的不同是它使用 Context 而不是 Waker。另一个不同之处在于它返回一个名为 Poll 的枚举,而不是 PollState

  • Context 是 Rust 的 Waker 类型的包装器。它的唯一目的是为了使 API 兼容未来,以便将来可以持有额外的数据,而无需更改与 Waker 相关的任何内容。

  • Poll 枚举返回两种状态之一,Ready(T)Pending。这与我们当前的 PollState 枚举略有不同,但这两个状态在我们的当前实现中与 Ready(T)/NotReady 意义相同。

  • 在 Rust 中创建 Wakers 比我们当前的 Waker 要复杂一些。我们将在本章后面讨论如何以及为什么。

除了上述的不同之处,其他所有内容都可以保持原样。大部分情况下,我们这次只是进行了重命名和重构。

既然我们已经有了需要做什么的想法,现在是时候设置一切,以便我们可以启动我们的新示例。

注意

尽管我们在 Rust 中创建了一个运行时来正确运行 futures,但我们仍然通过避免错误处理和不专注于使运行时更加灵活来保持其简单性。改进我们的运行时当然是有可能的,而且虽然有时正确使用类型系统并取悦借用检查器可能有点棘手,但这与 异步 Rust 的关系相对较小,而更多的是与 Rust 本身有关。

设置示例

提示

你可以在书的仓库中找到这个示例,在 ch1``0``/a-rust-futures 文件夹中。

我们将继续在上一个章节中留下的地方继续,所以让我们将我们拥有的所有内容复制到一个新项目中:

  1. 创建一个名为 a-rust-futures 的新文件夹。

  2. 将前一章中的示例中的所有内容复制过来。如果你遵循了我建议的命名规则,它将存储在 e-coroutines-pin 文件夹中。

  3. 现在你应该有一个包含我们之前示例副本的文件夹,所以最后要做的就是将 Cargo.toml 中的项目名称更改为 a-rust-futures

好的,那么让我们从我们想要运行的程序开始。打开 main.rs

main.rs

在尝试任何更复杂的事情之前,我们先回到程序最简单的版本,并让它运行起来。打开 main.rs 并用以下代码替换该文件中的所有代码:

ch10/a-rust-futures/src/main.rs

mod http;
mod runtime;
use crate::http::Http;
fn main() {
    let mut executor = runtime::init();
    executor.block_on(async_main());
}
async fn async_main() {
    println!("Program starting");
    let txt = Http::get("/600/HelloAsyncAwait").await;
    println!("{txt}");
    let txt = Http::get("/400/HelloAsyncAwait").await;
    println!("{txt}");
}

这次不需要 corofy 或任何特殊的东西。编译器会为我们重写这些。

注意

注意,我们已经移除了 future 模块的声明。这是因为我们根本不再需要它了。唯一的例外是,如果你想要保留并使用我们创建的用于将多个 future 连接起来的 join_all 函数。你可以尝试自己重写它,或者查看仓库并定位到 ch1``0``/a-rust-futures-bonus/src/future.rs 文件,在那里你可以找到我们示例的相同版本,只是这个版本保留了带有与 Rust futures 一起工作的 join_all 函数的 future 模块。

future.rs

你可以完全删除这个文件,因为我们不再需要我们自己的 Future trait 了。

让我们直接进入 http.rs 并看看我们需要在那里做哪些更改。

http.rs

我们需要更改的第一件事是我们的依赖项。我们将不再依赖于我们自己的 FutureWakerPollState;相反,我们将依赖于标准库中的 FutureContextPoll。我们的依赖项现在应该看起来像这样:

ch10/a-rust-futures/src/http.rs

use crate::runtime::{self, reactor};
use mio::Interest;
use std::{
    future::Future,
    io::{ErrorKind, Read, Write},
    pin::Pin,
    task::{Context, Poll},
};

我们必须在 HttpGetFuturepoll 实现中进行一些小的重构。

首先,我们需要更改 poll 函数的签名,使其符合新的 Future trait:

ch10/a-rust-futures/src/http.rs

fn poll(mut self: Pin<&mut Self>, cx, we have to change what we pass in to set_waker with the following:
			ch10/a-rust-futures/src/http.rs

runtime::reactor().set_waker(Poll instead of PollState. 要做到这一点,找到 poll 方法,并首先更改签名,使其与标准库中的 Future trait 匹配:

        ch10/a-rust-futures/src/http.rs
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
        接下来,我们需要更改函数返回类型的地方(我在这里只展示了函数体的相关部分):

        ch10/a-rust-futures/src/http.rs
loop {
            match self.stream.as_mut().unwrap().read(&mut buff) {
                Ok(0) => {
                    let s = String::from_utf8_lossy(&self.buffer).to_string();
                    runtime::reactor().deregister(self.stream.as_mut().unwrap(), id);
                    break Poll::Ready(s.to_string());
                }
                Ok(n) => {
                    self.buffer.extend(&buff[0..n]);
                    continue;
                }
                Err(e) if e.kind() == ErrorKind::WouldBlock => {
                    // always store the last given Waker
                    runtime::reactor().set_waker(cx, self.id);
                    break Poll::Pending;
                }
                Err(e) => panic!("{e:?}"),
            }
        }
        这个文件就到这里。不错吧?让我们看看在 executor 中我们需要做哪些更改,并打开 `executor.rs`。

        executor.rs

        在 `executor.rs` 中,我们需要更改的第一件事是我们的依赖项。这次,我们只依赖于标准库中的类型,并且我们的 `dependencies` 部分现在应该看起来像这样:

        ch10/a-rust-futures/src/runtime/executor.rs
use std::{
    cell::{Cell, RefCell},
    collections::HashMap,
    future::Future,
    pin::Pin,
    sync::{Arc, Mutex},
    task::{Poll, Context, Wake, Waker},
    thread::{self, Thread},
};
        我们的任务不再局限于仅输出 String,因此我们可以安全地为我们顶级 future 使用更合理的 `Output` 类型:

        ch10/a-rust-futures/src/runtime/executor.rs
type Task = Pin<Box<dyn Future<Output = Waker since the changes we make here will result in several other changes to this file.
			Creating a waker in Rust can be quite a complex task since Rust wants to give us maximum flexibility on how we choose to implement wakers. The reason for this is twofold:

				*   Wakers must work just as well on a server as it does on a microcontroller
				*   A waker must be a zero-cost abstraction

			Realizing that most programmers never need to create their own wakers, the cost that the lack of ergonomics has was deemed acceptable.
			Until quite recently, the only way to construct a waker in Rust was to create something very similar to a trait object without being a trait object. To do so, you had to go through quite a complex process of constructing a *v-table* (a set of function pointers), combining that with a pointer to the data that the waker stored, and creating  `RawWaker`.
			Fortunately, we don’t actually have to go through this process anymore as Rust now has the `Wake` trait. The `Wake` trait works if the `Waker` type we create is placed in `Arc`.
			Wrapping `Waker` in an `Arc` results in a heap allocation, but for most `Waker` implementations on the kind of systems we’re talking about in this book, that’s perfectly fine and what most production runtimes do. This simplifies things for us quite a bit.
			Info
			This is an example of Rust adopting what turns out to be best practices from the ecosystem. For a long time, a popular way to construct wakers was by implementing a trait called `ArcWake` provided by the `futures` crate ([`github.com/rust-lang/futures-rs`](https://github.com/rust-lang/futures-rs)). The `futures` crate is not a part of the language but it’s in the `rust-lang` repository and can be viewed much like a toolbox and nursery for abstractions that might end up in the language at some point in the future.
			To avoid confusion by having multiple things with the same name, let’s rename our concrete `Waker` type to `MyWaker`:
			ch10/a-rust-futures/src/runtime/executor.rs

[derive(Clone)]

pub struct MyWaker {

thread: Thread,

id: usize,

ready_queue: Arc<Mutex<Vec>>,

}


			We can keep the implementation of `wake` pretty much the same, but we put it in the implementation of the `Wake` trait instead of just having a `wake` function on `MyWaker`:
			ch10/a-rust-futures/src/runtime/executor.rs

impl Wake for MyWaker {

fn wake(self: Arc) {

self.ready_queue

.lock()

.map(|mut q| q.push(self.id))

.unwrap();

self.thread.unpark();

}

}


			You’ll notice that the `wake` function takes a `self: Arc<Self>` argument, much like we saw when working with the `Pin` type. Writing the function signature this way means that `wake` is only callable on `MyWaker` instances that are wrapped in `Arc`.
			Since our `waker` has changed slightly, there are a few places we need to make some minor corrections. The first is in the `get_waker` function:
			ch10/a-rust-futures/src/runtime/executor.rs

fn get_waker(&self, id: usize) -> Arc {

Arc::new(MyWaker {

id,

thread: thread::current(),

ready_queue: CURRENT_EXEC.with(|q| q.ready_queue.clone()),

})

}


			So, not a big change here. The only difference is that we heap-allocate the waker by placing it in `Arc`.
			The next place we need to make a change is in the `block_on` function.
			First, we need to change its signature so that it matches our new definition of a top-level future:
			ch10/a-rust-futures/src/runtime/executor.rs

pub fn block_on(&mut self, future: F)

where

F: Future<Output = ()> + 'static,

{


			The next step is to change how we create a waker and wrap it in a `Context` struct in the `block_on` function:
			ch10/a-rust-futures/src/runtime/executor.rs

...

// 防止假唤醒

None => continue,

};

let waker: Waker = self.get_waker(id).into();

let mut cx = Context::from_waker(&waker);

match future.as_mut().poll(&mut cx) {

...


			This change is a little bit complex, so we’ll go through it step by step:

				1.  First, we get `Arc<MyWaker>` by calling the `get_waker` function just like we did before.
				2.  We convert `MyWaker` into a simple `Waker` by specifying the type we expect with `let waker: Waker` and calling `into()` on `MyWaker`. Since every instance of `MyWaker` is also a kind of `Waker`, this will convert it into the `Waker` type that’s defined in the standard library, which is just what we need.
				3.  Since `Future::poll` expects  `Context` and not `Waker`, we create a new `Context` struct with a reference to the waker we just created.

			The last place we need to make changes is to the signature of our `spawn` function so that it takes the new definition of top-level futures as well:
			ch10/a-rust-futures/src/runtime/executor.rs

pub fn spawn(future: F)

where

F: Future<Output = reactor.rs.

        reactor.rs

        我们首先确保我们的依赖项是正确的。我们必须删除对旧 `Waker` 实现的依赖,而是从标准库中引入这些类型。`dependencies` 部分应该看起来像这样:

        ch10/a-rust-futures/src/runtime/reactor.rs
use mio::{net::TcpStream, Events, Interest, Poll, Registry, Token};
use std::{
    collections::HashMap,
    sync::{
        atomic::{AtomicUsize, Ordering},
        Arc, Mutex, OnceLock,
    },
    thread, task::{Context, Waker},
};
        我们需要做出两个小的更改。第一个更改是我们的 `set_waker` 函数现在接受 `Context`,它需要从中获取一个 `Waker` 对象:

        ch10/a-rust-futures/src/runtime/reactor.rs
pub fn set_waker(&self, cx: &Context, id: usize) {
        let _ = self
            .wakers
            .lock()
            .map(|mut w| w.insert(id, cx.waker().clone()).is_none())
            .unwrap();
    }
        最后的更改是在调用 `event_loop` 函数中的 `wake` 时需要调用一个稍微不同的方法:

        ch10/a-rust-futures/src/runtime/reactor.rs
if let Some(waker) = wakers.get(&id) {
    waker.wake_by_ref();
}
        由于现在调用 `wake` 会消耗 `self`,因此我们调用接受 `&self` 的版本,因为我们想保留这个 `waker` 以供以后使用。

        就这样。我们的运行时现在可以运行并充分利用异步 Rust 的全部功能。让我们在终端中输入 `cargo run` 来试试。

        我们应该得到之前看到过的相同输出:
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
HTTP/1.1 200 OK
content-length: 15
[==== ABBREVIATED ====]
HelloAsyncAwait
main: All tasks are finished
        这非常不错,不是吗?

        因此,现在我们已经创建了自己的异步运行时,它使用 Rust 的 `Future`、`Waker`、`Context` 和 `async/await`。

        现在我们可以自豪地称自己是运行时实现者,是时候做一些实验了。我会选择几个实验,这些实验也会让我们了解 Rust 中的运行时和 futures。我们还没有学完。

        在我们的运行时中进行实验

        注意

        你可以在书的存储库中找到这个示例,在 `ch1``0``/b-rust-futures-experiments` 文件夹中。不同的实验将作为 `async_main` 函数的不同版本按时间顺序实现。我将在代码片段的标题中指示哪个函数对应于存储库示例中的哪个函数。

        在我们开始实验之前,让我们把我们现在拥有的所有内容复制到一个新的文件夹中:

            1.  创建一个名为 `b-rust-futures-experiments` 的新文件夹。

            1.  将 `a-rust-futures` 文件夹中的所有内容复制到新文件夹中。

            1.  打开 `Cargo.toml` 并将 `name` 属性更改为 `b-rust-futures-experiments`。

        第一个实验将是用合适的 HTTP 客户端替换我们非常有限的 HTTP 客户端。

        做这件事最简单的方法是简单地选择另一个支持异步 Rust 的生产级 HTTP 客户端库,并使用它代替。

        因此,当我们试图找到我们 HTTP 客户端的合适替代品时,我们检查了最受欢迎的高级 HTTP 客户端库列表,并发现 `reqwest` 排在首位。这可能适用于我们的目的,所以让我们先尝试一下。

        我们首先做的事情是在 `Cargo.toml` 中将 `reqwest` 添加为依赖项,通过输入以下内容:
cargo add reqwest@0.11
        接下来,让我们更改我们的 `async_main` 函数,以便我们使用 `reqwest` 而不是我们自己的 HTTP 客户端:

        ch10/b-rust-futures-examples/src/main.rs (async_main2)
async fn async_main() {
    println!("Program starting");
    let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
    let res = reqwest::get(url).await.unwrap();
    let txt = res.text().await.unwrap();
    println!("{txt}");
    let url = "http://127.0.0.1:8080/400/HelloAsyncAwait2";
    let res = reqwest::get(url).await.unwrap();
    let txt = res.text().await.unwrap();
    println!("{txt}");
}
        除了使用 `reqwest` API 之外,我还更改了我们发送的消息。大多数 HTTP 客户端不会返回原始 HTTP 响应给我们,通常只提供一种方便的方式来获取响应的 *body*,直到现在,我们的请求对此都是类似的。

        这应该就是我们需要更改的所有内容,所以让我们尝试通过编写 `cargo run` 来运行我们的程序:
     Running `target\debug\a-rust-futures.exe`
Program starting
thread 'main' panicked at C:\Users\cf\.cargo\registry\src\index.crates.io-6f17d22bba15001f\tokio-1.35.0\src\net\tcp\stream.rs:160:18:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
        好吧,所以错误告诉我们没有运行反应器,并且它必须从 Tokio 1.x 运行时的上下文中调用。我们知道有一个反应器正在运行,只是不是 `reqwest` 期望的那个,所以让我们看看我们如何解决这个问题。

        我们显然需要将 Tokio 添加到我们的程序中,由于 Tokio 严重功能受限(这意味着默认情况下只有很少的功能被启用),我们将简化操作,并启用所有功能:
cargo add tokio@1 --features full
        根据文档,我们需要启动一个 Tokio 运行时,并显式进入它以启用反应器。`enter` 函数将返回 `EnterGuard` 给我们,只要我们需要反应器运行,我们就可以持有它。

        将此添加到我们的 `async_main` 函数顶部应该可以工作:

        ch10/b-rust-futures-examples/src/main.rs (async_main2)
use tokio::runtime::Runtime;
async fn async_main
    let rt = Runtime::new().unwrap();
    let _guard = rt.enter();
    println!("Program starting");
    let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
    ...
        注意

        调用 `Runtime::new` 创建一个多线程的 Tokio 运行时,但 Tokio 也有一个单线程的运行时,你可以通过使用运行时构建器来创建,如下所示:`Builder::new_current_thread().enable_all().build().unwrap()`。如果你这样做,你最终会遇到一个奇特的问题:死锁。这个原因很有趣,你应该知道。

        Tokio 的单线程运行时只使用它被调用的线程来执行执行器和反应器。这与我们在运行时的第一个版本中做的事情非常相似。第八章。我们使用了 `Poll` 实例直接挂起我们的执行器。当我们的反应器和执行器在同一个线程上执行时,它们必须具有相同的机制来自动挂起并等待新事件,这意味着它们之间将存在紧密的耦合。

        在处理事件时,反应器必须首先唤醒以调用`Waker::wake`,但执行器是最后一个调用`thread::park`(就像我们做的那样)的。如果执行器通过调用`thread::park`(就像我们做的那样)自己挂起,那么反应器也会挂起,并且由于它们在同一个线程上运行,将永远不会唤醒。使这一切正常工作的唯一方法就是执行器挂起与反应器共享的东西(就像我们用`Poll`做的那样)。由于我们与 Tokio 没有紧密集成,我们得到的只是一个死锁。

        现在,如果我们再次尝试运行我们的程序,我们会得到以下输出:
Program starting
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait1
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait2
main: All tasks are finished
        好吧,所以现在一切如预期工作。唯一的区别是我们被唤醒了几次,但程序完成了并产生了预期的结果。

        在我们讨论我们刚才看到的情况之前,让我们再做一个实验。

        **Isahc**是一个承诺为*执行器无关*的 HTTP 客户端库,这意味着它不依赖于任何特定的执行器。让我们来测试一下。

        首先,我们通过输入以下内容添加对`isahc`的依赖:
cargo add isahc@1.7
        然后,我们重写我们的`main`函数,使其看起来像这样:

        ch10/b-rust-futures-examples/src/main.rs (async_main3)
use isahc::prelude::*;
async fn async_main() {
    println!("Program starting");
    let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
    let mut res = isahc::get_async(url).await.unwrap();
    let txt = res.text().await.unwrap();
    println!("{txt}");
    let url = "http://127.0.0.1:8080/400/HelloAsyncAwait2";
    let mut res = isahc::get_async(url).await.unwrap();
    let txt = res.text().await.unwrap();
    println!("{txt}");
}
        现在,如果我们通过编写`cargo run`来运行我们的程序,我们会得到以下输出:
Program starting
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait1
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait2
main: All tasks are finished
        因此,我们得到了预期的输出,而无需跳过任何障碍。

        *为什么这一切都必须如此不直观?*

        那个问题的答案把我们带到了我们所有人编程时都会遇到的一些常见挑战的话题,所以让我们来谈谈其中一些最明显的挑战,并解释它们存在的原因,这样我们就可以找出如何最好地处理它们。

        异步 Rust 的挑战

        所以,虽然我们亲眼看到执行器和反应器可以被松散耦合,这反过来意味着理论上你可以混合匹配反应器和执行器,但问题是为什么我们在尝试这样做时会遇到如此多的摩擦?

        大多数使用过异步 Rust 的程序员都经历过由不兼容的异步库引起的问题,我们之前看到了你可能会得到的错误消息的例子。

        要理解这一点,我们必须稍微深入了解一下 Rust 中现有的异步运行时,特别是那些我们通常用于桌面和服务器应用程序的。

        显式与隐式反应器实例化

        信息

        我们接下来要讨论的未来类型是叶子未来,这种类型实际上代表了一个 I/O 操作(例如,`HttpGetFuture`)。

        当你在 Rust 中创建运行时,你还需要创建 Rust 标准库的非阻塞原语。互斥锁、通道、计时器、TcpStreams 等等都是需要异步等价物的东西。

        这些大多数都可以实现为不同类型的反应器,但随之而来的问题是:那个反应器是如何启动的?

        在我们自己的运行时和 Tokio 中,反应器作为运行时初始化的一部分启动。我们有一个`runtime::init()`函数,它调用`reactor::start()`,而 Tokio 有一个`Runtime::new()`和`Runtime::enter()`函数。

        如果我们尝试在没有启动反应器的情况下创建一个叶子 future(我们创建的唯一一个是`HttpGetFuture`),那么我们的运行时和 Tokio 都会崩溃。反应器必须**显式**实例化。

        相反,Isahc 带来了它自己的一种反应器。Isahc 建立在`libcurl`之上,这是一个高度可移植的 C 库,`libcurl`接受一个在操作准备就绪时被调用的回调。因此,Isahc 将接收到的唤醒器传递给这个回调,并确保在回调执行时调用`Waker::wake`。这有点过于简化,但基本上就是这样发生的。

        实际上,这意味着 Isahc 带来了它自己的反应器,因为它带有存储唤醒器并在操作准备就绪时对它们调用`wake`的机制。反应器是**隐式**启动的。

        偶然的是,这也是`async_std`和 Tokio 之间的一大主要区别。Tokio 需要**显式**实例化,而`async_std`则依赖于**隐式**实例化。

        我并不是为了好玩而深入探讨这个问题;虽然这似乎是一个微小的差异,但它对 Rust 中异步编程的直观性有着相当大的影响。

        这个问题主要在你开始使用不同于 Tokio 的其他运行时编程时出现,然后必须使用内部依赖于 Tokio 反应器存在的库。

        由于你无法在同一个线程上运行两个 Tokio 实例,因此库不能隐式启动一个 Tokio 反应器。相反,通常会发生的情况是,你尝试使用那个库,并得到一个像我们在前面的例子中遇到的那种错误。

        现在,你必须自己启动一个 Tokio 反应器,使用其他人创建的某种兼容包装器,或者查看你使用的运行时是否有内置机制来运行依赖于 Tokio 反应器的 future。

        对于大多数不了解反应器、执行器和不同类型的叶子 future 的人来说,这可能会相当不直观,并造成相当多的挫败感。

        注意

        我们在这里描述的问题相当常见,而且由于异步库很少很好地解释这一点,甚至很少尝试明确说明它们使用的运行时类型,这并没有得到帮助。一些库可能在`README`文件中某处提到它们是建立在 Tokio 之上的,而一些库可能只是简单地声明它们是建立在 Hyper 之上的,例如,假设你知道 Hyper 是建立在 Tokio 之上的(至少默认情况下是这样)。

        但是现在,你知道你应该检查这一点以避免任何惊喜,如果你遇到这个问题,你就知道确切的问题是什么。

        人体工程学 versus 效率和灵活性

        Rust 擅长于易用性和效率,这几乎让人难以忘记,当 Rust 面临在效率*或*易用性之间做出选择时,它将选择效率。生态系统中最受欢迎的许多 crate 都反映了这些价值观,这包括异步运行时。

        如果任务与执行者紧密集成,它们可以更有效率,因此,如果你在库中使用它们,你将依赖于那个特定的运行时。

        以**计时器**为例,但任务通知,其中*任务 A*通知*任务 B*它可以继续,这也是另一个具有一些相同权衡的例子。

        任务

        我们在未明确区分任务和未来的情况下使用了这些术语,所以让我们在这里澄清一下。我们首先在*第一章*中介绍了任务,它们仍然保留着相同的一般含义,但当我们谈论 Rust 中的运行时,它们有一个更具体的定义。任务是一个*顶级未来*,是我们向执行者播种的那个。执行者在不同任务之间进行调度。在运行时中,任务在很多方面代表了与操作系统中的线程相同的抽象。每个任务在 Rust 中都是一个未来,但根据这个定义,每个未来并不都是一个任务。

        你可以将`thread::sleep`视为一个计时器,在异步环境中我们经常需要这样的东西,因此我们的异步运行时将需要有一个`sleep`等效功能,告诉执行者将这个任务休眠指定的时间。

        我们可以将其实现为一个反应器,并为指定的持续时间让单独的操作系统线程休眠,然后唤醒正确的`Waker`。这将很简单,并且与执行者无关,因为执行者对发生的事情一无所知,它只关心在`Waker::wake`被调用时调度任务。然而,对于所有工作负载来说,这也不是最优效的(即使我们为所有计时器使用相同的线程)。

        另一种,且更为常见的方法是委托这个任务给执行者。在我们的运行时中,这可以通过让执行者存储一个有序的瞬间列表和相应的`Waker`来实现,这个`Waker`用于确定在它调用`thread::park`之前是否有任何计时器已过期。如果没有过期,我们可以计算出下一个计时器过期前的持续时间,并使用类似`thread::park_timeout`的东西来确保我们至少醒来处理那个计时器。

        用于存储计时器的算法可以高度优化,并且你可以避免仅为了计时器而需要额外线程的需求,以及这些线程之间同步的额外开销,仅为了通知一个计时器已过期。在多线程运行时中,当多个执行者频繁地向同一反应器添加计时器时,甚至可能会出现竞争。

        一些计时器以反应器风格作为独立的库实现,对于许多任务来说,这已经足够了。这里的关键点是,通过使用默认设置,你最终会绑定到一个特定的运行时,如果你想要避免你的库与特定的运行时紧密耦合,就必须进行仔细的考虑。

        每个人都同意的常见特质

        异步 Rust 中引起摩擦的最后一个话题是缺乏普遍认可的特质和接口,用于典型的异步操作。

        我想通过指出这一点来为这一部分内容做开场白:这是一个每天都在不断改进的领域,在`futures-rs`包中有一个异步 Rust 的特性和抽象的苗圃([`github.com/rust-lang/futures-rs`](https://github.com/rust-lang/futures-rs))。然而,由于异步 Rust 还处于早期阶段,在这样一本书中提及这一点是值得的。

        让我们以创建任务为例。当你用 Rust 编写一个高级异步库,比如一个网络服务器时,你可能会希望能够创建新的任务(顶级未来)。例如,服务器上的每个连接很可能是你想要在执行器上创建的新任务。

        现在,创建任务对每个执行器都是特定的,Rust 没有定义如何创建任务的特质。在`future-rs`包中建议了一个用于创建任务的特质,但创建一个既无成本又足够灵活以支持所有运行时的创建特质非常困难。

        有一些方法可以绕过这个问题。例如,流行的 HTTP 库 Hyper ([`hyper.rs/`](https://hyper.rs/))使用一个特质来表示执行器,并在内部使用它来创建新任务。这使得用户能够为不同的执行器实现这个特质并将其返回给 Hyper。通过为不同的执行器实现这个特质,Hyper 将使用一个与默认选项不同的创建者(默认选项是 Tokio 的执行器)。以下是如何使用 Hyper 与`async_std`一起使用的一个例子:[`github.com/async-rs/async-std-hyper`](https://github.com/async-rs/async-std-hyper)。

        然而,由于没有一种通用的方法来实现这一点,大多数依赖于特定执行器功能的库会做以下两件事之一:

            1.  选择一个运行时并坚持下去。

            1.  实现两个版本的库,支持用户通过启用正确的功能选择的不同流行运行时。

        异步释放

        异步释放,或者说异步析构函数,是异步 Rust 在撰写本书时尚未完全解决的问题。Rust 使用一种称为 RAII 的模式,这意味着当类型被创建时,它的资源也会被创建,当类型被销毁时,资源也会被释放。编译器会自动在对象超出作用域时插入一个释放调用的调用。

        以我们的运行时为例,当资源被释放时,它们以阻塞的方式释放。这通常不是一个大问题,因为释放不太可能长时间阻塞执行器,但并不总是这样。

        如果我们有一个需要很长时间才能完成的释放实现(例如,如果释放需要管理 I/O,或者需要对操作系统内核进行阻塞调用,这在 Rust 中是合法的,有时甚至不可避免),它可能会阻塞执行器。所以,在这种情况下,异步释放应该能够以某种方式向调度器让步,但目前这是不可能的。

        现在,这并不是作为异步库用户你可能会遇到的异步 Rust 的粗糙边缘,但了解这一点是值得的,因为目前,确保这不会引起问题的唯一方法是在类型中使用异步上下文时,小心地将内容放入释放实现中。

        因此,虽然这不是异步 Rust 中所有导致摩擦因素的详尽列表,但它是我认为最明显且值得了解的一些点。

        在我们结束这一章之前,让我们花一点时间谈谈在 Rust 中进行异步编程时,我们应该期待未来有什么。

        异步 Rust 的未来

        使异步 Rust 与其他语言不同的某些特性是不可避免的。异步 Rust 非常高效,延迟低,由于语言的设计和其核心价值,它背后有一个非常强大的类型系统。

        然而,今天感知到的许多复杂性更多与生态系统有关,以及大量程序员在没有正式结构的情况下必须就解决不同问题的最佳方式达成一致所导致的问题。生态系统在一段时间内变得碎片化,再加上异步编程对许多程序员来说是一个难以理解的话题,这最终增加了与异步 Rust 相关的认知负荷。

        我在本章中提到的所有问题和痛点都在不断改进。一些几年前可能出现在这个列表上的点现在甚至不值得提及。

        越来越多的常见特性和抽象将最终出现在标准库中,这使得异步 Rust 更加易用,因为使用它们的任何东西都将“直接工作”。

        随着不同的实验和设计比其他设计获得更多的关注,它们成为了事实上的标准,尽管在编写异步 Rust 时,你仍然有很多选择,但将会有一些路径可供选择,这些路径对那些想要“直接工作”的人来说摩擦最小。

        在对异步 Rust 和异步编程有足够的了解之后,我提到的这些问题毕竟相对较小,而且由于你对异步 Rust 的了解比大多数程序员都要多,我很难想象这些问题会给你带来很多麻烦。

        这并不意味着它不是一些值得了解的事情,因为你的同行程序员可能会在某个时刻遇到这些问题。

        摘要

        因此,在本章中,我们做了两件事。首先,我们对我们的运行时进行了一些相当小的修改,使其能够作为 Rust futures 的实际运行时。我们使用两个外部 HTTP 客户端库测试了运行时,以了解 Rust 中的 reactors、运行时和异步库。

        我们接下来讨论了一些使异步 Rust 对许多来自其他语言的程序员来说困难的事情。最后,我们也讨论了未来可以期待什么。

        根据你如何跟随学习以及你对所创建的示例进行了多少实验,如果你想要学习更多,你可以自己承担任何项目。

        学习的一个重要方面只在你自己进行实验时才会发生。拆解一切,看看什么会出错,以及如何修复它。改进我们创建的简单运行时,以学习新知识。

        有足够有趣的项目可以选择,但这里有一些建议:

            +   将我们使用`thread::park`实现的 parker 替换为合适的 parker。你可以从库中选择一个,或者自己创建一个 parker(我在`ch1``0`文件夹的末尾添加了一个名为`parker-bonus`的小奖励,其中包含一个简单的 parker 实现)。

            +   使用你自己创建的运行时实现一个简单的`delayserver`。为此,你必须能够编写一些原始的 HTTP 响应并创建一个简单的服务器。如果你阅读了名为《Rust 编程语言》的免费入门书籍,你就在最后一章中创建了一个简单的服务器([`doc.rust-lang.org/book/ch20-02-multithreaded.html`](https://doc.rust-lang.org/book/ch20-02-multithreaded.html)),这为你提供了所需的基础。你还需要创建一个计时器,就像我们上面讨论的那样,或者使用现有的异步计时器 crate。

            +   你可以创建一个“正确”的多线程运行时,探索拥有全局任务队列的可能性,或者作为替代方案,实现一个可以当其他执行器完成自己的任务后从它们本地队列中窃取任务的 work-stealing 调度器。

        只有你的想象力才能设定你能做到的界限。重要的是要注意,仅仅因为你可以并且只是为了乐趣而做某件事情,这本身就是一种乐趣,我希望你能从中获得和我一样的乐趣。

        我将以几句话结束本章,谈谈如何使异步程序员的日常生活尽可能轻松。

        第一件事是意识到异步运行时不仅仅是一个你使用的库。它非常侵入性,几乎影响你程序中的每一件事。它是一个层,它重写、调度任务,并重新排序你习惯的程序流程。

        如果你不是特别想学习运行时,或者有非常具体的需求,我的明确建议是选择一个运行时,并坚持使用一段时间。了解它的所有内容——不一定一开始就了解所有内容,但随着你需要越来越多的功能,你最终会了解所有内容。这几乎就像在 Rust 的标准库中熟悉所有内容一样。

        你开始使用哪个运行时取决于你使用最多的 crates。Smol 和`async-std`共享许多实现细节,并且行为相似。它们的主要卖点在于它们的 API 力求尽可能接近标准库。结合反应器隐式实例化的事实,这可以带来略微更直观的体验和更平缓的学习曲线。两者都是生产级运行时,并且得到了广泛的应用。Smol 最初创建的目的是为了拥有一个程序员易于理解和学习的代码库,我认为这一点在今天仍然适用。

        话虽如此,截至写作时,对于寻找通用运行时的用户来说,最受欢迎的替代方案是**Tokio**([`tokio.rs/`](https://tokio.rs/))。Tokio 是 Rust 中最古老的异步运行时之一。它正在积极开发中,拥有一个友好且活跃的社区。文档非常出色。作为最受欢迎的运行时之一,这也意味着你很可能找到一个库,它正好满足你的需求,并且默认支持 Tokio。就我个人而言,我倾向于选择 Tokio,原因如上所述,但除非你有非常具体的需求,否则你不会选择这两个运行时中的任何一个出错。

        最后,我们不要忘记提到`futures-rs`crate([`github.com/rust-lang/futures-rs`](https://github.com/rust-lang/futures-rs))。我之前提到过这个 crate,但了解它非常有用,因为它包含了一些特质、抽象和异步 Rust 的执行器([`docs.rs/futures/latest/futures/executor/index.html`](https://docs.rs/futures/latest/futures/executor/index.html))。它充当一个异步工具箱,在许多情况下都很有用。

        后记

        因此,你已经走到了尽头。首先,恭喜!你已经完成了一段相当漫长的旅程!

        我们首先在*第一章*中讨论了并发和并行。我们甚至简要地介绍了历史、CPU 和操作系统、硬件以及中断。在*第二章*中,我们讨论了编程语言如何模拟异步程序流程。我们介绍了协程以及堆栈式和堆栈无关协程的区别。我们还讨论了操作系统线程、纤程/绿色线程和回调及其优缺点。

        然后,在 *第三章*(B20892_03.xhtml#_idTextAnchor063)中,我们研究了基于操作系统的事件队列,如 `epoll`、`kqueue` 和 IOCP。我们甚至深入研究了系统调用和跨平台抽象。

        在 *第四章*(B20892_04.xhtml#_idTextAnchor081)中,当我们使用 epoll 实现自己的类似 mio 的事件队列时,遇到了一些相当困难的地形。我们甚至不得不学习边缘触发和电平触发事件之间的区别。

        如果 *第四章*(B20892_04.xhtml#_idTextAnchor081)有些艰难,那么 *第五章*(B20892_05.xhtml#_idTextAnchor092)就像是攀登珠穆朗玛峰。没有人期望你记住那里涵盖的所有内容,但你阅读了它,并有一个可以用来实验的工作示例。我们实现了自己的纤程/绿色线程,在这个过程中,我们了解了一些关于处理器架构、ISAs、ABIs 和调用约定。我们甚至在 Rust 中学习了内联汇编。如果你曾经对栈与堆的区别感到不安全,那么在你创建了我们让 CPU 跳转到的栈之后,你现在肯定理解了。

        在 *第六章* 中,我们在深入探讨 *第七章*(B20892_07.xhtml#_idTextAnchor122)及以后的内容之前,对异步 Rust 进行了高级介绍,其中包括创建我们自己的协程和 `coroutine/wait` 语法。在 *第八章*(B20892_08.xhtml#_idTextAnchor138)中,我们在讨论基本运行时设计的同时创建了我们自己的运行时版本。我们还深入研究了反应器、执行器和唤醒器。

        在 *第九章*(B20892_09.xhtml#_idTextAnchor156)中,我们改进了我们的运行时,并发现了 Rust 中自引用结构体的危险。然后我们彻底研究了 Rust 中的 pinning 以及它如何帮助我们解决遇到的问题。

        最后,在 *第十章*(B20892_10.xhtml#_idTextAnchor178)中,我们看到了通过进行一些相当小的改动,我们的运行时变成了一个完整的 Rust futures 运行时。我们通过讨论异步 Rust 的一些已知挑战和对未来的期望来结束一切。

        Rust 社区非常包容和欢迎,如果你对这个主题感兴趣并想了解更多,我们很乐意欢迎你参与和贡献。异步 Rust 变得更好的一个方式是通过不同经验水平的人的贡献。如果你想参与其中,那么异步工作组([`rust-lang.github.io/wg-async/welcome.html`](https://rust-lang.github.io/wg-async/welcome.html))是一个不错的起点。还有一个围绕 Tokio 项目([`github.com/tokio-rs/tokio/blob/master/CONTRIBUTING.md`](https://github.com/tokio-rs/tokio/blob/master/CONTRIBUTING.md))的非常活跃的社区,以及许多其他社区,具体取决于你想深入研究哪个特定领域。不要害怕加入不同的频道并提问。

        现在我们已经到了最后,我想感谢你一直读到最后一页。我希望这本书能感觉像是我们共同经历的一次旅行,而不是一场讲座。我希望你是焦点,而不是我。

        我希望我做到了这一点,并且我真诚地希望你学到了一些你认为有用并且可以继续前进的知识。如果你做到了,那么我真心地为我所做的对你有价值而感到高兴。我祝愿你在异步编程的道路上一切顺利。

        下次再会!

        卡尔·弗雷德里克


posted @ 2025-09-06 13:43  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报