Rust 中的异步编程——并发与异步编程:详细概述

409 阅读35分钟

异步编程是许多程序员感到困惑的主题之一。你可能觉得自己已经掌握了它,但之后又会发现其中的复杂性远超想象。如果你参与讨论、听够了相关演讲,并在网络上阅读相关内容,你很可能会遇到一些看似互相矛盾的说法。至少,这正是我刚接触这个主题时的感受。

这种困惑的原因通常是缺乏上下文,或是作者假定了特定的上下文却未明确说明,加之并发和异步编程相关术语定义不够明确。

在本章中,我们将涵盖大量内容,并将主题分为以下几个主要部分:

  • 异步编程的历史
  • 并发与并行
  • 操作系统与 CPU
  • 中断、固件和 I/O

本章的内容偏向通用性质,不特别聚焦于 Rust 或任何特定编程语言,而是我们需要了解的背景信息,以确保大家步调一致。这些知识无论在使用什么编程语言时都很有用。在我看来,这也让本章成为本书最有趣的章节之一。

本章代码不多,因此是个轻松的开始。可以泡杯茶,放松心情,舒适地坐下来,准备开始我们的这段旅程。

技术要求

本章中的所有示例都将用 Rust 编写,你有两种运行示例的选择:

  • 在 Rust Playground 上编写并运行我们将要编写的示例
  • 在你的机器上安装 Rust 并本地运行示例(推荐)

阅读本章的理想方式是克隆随书提供的代码库(github.com/PacktPublis…),打开 ch01 文件夹,并在阅读本书的同时保持其打开状态。在该文件夹中,你将找到本章编写的所有示例,甚至还有一些额外的信息,可能会对你有所帮助。当然,如果现在不方便获取,你也可以稍后再访问该代码库。

多任务处理的演变之旅

最初,计算机只有一个 CPU,由程序员编写的一组指令依次执行。没有操作系统(OS)、没有调度、没有线程、没有多任务处理。这就是计算机运行的方式,在相当长一段时间内都是如此。我们所说的那个时代,是程序通过一叠打孔卡片来组装的,如果不幸将卡片掉落在地上,你会面临很大的麻烦。

很早就开始了对操作系统的研究,而在20世纪80年代个人计算机开始普及时,像 DOS 这样的操作系统成为大多数消费级 PC 的标准。这些操作系统通常将整个 CPU 的控制权交给当前执行的程序,由程序员负责使程序正常运行并实现任何形式的多任务处理。这种方式在当时效果很好,但随着鼠标交互界面和窗口化操作系统的普及,这种模型已无法再继续适用了。

非抢占式多任务处理

非抢占式多任务处理是最早用于保持用户界面交互性(以及运行后台进程)的方法。这种多任务处理将让操作系统运行其他任务的责任,例如响应鼠标输入或运行后台任务,交给程序员。通常,程序员需要主动将控制权让给操作系统。这种方法不仅将巨大的责任分担给了每一个编写程序的平台程序员,而且天然易出错。程序代码中的小错误就可能导致整个系统停止或崩溃。

非抢占式多任务处理的另一个流行术语是协作式多任务处理。Windows 3.1 就采用了协作式多任务处理,并要求程序员通过特定的系统调用将控制权交给操作系统。一个不良表现的应用程序可能会导致整个系统停滞。

抢占式多任务处理

尽管非抢占式多任务处理听起来是个不错的主意,但它也带来了严重的问题。让每个程序和程序员负责操作系统的交互性最终会导致糟糕的用户体验,因为任何程序中的错误都可能导致整个系统停滞。解决方案是让操作系统负责在请求CPU资源的程序之间进行调度(包括操作系统本身)。操作系统可以停止一个进程的执行,去做其他事情,然后再切换回来。

在这种系统上,如果你在单核机器上编写并运行一个带有图形用户界面的程序,操作系统会在更新鼠标位置时暂停你的程序,然后再切换回去继续执行。这种切换发生得非常频繁,以至于我们通常不会注意到 CPU 是否在处理大量任务或空闲。操作系统负责调度任务,并通过在 CPU 上切换上下文来完成。这一过程每秒钟可以发生多次,不仅保持 UI 响应性,还为其他后台任务和 IO 事件提供一些时间。

这现在是设计操作系统的主流方式。

在本书的后面部分,我们将编写自己的绿色线程,涵盖上下文切换、线程、堆栈和调度的基本知识,这将帮助你更深入理解该主题,所以请继续关注。

超线程

随着CPU的发展,增加了更多功能,例如多个算术逻辑单元(ALU)和附加逻辑单元,CPU制造商意识到整个CPU并未被充分利用。例如,当某个操作只需要CPU的部分资源时,指令可以同时在ALU上运行。由此,超线程技术应运而生。

例如,你的计算机可能有6个内核和12个逻辑内核。这正是超线程发挥作用的地方。它通过使用CPU中未使用的部分,在一个核心上“模拟”出两个核心,在线程1上运行代码的同时,使用一些聪明的技巧(例如在ALU上)推动线程2的进展。现在,利用超线程,我们实际上可以在一个线程上分担一些工作,同时在第二个线程上响应事件,即使只有一个CPU核心,从而更好地利用硬件资源。

你可能会想知道超线程的性能

事实证明,自90年代以来,超线程一直在不断改进。由于并非实际运行在两个CPU上,某些操作需要等待对方完成。相比于单核心多任务处理,超线程的性能提升似乎接近30%,但这在很大程度上取决于工作负载。

多核处理器

众所周知,处理器的时钟频率已经停滞了很长时间。处理器的速度通过改进缓存、分支预测和推测执行以及改进处理器的流水线来提高,但这些改进的效果似乎在递减。另一方面,新处理器的体积如此小,以至于可以在同一芯片上容纳多个处理器。现在,大多数CPU都有多个核心,而且通常每个核心还具备执行超线程的能力。

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

这在很大程度上取决于你的视角。从进程和代码的角度来看,通常一切都是按照编写的顺序发生的。从操作系统的角度来看,它可能会中断你的代码,暂停它,然后在恢复进程之前运行其他代码。从CPU的角度来看,它大部分时间是逐条执行指令的。*CPU 不关心代码是谁写的,所以当硬件中断发生时,它会立即停止并将控制权交给中断处理程序。这就是CPU处理并发的方式。

*然而,现代CPU也能并行完成许多任务。大多数CPU是流水线的,这意味着当前指令执行时,下一条指令已经加载。它可能有一个分支预测器,尝试推测要加载的下一条指令。如果认为这样会更快,处理器也可以通过乱序执行重新排序指令,而无需“请求”或“告知”程序员或操作系统,因此无法保证A在B之前发生。

CPU 还会将部分工作卸载给如浮点运算单元(FPU)之类的“协处理器”,以便主CPU腾出资源去做其他任务等。

作为概述,将CPU视为以同步方式操作是可以的,但现在我们需要记住,这只是一个有一些注意事项的模型,这些注意事项在谈论并行性、同步原语(例如互斥锁和原子操作)以及计算机和操作系统的安全性时尤为重要。

并发与并行

首先,我们将通过定义并发的概念来深入了解这一主题。由于“并发”和“并行”容易混淆,我们将从一开始就试图对两者做出清晰的区分。

重要

  • 并发是指同时处理许多事情。
  • 并行是指同时执行许多事情。

我们将同时推进多个任务的概念称为多任务处理。实现多任务处理有两种方式:一种是以并发的方式推进任务,但不是在完全相同的时间进行;另一种是以并行的方式推进任务,即在确切相同的时间执行任务。图 1.1 显示了这两种情况的区别。

image.png

首先,我们需要达成一些定义共识:

  • 资源:这是我们推进任务所需的东西。资源是有限的,例如CPU时间或内存。
  • 任务:这是一组需要某种资源来推进的操作。任务必须包含多个子操作。
  • 并行:指独立地、在完全相同的时间发生的事情。
  • 并发:指任务在同一时间段内处于进行状态,但不一定是同时推进的。

这是一个重要的区分。如果两个任务是并发的,但不是并行的,它们必须能够暂停并恢复进程。如果任务允许这种类型的并发,我们称该任务为“可中断的”。

我的思维模型

我坚信我们很难区分并行和并发编程的主要原因在于我们日常生活中对事件的建模方式。我们往往会模糊地定义这些术语,因此我们的直觉常常是错误的。

词典将“并发”定义为在同一时间操作或发生的事件,这实际上并没有帮助我们理解它与“并行”的区别。

我开始理解为什么我们要区分并行和并发时,才第一次真正“开窍”了!

之所以要区分,完全是为了资源利用和效率问题。

效率是指在做某件事或生产期望结果时避免浪费材料、能源、努力、金钱和时间的(通常可衡量的)能力。 并行是指通过增加资源来解决任务,与效率无关。 并发则完全与效率和资源利用有关。并发无法让单个任务更快完成,但可以帮助我们更好地利用资源,从而更快完成一组任务。

让我们将并发与并行的概念与流程经济学做个对比

在生产商品的企业中,我们经常谈到精益流程(LEAN)。这与程序员关心并发处理任务能实现的效果类似。假设我们在经营一家酒吧。我们只供应吉尼斯啤酒,别无他物,并且以完美的方式提供。是的,我知道这有些小众,但请耐心听我讲下去。

你是这家酒吧的经理,你的目标是尽可能高效地运营。你可以把每个调酒师视为一个CPU核心,每个订单视为一个任务。为了管理好这家酒吧,你需要了解服务一杯完美的吉尼斯啤酒的步骤:

  1. 将吉尼斯啤酒倾斜45度倒入玻璃杯,至3/4满(15秒)。
  2. 让啤酒泡沫沉淀100秒。
  3. 将玻璃杯倒满(5秒)。
  4. 服务顾客。

由于酒吧只有一种饮品,顾客只需用手指表示所需数量,点单过程是即时的。为了简化起见,付款也是如此。为了选择最佳的运营方式,你有以下几种选择。

方案一 – 完全同步的任务执行,配备一位调酒师

你最初只有一个调酒师(CPU)。调酒师接一份订单,完成后再处理下一份订单。门口排起了长队,队伍甚至延伸了两个街区——一开始似乎不错!一个月后,你几乎破产了,并开始怀疑原因。

问题在于,尽管调酒师接单速度很快,他们每小时只能服务30位顾客。记住,他们在等待啤酒沉淀的100秒内几乎是闲置的,而真正倒酒的时间只需20秒。调酒师必须等到一个订单完全完成后,才能服务下一个顾客。

结果是收入低下、顾客不满、成本高昂。显然,这种方式行不通。

方案二 – 并行且同步的任务执行

于是,你雇佣了12位调酒师,预计每小时能服务约360位顾客。现在,排队的人几乎不再出现在门外,收入看起来相当不错。

一个月后,你再次面临破产的危机。这是怎么回事?

事实证明,雇佣12位调酒师的成本非常高。尽管收入很高,但成本更高。仅仅增加资源并没有真正提高酒吧的效率。

方案三 – 异步任务执行,配备一位调酒师

于是,我们回到原点。让我们深入思考,找到一种更聪明的工作方式,而不是单纯地增加资源。

你询问调酒师,是否可以在啤酒沉淀时开始接新订单,以免有顾客等待时调酒师闲置。开业之夜来临时……

哇!在一个忙碌的夜晚,调酒师不间断地工作几个小时后,你计算出他们现在每份订单只需稍多于20秒。几乎消除了所有等待时间。你的理论吞吐量现在是每小时240杯。如果再增加一位调酒师,你的吞吐量将超过之前的12位调酒师。

然而,你意识到实际并未达到每小时240杯的上限,因为订单并不是平均分布的。有时调酒师正忙着处理新订单,无法立即倒满并递出已完成的啤酒。现实中,吞吐量仅为每小时180杯。

尽管如此,两位调酒师通过这种方式可以每小时服务360杯啤酒,与雇佣12位调酒师的效果相同。

这种方式不错,但你想知道能否进一步提升效率。

方案四 – 并行且异步的任务执行,配备两位调酒师

假设你雇佣两位调酒师,并让他们按照方案三中的方式工作,但加一个变动:允许他们相互接手任务。比如,调酒师1可以开始倒酒并让啤酒沉淀,而调酒师2可以在调酒师1忙于接新订单时将啤酒倒满并服务顾客。这样一来,几乎不会出现两位调酒师同时忙碌且需要立即服务的啤酒无人接手的情况。几乎所有订单都在最短时间内完成和服务,顾客可以更快地拿到啤酒,给新顾客腾出空间。

通过这种方式,你的吞吐量进一步提升。尽管仍未达到理论最大值,但非常接近。开业之夜,你发现两位调酒师每小时可以各处理230份订单,总吞吐量达到了每小时460杯。

收入不错,顾客满意,成本最低,你成了这家全球效率最高的“奇特酒吧”的开心经理。

关键要点

并发是通过更聪明的工作方式提高效率;而并行则是通过增加资源来解决问题。

并发与 I/O 的关系

正如你从之前的内容中可能了解到的,编写异步代码主要在于聪明地利用资源来优化使用。当你编写一个努力解决问题的程序时,并发往往帮不上什么忙。这时并行派上用场,因为它提供了一种方法,可以将问题分解成可以并行处理的部分,进而投入更多资源。

考虑以下两种使用并发的情况:

  1. 在执行 I/O 操作时,你需要等待某个外部事件发生;
  2. 你需要分配注意力,防止某个任务等待过久。

第一个是典型的 I/O 示例:你需要等待网络请求、数据库查询或其他事件才能继续推进任务。然而,你有很多任务要做,因此不必等待,而是继续处理其他任务,要么定期检查任务是否可以继续,要么确保在任务可继续时收到通知。

第二个例子通常出现在涉及 UI 时。假设你只有一个核心,如何防止整个 UI 因执行其他 CPU 密集型任务而变得无响应?你可以每隔16毫秒暂停当前任务,执行一次 UI 更新任务,然后再恢复原任务。这样,你每秒需要停止/恢复任务60次,但同时可以让 UI 完全响应,并保持大约60Hz的刷新率。

关于操作系统提供的线程

我们在本书稍后讨论处理 I/O 的策略时会详细讲解线程,但这里也简单提一下。使用操作系统线程理解并发的一个挑战在于它们看似被映射到核心上。然而,这并不完全准确,尽管大多数操作系统会尝试将一个线程映射到一个核心,直到线程数等于核心数量为止。

一旦创建的线程数量超过核心数,操作系统会在线程之间切换,通过调度器让每个线程获得运行时间,以并发方式推进它们。你还需考虑到你的程序并不是系统上唯一在运行的程序。其他程序可能也会生成多个线程,这意味着线程总数会远远超过 CPU 上的核心数。

因此,线程既可以用于并行执行任务,也可以用于实现并发。

选择合适的参照框架

当你编写从自身角度看是完全同步的代码时,停下来想一下从操作系统的角度来看它是什么样的。操作系统可能不会从头到尾连续运行你的代码。它可能会多次暂停并恢复你的进程。CPU可能会在你认为只专注于你的任务时中断并处理一些输入。

所以,同步执行只是一种假象。但从程序员的视角来看并非如此,而这是关键要点:

当我们谈到并发而不提供其他上下文时,我们默认是以你作为程序员及你的代码(你的进程)作为参照框架。如果没有记住这一点,思考并发问题会很快变得混乱。

我花这么多时间讨论这个问题的原因是,一旦你意识到拥有相同定义和相同参照框架的重要性,你会发现一些你听到或学到的、可能看似矛盾的东西其实并不矛盾。你只需首先考虑参照框架。

异步与并发

你可能会想,既然本书是关于异步编程的,为什么要花这么多时间讨论多任务处理、并发和并行?主要原因是这些概念彼此紧密相关,并且根据使用的上下文,它们甚至可能具有相同或重叠的含义。

为了让这些定义尽可能清晰,我们将比通常见到的更加严格地定义这些术语。但需要注意的是,这样的定义可能无法满足所有人,我们这样做只是为了让这个主题更易于理解。不过,如果你喜欢参与网络辩论,这是一个不错的开始。只要声称别人的并发定义100%是错误的,或者你的定义100%是正确的,争论就可以开始了。

在本书中,我们将坚持以下定义:异步编程是编程语言或库对并发操作的一种抽象方式,也是我们作为语言或库用户利用该抽象来并发执行任务的方法。

操作系统已经有一个覆盖此类任务的抽象,称为线程。使用操作系统线程来处理异步通常被称为多线程编程。为避免混淆,我们不会将直接使用操作系统线程称为异步编程,尽管它也能解决相同的问题。

由于异步编程现在被限定为语言或库对并发或并行操作的抽象,因此我们可以更容易地理解,即使在没有操作系统的嵌入式系统上,它也和在高级操作系统的复杂系统中同样适用。该定义本身不暗示任何特定的实现,尽管我们将在本书中研究一些流行的实现方式。

如果这些概念依然让你觉得复杂,我理解。思考并发确实是件难事,但如果我们在编写异步代码时试着将这些想法牢记在心,我保证它会变得越来越清晰。

操作系统的角色

操作系统 (OS) 是编程中的核心(除非你在编写操作系统或从事嵌入式开发)。因此,我们在讨论任何编程基础知识时都需要涉及操作系统的相关内容。

从操作系统的角度看并发

这与之前谈到的并发需要在某个参照框架内讨论的观点有关。我解释过,操作系统可以随时暂停和启动你的进程。我们所谓的同步代码大多数情况下只是对程序员而言的同步。无论是操作系统还是CPU,都不处于完全同步的世界中。

操作系统使用抢占式多任务处理,只要你运行的操作系统是抢占式调度的,就不能保证你的代码会连续地、逐条地不被打断地运行。操作系统会确保所有重要进程都能从CPU中获得一些时间来推进进程。

在拥有4、6、8或12个物理核心的现代机器上,事情变得不那么简单,因为如果系统负载很低,你的代码确实可能在一个CPU上不被中断地运行。这里重要的是,你无法确定这一点,也不能保证代码会不被打断地运行。

与操作系统协作

当你发起一个网络请求时,你并不是直接请求CPU或网络卡为你工作,而是请求操作系统与网络卡进行通信。程序员无法在不利用操作系统优势的情况下让系统达到最佳效率。实际上,你无法直接访问硬件。操作系统本质上是硬件之上的抽象层。

然而,这也意味着要从底层理解一切,你需要了解操作系统如何处理这些任务。为了能够与操作系统协作,你需要知道如何与之通信,这就是我们接下来要讲的内容。

与操作系统通信

与操作系统的通信通过我们称为系统调用(syscall)的机制实现。我们需要知道如何发起系统调用,并了解它对我们在与操作系统协作和通信时的重要性。此外,我们还需要理解我们每天使用的基本抽象是如何在幕后利用系统调用的。第3章会详细介绍系统调用,在此我们先简单概述。

系统调用使用操作系统提供的公共API,使得我们在“用户态”中编写的程序可以与OS进行通信。大多数情况下,这些调用对程序员来说被语言或运行时抽象掉了。

系统调用的一个特点是,它是与操作系统内核通信的独特方式。UNIX 系列内核有许多相似之处,UNIX 系统通过 libc 提供这一功能。Windows 则使用自己的 API,通常称为 WinAPI,其运行方式可能与 UNIX 系统有很大不同。

大多数情况下,这些系统在功能上可以实现相同的效果。尽管在表面上功能上差异不大,但在 epoll、kqueue 和 IOCP 等机制的具体实现上,差异可能非常显著。

不过,系统调用并不是我们与操作系统交互的唯一方式,接下来我们会进一步探讨。

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

现在,如果保留 dereference 函数,但将 main 函数替换为一个创建指向地址 99999999999999 的指针(我们知道这是一个无效地址),我们得到以下函数:

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等操作系统实际使用的范围。

这里我想特别提到两个方面:

  1. CPU如何防止我们访问不应该访问的内存。
  2. CPU如何处理异步事件(例如I/O)。

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

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

如前所述,现代CPU架构在设计上定义了一些基本概念,例如:

  • 虚拟内存
  • 页表
  • 页故障
  • 异常
  • 特权级别

这些概念的具体实现取决于特定的CPU,因此我们这里进行泛化的讨论。

大多数现代CPU都配有一个内存管理单元(MMU),通常集成在同一个芯片上。MMU的任务是将我们程序中使用的虚拟地址转换为物理地址。

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

现在,当我们在之前的代码中尝试解引用t_ptr时,地址会被发送到MMU进行翻译,MMU会在页表中查找该地址并将其转换为内存中的物理地址,从而获取数据。在第一个例子中,它会指向堆栈中的一个内存地址,该地址保存了值100。

当我们传入99999999999999并请求获取该地址中存储的内容时(这是解引用的作用),MMU在页表中查找不到该地址的翻译。CPU于是将此视为页故障。

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

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

然后,操作系统向我们显示一条信息,告知我们遇到了所谓的“段错误”(segmentation fault)。因此,这条信息会根据运行代码的操作系统不同而有所不同。

但是我们不能直接更改CPU中的页表吗?

这就引入了特权级别的概念。大多数现代操作系统有两个环级:环0(内核空间)和环3(用户空间)。

image.png

大多数CPU的环级概念超出了现代操作系统实际使用的范围。这有历史原因,因此使用了环0和环3(而不是1和2)。

页表中的每个条目都包含额外的信息,其中包括它所属的环级信息。这些信息在操作系统启动时设置好。

在环0中执行的代码几乎可以不受限制地访问外部设备和内存,也可以自由更改硬件层面的安全寄存器。而在环3中执行的代码对I/O和某些CPU寄存器(以及指令)的访问受到极大限制。尝试在环3中发出指令或设置寄存器以更改页表时,CPU会阻止此操作。CPU将其视为异常,并跳转到操作系统提供的异常处理程序。

这也是你必须通过系统调用与操作系统合作处理I/O任务的原因。如果不这样做,系统将不够安全。

总结一下:是的,CPU和操作系统之间有着紧密的合作。大多数现代桌面CPU在设计时考虑到了操作系统的需求,因此提供了操作系统在启动时所需的挂钩和基础设施。当操作系统启动一个进程时,它还会设置该进程的特权级别,以确保普通进程在操作系统定义的边界内运行,从而维持系统的稳定性和安全性。

中断、固件和I/O

本书中的计算机科学基础部分即将结束,很快我们将逐步走出“兔子洞”。本部分试图将各个知识点串联起来,看看整台计算机如何作为一个系统来处理I/O和并发。

让我们开始吧!

简化概览

让我们来看一下从网络卡读取数据时的一些关键步骤:

image.png

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

步骤 1 – 我们的代码

我们注册一个 socket。这通过向操作系统发出系统调用来完成。根据操作系统的不同,我们会获得一个文件描述符(macOS/Linux)或一个 socket(Windows)。接下来,我们注册对该 socket 的读取事件的兴趣。

步骤 2 – 向操作系统注册事件

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

  1. 我们告诉操作系统我们对读取事件感兴趣,但我们希望通过将线程的控制权让给操作系统来等待事件发生。操作系统会通过存储寄存器状态来挂起我们的线程,并切换到其他线程。这对我们来说,会阻塞线程直到可以读取数据。
  2. 我们告诉操作系统我们对读取事件感兴趣,但只希望获得一个可以轮询的任务句柄,以检查事件是否准备好。操作系统不会挂起线程,因此不会阻塞代码。
  3. 我们告诉操作系统可能会对多个事件感兴趣,但我们希望订阅一个事件队列。我们轮询该队列时,它将阻塞线程直到一个或多个事件发生。这将阻塞线程等待事件发生。

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

步骤 3 – 网络卡

这里我们省略了一些步骤,但这并不影响理解。在网络卡上运行一个小型微控制器,其上运行特定的固件。可以想象,这个微控制器在忙循环中轮询,检查是否有数据进入。网络卡的内部处理方式可能因供应商而异,关键是网络卡上的一个简单但专用的CPU负责检查是否有输入事件。

一旦固件检测到有数据进入,它会发出一个硬件中断。

步骤 4 – 硬件中断

现代CPU有一组中断请求线(IRQ)来处理来自外部设备的事件。CPU有固定的一组中断线。硬件中断是一种可以随时发生的电信号,CPU会立即中断其正常流程来处理中断,保存寄存器状态并查找中断处理程序。中断处理程序在中断描述符表(IDT)中定义。

步骤 5 – 中断处理程序

IDT是一个表,操作系统(或驱动程序)在其中注册不同中断的处理程序。每个条目指向特定中断的处理函数。网络卡的中断处理函数通常由网络卡的驱动程序注册并处理。


IDT 并不像图 1.3 所示那样存储在CPU中,而是在主内存的一个固定位置。CPU 只是在其寄存器之一中持有一个指向该表的指针。

步骤 6 – 写入数据

此步骤因CPU和网络卡固件而异。如果网络卡和CPU支持直接内存访问(DMA),这在现代系统中应是标准,网络卡会直接将数据写入操作系统已在主内存中设置的缓冲区。在这样的系统中,网络卡的固件可能在数据写入内存后发出中断。DMA效率很高,因为CPU只在数据已写入内存后才被通知。在旧系统中,CPU需要分配资源来处理从网络卡的数据传输。

图中增加了直接内存访问控制器(DMAC),在这种系统中它负责控制内存访问。它并非CPU的一部分。我们已经深入了解了系统各部分的功能,而准确位置对我们来说并不重要,所以继续往下看。

步骤 7 – 驱动程序

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

步骤 8 – 读取数据

根据我们选择的第一、第二或第三种方法,操作系统将执行以下操作:

  • 唤醒线程
  • 在下次轮询时返回就绪状态
  • 唤醒线程并返回我们注册的处理程序的读取事件

中断

如你所知,中断分为两种类型:

  1. 硬件中断
  2. 软件中断

它们的本质截然不同。

硬件中断

硬件中断通过IRQ(中断请求)发送电信号产生。这些硬件线路直接向CPU发出信号。

软件中断

软件中断是由软件而非硬件触发的中断。与硬件中断一样,CPU跳转到中断描述符表(IDT)并运行指定中断的处理程序。

固件

固件通常不会引起太多关注,但它是我们所处世界的一个关键组成部分。固件运行在各种硬件上,采用各种独特的方式使计算机正常工作。

固件需要微控制器来工作。甚至CPU本身也有固件来支撑其运行。这意味着系统中实际上存在许多比我们可编程核心更多的小型“CPU”。

这为什么重要呢?你还记得并发的核心是效率,对吧?由于我们的系统上已经有许多CPU/微控制器在为我们工作,我们在编写代码时应避免重复这些工作。

例如,如果网络卡的固件持续检查是否有新数据到达,那么如果我们让CPU不断地重复这种检查,这将是很浪费的。更好的方式是偶尔检查一次,或者更理想的是,当数据到达时收到通知。

总结

本章内容覆盖面广,辛苦你完成了这些基础知识的学习。我们简单了解了CPU和操作系统的历史演变,讨论了非抢占式和抢占式多任务处理的区别。我们分析了并发与并行的区别,讨论了操作系统的角色,并了解到系统调用是与主机操作系统交互的主要方式。此外,我们还了解了CPU和操作系统如何通过CPU设计的一套基础设施进行合作。

最后,我们通过一个流程图讲解了发起网络调用时发生的过程。你已经了解了至少三种不同的方式来应对I/O调用的执行时间延迟,并且需要选择一种方法来处理等待时间。

这涵盖了接下来内容所需的大部分通用背景信息,以确保我们在继续深入之前拥有一致的定义和概览。接下来,我们将逐步深入。本书下一章将首先介绍编程语言如何通过线程、协程和future模型异步程序流。