异步 Rust——异步编程简介

559 阅读32分钟

多年来,软件工程师一直受益于硬件性能的持续提升。当需要为缓慢的算法、仓促的开发方法或运行缓慢的编程语言辩护时,“只需增加更多的计算能力”或“写入时间比读取时间更昂贵”这样的短语已经成为常见的口头禅。然而,截至本文撰写时,多家微处理器制造商报告称,自2010年以来,半导体技术的进步速度已放缓,这也促使NVIDIA首席执行官黄仁勋在2022年提出了颇具争议的观点——“摩尔定律已死”。随着对软件需求的增加,以及像微服务这样的系统中I/O网络调用数量的增多,我们必须更加高效地利用资源。

这就是异步编程的用武之地。通过异步编程,我们无需再为CPU增加一个核心来提高性能。相反,在异步编程中,我们可以在单个线程上高效地处理多个任务,特别是在这些任务中存在一些空闲时间,例如等待服务器的响应。

事实上,我们的日常生活就是以一种异步方式进行的。例如,当我们把衣物放进洗衣机时,我们并不会静坐无所事事地等待洗衣完成,而是会去做其他事情。如果我们希望我们的计算机和程序能够高效运作,就需要接受异步编程的理念。

然而,在正式开始深入探讨异步编程之前,我们需要了解这一主题在计算机整体架构中的位置。本章将概述线程和进程的工作原理,并展示异步编程在I/O操作中的高效性。

阅读本章后,您应该能够从宏观层面理解什么是异步编程,而无需掌握异步程序的复杂细节。您还会了解一些关于线程和Rust的基本概念,因为异步运行时在执行异步任务时会使用线程。这些概念在异步编程中经常出现。阅读本章后,您应该能够准备好探索下一章中有关异步编程的具体示例及其工作原理。如果您已经熟悉进程、线程以及它们之间的数据共享,可以跳过本章。在第二章中,我们将讨论异步编程的特定概念,例如 future、任务,以及异步运行时如何执行任务。

什么是异步?

当我们使用电脑时,希望它能同时执行多个任务,否则体验会非常糟糕。然而,想想电脑在同一时间完成的所有任务。以我们写这本书时为例,我们查看了一下拥有8核的Apple M1 MacBook的活动监视器。这台笔记本电脑一度运行了3118个线程和453个进程,但仅使用了7%的CPU。

为什么会有这么多的进程和线程?原因在于同时运行的多个应用程序、打开的浏览器标签页以及其他后台进程。那么,这台笔记本是如何让所有这些线程和进程同时运行的呢?事实是,电脑并没有真正同时运行3118个线程和453个进程,而是需要对资源进行调度。

为了说明资源调度的必要性,我们可以运行一些计算密集型的代码,看看活动监视器的变化。以下是一个用递归计算斐波那契数的例子:

fn fibonacci(n: u64) -> u64 {
    if n == 0 || n == 1 {
        return n;
    }
    fibonacci(n - 1) + fibonacci(n - 2)
}

然后,我们可以启动8个线程并用以下代码计算第4000个斐波那契数:

use std::thread;

fn main() {
    let mut threads = Vec::new();

    for i in 0..8 {
        let handle = thread::spawn(move || {
            let result = fibonacci(4000);
            println!("Thread {} result: {}", i, result);
        });
        threads.push(handle);
    }
    for handle in threads {
        handle.join().unwrap();
    }
}

运行这段代码时,我们的CPU使用率会飙升至99.95%,但进程和线程数量并没有显著变化。从中可以推断出,大多数这些进程和线程并不是一直在使用CPU资源。

现代CPU设计非常复杂。我们需要知道的是,当创建一个线程或进程时,会为其分配一部分CPU时间。创建的线程或进程中的任务被安排在某个CPU核心上运行,直到CPU中断或线程自愿让出资源。一旦发生中断,CPU会保存该线程或进程的状态,然后切换到另一个线程或进程。

了解了CPU如何与进程和线程交互后,让我们看看基本的异步代码是如何运行的。本章的重点不是理解每一行代码的具体作用,而是体会异步代码如何利用CPU资源。首先,我们需要以下依赖项:

[dependencies]
reqwest = "0.11.14"
tokio = { version = "1.26.0", features = ["full"] }

Rust的Tokio库为我们提供了异步运行时的高级抽象,而reqwest则支持我们进行异步HTTP请求。HTTP请求是异步编程的一个简单而现实的例子,因为在向服务器发出请求时,由于网络延迟,CPU无需执行任何操作。以下代码使用Tokio作为异步运行时来计时完成一个简单HTTP请求所需的时间:

use std::time::Instant;
use reqwest::Error;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let url = "https://jsonplaceholder.typicode.com/posts/1";
    let start_time = Instant::now();

    let _ = reqwest::get(url).await?;

    let elapsed_time = start_time.elapsed();
    println!("Request took {} ms", elapsed_time.as_millis());

    Ok(())
}

根据运行时情况的不同,请求时间可能有所不同,但在本文撰写时,大约需要140毫秒完成一次请求。我们可以通过简单地复制粘贴请求代码来增加请求数量,例如:

let first = reqwest::get(url);
let second = reqwest::get(url);
let third = reqwest::get(url);
let fourth = reqwest::get(url);

let first = first.await?;
let second = second.await?;
let third = third.await?;
let fourth = fourth.await?;

再次运行程序时,耗时变为656毫秒。这是可以理解的,因为请求数量增加了四倍。如果耗时少于140×4,这就不合理,因为增加请求数量与总耗时的增加应该成正比。

请注意,尽管我们使用了异步语法,但实际编写的仍然是同步代码。这意味着每次请求都在前一个请求完成后才执行。为了让代码真正实现异步执行,我们可以将任务组合起来,并让它们同时运行,如下所示:

let (_, _, _, _) = tokio::join!(
    reqwest::get(url),
    reqwest::get(url),
    reqwest::get(url),
    reqwest::get(url),
);

在这里,我们使用了Tokio提供的宏tokio::join!。这个宏允许多个任务并发运行,与前面的示例不同,此方法使请求可以同时进行。运行这段代码时,耗时为137毫秒。这意味着在不增加线程数量的情况下,我们的程序速度提升了4.7倍!这正是异步编程的精髓。通过异步编程,我们可以通过不阻塞CPU来释放CPU资源(例如等待网络响应时)。详见图1-1。

为了更好地理解异步编程的背景,我们需要简要探索进程和线程的工作原理。尽管在异步编程中不会直接使用进程,但了解进程的工作方式及其如何相互通信对理解线程和异步编程是有帮助的。

image.png

进程简介

Rust中的标准异步编程并不使用多进程;然而,我们可以通过多进程实现异步行为。为了实现这一点,我们的异步系统必须运行在一个进程中。

让我们以数据库PostgreSQL为例。它为每个连接生成一个进程,这些进程是单线程的。如果你曾经研究过Rust的Web框架,可能注意到定义Rust Web服务器端点的函数通常是异步函数。这意味着,对于Rust服务器而言,不会为每个连接生成一个独立的进程。相反,Rust Web服务器通常使用线程池,而传入的HTTP请求作为异步任务运行在这个线程池上。关于异步任务如何与线程池交互,我们将在第3章中详细讨论。目前,让我们集中注意力于进程在异步编程中的作用。

进程是操作系统提供的一种由CPU执行的抽象。进程可以由程序或应用程序运行。程序的指令被加载到内存中,CPU按顺序执行这些指令以完成一个或一组任务。进程类似于处理外部输入(例如来自用户的键盘输入或其他进程的数据)的线程,并且可以生成输出,如图1-2所示。

image.png

进程与线程的主要区别在于,每个进程都有自己独立的内存空间。这是CPU管理的重要组成部分,因为这种隔离防止了数据损坏或内存跨进程泄漏。

进程有一个独立的标识符,称为进程ID(PID) ,计算机的操作系统可以通过PID监控和控制进程。许多程序员使用命令kill PID来终止卡住或出错的程序,但可能并不完全理解PID的含义。实际上,PID是操作系统为进程分配的唯一标识符,它允许操作系统跟踪与该进程相关的所有资源,例如内存使用和CPU时间。

以PostgreSQL为例。尽管其为每个连接生成一个进程的设计部分受历史原因影响,但这一方法仍有其优点。例如,每个连接生成一个进程可以实现真正的故障隔离和内存保护。这意味着每个连接完全不会访问或损坏其他连接的内存。同时,这种方式没有共享状态,因而是一种更简单的并发模型。

然而,共享状态可能带来复杂性。例如,如果两个表示独立连接的异步任务都依赖于共享内存中的数据,就必须引入诸如锁之类的同步原语。这些同步原语可能引发如死锁的复杂问题,进而导致所有依赖该锁的连接停滞。死锁问题难以调试,我们将在第11章讨论如何测试和解决这些问题。虽然进程的简单并发模型降低了同步问题的风险,但并未完全消除。例如,即使状态隔离,获取文件锁等外部锁定操作仍可能导致问题。

此外,进程的状态隔离还可以防止内存问题。例如,在像C或C++这样的语言中,代码可能无法正确释放内存,从而导致内存泄漏,直到计算机内存耗尽。(我们将在第6章讨论内存泄漏。)如果某些连接运行的函数存在内存泄漏,整个程序的内存可能会耗尽。然而,运行独立进程时可以设置内存限制,从而隔离内存使用最严重的部分。

为了展示进程在Rust异步编程中的作用,我们可以通过以下项目结构,模拟执行四个HTTP请求的异步行为:

├── connection
│   ├── Cargo.toml
│   ├── connection
│   └── src
│       └── main.rs
├── scripts
│   ├── prep.sh
│   └── run.sh
└── server
    ├── Cargo.toml
    ├── server
    └── src
        └── main.rs

在这个结构中,我们将服务器包命名为server_bin,连接包命名为connection_bin。连接包的依赖与前一部分的异步示例相同。在connectionmain.rs文件中,执行HTTP请求并打印结果,代码如下:

use reqwest::Error;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let url = "https://jsonplaceholder.typicode.com/posts/1";
    let response = reqwest::get(url).await?;

    if response.status().is_success() {
        let body = response.text().await?;
        println!("{}", body);
    } else {
        println!(
            "Failed to get a valid response. Status: {}",
            response.status()
        );
    }
    Ok(())
}

在服务器中,每次运行二进制文件时创建一个进程,代码如下:

use std::process::{Command, Output};

fn main() {
    let output: Output = Command::new("./connection_bin")
        .output()
        .expect("Failed to execute command");
    if output.status.success() {
        let stdout = String::from_utf8_lossy(&output.stdout);
        println!("Output: {}", stdout);
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        eprintln!("Error: {}", stderr);
    }
}

这里,命令运行了连接的二进制文件,同时处理了进程的输出。如果进程以状态码0退出,则表示无错误;状态码1则表示出现错误。无论结果如何,都会序列化输出并打印。

scripts/prep.sh中,编译二进制文件并移动到根目录:

#!/usr/bin/env bash

SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..
cd connection && cargo build --release && cd ..
cd server && cargo build --release && cd ..
cp connection/target/release/connection_bin ./
cp server/target/release/server_bin ./

scripts/run.sh中,运行四个连接并获取每个进程的PID:

#!/usr/bin/env bash

SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..

./server_bin &
pid1=$!
./server_bin &
pid2=$!
./server_bin &
pid3=$!
./server_bin &
pid4=$!

wait $pid1
exit_code1=$?
wait $pid2
exit_code2=$?
wait $pid3
exit_code3=$?
wait $pid4
exit_code4=$?

echo "Task 1 (PID $pid1) exited with code $exit_code1"
echo "Task 2 (PID $pid2) exited with code $exit_code2"
echo "Task 3 (PID $pid3) exited with code $exit_code3"
echo "Task 4 (PID $pid4) exited with code $exit_code4"

通过运行time sh scripts/run.sh命令,我们发现运行时间为123毫秒,比异步示例略快。这表明,通过等待进程也可以实现异步行为。然而,这并不意味着进程优于标准异步编程。进程更消耗资源,而标准异步程序只需一个进程和多个线程。这可能导致CPU在调度共享内存的异步任务时出现竞争问题,但进程因其隔离性而更易于CPU调度。

在开始为每件事生成进程之前,我们需要考虑一些缺点。操作系统对线程的生成数量可能存在限制,而生成进程的开销更大,且扩展性较差。一旦我们用满了所有CPU核心,新生成的进程将会开始被阻塞。此外,还需要关注系统中多个组件的复杂性;进程间通信可能成本较高,因为需要进行数据序列化。

有趣的是,即使在我们的进程中,也运行了异步代码。这很好地展示了进程的角色。例如,在PostgreSQL中,一个连接就是一个进程,但该进程中包含运行命令和访问数据存储的异步代码。

为了减少复杂的组件数量,我们可以使用Tokio的异步任务来指向我们启动的进程:

let mut handles = vec![];
for _ in 0..4 {
    let handle = tokio::spawn(async {
        let output = Command::new("./connection_bin")
            .output()
            .await;
        match output {
            Ok(output) => {
                println!(
                    "Process completed with output: {}",
                    String::from_utf8_lossy(&output.stdout)
                );
                Ok(output.status.code().unwrap_or(-1))
            }
            Err(e) => {
                eprintln!("Failed to run process: {}", e);
                Err(e)
            }
        }
    });
    handles.push(handle);
}

然后,我们处理进程的结果:

let mut results = Vec::with_capacity(handles.len());
for handle in handles {
    results.push(handle.await.unwrap());
}
for (i, result) in results.into_iter().enumerate() {
    match result {
        Ok(exit_code) => println!(
            "Process {} exited with code {}",
            i + 1, exit_code
        ),
        Err(e) => eprintln!(
            "Process {} failed: {}",
            i + 1, e
        ),
    }
}

尽管这种方法减少了组件的复杂性,但我们仍然面临进程扩展性问题以及在进程与主程序之间序列化数据的开销问题。由于Rust具有内存安全性,并且可以将错误作为值处理,因此进程提供的隔离优势显得不那么重要。例如,除非我们显式编写不安全(unsafe)的Rust代码,否则在线程中不会出现内存泄漏。

回顾一下我们在异步编程中的目标:我们希望能够启动轻量化的、非阻塞的任务,并等待它们完成。在许多情况下,我们还需要从这些任务中获取数据并加以利用。此外,我们希望能够将任务和数据一起发送到异步运行时。相比之下,线程由于数据共享的便利性,在异步编程中比进程更具优势。

接下来,我们将讨论线程的相关内容。

什么是线程?

线程是CPU可以执行的最小编程指令序列。线程可以由调度器独立管理。在一个进程中,我们可以在多个线程之间共享内存(见图1-3)。

image.png

虽然线程和异步任务都由调度器管理,但它们是不同的。线程可以在不同的 CPU 核心上同时运行,而异步任务通常会等待轮到它们使用 CPU。我们将在第2章中更详细地讲解异步任务。

为了创建线程,我们可以回顾我们的斐波那契数递归函数,并将斐波那契数的计算分布到四个线程上。首先,我们需要导入以下内容:

use std::time::Instant;
use std::thread;

然后,我们可以在主函数中计时计算第50个斐波那契数的时间:

let start = Instant::now();
let _ = fibonacci(50);
let duration = start.elapsed();
println!("fibonacci(50) in {:?}", duration);

接下来,我们可以重置计时器,计算在四个线程上计算四个第50个斐波那契数所花费的时间。我们通过迭代四次来生成四个线程,并将每个 JoinHandle 附加到一个向量中,从而实现多线程:

let start = Instant::now();
let mut handles = vec![];
for _ in 0..4 {
    let handle = thread::spawn(|| {
        fibonacci(50)
    });
    handles.push(handle);
}

JoinHandle 允许你等待一个线程完成,程序会暂停直到该线程完成。加入线程意味着阻塞程序,直到线程结束。JoinHandle 实现了 SendSync 特性,这意味着它可以在线程之间传递。然而,JoinHandle 并没有实现 Clone 特性。这是因为每个线程需要一个唯一的 JoinHandle。如果一个线程有多个 JoinHandle,就可能导致多个线程同时尝试加入该线程,从而引发数据竞争。

注意
如果你使用过其他编程语言,可能遇到过绿色线程。绿色线程是由操作系统以外的东西(例如运行时或虚拟机)调度的。Rust 在版本 1 之前实现了绿色线程,但后来将其移除。移除绿色线程并转向本地线程的主要原因是,在 Rust 中,线程和 I/O 操作是耦合的,这迫使本地线程和绿色线程必须具有相同的 API。这导致了使用 I/O 操作和分配指定时的一些问题。有关绿色线程的更多信息,请参阅 Rust 文档。需要注意的是,虽然 Rust 本身不实现绿色线程,但像 Tokio 这样的运行时实现了绿色线程。

现在我们有了 JoinHandle 向量,我们可以等待它们执行,然后打印所花费的时间:

for handle in handles {
    let _ = handle.join();
}
let duration = start.elapsed();
println!("4 threads fibonacci(50) took {:?}", duration);

运行程序会输出以下内容:

fibonacci(50) in 39.665599542s
4 threads fibonacci(50) took 42.601305333s

我们可以看到,在 Rust 中使用线程时,多个 CPU 密集型任务可以同时处理。因此,我们可以推断出,多个线程也可以并发地处理等待的任务。即使我们没有使用斐波那契计算的结果,我们仍然可以在主程序中使用线程的结果。如果我们在这个示例中调用 JoinHandlejoin,我们会返回一个 Result<u64, Box<dyn Any + Send>>u64 是从线程计算得到的斐波那契数的结果。Box<dyn Any + Send>> 是一个动态特性对象,提供了灵活处理各种类型错误的能力。这些错误类型需要传递,但线程可能会出现多种原因的错误。然而,这种方法也有一些开销,因为我们需要动态的类型转换和装箱,因为在编译时我们不知道大小。

线程还可以直接通过内存在程序中相互作用。本章的最后一个示例使用了通道,但目前我们可以使用 ArcMutexCondvar 来创建如图 1-4 所示的系统。

image.png

在这里,我们将使用两个线程。一个线程将更新 Condvar,另一个线程将监听 Condvar 的更新,并在更新发生时打印出文件已更新。然而,在编写代码之前,我们需要定义以下结构体:

Arc
Arc 代表原子引用计数,意味着 Arc 会计算引用包裹在 Arc 中的变量的次数。因此,如果我们定义一个 Arc<i32>,然后在四个线程中引用它,引用计数会增加到四。只有当四个线程都完成引用后,Arc<i32> 才会被丢弃,引用计数变为零。

Mutex
记住,Rust 只允许我们在任何给定时刻对一个变量拥有一个可变引用。Mutex(互斥锁)是一种智能指针类型,通过将值存放在 Mutex 内部提供内部可变性。这意味着我们可以通过多个线程提供对单个变量的可变访问。这是通过线程获取锁来实现的。当我们获取锁时,我们获得对 Mutex 内部值的唯一可变引用。我们执行事务后,释放锁,以便其他线程可以执行事务。锁确保每次只有一个线程可以访问可变引用,确保 Rust 的“每次只有一个可变引用”的规则不被违反。获取锁需要一定的开销,因为我们可能需要等待直到锁被释放。

Condvar
Condvar 是条件变量的缩写,它允许我们的线程休眠并在通过 Condvar 发送通知时被唤醒。我们不能通过 Condvar 发送变量,但多个线程可以订阅一个 Condvar

现在我们已经介绍了我们将使用的内容,可以通过首先导入以下内容来构建我们的系统:

use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::Relaxed;

在我们的主函数中,我们可以定义将要在两个线程之间共享的数据:

let shared_data = Arc::new((Mutex::new(false), Condvar::new()));
let shared_data_clone = Arc::clone(&shared_data);
let STOP = Arc::new(AtomicBool::new(false));
let STOP_CLONE = Arc::clone(&STOP);

这里我们有一个元组,包装在 Arc 中。我们将要更新的布尔变量被包装在 Mutex 中。然后我们克隆我们的数据包,这样两个线程都可以访问共享数据。shared_data_clone 线程传递给 background_thread,而原始的 shared_data 保留在主线程中,稍后在 updater_thread 中使用它。如果没有克隆,shared_data 的所有权将会传递给第一个接收到它的线程,主线程将失去对它的访问。

现在我们的数据已经可用,我们可以定义第一个线程:

let background_thread = thread::spawn(move || {
    let (lock, cvar) = &*shared_data_clone;
    let mut received_value = lock.lock().unwrap();
    while !STOP.load(Relaxed) {
        received_value = cvar.wait(received_value).unwrap();
        println!("Received value: {}", *received_value);
    }
});

在这里,我们可以看到我们在等待 Condvar 的通知。在等待时,线程被称为“停车”。这意味着线程被阻塞,不执行。收到来自 Condvar 的通知后,线程在被唤醒时访问 Mutex 内的变量。然后我们打印出该变量,线程再次进入休眠状态。我们依赖于 AtomicBoolfalse 来使循环无限继续。这使得我们可以在需要时停止线程。

注意
我们在代码中大量使用了 unwrap。这样做使代码简洁,可以让我们专注于异步编程的主要概念,但请记住,生产代码应该有错误处理。唯一可能接受使用 unwrap() 的地方是锁定 Mutex,因为它触发 panic 的唯一原因是如果一个线程在持有锁时发生了 panic,这种情况叫做锁中毒(lock poisoning)。

在下一个线程中,我们只执行四次迭代,然后完成线程的工作:

let updater_thread = thread::spawn(move || {
    let (lock, cvar) = &*shared_data;
    let values = [false, true, false, true];

    for i in 0..4 {
        let update_value = values[i as usize];
        println!("Updating value to {}...", update_value);
        *lock.lock().unwrap() = update_value;
        cvar.notify_one();
        thread::sleep(Duration::from_secs(4));
    }
    STOP_CLONE.store(true, Relaxed);
    println!("STOP has been updated");
    cvar.notify_one();
});
updater_thread.join().unwrap();

我们更新值后,通知另一个线程值已发生变化。然后我们阻塞主程序,直到 updater_thread 完成。

注意,我们使用了 Relaxed 术语。这对于确保操作按特定顺序发生,以避免数据竞争和奇怪的不一致性至关重要。这就是内存排序发挥作用的地方。Relaxed 排序与 AtomicBool 一起使用,确保对原子变量的操作对所有线程可见,但不会强制对周围的操作执行任何特定的顺序。对于我们的示例,这已经足够,因为我们只需要检查 STOP 的值,并不关心其他操作的严格顺序。

运行程序将输出以下内容:

Updating value to false...
Received value: false
Updating value to true...
Received value: true
Updating value to false...
Received value: false
Updating value to true...
Received value: true

我们的更新线程正在更新共享数据的值,并通知第一个线程去访问它。值是一致的,这正是我们想要的,尽管可以说这是一个粗略的异步行为实现。线程正在停止并等待更新。如果为 updater_thread 添加多个 Condvar 来循环检查,结果将是一个线程跟踪多个任务,并在任务发生变化时对其进行处理。虽然这无疑会引发在线讨论是否这真正算作异步行为,但我们可以肯定地说,这不是实现异步编程的最佳或标准方式。然而,我们可以看到,线程是异步编程的关键构建块。异步运行时以允许多个异步操作在单个线程内并发运行的方式处理任务。这个线程通常与主线程分开。运行时还可以有多个线程执行任务。在下一节中,我们将使用标准的异步代码实现。

我们在哪里可以使用异步?

我们已经向您介绍了异步编程,并通过一些示例展示了它的好处(例如多个 HTTP 请求)。这些示例是为了展示异步的强大功能而设计的玩具示例。本节将展示异步编程的实际应用,以及为什么你可能想在下一个项目中使用它们。

首先,让我们思考一下异步可以用于哪些方面。毫不意外,主要的应用场景涉及那些执行某些操作时可能会有延迟或需要等待的操作,例如文件系统的 I/O 操作或网络请求。异步编程使得调用这些操作的程序能够继续运行,而不会被阻塞,这样就避免了程序挂起或变得不响应的情况。

I/O 操作,如写文件,相较于内存操作而言通常是比较慢的,因为它们通常依赖于外部设备,比如硬盘。大多数硬盘仍然依赖机械部件,这些部件需要物理移动,因此它们比内存或 CPU 中的电子操作慢。此外,从 CPU 向设备传输数据的速度可能会受到限制——例如,通过 USB 连接。

为了更好地理解这一点,让我们比较一下不同时间尺度的差异:

纳秒(ns)
这是秒的十亿分之一(1/1,000,000,000 秒)。CPU 和内存中的操作通常发生在纳秒级别。例如,访问 RAM 中的数据可能需要大约 100 纳秒。

毫秒(ms)
这是秒的千分之一(1/1,000 秒)。I/O 操作,如写入硬盘或通过网络发送数据,通常发生在毫秒级别。例如,将数据写入传统硬盘可能需要几毫秒。

这些差异看起来可能微不足道,但在计算机领域,它们是巨大的。一个 CPU 在打开一个文件所需的时间内可以执行数百万次操作。这也是为什么 I/O 操作通常是程序性能瓶颈的原因。

在撰写本文时,异步文件读取实际上并不会加速文件 I/O 操作。这是因为文件 I/O 操作仍然受到磁盘性能的限制,因此瓶颈在于磁盘的读写速度,而不是 CPU。然而,异步编程能够做的是确保在文件 I/O 操作进行时,程序可以继续执行,而不会被这些操作阻塞。

使用异步进行文件 I/O

为了跟踪文件的变化,我们需要在一个线程中运行一个循环,检查文件的元数据,并在文件元数据发生变化时将结果反馈给主线程中的主循环,如图 1-5 所示。

image.png

在检测到文件变化后,我们可以执行各种操作,但为了本练习的目的,我们将文件内容打印到控制台。在开始处理图1-5中的组件之前,我们需要导入以下结构体和特性:

use std::path::PathBuf;
use tokio::fs::File as AsyncFile;
use tokio::io::AsyncReadExt;
use tokio::sync::watch;
use tokio::time::{sleep, Duration};

我们将在后续讲解中涵盖这些结构体和特性如何使用。回到图1-5,首先处理文件操作,然后再处理主循环是有道理的。我们最简单的操作是使用一个函数读取文件:

async fn read_file(filename: &str) -> Result<String, std::io::Error> {
    let mut file = AsyncFile::open(filename).await?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    Ok(contents)
}

我们打开文件并将内容读取到一个字符串中,然后返回该字符串。但是请注意,在撰写本文时,标准的异步文件读取实现并不是异步的。相反,它是阻塞的,因此文件打开操作并不真正是异步的。异步文件读取的不一致性归因于操作系统支持的文件 API。例如,如果你的 Linux 内核版本为 5.10 或更高,你可以使用 Tokio-uring crate 来实现对文件 API 的真正异步 I/O 调用。然而,目前我们的函数已经满足我们所需的功能。

接下来,我们可以继续编写一个周期性检查文件元数据的循环:

async fn watch_file_changes(tx: watch::Sender<bool>) {
    let path = PathBuf::from("data.txt");

    let mut last_modified = None;
    loop {
        if let Ok(metadata) = path.metadata() {
            let modified = metadata.modified().unwrap();

            if last_modified != Some(modified) {
                last_modified = Some(modified);
                let _ = tx.send(());
            }
        }
        sleep(Duration::from_millis(100)).await;
    }
}

我们可以看到,这个异步函数执行以下步骤:

  1. 获取要检查的文件路径。
  2. 将最后修改时间设置为 None,因为我们还没有检查该文件。
  3. 然后进入一个无限循环。
  4. 在循环中,我们提取文件的最后修改时间。
  5. 如果提取的时间戳与我们缓存的时间戳不同,我们将更新缓存的时间戳,并通过传递给函数的发送者通过通道发送一个消息。这条消息会通知主循环文件已经被更新。
  6. 我们忽略了 tx.send(()) 的结果,因为唯一可能发生的错误是接收者不再监听。在这种情况下,我们的函数不需要做更多的事情,因此可以安全地忽略结果。

每次迭代后,我们会短暂休眠,以避免不断地访问我们正在检查的文件。

注意
如果我们使用 Tokio 线程来运行这个函数,Tokio 运行时将能够切换上下文并在同一进程中执行另一个线程。如果我们使用标准库的 sleep 函数,线程将被阻塞。这是因为标准库的 sleep 不会将任务发送到 Tokio 执行器。我们将在第3章详细介绍执行器。

现在我们的第一个循环已经定义完毕,接下来可以继续编写主函数中的循环。此时,如果你知道如何启动 Tokio 线程和通道,你可以尝试自己编写主函数。

如果你尝试自己编写主函数,希望它看起来像下面这样:

#[tokio::main]
async fn main() {
    let (tx, mut rx) = watch::channel(false);

    tokio::spawn(watch_file_changes(tx));

    loop {
        // 等待文件发生变化
        let _ = rx.changed().await;

        // 读取文件并将其内容打印到控制台
        if let Ok(contents) = read_file("data.txt").await {
            println!("{}", contents);
        }
    }
}

我们的主函数执行了以下步骤:

  1. 我们创建了一个通道,这是一个单生产者、多消费者的通道,只保留最后一个设置的值。这个通道允许一个生产者向多个消费者发送消息,从而实现并发的数据分发。
  2. 我们将该通道的发送者传递给文件监控函数,该函数运行在我们启动的 Tokio 线程中。
  3. 现在文件监控循环正在运行,我们转向主循环,主循环会一直等待直到通道中的值发生变化。
  4. 因为我们并不关心来自通道的具体值,所以我们将变量赋值为下划线 _。我们的主循环会保持在这里,直到通道中的值发生变化。
  5. 一旦通道中的值由于文件元数据变化而发生变化,循环的其余部分会继续执行,读取文件并打印出内容。

在运行之前,我们需要在项目的根目录下(与 Cargo.toml 同级)创建一个 data.txt 文件。然后我们可以运行该系统,在 IDE 中打开 data.txt 文件并输入一些内容。保存文件后,你将看到文件的内容打印到控制台!

现在,我们已经在本地使用异步编程,接下来我们可以继续实现网络中的异步编程。

使用异步提高 HTTP 请求性能

I/O 操作不仅仅涉及读写文件,还包括从 API 获取信息、执行数据库操作或接收鼠标或键盘的信息。它们的共同点是,这些操作比在内存中执行的操作(如在 RAM 中执行的操作)要慢。异步允许程序在进行 I/O 操作时不被阻塞,可以在等待异步操作时执行其他任务。

在以下示例中,假设用户已登录到一个网站,我们希望显示一些数据以及用户登录以来的时间。为了获取数据,我们将使用一个外部 API,该 API 提供了一个特定的延迟。接收到数据后,我们需要对其进行处理,因此我们将定义一个 Response 结构体,并通过标注 Deserialize 特性来启用将 API 数据反序列化为可用对象。

为了进行 API 调用,我们将使用 reqwest 包。由于我们将处理 JSON 数据,我们通过在依赖配置中指定 features=["json"] 来启用 reqwest 的 JSON 特性。这使得我们在进行 API 请求和处理响应时能够方便地处理 JSON 数据。

我们需要在 Cargo.toml 中添加以下依赖项:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

接下来,我们导入所需的库并定义 Response 结构体:

use reqwest::Error;
use serde::Deserialize;
use tokio::time::sleep;
use std::time::Duration;
use std::time::Instant;
use serde_json;

#[derive(Deserialize, Debug)]
struct Response {
    url: String,
    args: serde_json::Value,
}

现在,我们实现 fetch_data 函数。调用时,它会向 https://httpbin.org/delay/ 发送一个 GET 请求,该请求将在指定的秒数后返回响应。在我们的示例中,我们将延迟设置为 5 秒,以强调设计一个能够有效处理延迟的程序在现实场景中的重要性:

async fn fetch_data(seconds: u64) -> Result<Response, Error> {
    let request_url = format!("https://httpbin.org/delay/{}", seconds);
    let response = reqwest::get(&request_url).await?;
    let delayed_response: Response = response.json().await?;
    Ok(delayed_response)
}

在数据被获取时,我们创建一个函数来计算用户自登录以来的时间。这通常需要数据库检查,但我们通过设置 1 秒的休眠来模拟检查所需的时间。这样简化了示例,不需要涉及数据库的配置:

async fn calculate_last_login() {
    sleep(Duration::from_secs(1)).await;
    println!("Logged in 2 days ago");
}

现在我们将代码组合在一起:

#[tokio::main]
async fn main() -> Result<(), Error> {
    let start_time = Instant::now();
    let data = fetch_data(5);
    let time_since = calculate_last_login();
    let (posts, _) = tokio::join!(
        data, time_since
    );
    let duration = start_time.elapsed();
    println!("Fetched {:?}", posts);
    println!("Time taken: {:?}", duration);
    Ok(())
}

让我们检查输出:

Fetched Ok(Response { url: "https://httpbin.org/delay/5", args: Object {} })
Time taken: 5.494735083s

在主函数中,我们首先通过调用 fetch_data 函数发起 API 请求,然后调用 calculate_last_login 函数。API 请求设计为需要 5 秒钟才能返回响应。由于 fetch_data 是一个异步函数,它以非阻塞的方式执行,这使得程序可以继续执行。因此,calculate_last_login 被执行,并且它的输出首先被打印到终端。经过 5 秒的延迟后,fetch_data 完成,结果返回并打印。

与最初的 HTTP 请求示例不同,这个示例展示了异步编程如何允许任务并发执行而不会阻塞程序的流程,从而导致网络请求无序完成。因此,我们可以使用异步处理多个网络请求,只要按照我们需要数据的顺序等待每个请求即可。

总结

在本章中,我们介绍了异步编程及其与计算机系统中线程和进程的关系。接着,我们展示了与线程和进程的基本高层交互,证明了线程在异步编程中的有用性。我们进一步探讨了基础的异步编程,通过在等待其他请求响应的同时发送更多请求来提高多个 HTTP 调用的性能。我们还使用了异步原则来跟踪文件的变化。

我们希望本章展示了异步编程作为一个强大的工具,能够同时处理多个不需要持续占用 CPU 时间的任务。因此,异步编程使我们能够使用一个线程同时处理多个任务。现在,您已经了解了异步编程在计算机系统中的位置,接下来我们将在第二章中探讨基础的异步编程概念。