【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时

13.1 这节课解决什么问题

第 12 课的线程模型有个物理瓶颈:线程是操作系统资源 ------每个线程要一块独立栈(默认几 MB),上下文切换有内核开销。1 万个并发连接开 1 万线程,系统直接顶不住。而网络/LLM/文件这类场景,绝大多数时间线程只是(等网络包、等响应),白白占着资源。

异步(async)的答案是用户态任务

arduino 复制代码
线程(thread)        → 操作系统调度,1:1 内核线程,栈大、切换贵
异步任务(future)    → 运行时(tokio)自己调度,一个线程上跑成千上万个任务
                      "等 IO"时任务挂起让出线程,事件就绪了再被唤醒继续跑

在 Rust 里 async fn 返回一个 Future------一个"可被驱动到完成的惰性状态机"。它自己不执行,必须由执行器/运行时(tokio)去 poll 它。本课目标:能用 tokio 写出并发 IO 程序,并理解 async 的"必须知道"的规矩,为 17-19 课的 LLM 流式问答与 SSE 打好地基。

rust 复制代码
关键心智:async 不改变并发本质,只改变"并发单元"------由线程变成任务。
任务在线程间可迁移,所以 spawn 出去的任务要求 Send + 'static(和线程同款规矩)。

💡 为什么本课程选 tokio?AI/HTTP/SSE 生态事实标准(reqwest、axum 都基于 tokio);官方 async book 也用它示范。先精通 tokio 一个运行时,胜过对十个运行时一知半解。

13.2 Future 的直觉:一个可等待的状态机

rust 复制代码
// 一个"async 函数"调用时不执行,返回 Future
async fn fetch_data() -> String {
    // 假设这里做了耗时 IO......
    String::from("数据")
}

// 等价于展开成状态机:
//   第 0 次 poll:开始执行,遇到 await 没就绪 → 返回 Pending,存下状态
//   数据就绪后再次 poll:从上次挂起点继续 → 最终 Ready(value)

三个必须建立的直觉:

  1. async fn 只有被 poll 才会真正干活------和迭代器 lazy 一个道理(第 9 课);
  2. .await 是"让出点" :执行到 future.await 时,若还没就绪,任务挂起,线程去跑别的任务;
  3. 必须有个执行器一直 poll 这些 future------tokio 就是那个执行器。
rust 复制代码
// 不经过运行时,future 不会推进:
async fn hello() -> String { String::from("world") }

fn main() {
    let fut = hello();        // 只是构建 future,什么都没发生
    // println!("{}", fut.await);   // ❌ .await 只能用在 async 上下文里
    // 必须靠运行时驱动(见 13.3 的 #[tokio::main])
}

13.3 tokio 起步:运行时与 #tokio::main

toml 复制代码
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
rust 复制代码
#[tokio::main]                        // 宏:把 main 包进多线程运行时
async fn main() {
    println!("hello from async");
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    println!("等了 100ms");
}

#[tokio::main] 背后的等价物(理解它,调试时才有直觉):

rust 复制代码
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)                 // 默认=CPU 核数
        .enable_all()                      // 打开 IO/时间驱动
        .build()
        .unwrap()
        .block_on(async {
            println!("手工搭运行时也行");
        });
}

两个常用宏一并介绍:

rust 复制代码
#[tokio::main]
async fn main() {
    // join!: 并发跑多个 future,全部完成才继续(类似 Promise.all)
    let (a, b) = tokio::join!(compute_a(), compute_b());

    // try_join!: 任一失败即整体失败(要求各 future 返回 Result)
    // let (x, y) = tokio::try_join!(fallible_x(), fallible_y())?;

    println!("{a} {b}");
}

async fn compute_a() -> String { String::from("A") }
async fn compute_b() -> String { String::from("B") }

13.4 并发执行任务:tokio::spawn

把一个 future 脱离当前执行流程 地丢给运行时并发跑,用 spawn

rust 复制代码
use std::time::Duration;

#[tokio::main]
async fn main() {
    let handle1 = tokio::spawn(async {
        tokio::time::sleep(Duration::from_millis(50)).await;
        println!("任务1 完成");
        10
    });

    let handle2 = tokio::spawn(async {
        tokio::time::sleep(Duration::from_millis(20)).await;
        println!("任务2 完成");
        20
    });

    // await JoinHandle 取回任务返回值(Result,任务 panic 会 Err)
    let r1 = handle1.await.unwrap();
    let r2 = handle2.await.unwrap();
    println!("{r1} + {r2} = {}", r1 + r2);
}

spawn 的约束与线程相同------闭包必须 Send + 'static(可能被迁移到别的 worker 线程,不能借用局部数据):

rust 复制代码
#[tokio::main]
async fn main() {
    let data: &'static str = "常量没问题";     // 'static 数据可被借用
    let owned = String::from("拥有的数据");     // 推荐:move 进去
    tokio::spawn(async move {
        println!("{data} {owned}");
    })
    .await
    .unwrap();
}

⚠️ tokio::spawn立即并发 ;而 async{}.await顺序等待。区别(重要):

rust 复制代码
async fn slow(tag: &str) { tokio::time::sleep(Duration::from_millis(100)).await; println!("{tag}"); }

#[tokio::main]
async fn main() {
    // 顺序版:先等 100ms 的 a,再等 100ms 的 b → 共 ~200ms
    slow("a").await;
    slow("b").await;

    // 并发版:spawn 两个任务,再一起等 → 共 ~100ms
    let ha = tokio::spawn(slow("c"));
    let hb = tokio::spawn(slow("d"));
    ha.await.unwrap();
    hb.await.unwrap();
}

13.5 async 里的 channel:tokio::sync

线程版用 std::sync::mpsc,异步版用 tokio::sync::mpsc.await 式收发,不阻塞线程):

rust 复制代码
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);      // 容量 32 的有界通道

    let producer = tokio::spawn(async move {
        for i in 0..5 {
            if tx.send(i).await.is_err() {      // 接收端已关闭 → 停止发送
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
    });

    let consumer = tokio::spawn(async move {
        while let Some(n) = rx.recv().await {   // None = 所有发送端关闭且队列清空
            println!("收到 {n}");
        }
    });

    producer.await.unwrap();
    consumer.await.unwrap();
}

常用变体:watch(一发多收的"最新值广播",做配置热更新)、broadcast(多收方都拿到全量消息)、oneshot(一次性的"应答"通道,请求-响应很常用------13.9 练习会用到)。

13.6 超时、取消与 select

13.6.1 timeout:给 await 加"最长等待"

rust 复制代码
use std::time::Duration;

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

    match tokio::time::timeout(Duration::from_millis(100), slow).await {
        Ok(result) => println!("及时返回: {result}"),
        Err(_elapsed) => println!("超时了!future 已被丢弃"),
    }
}

timeout 超时会**取消(drop)**内部的 future------这就是"可取消性":async 任务被丢弃即停止,没有僵尸线程。

13.6.2 select!:等待多个事件,谁先来处理谁

rust 复制代码
use tokio::sync::mpsc;
use std::time::Duration;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(8);

    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        tx.send(String::from("延迟消息")).await.ok();
    });

    // select!: 同时等"收消息"与"倒计时",先就绪的执行,另一个被取消(继续等下一次循环)
    loop {
        tokio::select! {
            Some(msg) = rx.recv() => {
                println!("通道来了: {msg}");
                break;
            }
            _ = tokio::time::sleep(Duration::from_millis(50)) => {
                println!("还没消息,先干点别的......");
            }
        }
    }
}

💡 select! 内部会自动公平轮询各分支(默认随机顺序,可用 biased; 改为按书写顺序)。它是实现"带心跳的请求"、"超时降级"、"优雅关闭信号"的瑞士军刀------17 课的 SSE 阅读循环会用到它的实际场景。

13.7 ⚠️ 异步的两大红线

红线一:async 里别做"阻塞式"调用

rust 复制代码
#[tokio::main]
async fn main() {
    // ❌ 会阻塞整个 worker 线程:async 世界里没有"等一会再让出"
    // std::thread::sleep(Duration::from_millis(100));

    // ❌ 大 CPU 密集 / 同步 IO(read 文件阻塞)也应避免直接 .await 里跑

    // ✅ 用 tokio::time::sleep(挂起而非阻塞)
    tokio::time::sleep(Duration::from_millis(100)).await;

    // ✅ 确实要做同步重活 → 丢给阻塞线程池
    let heavy = tokio::task::spawn_blocking(|| {
        // 这里可以放心用 std::thread::sleep / 同步文件 IO / CPU 密集计算
        let mut sum = 0u64;
        for i in 0..10_000_000u64 { sum += i; }
        sum
    });
    let result = heavy.await.unwrap();
    println!("重活结果: {result}");
}

为什么 :tokio 的 worker 线程数是有限的(默认 = CPU 核数)。一个任务把线程"睡"住或算满,其他任务全卡在那条线程上。判断口诀:遇到 std::thread::sleep / 同步 read_to_string / 大循环 → 换 tokio::time::sleepspawn_blocking

红线二:spawn 的任务不能借用非 'static 数据

需要共享状态时(async 世界里也要 Arc):

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

#[tokio::main]
async fn main() {
    let shared = Arc::new(42);
    for _ in 0..3 {
        let s = Arc::clone(&shared);
        tokio::spawn(async move {
            println!("task sees {s}");
        });
    }
}

💡 异步任务之间若要共享可变状态 :首选把状态收进单一 actor(任务持有状态 + 收消息),其次才考虑 Arc<tokio::sync::Mutex<T>>。tokio 的 Mutex 与 std 的 Mutex 都可用,但不要.await 挂起期间长时间持有 std::sync::Mutex guard(会阻塞线程);tokio::sync::Mutex 则允许跨 await。

13.8 组合示范:并发拉取 + 超时 + 汇总(常见范式)

把本课知识点缝成一个"并发请求并带超时"的最小骨架(reqwest 第 14 课系统登场,这里只做任务编排演示):

rust 复制代码
use std::time::Duration;

// 模拟一个 80ms 的"网络请求"
async fn fake_fetch(url: &'static str) -> String {
    tokio::time::sleep(Duration::from_millis(80)).await;
    format!("{url} 的内容")
}

#[tokio::main]
async fn main() {
    let urls = ["https://a.example.com", "https://b.example.com", "https://c.example.com"];

    // 并发发起所有请求(spawn 后统一 await,天然并发)
    let tasks: Vec<_> = urls
        .iter()
        .map(|u| tokio::spawn(async move { fake_fetch(u).await }))
        .collect();

    // 逐个带超时地收结果,失败/超时的标为降级信息
    let mut results = Vec::new();
    for t in tasks {
        let r = match tokio::time::timeout(Duration::from_millis(200), t).await {
            Ok(Ok(ok)) => ok,
            Ok(Err(_)) => String::from("<任务内部出错>"),
            Err(_) => String::from("<超时>"),
        };
        results.push(r);
    }
    println!("{results:#?}");
}

13.9 📝 动手练习

参考实现放 code/13-async/(写作时同步给出),本课工程需 Cargo.toml 加 tokio 依赖。

  1. 运行时验证 :写 async fn 里分别用 #[tokio::main] 与手工 block_on 两种方式运行,体会"async fn 不跑"到底是什么意思。
  2. 并发 vs 顺序 :两个各睡 200ms 的 async fn,先顺序 await 测耗时(应 ~400ms),再 tokio::spawn + join 测耗时(应 ~200ms),贴出两次 Instant 计时结果。
  3. channel 流水线 :生产 1..=100 经 mpsc 发给消费者,消费者求和返回,验证 5050;再用 select! 在消费的同时每 100ms 打一次"心跳"。
  4. timeout 降级 :mock 一个"永远 pending"的 future(futures::future::pending() 或死循环 sleep),用 timeout(100ms) 保证 100ms 后能正常返回降级结果------这是"外部 LLM 不响应"演练。
  5. 超时与取消:观察 timeout 触发后,future 内的循环是否还打印(体会 drop 取消语义)。
  6. oneshot 应答let (tx, rx) = tokio::sync::oneshot::channel(),spawn 一个任务算 1_000_000 内质数个数后 tx.send,主任务 rx.await 取结果。
  7. spawn_blocking :把 1..=1_000_000u64 求和(纯 CPU)放 spawn_blocking 里跑,对比在主 async 里直接跑,体会对 worker 线程的占用(可同时开一个每秒打印的心跳任务观察卡顿差异)。
  8. 综合(重点) :实现"并发下载 3 个 mock 数据 + 任一超时 150ms 即放弃整体":select! 同时等 3 个任务的 JoinHandle 或一个统一 timeout;用 biased; 观察分支优先级差异并注释说明。

验收门禁 :能解释 async fn 返回 Future 且需要执行器;spawn 与直接 .await 的并发差异;.await 挂起与 std::thread::sleep 阻塞的区别;timeout 丢弃 future = 取消;async 任务要求 Send + 'static 的原因。

✅ 本节小结

  • Future 模型 :惰性状态机,执行器 poll 驱动,.await 是让出点;
  • tokio#[tokio::main] 建立多线程运行时;join!/try_join! 聚合并发 future;
  • spawn :任务脱离主流程并发跑,返回 JoinHandle;约束 Send + 'static
  • 同步原语tokio::sync 的 mpsc/watch/broadcast/oneshot 都基于 await 而非阻塞;
  • 编排timeout(超时=取消 future)、select!(多路复用)、spawn_blocking(把阻塞活丢出 worker 线程);
  • 红线:async 上下文里不 sleep/不阻塞(换 tokio::time / spawn_blocking);共享状态用 Arc 收进任务;
  • 认知:async 是"更省的并发单元",线程与 async 各司其职、常混合使用。

下一课预告 :第 14 课《网络请求与 JSON:reqwest + serde》------把 async 用起来:发 HTTP 请求、把 JSON 反序列化成领域类型、处理错误与超时。serde 的 derive 流程你已在 6-8 课反复铺垫,这课正式授衔。13 课 + 14 课 = 15 课 SSE 流式读取的所有前置。

相关推荐
传奇开心果编程2 小时前
【Rust入门练中学】第4课:函数与所有权入门
开发语言·学习·rust
右耳朵猫AI3 小时前
Rust周刊2026W38 | mold重写Rust、认证级Rust裸机、Slint 1.18发布、lint提速3133倍
后端·rust·系统编程
Source.Liu3 小时前
【A11】时间单位与精度设计笔记
rust
梦醒沉醉18 小时前
std1.97.1——cmp模块细览
rust
PC2005-cloud21 小时前
Rust学习笔记:错误处理——panic!、Result、Option与_运算符
rust
Amos_Web1 天前
Rspack 源码解析(十):Hash 与 Asset 生成
前端·rust·源码阅读
柯南46681 天前
【AI开发之Rust】第 12 课:并发模型与同步原语
rust·编程语言