第十篇:《异步编程:Tokio 入门与 async/await》

在上一篇文章中,我们掌握了多线程并发------通过创建操作系统线程来并行执行任务。但线程有它的局限:每个线程需要 1-8MB 的栈空间,创建和切换线程有不可忽视的开销。当需要同时处理数万个连接时(如 Web 服务器、数据库代理),线程模型会迅速耗尽内存。异步编程通过"单线程内并发执行大量任务"解决了这个问题。Rust 的 async/await 语法让异步代码看起来像同步代码,而 Tokio 作为 Rust 异步生态的事实标准,提供了高性能的运行时。本文从同步与异步的区别讲起,系统讲解 Future trait、Tokio 运行时、异步 I/O、超时与取消,并通过一个并发 HTTP 客户端实战帮你掌握 Rust 异步编程的核心技能。

一、同步 vs 异步:为什么需要异步?

1.1 同步阻塞模型的问题

在同步模型中,每个连接需要一个线程。当线程等待 I/O 时(如等待网络响应),它会被操作系统挂起,但线程资源仍然被占用:

rust 复制代码
use std::net::TcpListener;
use std::thread;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        thread::spawn(|| {
            handle_connection(stream);   // 阻塞处理
        });
    }
}

问题:

每个连接一个线程,1 万个连接需要 1 万个线程(约 10GB 栈内存)。

线程切换开销大,上下文切换消耗 CPU。

大量时间花在等待 I/O 上,CPU 利用率低。

1.2 异步非阻塞模型

异步模型使用少量线程(通常等于 CPU 核心数),在单个线程内并发执行大量任务。当任务等待 I/O 时,让出 CPU 给其他任务,而不是阻塞线程:

text

同步:任务A等待I/O → 线程阻塞 → 任务B等待线程 → 任务C排队

异步:任务A等待I/O → 让出 → 任务B执行 → 任务C执行 → I/O完成 → 任务A恢复

优势:

1 万个连接只需要几个线程。

内存占用极低(每个任务只需几百字节)。

CPU 利用率高(不会浪费在等待 I/O 上)。

二、async/await:Rust 的异步语法

2.1 async 函数

async fn 定义一个异步函数,它返回一个 Future:

rust 复制代码
async fn hello() -> String {
    String::from("Hello, async!")
}

关键理解:async fn hello() 的返回类型不是 String,而是 impl Future<Output = String>。调用 hello() 不会立即执行函数体,而是返回一个 Future------Future 是惰性的,只有在被 await 或轮询时才会执行。

2.2 await 表达式

await 等待一个 Future 完成并获取其结果:

rust 复制代码
async fn main_async() {
    let result = hello().await;   // 等待 hello 完成
    println!("{}", result);
}

执行流程:

hello() 返回一个 Future。

.await 轮询该 Future,直到它返回 Ready。

在轮询过程中,如果 Future 未就绪(如等待 I/O),它会返回 Pending,让出 CPU 给其他任务。

I/O 完成后,运行时重新调度该任务,继续执行。

2.3 Future trait

Future 是异步计算的核心抽象:

rust 复制代码
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),      // 完成,返回结果
    Pending,       // 未完成,稍后再轮询
}

核心要点:

poll 被调用时,Future 尝试推进计算。

如果完成,返回 Poll::Ready(T)。

如果未完成,返回 Poll::Pending,并注册一个 Waker,当 Future 可以继续时通知运行时。

Pin 的作用:Pin<&mut Self> 保证 Future 在内存中不会被移动,因为 async 函数编译后会生成一个自引用的状态机。

三、Tokio:Rust 异步运行时

Rust 标准库只定义了 Future trait,不提供运行时。运行时由第三方 crate 提供,其中 Tokio 是绝对的主流。

3.1 安装 Tokio

toml

dependencies

tokio = { version = "1.40", features = "full" }

常用 feature:

rt:基本运行时

rt-multi-thread:多线程运行时

macros:#tokio::main 和 #tokio::test

net:异步网络

fs:异步文件系统

time:异步定时器

sync:异步同步原语

full:所有功能

3.2 启动运行时

rust 复制代码
#[tokio::main]
async fn main() {
    println!("Hello from Tokio!");
}

#tokio::main 宏将 async fn main 转换为同步的 fn main,内部创建 Tokio 运行时并执行 Future:

等价的展开形式:

rust 复制代码
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("Hello from Tokio!");
        });
}

3.3 多线程 vs 当前线程运行时

rust 复制代码
// 多线程运行时(默认,适合大多数场景)
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() { }

// 当前线程运行时(适合轻量级场景)
#[tokio::main(flavor = "current_thread")]
async fn main() { }

选择建议:

多线程:Web 服务器、CPU 密集型任务、需要并行执行。

当前线程:单核嵌入式、测试、需要精确控制任务执行顺序。

3.4 spawn:启动异步任务

tokio::spawn 将一个 Future 提交到运行时,在后台并发执行:

rust 复制代码
#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        "任务完成"
    });

    println!("主任务继续执行...");

    let result = handle.await.unwrap();
    println!("{}", result);
}

spawn 的约束:Future 必须是 Send + 'static,因为它在多线程运行时中可能被调度到不同线程。

四、异步 I/O

4.1 异步文件操作

rust 复制代码
use tokio::fs;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // 异步写入
    fs::write("hello.txt", "Hello, Tokio!").await?;

    // 异步读取
    let content = fs::read_to_string("hello.txt").await?;
    println!("内容: {}", content);

    Ok(())
}

⚠️ 注意:异步文件操作在底层仍然使用线程池(因为大多数操作系统没有真正的异步文件 I/O),但 API 是异步的。

4.2 异步网络

rust 复制代码
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;

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

        tokio::spawn(async move {
            let mut buf = [0; 1024];

            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(0) => return,   // 连接关闭
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("读取错误: {}", e);
                        return;
                    }
                };

                if socket.write_all(&buf[..n]).await.is_err() {
                    return;
                }
            }
        });
    }
}

核心优势:每个连接由 tokio::spawn 处理,但只占用极小的内存,单机可支撑数十万并发连接。

五、超时与取消

5.1 超时控制

rust 复制代码
use tokio::time::{timeout, Duration};

#[tokio::main]
async fn main() {
    let result = timeout(Duration::from_secs(1), async {
        tokio::time::sleep(Duration::from_secs(5)).await;
        "完成"
    }).await;

    match result {
        Ok(value) => println!("结果: {}", value),
        Err(_) => println!("超时!"),
    }
}

5.2 select:多路等待

tokio::select! 同时等待多个 Future,返回最先完成的:

rust 复制代码
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        _ = sleep(Duration::from_secs(1)) => {
            println!("1 秒任务完成");
        }
        _ = sleep(Duration::from_secs(2)) => {
            println!("2 秒任务完成");
        }
    }
}

select! 的典型用途:

超时控制:等待任务或超时,谁先完成取谁。

优雅关闭:等待正常任务或关闭信号。

竞速:多个数据源,取最快返回的。

5.3 取消安全性

Rust 的异步任务在 await 点可以被取消。当 select! 返回时,未完成的 Future 会被丢弃:

rust 复制代码
async fn cancel_safe_operation() {
    // 在 await 点之间没有副作用,取消是安全的
    let data = fetch_data().await;
    process(data).await;
}

// ⚠️ 取消不安全:await 之间可能有副作用
async fn cancel_unsafe_operation() {
    send_request().await;
    // 如果在这里被取消,请求已发送但响应未处理
    handle_response().await;
}

六、异步同步原语

Tokio 提供了异步版本的同步原语,用于在异步任务之间共享状态:

6.1 tokio::sync::Mutex vs std::sync::Mutex

rust 复制代码
use std::sync::Arc;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0));

    let mut handles = vec![];
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(tokio::spawn(async move {
            let mut num = counter.lock().await;   // 异步获取锁
            *num += 1;
        }));
    }

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

    println!("计数: {}", *counter.lock().await);
}

选择原则:

锁的持有时间短、不涉及 .await → 用 std::sync::Mutex(性能更好)。

锁的持有时间长、期间需要 .await → 用 tokio::sync::Mutex(避免阻塞运行时)。

6.2 信号量:限制并发数

rust 复制代码
use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Arc::new(Semaphore::new(3));   // 最多 3 个并发

    let mut handles = vec![];
    for i in 0..10 {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            println!("任务 {} 开始", i);
            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
            println!("任务 {} 完成", i);
            drop(permit);   // 释放信号量
        }));
    }

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

七、实战:并发 HTTP 客户端

综合所学知识,实现一个并发 HTTP 客户端:

rust 复制代码
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};

/// 模拟 HTTP 请求
async fn fetch(url: &str) -> Result<String, String> {
    // 模拟网络延迟
    tokio::time::sleep(Duration::from_millis(100)).await;

    if url.contains("error") {
        Err(format!("请求 {} 失败", url))
    } else {
        Ok(format!("来自 {} 的响应", url))
    }
}

/// 带超时和重试的请求
async fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, String> {
    for attempt in 1..=max_retries {
        match timeout(Duration::from_secs(2), fetch(url)).await {
            Ok(Ok(response)) => return Ok(response),
            Ok(Err(e)) => {
                eprintln!("第 {} 次尝试失败: {}", attempt, e);
                if attempt == max_retries {
                    return Err(e);
                }
            }
            Err(_) => {
                eprintln!("第 {} 次尝试超时", attempt);
                if attempt == max_retries {
                    return Err(format!("{} 超时", url));
                }
            }
        }
        tokio::time::sleep(Duration::from_millis(500)).await;
    }
    Err("未知错误".to_string())
}

#[tokio::main]
async fn main() {
    let urls = vec![
        "https://api.example.com/users",
        "https://api.example.com/orders",
        "https://api.example.com/products",
        "https://api.example.com/error",
        "https://api.example.com/payments",
        "https://api.example.com/reviews",
    ];

    // 限制并发数为 3
    let semaphore = Arc::new(Semaphore::new(3));
    let mut handles = vec![];

    for url in urls {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        let url = url.to_string();

        handles.push(tokio::spawn(async move {
            let result = fetch_with_retry(&url, 3).await;
            drop(permit);
            (url, result)
        }));
    }

    // 收集结果
    for handle in handles {
        match handle.await {
            Ok((url, Ok(response))) => println!("✅ {}: {}", url, response),
            Ok((url, Err(e))) => println!("❌ {}: {}", url, e),
            Err(e) => eprintln!("任务 panic: {}", e),
        }
    }
}

代码要点:

Semaphore 限制并发数为 3,避免同时发起过多请求。

timeout 为每个请求设置 2 秒超时。

重试机制:失败后等待 500ms 重试,最多 3 次。

tokio::spawn 并发执行所有请求。

acquire_owned() 获取信号量许可,drop(permit) 释放。

八、同步 vs 异步:如何选择?

维度 多线程(std::thread) 异步(Tokio)

适用场景 CPU 密集型 I/O 密集型

并发规模 数百到数千 数十万

内存开销 每线程 1-8MB 每任务数百字节

编程复杂度 中等 较高(async 传染)

调试难度 中等 较高

生态成熟度 极高 成熟

选择建议:

CPU 密集型(如图像处理、加密计算)→ 多线程 + Rayon。

I/O 密集型(如 Web 服务器、数据库代理)→ 异步 + Tokio。

混合场景 → 异步运行时 + spawn_blocking 执行 CPU 密集任务。

九、小结

同步 vs 异步:同步每连接一线程,异步少量线程处理大量并发。

async/await :async fn 返回 Future,.await 等待其完成。

Future trait:poll 返回 Poll::Ready 或 Poll::Pending,Pin 保证自引用安全。

Tokio 运行时:#tokio::main 启动,多线程运行时适合大多数场景。

异步 I/O:tokio::fs 和 tokio::net 提供异步文件与网络操作。

超时与取消:timeout 设置超时,select! 多路等待。

异步同步原语:tokio::sync::Mutex、Semaphore 等,锁持有时间长时使用。

实战模式:信号量限制并发 + 超时 + 重试 + 任务收集。

相关推荐
程序员天天困4 小时前
RustFS 1.0.0 深度解析:GA 之后,它值得替代 MinIO 吗?
后端·云原生·rust
Amos_Web8 小时前
Rspack 源码解析(八):Module、Chunk 与 Runtime ID
前端·rust·源码阅读
geovindu9 小时前
rust: Factory Method Pattern
开发语言·设计模式·rust·工厂方法模式·创建型模式
Source.Liu10 小时前
【A11】6 输入框界面:前端 + Rust 基本配置
rust
Kapaseker10 小时前
Rust 编译器总是在抱怨什么?
rust
Ramble_Naylor1 天前
错误是一种值:Result、? 与 panic!
rust
柯南46681 天前
【AI开发之Rust】第 10 课:Cargo 工程化 —— 模块、依赖与测试(基础篇收尾)
rust·编程语言
qq_452396231 天前
第九篇:《并发编程:线程、Channel 与共享状态》
rust