8Rust程序设计语言

异步编程

async/await

Futureasync/awaitTokio

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第八章 异步编程 (Asynchronous Programming)

一、导读

1.1 本章学习目标

理解同步(Synchronous)与异步(Asynchronous)编程模型的本质区别

掌握 Rust 的 async/await 语法及其编译后的状态机(State Machine)转换

理解 Future trait 的定义和工作机制,掌握 poll 模型的核心思想

掌握 tokio 异步运行时的基本使用:任务创建、异步 I/O、定时器

理解 async 块、async 函数与 Pin 类型的关系

掌握异步流(Stream)和异步迭代器的使用方式

了解 async/await 与多线程的协作模型

能够编写高性能的异步网络应用

1.2 为什么本章如此重要

异步编程是现代高性能网络应用的基础。传统的多线程模型在处理大量并发连接时面临严峻挑战——每个连接需要一个线程,线程的创建、调度和上下文切换开销在连接数达到数万甚至数十万时变得不可接受。异步编程通过事件循环(Event Loop)和协作式多任务(Cooperative Multitasking)解决了这一问题:少量线程即可处理海量并发连接。

Rust 的异步模型采用了 async/await 语法,与 JavaScript、Python、C# 等语言的异步模型在表面上相似,但在底层实现上有根本性差异。Rust 的 async 代码在编译后被转换为零成本的状态机,没有堆分配、没有虚方法调用、没有 GC 暂停。这使得 Rust 的异步应用在性能上可以与手写事件循环代码相媲美,同时保持了代码的可读性和可维护性。

tokio 是 Rust 生态中最成熟的异步运行时,它提供了任务调度、异步 I/O、定时器、信号处理等基础设施。类似于 Node.js 的事件循环或 Go 的 goroutine 调度器,tokio 是 Rust 异步应用的运行基础。

本章将从 Future trait 开始,逐步讲解 async/await 的底层机制,然后介绍 tokio 运行时的使用,最后探讨异步编程中的常见模式和最佳实践。

1.3 核心问题引导

Rust 的 async/await 编译后生成了什么?与 JavaScript 的 Promise 有什么本质区别?

Future trait 的 poll 方法为什么返回 Poll::Pending 而不是阻塞等待?

tokio 的工作窃取(work-stealing)调度器是如何工作的?

为什么 async 函数返回的 Future 是 !Unpin 的?Pin 的作用是什么?

异步代码中的 Send 约束有什么意义?为什么跨 .await 点的引用必须是 Send?

异步编程中如何避免阻塞运行时线程?spawn_blocking 的作用是什么?


二、核心概念详解

8.1 Future 与异步模型

Future 是 Rust 异步编程的核心 trait,它表示一个尚未完成的计算:

rust
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

// Future trait 的简化定义
trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

enum Poll<T> {
    Ready(T),    // 计算完成
    Pending,     // 尚未完成,稍后再试
}

`poll` 模型:与"阻塞等待结果"不同,Future 采用"轮询"模型。执行器(executor)反复调用 poll 方法,如果返回 Poll::Ready(value),则计算完成;如果返回 Poll::Pending,则 Future 会在某个条件满足时被唤醒(通过 Waker),执行器稍后再次轮询。

这种非阻塞模型使得单个线程可以处理大量并发任务——当一个任务等待 I/O 时,线程可以切换到其他任务,而不是阻塞等待。

8.2 async/await 语法

async 关键字将函数或代码块转换为返回 Future 的状态机。await 关键字在 Future 上等待其完成,但不阻塞线程:

rust
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?;
    let body = response.text().await?;
    Ok(body)
}

// async 块
let future = async {
    let x = compute_async().await;
    let y = compute_async().await;
    x + y
};

编译后的状态机:编译器将 async 函数转换为一个实现了 Future 的匿名类型。每个 .await 点是一个状态转换,局部变量被保存在状态机中。这使得 async 代码没有堆分配——状态机的大小在编译时确定。

rust
// 以下两段代码在语义上等价
async fn example() -> i32 {
    let x = async_fn1().await;
    let y = async_fn2().await;
    x + y
}

// 编译器大致生成(概念性)
enum ExampleFuture {
    State0 { },
    State1 { x: i32, future2: AsyncFn2Future },
    Done,
}

impl Future for ExampleFuture {
    type Output = i32;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<i32> {
        // 状态机推进逻辑
        todo!()
    }
}

8.3 Tokio 运行时

tokio 是 Rust 最流行的异步运行时,提供了多线程工作窃取调度器:

rust
// Cargo.toml: tokio = { version = "1", features = ["full"] }

use tokio;

#[tokio::main]
async fn main() {
    // 并发执行多个任务
    let task1 = tokio::spawn(async {
        println!("任务 1");
    });

    let task2 = tokio::spawn(async {
        println!("任务 2");
    });

    task1.await.unwrap();
    task2.await.unwrap();
}

tokio 的核心组件:

rust
use tokio::time::{sleep, Duration, interval};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

// 定时器
async fn timeout_example() {
    let result = tokio::time::timeout(
        Duration::from_secs(5),
        some_async_operation()
    ).await;
    
    match result {
        Ok(value) => println!("完成: {:?}", value),
        Err(_) => println!("超时"),
    }
}

// TCP 服务器
async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("服务器监听在 8080 端口");

    loop {
        let (mut socket, addr) = listener.accept().await?;
        println!("新连接: {}", addr);

        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = socket.read(&mut buf).await.unwrap();
                if n == 0 { break; }
                socket.write_all(&buf[..n]).await.unwrap();
            }
        });
    }
}

8.4 并发执行异步任务

tokio 提供了多种并发执行异步任务的方式:

rust
use tokio::join;
use futures::future::join_all;

// join! 宏:同时等待多个 Future
async fn concurrent_fetch() -> (String, String) {
    let (result1, result2) = join!(
        fetch_data("https://api.example.com/users"),
        fetch_data("https://api.example.com/posts")
    );
    (result1.unwrap_or_default(), result2.unwrap_or_default())
}

// select! 宏:等待第一个完成的 Future
async fn with_timeout() -> String {
    tokio::select! {
        result = fetch_data("https://slow-api.com") => result.unwrap_or_default(),
        _ = sleep(Duration::from_secs(3)) => "超时".to_string(),
    }
}

// 批量并发
async fn batch_fetch(urls: Vec<String>) -> Vec<String> {
    let futures = urls.into_iter().map(|url| fetch_data(&url));
    join_all(futures).await.into_iter()
        .map(|r| r.unwrap_or_default())
        .collect()
}

8.5 Pin 与自引用结构

Pin 是 Rust 异步模型中最复杂的概念之一。当 async 函数中有跨 .await 点的引用时,生成的状态机是自引用的(self-referential)——结构体中的字段引用了同一结构体中的其他字段。

Pin<&mut T> 保证被指向的值不会被移动(即其内存地址不会改变),这对于自引用结构体的安全性至关重要。如果自引用结构体被移动,内部引用就会变成悬垂指针。

rust
use std::pin::Pin;
use std::future::Future;

// 大多数情况下,开发者不需要直接操作 Pin
// async/await 会自动处理
async fn example() {
    let data = String::from("hello");
    let reference = &data;
    some_async_operation().await; // 跨 await 点的引用
    println!("{}", reference);    // data 和 reference 都在状态机中
}

对于不跨 .await 点的局部变量,不需要 Pin。只有当 Future 包含自引用时才需要关注 Pin。大多数异步代码不需要手动处理 Pin。


三、深入分析与讨论

3.1 异步 vs 多线程:何时使用哪种

异步编程和操作系统线程各有适用场景。异步编程适合 I/O 密集型任务(网络请求、文件读写、数据库查询),因为它可以用少量线程处理大量并发连接。多线程适合 CPU 密集型任务(计算、加密、图像处理),因为它可以利用多核并行。

在实际项目中,两者经常结合使用:用异步处理 I/O,用 spawn_blocking 将 CPU 密集型任务移到专用线程池,避免阻塞异步运行时的工作线程。

rust
// CPU 密集型任务使用 spawn_blocking
async fn process_data(data: Vec<u8>) -> Vec<u8> {
    tokio::task::spawn_blocking(move || {
        // 这里的代码在阻塞线程池中运行
        heavy_computation(data)
    }).await.unwrap()
}

3.2 异步生态与库选择

Rust 的异步生态以 tokio 为主导,但也有其他选择:async-std(模仿标准库 API)、smol(轻量级运行时)。选择运行时时应考虑生态兼容性——大多数异步库(如 reqwest、sqlx、tonic)默认基于 tokio。

异步流(Stream)是迭代器的异步版本,用于处理异步数据序列。futures crate 和 tokio-stream 提供了 Stream 的实现和工具方法。

3.3 异步错误处理与取消

异步任务的取消通过 Drop 实现——当一个 Future 被丢弃(drop),它的执行就被取消。这可能导致资源泄漏(如未完成的数据库事务)。使用 tokio::select! 时应注意取消安全性。


四、实践案例

4.1 异步 HTTP 服务器

使用 tokio 构建一个简单的 HTTP 服务器。

rust
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("HTTP 服务器运行在 http://127.0.0.1:8080");

    loop {
        let (mut socket, _) = listener.accept().await?;

        tokio::spawn(async move {
            let mut buf = [0; 4096];
            let n = socket.read(&mut buf).await.unwrap();
            let request = String::from_utf8_lossy(&buf[..n]);

            let response = if request.starts_with("GET / ") {
                "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<h1>Hello, Async Rust!</h1>"
            } else if request.starts_with("GET /health") {
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"status\":\"ok\"}"
            } else {
                "HTTP/1.1 404 Not Found\r\n\r\nNot Found"
            };

            socket.write_all(response.as_bytes()).await.unwrap();
        });
    }
}

4.2 并发任务调度器

实现一个简单的异步任务调度器,支持并发限制和超时控制。

rust
use std::time::Duration;
use tokio::time::{sleep, timeout};

async fn simulated_task(id: usize, duration_ms: u64) -> String {
    println!("任务 {} 开始", id);
    sleep(Duration::from_millis(duration_ms)).await;
    println!("任务 {} 完成", id);
    format!("任务 {} 的结果(耗时 {}ms)", id, duration_ms)
}

async fn run_with_limit(tasks: Vec<(usize, u64)>, concurrency: usize) -> Vec<String> {
    let mut results = Vec::new();
    let mut handles = Vec::new();

    for (id, duration) in tasks {
        if handles.len() >= concurrency {
            // 等待任意一个完成
            let (result, _, remaining) = futures::future::select_all(handles).await;
            results.push(result);
            handles = remaining;
        }

        let handle = tokio::spawn(async move {
            match timeout(Duration::from_secs(5), simulated_task(id, duration)).await {
                Ok(result) => result,
                Err(_) => format!("任务 {} 超时", id),
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        results.push(handle.await.unwrap());
    }
    results
}

#[tokio::main]
async fn main() {
    let tasks = vec![
        (1, 100), (2, 200), (3, 150),
        (4, 300), (5, 50), (6, 250),
    ];

    let results = run_with_limit(tasks, 3).await;
    for result in results {
        println!("{}", result);
    }
}

五、常见误区与注意事项

在异步代码中使用阻塞操作:std::thread::sleep、同步文件 I/O、blocking HTTP 请求等会阻塞整个工作线程。应使用对应的异步版本(tokio::time::sleep、tokio::fs、reqwest)。

忽略 `.await`:调用 async 函数但不 .await 不会执行任何操作——它只是创建了一个 Future。必须 .await 或 spawn 才能执行。

跨 `.await` 点持有 `!Send` 类型:如果 Future 需要在多线程运行时中执行,跨 .await 点的值必须是 Send。Rc、Cell、RefCell 等不是 Send。

过度使用 `block_on`:block_on 会阻塞当前线程直到 Future 完成。在异步代码中不应使用 block_on,应使用 .await。

忽略任务取消的影响:tokio::spawn 返回的 JoinHandle 被 drop 时,任务不会被取消(它继续运行)。但 select! 中未完成的分支会被取消,需要确保取消安全性。


六、本章小结

本章深入讲解了 Rust 的异步编程模型。Rust 采用 async/await 语法,编译后生成零成本的状态机,没有堆分配和运行时开销。Future trait 的 poll 模型实现了非阻塞的异步执行。tokio 运行时提供了多线程工作窃取调度器、异步 I/O 和定时器。Pin 类型保证了自引用 Future 的内存安全。异步编程适合 I/O 密集型场景,与多线程结合可以构建高性能的并发应用。


七、思考题与练习

解释 Future::poll 方法的工作机制。为什么它需要 Waker 参数?

使用 tokio 实现一个异步的 TCP 回声服务器,支持多个客户端同时连接。

比较 join!、try_join! 和 select! 三个宏的区别和使用场景。

解释为什么 async fn 返回的 Future 默认是惰性(lazy)的。与 JavaScript 的 Promise 有何不同?

实现一个异步的"扇出-扇入"(fan-out/fan-in)模式:并发发送多个请求,等待所有结果后汇总。

解释 spawn_blocking 的作用。在什么场景下必须使用它?

使用 tokio::sync::watch 或 tokio::sync::broadcast 实现异步任务间的状态通知。

研究 async-stream crate 的用法,实现一个异步的文件行读取器。

8/10异步编程
0%