# Tokio学习文档 **Repository Path**: adminfern/Tokiodocument ## Basic Information - **Project Name**: Tokio学习文档 - **Description**: **适合人群:** 本书专为零基础前端新手打造,只要你会一点点 HTML/CSS/JavaScript,就能跟着一步步学会 Naive UI。 **版本:** v3.0(企业实战扩展版 · 含天气查询完整项目) **适用 Naive UI 版本:** 2.x(Vue 3 + TypeScript) **阅读建议:** 按篇章顺序阅读,每章末尾的「本章踩坑小结」和「本章回顾」一定要看! - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-07-18 - **Last Updated**: 2026-10-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Tokio 从零入门 > 一本通俗易懂、内容详尽的 Rust 异步编程实战教程 **适用读者**:已掌握 Rust 基础语法(变量、所有权、结构体、trait、错误处理),希望系统、深入地学习异步编程与 Tokio 的开发者。 **代码环境**:Rust 1.75+ / Tokio 1.x。本书假设你已经会创建 Cargo 项目、添加依赖并运行 `cargo run`(这些基础操作不再赘述);所有示例的注释全部使用中文,可直接复制到自己的项目中运行。 --- ## 前言 如果你写过 Rust 的同步代码,一定体验过"所有权检查器带来的痛苦与安全感"。而当你第一次想写一个网络服务器、一个定时任务、或者一个需要同时等待多件事的程序时,你会发现:标准库的 `std::thread` 虽然简单,但一个线程要占几 MB 内存,开上千个线程系统就吃不消了。 这时候就该异步登场了。**异步编程让我们用极少的线程同时处理成千上万个任务**——这正是 Nginx、Redis 这类高性能软件的秘密。 Rust 异步生态的事实标准是 **Tokio**:目前世界上使用最广泛的 Rust 异步运行时,Discord、AWS、微软、阿里云等公司都在生产环境使用它。你将来要用的 Web 框架(axum、actix-web)、数据库驱动(sqlx)、RPC 框架(tonic),底层都是 Tokio。 本书的目标很朴素:**从零开始,把 Tokio 的每一个模块都讲透、用熟**。为了做到"讲透",本书不满足于"API 能跑通",而是每个主题都回答三层问题: 1. **是什么**——这个 API 的准确语义是什么?(很多坑源于语义理解偏差) 2. **为什么**——它底层是怎么工作的?(懂原理才能预判问题) 3. **怎么用**——生产环境里的标准用法和最佳实践是什么?(每章配完整可运行示例) 读完本书,你将能够: - 理解 `async/await` 背后的状态机、`Future`、`Pin`、`Waker`,不再对异步报错感到恐慌; - 熟练使用 Tokio 全部核心模块:`task`、`runtime`、`io`、`net`、`time`、`fs`、`process`、`signal`、`sync`、宏; - 掌握配套工具箱 `tokio-stream`、`tokio-util`、`bytes` 字节缓冲四件套(`Buf`/`BufMut`/`Bytes`/`BytesMut`)和测试设施(虚拟时间、duplex); - 独立完成三个实战项目:并发爬虫、多人聊天室、迷你 Web 服务,并理解每个设计决策背后的"为什么"。 **如何阅读本书**:第 1~3 章是地基,请务必按顺序读完;第 4~13 章是核心模块,可按顺序也可当手册查阅;第 14~18 章是进阶,建议通读;第 19~22 章是实战,跟着敲一遍收获最大。每章末尾有"本章要点小结",关键处有"⚠️ 常见坑"提示。 **按需取阅路线**:只想快速上手写服务 → 第 2、3、4、7、8、12 章;要写生产级后端 → 全书 + 实战三章精读;排查诡异问题 → 第 17 章(取消安全)+ 第 19 章(调优)+ 附录 C(FAQ)。 **约定**:书中 `main.rs` 均指 `src/main.rs`;示例若需要额外依赖,会注明 `Cargo.toml` 的写法。 --- ## 全书目录 ### 第 1 部分 入门篇 - 第 1 章 初识异步编程与 Tokio - 第 2 章 第一个 Tokio 程序 - 第 3 章 异步编程核心概念 ### 第 2 部分 核心模块篇 - 第 4 章 任务管理:task 模块 - 第 5 章 运行时:runtime 模块 - 第 6 章 异步 IO:io 模块 - 第 7 章 网络编程:net 模块 - 第 8 章 时间管理:time 模块 - 第 9 章 文件操作:fs 模块 - 第 10 章 进程管理:process 模块 - 第 11 章 信号处理:signal 模块 - 第 12 章 同步原语 sync(上):通道 - 第 13 章 同步原语 sync(下):锁与协调 ### 第 3 部分 进阶篇 - 第 14 章 并发宏:join!、select!、pin! - 第 15 章 流处理:tokio-stream - 第 16 章 工具集:tokio-util 与 bytes 字节缓冲 - 第 17 章 错误处理与取消安全 - 第 18 章 测试异步代码 ### 第 4 部分 实战篇 - 第 19 章 性能调优与最佳实践 - 第 20 章 实战项目一:并发网络爬虫 - 第 21 章 实战项目二:多人聊天室 - 第 22 章 实战项目三:迷你 Web 服务与全书总结 ### 附录 - 附录 A Tokio feature 特性开关全解 - 附录 B Tokio 与标准库 API 对照表 - 附录 C 常见报错与 FAQ - 附录 D 常用 API 速查表 - 附录 E 易漏 API 补遗(全景清单) --- # 第 1 部分 入门篇 # 第 1 章 初识异步编程与 Tokio > 本章目标:建立对"异步"的准确直觉——它解决什么问题、不解决什么问题;看清 Rust 异步生态的三层结构,知道 Tokio 在其中扮演什么角色;认识 Tokio 的全部模块,拿到全书地图。 ## 1.1 从餐厅服务员说起 想象一家餐厅里有两种经营模式。 **模式一:一个服务员死等一桌客人。** 服务员把菜单递给 1 号桌后,就站在桌边一动不动,直到客人点完菜、上完菜、吃完结账,他才转身去招呼 2 号桌。哪怕客人只是看着菜单发呆十分钟,服务员也只能干站着。想让餐厅接待更多客人?只能再雇服务员(线程),而每个服务员都要一份工位、一口储物柜(内存),雇多了餐厅开支爆炸。 **模式二:一个服务员来回跑。** 服务员把菜单递给 1 号桌后说"您先看着,想好了叫我",转身就去给 2 号桌上菜;2 号桌说菜还没炒好,他就再去 3 号桌收台。谁"好了"(菜上齐了、客人买单了),他就去处理谁。一个服务员,照样让整家餐厅转得飞快。 模式二就是**异步编程**:服务员是 CPU 线程,客人的各种请求是任务(Task),"您先看着,想好了叫我"就是 `await`——**等待的时候不占着线程,而是先去干别的活**。 用数字感受一下差距。假设接待 1 万桌客人,每桌平均有 9 分钟在"干等"(看菜单、等上菜): - 模式一:需要约 1 万名服务员同时在场(线程),每人一套 8MB 的储物柜(线程栈)——仅储物柜就 80GB; - 模式二:客人"干等"时服务员根本不在场,几十名服务员(线程)足以应付——总内存不到 1GB。 这就是异步的第一性原理:**等待是大多数任务的常态,让"等待"不占用执行资源,就是最大的优化**。 ## 1.2 程序的时间都花在哪了 写高性能程序前,先要知道"慢"从哪来。把一个网络服务的单次请求拆开看: ``` [解析请求] ~微秒级 ← CPU 在干活 [业务计算] ~微秒级 ← CPU 在干活 [查数据库] ~毫秒~百毫秒 ← 等网络往返,线程闲着 [读文件] ~毫秒级 ← 等磁盘,线程闲着 [调用下游 API] ~几十毫秒 ← 等网络,线程闲着 [组装响应] ~微秒级 ← CPU 在干活 ``` 关键观察:**90% 以上的时间,CPU 其实没事干,只是在"等"**。等待分两类: - **等 IO**:网络数据没到、磁盘没读完; - **等时间**:定时器没到点、需要"过 5 秒再重试"。 同步代码对付这些等待的方式是"傻等"——线程停在系统调用上,什么都不做。异步代码的方式是"登记 + 离开":把"数据到了叫我"的登记表交给操作系统(epoll / IOCP / kqueue 这些多路复用机制),线程转身去处理别的任务。**这套"登记表 + 回来叫我"的机制,就是事件驱动(event-driven)架构**,Nginx 凭它单机扛住数万并发连接,Redis 凭它做到十万级 QPS,Node.js 凭它用单线程服务海量请求。 Rust 的 Tokio 做的是同一件事,但多了一样别人没有的东西——**编译期所有权检查**带来的"无所畏惧的并发":数据竞争在编译期就被拦下,异步任务在多线程间传递数据不需要担心悬垂指针。这是 Rust 异步相对其他语言的核心优势。 ## 1.3 分清四个词:串行、并发、并行、异步 初学者最容易混的四个概念,用一张表钉死: | 概念 | 含义 | 生活类比 | |---|---|---| | **串行** | 一件事做完再做下一件 | 排一个队,一次服务一人 | | **并发(Concurrency)** | 多件事**交替推进**,宏观上同时进行 | 一个服务员同时照顾 10 桌 | | **并行(Parallelism)** | 多件事**物理上同时**执行 | 4 个服务员各自照顾一桌 | | **异步(Async)** | 实现"并发"的一种手段:等待时不占执行资源 | "您先看着,想好了叫我" | 关系:异步是实现并发的手段;并发不一定并行(单线程也能并发,如 Node.js);并行也不一定要异步(多线程同步代码就是并行)。Rust 里对应关系是: ``` 并发手段 → Rust 实现 → 适合场景 异步任务 → tokio::spawn / await → 海量 IO 密集(网络/定时/文件) OS 线程 → std::thread::spawn → 少量 CPU 密集 进程 → std::process → 隔离性要求高、复用现有程序 ``` 本书主角是第一行,但会反复与第二行配合(见第 4、19 章)。 ## 1.4 Rust 异步生态全景 Rust 的异步是"语言 + 标准库 + 第三方运行时"三层结构,初学者最容易混淆,我们逐层拆开看: | 层 | 谁负责 | 干什么 | |---|---|---| | 语言层 | `async`、`await` 关键字 | 语法糖:把一段代码编译成一个**状态机** | | 标准库层 | `std::future::Future`、`std::task::Pin`、`std::task::Waker` | 定义"可暂停计算"的通用接口 | | 运行时层 | **Tokio**(以及 async-std、smol 等) | 真正驱动 Future 执行的"发动机":调度任务、监听 IO 事件、管理定时器 | 关键结论:**Rust 语言本身不会执行异步代码**。`async fn` 只是造出了一台"发动机零件"(Future),你还需要一台"发动机"(运行时)来启动它。主流选择对比: | 运行时 | 特点 | 适合 | |---|---|---| | **Tokio** | 功能最全、生态最大(axum/sqlx/tonic 全靠它)、文档最丰富 | 服务端开发的事实标准,本书主角 | | async-std | API 模仿 std,设计优雅 | 生态已式微,新项目不推荐 | | smol | 极小、组合式 | 嵌入式、极简依赖场景 | | embassy | no_std 异步 | 嵌入式单片机 | > 💡 面试常问:"为什么 `async fn main()` 不行?" 因为语言只负责生成 Future,必须有人来执行它。Tokio 提供的 `#[tokio::main]` 宏就是帮你把 `main` 包进运行时里执行,第 2 章你会看到它的完整拆解。 那为什么是 Tokio?除了生态,还有三个工程上的理由: 1. **生产验证**:Discord 用它扛住了千万级并发的长连接; 2. **全家桶**:运行时之外,网络、时间、文件、进程、信号、同步原语、测试设施一应俱全(见下一节); 3. **可观测**:tokio-console 调试器、metrics 指标、tracing 集成,出了问题查得动(第 19 章)。 ## 1.5 Tokio 全家福:模块一览与全书地图 Tokio 不只是一个库,而是一个 crate 家族。主 crate `tokio` 内部按功能划分为若干模块,本书第 2 部分会逐一攻克: | 模块 | 一句话介绍 | 本书章节 | |---|---|---| | `tokio::runtime` | 运行时:构建与配置"发动机" | 第 5 章 | | `tokio::task` | 任务:创建、管理、取消异步任务 | 第 4 章 | | `tokio::io` | 异步 IO trait、缓冲、标准输入输出 | 第 6 章 | | `tokio::net` | TCP / UDP / Unix 套接字 / Windows 命名管道 | 第 7 章 | | `tokio::time` | 睡眠、定时器、超时 | 第 8 章 | | `tokio::fs` | 异步文件与目录操作 | 第 9 章 | | `tokio::process` | 异步执行外部命令、管理子进程 | 第 10 章 | | `tokio::signal` | 监听 Ctrl+C 等系统信号 | 第 11 章 | | `tokio::sync` | 通道(mpsc/oneshot/broadcast/watch)、锁、信号量 | 第 12、13 章 | | `tokio::macros` | `#[tokio::main]`、`join!`、`select!`、`pin!` 等宏 | 第 2、14 章 | | `tokio::prelude` | 一站式导入常用 trait 的预lude 模块 | 各章 | 配套 crate(功能不塞进主 crate,按需引入): - **`tokio-stream`**:`Stream`(异步迭代器)的适配器与桥接工具(第 15 章); - **`tokio-util`**:编解码器 codec、取消令牌 CancellationToken、任务追踪器等实用工具(第 16 章); - **`bytes`**:字节缓冲四件套(`Buf`/`BufMut`/`Bytes`/`BytesMut`),经 `tokio_util::bytes` 与 Tokio 生态无缝配合(第 16 章); - **`tokio-test`**:测试辅助(第 18 章)。 全书学习地图(建议按编号顺序): ``` ┌──────────── 入门 ────────────┐ │ ① 异步直觉 ② 第一个程序 ③ 核心概念 │ └──────────────┬───────────────┘ ▼ ┌────────────── 核心模块 ──────────────┐ │ ④任务 ⑤运行时 ⑥IO ⑦网络 ⑧时间 │ │ ⑨文件 ⑩进程 ⑪信号 ⑫通道 ⑬锁 │ └──────────────┬───────────────┘ ▼ ┌──────────────── 进阶 ────────────────┐ │ ⑭并发宏 ⑮流 ⑯工具箱 ⑰取消安全 ⑱测试 │ └────────────────┬─────────────────┘ ▼ ┌──────────────── 实战 ────────────────┐ │ ⑲调优 ⑳爬虫 ㉑聊天室 ㉒Web 服务 │ └───────────────────────────────────┘ ``` ## 1.6 特性开关(feature flags):按需取用 Tokio 把功能拆成一个个"特性开关"(Cargo feature),你只用网络功能时就不必编译文件、进程等代码,**编译更快、二进制更小**。在 `Cargo.toml` 中这样选择: ```toml [dependencies] # 方式一:学习阶段,一把梭,全都要 tokio = { version = "1", features = ["full"] } # 方式二:实际项目,按需组合(只启用运行时、宏、网络、时间) # tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util"] } ``` 常用特性速览(完整清单见附录 A):`full`(全部)、`rt-multi-thread`(多线程运行时)、`macros`(`#[tokio::main]` 等)、`net`、`io-util`、`io-std`、`time`、`fs`、`process`、`signal`、`sync`。 > ⚠️ 常见坑:特性没开够时,可能编译通过但运行时 panic 提示缺少某特性,也可能直接编译报错"method not found"。初学阶段直接用 `full` 最省心;确认哪个 API 属于哪个特性,查 docs.rs 上该 API 文档顶部的 Feature flags 标注即可。 ## 1.7 先尝一口:10 行代码的 Tokio 程序 不用管细节,先感受节奏——这个程序同时启动了 3 个各睡 1 秒的任务: ```rust // Cargo.toml: tokio = { version = "1", features = ["full"] } use std::time::Duration; #[tokio::main] // 把 main 函数变成异步入口:内部会创建并启动 Tokio 运行时 async fn main() { let start = std::time::Instant::now(); // 启动一个异步任务:它不会阻塞主流程,而是和 main 并发运行 let task1 = tokio::spawn(async { tokio::time::sleep(Duration::from_secs(1)).await; // 异步睡眠:等待时不占用线程 println!("任务 1 醒了!"); }); // 再来一个任务 tokio::spawn(async { tokio::time::sleep(Duration::from_secs(1)).await; println!("任务 2 醒了!"); }); tokio::time::sleep(Duration::from_secs(1)).await; // 主任务也睡 1 秒 let _ = task1.await; // 等待任务 1 结束,取回它的结果 println!("全部完成,总耗时约 {:?}(而不是 3 秒)", start.elapsed()); } ``` 运行输出(顺序可能略有不同): ```text 任务 2 醒了! 任务 1 醒了! 全部完成,总耗时约 1.0005s(而不是 3 秒) ``` 三个各睡 1 秒的任务,总耗时只有 1 秒。时序图: ``` 时间 ─────────────────────────────────────► main :|--睡1s--| 任务 1 : |--睡1s--| 打印 任务 2 : |--睡1s--| 打印 ▲ 1 秒时三者同时"醒来",总耗时 = max(各任务) 而不是 sum ``` 这背后每个部件(宏、spawn、sleep、await)都值得单独一章,后面会逐一拆解。 ## 1.8 什么时候用异步,什么时候用线程 最后给一张决策表,替你省掉"我该不该上异步"的纠结: | 场景 | 建议 | 理由 | |---|---|---| | 网络服务器、爬虫、代理 | ✅ 异步 | 海量等待型连接 | | 同时等待多个下游 API | ✅ 异步 | `join!`/`spawn` 轻松并发 | | 定时任务、心跳、超时 | ✅ 异步 | `time` 模块天然支持 | | 视频转码、加解密、图像处理 | ❌ 多线程 / 进程 | CPU 密集,异步无益(用 `spawn_blocking` 衔接) | | 简单脚本、一次性任务 | 看情况 | 逻辑简单时同步代码更直白 | | GUI 程序后台任务 | ✅ 异步 | 不卡 UI 线程 | | 嵌入式、单片机 | 视环境 | embassy 等 no_std 异步方案 | > 💡 记住:异步不是"更高级",只是"更适合某些问题"。CPU 密集的活,老老实实多线程;IO 密集的活,异步是唯一正解。 ## 本章要点小结 - 异步解决的是"**大量等待型任务**的并发"问题:等待时不占线程,用少量线程服务海量任务;它不加速 CPU 计算。 - 串行/并发/并行/异步四词辨析:异步是手段,并发是效果,并行是物理同时。 - Rust 异步分三层:语言只造 Future(状态机),运行时(Tokio)负责真正执行——这就是 `async fn main` 不行的原因。 - Tokio = 调度器 + IO 反应器 + 定时器 + 全套异步工具,是 Rust 异步生态的事实标准。 - 功能按 feature 开关引入,学习期用 `features = ["full"]`。 # 第 2 章 第一个 Tokio 程序 > 本章目标:不依赖任何环境搭建教程,直接从一个最小项目出发,把"一个 Tokio 程序由哪些部分组成"拆到骨头——依赖怎么配、`#[tokio::main]` 到底做了什么、prelude 里有什么、最常见的编译错误长什么样。 ## 2.1 最小项目与依赖配置 在任意目录执行 `cargo new hello_tokio` 即可得到标准项目结构: ``` hello_tokio/ ├── Cargo.toml # 项目清单:名字、版本、依赖 └── src/ └── main.rs # 程序入口 ``` 打开 `Cargo.toml`,在 `[dependencies]` 段加入 Tokio: ```toml [package] name = "hello_tokio" version = "0.1.0" edition = "2021" [dependencies] # "full" 表示启用 Tokio 全部特性,学习阶段最方便 tokio = { version = "1", features = ["full"] } # 本书后半部分会用到的配套 crate,可先一并加入 tokio-stream = { version = "0.1", features = ["full"] } # 流处理(第 15 章) tokio-util = { version = "0.7", features = ["full"] } # 工具箱(第 16 章) bytes = "1" # 字节缓冲四件套(第 16 章;也可用 tokio_util::bytes 重导出) futures-util = { version = "0.3", default-features = false, features = ["sink"] } # Sink 扩展(第 16 章) ``` 依赖语法拆解:`version = "1"` 表示"兼容 1.x 的最新版"(SemVer 语义化版本,Cargo 会生成 `Cargo.lock` 锁定精确版本);`features = [...]` 是这个 crate 开放的"功能开关",第 1.6 节见过的 `full` 就是全部开关的集合。 > 💡 开发依赖放 `[dev-dependencies]` 段:只在测试/示例中用到的库(如 `tokio-test`)放这里,不会进入发布产物的依赖树。 ## 2.2 Hello Tokio:逐行拆解 把 `src/main.rs` 替换为: ```rust use std::time::Duration; // 引入标准库的时间类型,用来表示"时长" // #[tokio::main] 是一个属性宏,它会把下面的 async fn main // 转换成:创建一个 Tokio 运行时,并在其中执行这个异步 main #[tokio::main] async fn main() { println!("程序启动"); // 睡 1 秒:注意这是 Tokio 的异步 sleep,不是 std::thread::sleep // 两者的本质区别:异步 sleep 在等待期间会让出线程去跑别的任务 tokio::time::sleep(Duration::from_secs(1)).await; println!("1 秒后,Hello Tokio!"); } ``` 运行与输出: ```bash cargo run # 程序启动 # (约 1 秒后) # 1 秒后,Hello Tokio! ``` 这个 10 行程序包含了 Tokio 程序的全部三要素: 1. **运行时**(宏创建)——异步代码的执行环境; 2. **异步入口**(`async fn main`)——程序主任务; 3. **异步操作 + await**(`sleep(...).await`)——一次可暂停的等待。 后面所有章节,只是这三要素的排列组合与深入展开。 ## 2.3 #[tokio::main] 的真相:宏展开 `#[tokio::main]` 只是语法糖,下面两段代码**完全等价**: ```rust // 写法一:宏,简洁(推荐日常使用) #[tokio::main] async fn main() { println!("Hello Tokio!"); } // 写法二:宏展开后的真面目 fn main() { // 1. 创建一个多线程运行时,并启用 IO 与时间两大驱动 let rt = tokio::runtime::Runtime::new().unwrap(); // 2. block_on:把一个异步 future 交给运行时执行, // 并阻塞当前线程(main 线程)直到它完成 rt.block_on(async { println!("Hello Tokio!"); }); // 3. block_on 返回后,rt 被 drop,运行时关闭,进程退出 } ``` 看懂展开形式非常重要,它揭示了三个事实: - **异步程序仍然是普通进程**:main 线程真实存在,它的工作是"开着发动机等业务跑完"; - **`block_on` 是同步世界通往异步世界的唯一大门**:同步代码 → `block_on` → 异步世界(第 5 章详谈); - **运行时是一个值**(`Runtime` 结构体):它有生命周期,drop 时关闭。理解这一点,才能解释"为什么不能在异步代码里再建运行时"(第 5.5 节的经典大坑)。 > ⚠️ 常见坑:展开写法中忘记 `.unwrap()` 或在 `block_on` 之前就使用 `tokio::spawn`,都会 panic(后者报"no reactor running"——任务必须投递给某个运行时)。 ## 2.4 运行时参数:flavor 与 worker_threads `#[tokio::main]` 支持参数,用来切换运行时型号(第 5 章深入): ```rust // 默认:多线程运行时(工作线程数 = CPU 核心数) #[tokio::main] async fn a() {} // 切换为单线程(current_thread)运行时:所有任务跑在主线程上 // 好处:任务不要求 Send、调度开销更低;适合工具型程序 #[tokio::main(flavor = "current_thread")] async fn b() {} // 多线程 + 指定工作线程数为 4 #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn c() {} // 测试专用:启动即暂停虚拟时钟(sleep 瞬间完成,第 18 章详解) #[tokio::test(start_paused = true)] async fn d() {} ``` ## 2.5 prelude:一次导入常用工具 Tokio 提供了 `tokio::prelude` 模块,把最常用的 trait 打包在一起: ```rust // 一行导入大部分章节示例需要的 trait use tokio::prelude::*; // 效果等价于分别写: // use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt, AsyncSeekExt}; // use tokio::net::TcpStream; 以及 Future/Stream 等基础 trait 的重新导出 ``` 本书示例为了明确"这个方法来自哪个 trait",多数会显式写 `use tokio::io::AsyncReadExt;` 这样的导入——刻意"啰嗦"是为了帮你建立 API 的空间感:看到 `read_to_end` 你会知道它来自 `AsyncReadExt`,而不是某个类型的自带方法。 > ⚠️ 常见坑:遇到"**no method named `read` found for ...**"这类编译错误,九成是忘了导入对应的 Ext trait。看到方法找不到,先查 `use`,再查特性开关是否启用(比如 `lines()` 需要 `io-util`)。 ## 2.6 第一个并发程序:三个任务赛跑 在 Hello Tokio 基础上加点料,直观感受"并发"——三个任务各睡不同时长,看输出顺序: ```rust use std::time::Duration; #[tokio::main] async fn main() { let start = std::time::Instant::now(); // 任务 A:睡 300ms let a = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(300)).await; "A(300ms)" }); // 任务 B:睡 100ms —— 最快完成 let b = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(100)).await; "B(100ms)" }); // 任务 C:睡 200ms let c = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(200)).await; "C(200ms)" }); // 主任务按 spawn 顺序等待三个任务 // (注意:等待顺序 ≠ 完成顺序,这里只是 A、B、C 依次收结果) println!("先等到的:{}", b.await.unwrap()); // B 最先好,但排在第二等 println!("再等到的:{}", c.await.unwrap()); println!("最后等的:{}", a.await.unwrap()); println!("总耗时:{:?}", start.elapsed()); // 约 300ms,不是 600ms } ``` 运行输出: ```text 先等到的:B(100ms) 再等到的:C(200ms) 最后等的:A(300ms) 总耗时:300.4ms ``` 三个信息值得咀嚼: 1. **总耗时 ≈ 最慢者**(300ms),因为三个任务是并发推进的; 2. **等待顺序可以与完成顺序不同**:B 先完成,但主任务先等的也是 B,纯属巧合;如果把三行 `await` 顺序倒过来,B 完成后也要干等主任务来收; 3. 想要"谁先完成先收谁",用第 4 章的 `JoinSet`,或第 14 章的 `select!`。 ## 2.7 编译错误急救站 初学 Tokio,80% 的报错是下面三种。提前见一次,以后不再慌: **急救一:方法不存在(no method named ...)** ```text error[E0599]: no method named `read` found for struct `TcpStream` ``` 原因:扩展方法藏在 Ext trait 里,没导入。修复:`use tokio::io::AsyncReadExt;`。 **急救二:特性缺失(运行时 panic)** ```text panicked: `sleep` requires the `time` feature flag ``` 原因:`Cargo.toml` 特性没开够。修复:按提示补 features,或直接 `features = ["full"]`。 **急救三:忘记 await(must_use 警告)** ```text warning: unused implementer of `Future` that must be used futures do nothing unless you `.await` or poll them ``` 原因:创建了 Future 但没驱动。修复:补 `.await`(要结果)或 `tokio::spawn`(要并发)。这是异步 Rust 最著名的警告——**看到 must_use,先检查是不是漏了 await**。 ## 本章要点小结 - Tokio 程序三要素:运行时(`#[tokio::main]` 创建)、异步入口、异步操作 + `.await`。 - `#[tokio::main]` = `Runtime::new()` + `block_on`;运行时是"值",有生命周期,可配置(`flavor`、`worker_threads`)。 - `tokio::prelude` 一站式导入常用 trait;显式导入则能强化"方法来自哪个 trait"的空间感。 - 并发程序的总耗时 ≈ 最慢任务,而非任务之和;"等待顺序"与"完成顺序"是两回事。 - 三大高频报错各有一次:方法找不到 → 导 Ext trait;特性缺失 → 补 features;must_use → 补 await/spawn。 # 第 3 章 异步编程核心概念 > 本章目标:把 `async/await` 背后的机制一次性讲透——async fn 如何变成状态机、`.await` 的确切语义、`Future/Waker/Pin` 各自的角色、运行时如何驱动一切。这一章是全书的地基,慢读值得。 ## 3.1 async fn:从函数到状态机 在函数前加 `async`,就把它变成了一个**异步函数**。第一个惊人事实是: > **调用 async fn 不会执行函数体,它只是返回一个值——一个 `Future`。** ```rust // 这是一个异步函数 async fn make_coffee() -> String { // 这里的代码在"调用"它时,一行都不会执行! String::from("咖啡好了") } async fn demo() { let fut = make_coffee(); // 注意:没有任何输出!此时只是拿到了一个 Future // println!("{}", fut); // 编译错误:fut 不是 String,而是一个 Future let coffee = fut.await; // 只有 .await 之后,函数体才真正开始执行 println!("{}", coffee); // 咖啡好了 } ``` 可以把 `Future` 理解成一张**取餐凭条**:你点了单(调用 async fn),拿到凭条(Future),但咖啡还没做(代码没执行)。什么时候做?.await 的时候(以及运行时调度它的时候)。 **编译器到底把 async fn 改写成了什么?** 一个状态机(state machine)。以上面这个例子想象:函数体被切成若干段,每个 `.await` 是一个"档位",切分点之间的局部变量被**存进状态机结构体**里,以便暂停后恢复现场: ```rust // 你写的代码: async fn process(order_id: u32) -> String { let order = fetch_order(order_id).await; // 暂停点 1 let paid = charge(order.price).await; // 暂停点 2(order 要跨过暂停点 2 存活) format!("订单 {} 已收款 {}", order_id, paid) } // 编译器眼中等价的东西(伪代码): struct ProcessFuture { state: u8, // 执行到哪个档位了 order_id: u32, // 跨越 await 存活的局部变量 order: Option, // 跨越 await 存活的局部变量 // 内部还嵌着 fetch_order、charge 两个子 Future…… } ``` 两个由此而来的重要推论: 1. **Future 的大小 = 所有跨越 await 点存活的局部变量之和**。在 await 前后"夹带"大数组(比如 1MB 的缓冲区)会让每个任务膨胀 1MB——这就是为什么要用 `BufReader` 复用缓冲、用 `Bytes` 共享数据(第 19 章); 2. **await 之间的代码是"原子的"**:任务切换只发生在 `.await` 处,两段 await 之间的同步代码不会被其他任务打断。这既简化了推理(不用担心中途被抢),也埋下隐患(中间放长耗时操作会卡住整条线程,见 3.7)。 ## 3.2 .await:等待,但不堵路 `.await` 只能出现在异步上下文中(async fn、async 块、或被 spawn 的 async 表达式)。它的确切语义: 1. 询问这个 Future:"好了吗?"(poll 一次); 2. 没好?**让出当前线程**——线程转身去跑其他任务,自己挂起登记"数据到了叫我"; 3. 将来某个时刻条件满足(数据到了、时间到了),运行时把你从这个 await 点**唤醒**,从这一行继续往下执行。 ```rust async fn order() { println!("下单"); cook().await; // 在这里暂停:线程去跑别的任务了 println!("取餐"); // cook 完成后,从这里继续 eat().await; println!("吃完走人"); // 每一个 await 都是一个潜在的暂停点 } # async fn cook() {} # async fn eat() {} ``` > ⚠️ 常见误区一:**忘记 .await**。`Future` 实现了 `must_use`,忘记 await 时编译器会警告 `unused ... that must be used`,此时异步代码根本没执行。看到这个警告,十有八九是漏了 `.await` 或者忘了 `spawn`。 > 💡 await 的"位置"也有讲究:`let a = step1().await; let b = step2().await;` 是串行(总耗时 = 两者之和);`tokio::join!(step1(), step2())` 是并发(总耗时 ≈ 最长者)。**await 的写法直接决定并发结构**,这是 3.8 节的主题。 ## 3.3 Future trait:poll 循环与 Waker `Future` 是标准库定义的核心 trait,简化后长这样: ```rust pub trait Future { type Output; // 完成后产出的值的类型 // 由运行时反复调用:查询这个 future 是否完成 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll; } pub enum Poll { Ready(T), // 完成了,结果在这里 Pending, // 还没好,先去忙别的 } ``` 工作流程画出来是一个循环: ``` ┌────────────────────────────────────────────┐ │ │ ▼ │ 运行时 poll(future) ──Pending──► 登记 Waker,去跑别人 │ │ │ │ 事件发生 Ready (数据到达/ │ 时间到点) ▼ │ 取出结果,继续 await 之后的代码 ◄──────────────────┘ (Waker 触发 → future 重新进入调度队列) ``` `Waker`(唤醒器)是这个机制的关键角色:每次 poll 返回 `Pending` 时,运行时都会给 future 一张"唤醒卡"。当底层事件发生——网卡收到数据、定时器到点——唤醒卡的 `wake()` 被调用,这个任务就被重新放回调度队列,等待下一次 poll。 **洗衣机类比完整版**:你(运行时)同时看管 100 台洗衣机(任务)。逐台检查(第一次 poll),没洗完的(Pending)都记下"预计完成时间/完成的信号",然后去干别的;任何一台"嘀嘀"响了(Waker 触发),你再过去看它(重新 poll)。你永远不必盯着某一台干等——这就是"少量线程驱动海量任务"的全部秘密。 ## 3.4 Pin:为什么要"钉住"未来 `poll` 的签名里有个奇怪的 `self: Pin<&mut Self>`——`Pin`(钉住)是异步 Rust 最劝退的概念,但日常开发你几乎不需要手动操作它,理解动机即可。 问题根源:状态机结构体里可能存着**指向自己内部的引用**。比如: ```rust async fn demo() { let buf = vec![0u8; 64]; let n = reader.read(&mut buf).await; // buf 的引用被存进了 read 返回的 Future 里! // ^ 这个 Future 内部记着 "&buf[0]" 这样的地址 } ``` 如果这个状态机在暂停期间被**搬家**(内存移动,比如从栈上 move 到堆上、或者被装箱),它内部记录的 `&buf` 就指向了旧地址——悬垂指针,内存不安全。Rust 的解法:`Pin` 是一个类型系统层面的承诺——**被 Pin 住的东西,在 Drop 之前不允许再被移动**。运行时只会以 `Pin<&mut Future>` 的方式触碰 future,从此谁也搬不动它。 日常开发中,`Pin` 被三层宏/库吸收掉了: - `async fn`/`async` 块产生的 future 天然"自持"(自引用安全); - `tokio::spawn`/`block_on` 内部自动把它装箱(`Box::pin`)钉在堆上; - 需要手动处理时,用 `tokio::pin!`(第 14 章)钉在栈上,或 `Box::pin` 钉在堆上。 > 💡 一句话:Pin 解决的是"自引用结构不能被 move"的问题;你负责在正确的位置 `pin!`,剩下交给编译器和运行时。 ## 3.5 运行时解剖:调度器、反应器、定时器 再强调一次本章最重要的架构图: ``` 异步任务(Future)们 → 交给 → Tokio 运行时 → 跑在 → 少量 OS 线程上 │ ├── 调度器(Scheduler) │ 决定哪个任务在哪个线程上跑; │ 多线程模式下用"工作窃取"(work stealing): │ 忙的线程从闲的线程队列里偷任务,天然负载均衡 ├── 反应器(Reactor) │ 封装操作系统的多路复用: │ Linux epoll / macOS kqueue / Windows IOCP │ 一条线程即可监听上万个 socket 的"数据到了"事件 └── 定时器(Timer) 时间轮算法管理海量 sleep/timeout, 百万个定时器也只占极小开销 ``` 运行时内部通常只有 CPU 核心数那么几条工作线程(worker thread),但它们永不停歇:一个任务进入等待就让位,成千上万个任务就这样被"分时复用"在几条线程上——就像 1.1 节里那位来回跑的服务员。 还有两个值得知道的调度细节: - **协作式调度**:运行时不会抢占任务,任务只在 `.await` 点让出。所以"长时间不 await 的任务"会霸占线程(第 19 章的头号性能杀手); - **LIFO 槽位**:刚被唤醒的任务会先放进当前线程的"插队槽",以提高缓存命中率;配合工作窃取,实现高吞吐。你不需要配置这些,知道"Tokio 的调度器很聪明"即可。 ## 3.6 误区一:以为 async fn 调用即执行 见 3.1。补充一个真实事故模式:在循环里"调用"了异步函数却没 await,循环瞬间跑完、什么都没发生: ```rust // ❌ 错误:100 次"调用"全都没执行,只是创建了 100 个 Future 然后丢弃 async fn send_all_wrong(ids: Vec) { for id in ids { send_one(id); // 警告:unused Future!没有任何网络请求发出 } } // ✅ 正解 1:串行 await(简单,但无并发) async fn send_all_serial(ids: Vec) { for id in ids { send_one(id).await; // 一发完再发下一个 } } // ✅ 正解 2:并发(需要 Send + 'static,任务间无共享数据时最简单) async fn send_all_concurrent(ids: Vec) { let mut handles = Vec::new(); for id in ids { handles.push(tokio::spawn(send_one(id))); // 全部丢出去同时跑 } for h in handles { h.await.unwrap(); } // 最后统一等结果 } # async fn send_one(id: u32) {} ``` ## 3.7 误区二:在异步代码里做阻塞操作 这是最常见、危害最大的错误。运行时一共就几条线程,一条被堵住,挂在它上面的所有任务都会跟着卡死: ```rust use std::time::Duration; // ❌ 错误示范:在 async 上下文里用同步阻塞 API async fn bad() { std::thread::sleep(Duration::from_secs(3)); // 阻塞当前工作线程 3 秒! // 这 3 秒里,这条线程上的其他任务全部停摆 let data = std::fs::read_to_string("config.toml").unwrap(); // 同步文件 IO 同样是阻塞的 } // ✅ 正确做法 1:换用 Tokio 的异步版本 async fn good1() { tokio::time::sleep(Duration::from_secs(3)).await; // 异步睡眠 let data = tokio::fs::read_to_string("config.toml").await.unwrap(); // 异步文件 IO } // ✅ 正确做法 2:实在没有异步版本(比如某个同步库),丢给阻塞线程池 async fn good2() { // spawn_blocking 会把闭包放到专门的阻塞线程池执行,不占用工作线程 let result = tokio::task::spawn_blocking(|| { some_sync_heavy_function() // 同步重活 }).await; } # fn some_sync_heavy_function() -> i32 { 42 } ``` 如何判断"这算不算阻塞"?口诀:**凡是会长时间不让出线程的事都算**——同步网络/文件 IO、`thread::sleep`、大循环计算、加解密、图像处理等。时间极短(微秒级)的操作无所谓,秒级的必须处理。完整的诊断与治理流程见第 19 章。 ## 3.8 误区三:以为 await 了就并发了 `.await` 是**串行**地等待。要并发,用 `join!`(任务内并发)或 `spawn`(跨线程并行): ```rust use std::time::Duration; async fn boil_water() { // 烧水,需要 3 秒 tokio::time::sleep(Duration::from_secs(3)).await; println!("水开了"); } async fn cut_vegetables() { // 切菜,需要 2 秒 tokio::time::sleep(Duration::from_secs(2)).await; println!("菜切好了"); } // 方式 A:串行 —— 先等水开,再切菜,总共 5 秒 async fn serial() { boil_water().await; cut_vegetables().await; } // 方式 B:并发(join! 同时推进两个任务)—— 总共 3 秒 // join! 宏在第 14 章详讲,这里先混个脸熟 async fn concurrent() { tokio::join!(boil_water(), cut_vegetables()); } // 方式 C:并行(spawn 到多线程运行时,真正同时跑在不同 CPU 核上) async fn parallel() { let h1 = tokio::spawn(boil_water()); // spawn 立即把任务丢给运行时 let h2 = tokio::spawn(cut_vegetables()); h1.await.unwrap(); // JoinHandle.await 取回结果(包在 Result 里) h2.await.unwrap(); } ``` 三者的耗时对比(实测会略有浮动): ``` 方式 A(串行 await) :烧水 ████ 3s → 切菜 ██ 2s 总计 5s 方式 B(join! 并发) :烧水 ████ 3s 切菜 ██ 2s 总计 3s 方式 C(spawn 并行) :同 B,但两任务可分布在不同 CPU 核 总计 3s ``` ## 3.9 线程 vs 任务:成本与选型 | | `std::thread`(OS 线程) | Tokio 任务 | |---|---|---| | 创建成本 | 重:约几 MB 栈 + 系统调用 | 轻:几百字节堆分配,一条线程可承载几十万个任务 | | 擅长 | CPU 密集型计算 | IO 密集型、海量并发连接 | | 切换开销 | 大(内核态切换) | 小(用户态、仅在 await 点) | | 阻塞的影响 | 只堵自己 | 堵死整条工作线程(大忌) | | 传递数据 | 需 `Send` + 同步原语/通道 | 同样需 `Send`(多线程模式),但有通道等异步原语 | 实测感受一下任务的"轻": ```rust use std::time::Instant; #[tokio::main] async fn main() { let start = Instant::now(); // 一口气创建 10 万个任务:瞬间完成,内存占用约几十 MB let mut handles = Vec::with_capacity(100_000); for i in 0..100_000 { handles.push(tokio::spawn(async move { i })); } let spawned = start.elapsed(); // 全部收尾 let mut sum = 0u64; for h in handles { sum += h.await.unwrap() as u64; } println!("10 万任务 spawn 耗时 {:?},求和 {}", spawned, sum); // 对比:开 10 万条 std::thread 会直接耗尽系统资源 } ``` 经验法则:**异步程序里,IO 交给任务,CPU 重活交给 `spawn_blocking` 或 `std::thread`**。两者不是敌人,而是搭档。 ## 3.10 async fn 与 trait:边界知识 Rust 1.75 起支持在 trait 里直接写 `async fn`: ```rust trait Storage { // 异步 trait 方法:实现者可以自己 await async fn get(&self, key: &str) -> Option; } struct MemoryStorage; impl Storage for MemoryStorage { async fn get(&self, key: &str) -> Option { Some(format!("内存里的 {}", key)) } } ``` 两个前沿注意点(现在知道即可,踩到再回来看): 1. **Send 问题**:trait 的 async fn 编译后是否 Send,取决于实现者;想让"跨线程 spawn 的任务里能用这个 trait",目前常用 `async_trait` crate 或手动返回 `impl Future + Send` 处理; 2. **dyn 兼容性**:含 async fn 的 trait 目前不能直接做 `dyn Storage`(动态分发),需要 `Box` 场景时同样借助 `async_trait`。 ## 本章要点小结 - 调用 async fn 只是拿到 Future(取餐凭条),`.await` 或 `spawn` 才会真正执行;Future 是编译器生成的状态机,大小 = 跨 await 存活的局部变量之和。 - `poll`/`Waker` 构成"登记-唤醒"循环;`Pin` 保证自引用状态机不被搬家——日常被宏和运行时吸收,你只需会用 `pin!`。 - 运行时 = 调度器(工作窃取)+ 反应器(epoll/IOCP)+ 定时器(时间轮),用几条线程驱动海量任务,且是协作式调度。 - 三大误区:async fn 调用不执行(要 await/spawn);异步里不能阻塞(换异步 API 或 spawn_blocking);await 不等于并发(用 join!/spawn)。 - 线程管 CPU,任务管 IO,`spawn_blocking` 是它们之间的桥。 # 第 4 章 任务管理:task 模块 > 本章目标:掌握 Tokio 任务的全部生命周期管理——诞生(`spawn`)、追踪(`JoinHandle`)、批量(`JoinSet`)、取消(`abort`)、让位(`yield_now`)、上下文(`task_local!`),以及给同步重活开的后门(`spawn_blocking`)。 ## 4.1 tokio::spawn:把任务交给运行时 ```rust use std::time::Duration; #[tokio::main] async fn main() { // spawn 接收一个 async 块,立即返回一个 JoinHandle // 任务被"扔进"运行时,马上开始被调度,不等任何人 let handle: tokio::task::JoinHandle<&str> = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(500)).await; println!("子任务执行完毕"); "任务的结果" // async 块的返回值,将来可从 handle 取出 }); println!("spawn 之后,main 继续干自己的事"); // 在 handle 上 .await:阻塞【当前任务】直到子任务完成,并取回结果 // 注意返回类型是 Result:任务若 panic 或被取消,得到 Err let result = handle.await.unwrap(); println!("拿到子任务的结果:{}", result); } ``` `spawn` 的语义要点: - **立即调度**:spawn 返回的瞬间,任务已进入运行时队列,不需要"启动"动作; - **返回 `JoinHandle`**:既是"等结果"的凭据,也是"取消任务"的遥控器; - **异步环境限定**:必须已经处于某个运行时上下文中(在 `#[tokio::main]` 里,或持有 `Handle`,见第 5 章)。 关于 `spawn` 有三条铁律,每条都对应一类编译错误,我们逐条见识: **铁律一:任务的参数必须通过 `move` 拿走所有权。** 任务的生命周期可能超过创建它的函数,所以它不能"借用"局部变量,必须把数据搬进去: ```rust #[tokio::main] async fn main() { let name = String::from("Tokio"); // ❌ 编译错误:future 不能借用局部变量(可能活得比它久) // tokio::spawn(async { println!("{}", name); }); // ✅ 用 move 把 name 的所有权搬进任务 tokio::spawn(async move { println!("你好,{}", name); // name 已被移动进来,原函数中不可再用 }).await.unwrap(); // ✅ 多个任务共享同一份数据?用 Arc(原子引用计数) use std::sync::Arc; let shared = Arc::new(vec![1, 2, 3]); let mut handles = Vec::new(); for i in 0..3 { let shared = Arc::clone(&shared); // 每次克隆只 +1 引用计数 handles.push(tokio::spawn(async move { println!("任务 {} 看到 {:?}", i, shared); })); } for h in handles { h.await.unwrap(); } } ``` **铁律二:Future 必须 `Send`。** 多线程运行时会把任务在任意工作线程间调度,所以任务内部"跨越 await 点"持有的数据必须能安全跨线程。如果持有了 `Rc`、`RefCell` 这类非 `Send` 类型并跨越 await,会得到著名的编译错误: ```rust use std::rc::Rc; #[tokio::main] async fn main() { let data = Rc::new(42); // Rc 不是 Send(引用计数非原子) // ❌ future cannot be sent between threads safely // tokio::spawn(async move { // println!("{}", data); // data 跨越了 await 点存活 // tokio::time::sleep(std::time::Duration::from_millis(1)).await; // println!("again {}", data); // }).await.unwrap(); // ✅ 解法 1:换 Arc(原子引用计数,Send) use std::sync::Arc; let data = Arc::new(42); tokio::spawn(async move { println!("{}", data); }).await.unwrap(); // ✅ 解法 2:数据只在 await 前用完(不跨 await),即使是 Rc 也行 let rc = Rc::new(7); println!("await 之前用完:{}", rc); // 不跨 await,编译通过 tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_millis(1)).await; }).await.unwrap(); } ``` **铁律三:`'static`。** 任务可能活得比创建者久,所以它拥有的数据必须是不带借用的(全 Ownership)。这也是为什么常配合 `Arc`(共享所有权)而非引用。需要"非 Send + 非静态"的任务时,可用 `LocalSet`(见 4.8)。 ## 4.2 JoinHandle:等待、取值、放养与取消 `spawn` 返回的 `JoinHandle` 有四种用法: ```rust use std::time::Duration; #[tokio::main] async fn main() { // 用法一:await 等待任务结束并拿结果(最常见) let h = tokio::spawn(async { 1 + 1 }); assert_eq!(h.await.unwrap(), 2); // 用法二:直接丢弃 handle —— 任务进入"分离(detach)"状态 // 它依然会被执行到结束,只是没人再等它、拿不到它的结果了 tokio::spawn(async { tokio::time::sleep(Duration::from_secs(1)).await; println!("我是孤儿任务,但我还是会跑完"); }); // ⚠️ 注意:main 结束时整个运行时被销毁,未完成的任务会全部中断 tokio::time::sleep(Duration::from_millis(1100)).await; // 留时间让它跑完 // 用法三:abort —— 主动取消任务 let h2 = tokio::spawn(async { loop { tokio::time::sleep(Duration::from_secs(1)).await; // 无限循环 println!("还在转..."); } }); tokio::time::sleep(Duration::from_millis(1500)).await; h2.abort(); // 发出取消请求(立即返回,不等任务死) // 被 abort 的任务 await 时返回 Err(JoinError, kind = cancelled) let outcome = h2.await; println!("任务已取消:{}", outcome.is_err()); // 用法四:is_finished —— 非阻塞地探测任务是否已结束 let h3 = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(50)).await; }); tokio::time::sleep(Duration::from_millis(100)).await; println!("任务结束了吗?{}", h3.is_finished()); } ``` 关于取消(abort)的三个关键细节: 1. **协作式取消**:`abort` 不会立刻掐死任务,任务会在**下一个 `.await` 点**被终结。所以写长任务时应当勤加 await 点(哪怕只是 `yield_now().await`),否则取消请求会一直排队; 2. **取消时的清理**:任务被取消时,其局部变量依次 drop——利用 Drop 守卫可以做清理(第 17 章);更优雅的"软取消"用 `CancellationToken`(第 16 章); 3. **abort 是幂等的**:对已结束的任务调用 abort 无副作用;多次调用也安全。 ## 4.3 任务的成本:数字说话 第 3 章预告过任务的"轻",这里给出一个可复现的实测脚本,并解释内存构成: ```rust use std::time::Instant; #[tokio::main] async fn main() { let n = 100_000; let start = Instant::now(); // 创建 10 万个各持有一个 32 字节返回值的任务 let mut handles = Vec::with_capacity(n); for i in 0..n { handles.push(tokio::spawn(async move { i as u64 })); } println!("spawn {} 个任务耗时 {:?}", n, start.elapsed()); // 典型输出(因机器而异):spawn 100000 个任务耗时 ~50-200ms // 内存:每个任务 = 状态机(几十~几百字节)+ 调度元数据,10 万个约几十 MB let mut sum = 0u64; for h in handles { sum += h.await.unwrap(); } println!("结果总和 = {}", sum); // 4999950000,验证都真跑完了 } ``` 同样规模换算到 OS 线程:10 万线程 × 2MB 默认栈 = 200GB 起步,还没算内核调度器的元数据——根本不可行。这就是"任务管 IO"的底气。 > ⚠️ 但也别走极端:任务不是免费的。百万级小任务的光 spawn 开销和内存就很可观,任务粒度的权衡见第 19 章。 ## 4.4 spawn_blocking:给同步重活开小灶 有些活儿没有异步版本:CPU 密集计算、调用同步库、复杂的加密解密……直接在任务里跑会堵死工作线程。`spawn_blocking` 把它们调度到**专门的阻塞线程池**里执行: ```rust #[tokio::main] async fn main() { // 闭包里的代码跑在阻塞线程池上,随便堵,不影响工作线程 let sum: tokio::task::JoinHandle = tokio::task::spawn_blocking(|| { println!("这段代码在阻塞线程池里执行(线程:{:?})", std::thread::current().name()); (1..=1_000_000).sum::() // 一个纯 CPU 计算任务 }); let result = sum.await.unwrap(); // 在异步侧等待它的结果 println!("1 到 100 万的和是:{}", result); } ``` 阻塞线程池的工作方式: - **独立于工作线程**:默认上限 **512 条**,按需创建、空闲回收;可用 `Builder::max_blocking_threads(n)` 调整(第 5 章); - **API 与 spawn 完全一致**:`move + Send + 'static` 闭包,返回 `JoinHandle`,同样支持 abort/await; - **适用清单**:同步 SDK 调用、`std::fs` 批量操作、密码哈希(argon2)、图像处理、`std::sync` 锁保护的重计算等; - **不适用**:能在 await 点让出线程的活(那正是异步任务的本职)。 > 💡 记住这个句式:`let out = tokio::task::spawn_blocking(move || { 同步代码 }).await?;` 它是异步世界与同步世界的日常贸易通道。 ## 4.5 JoinSet:批量管理一组任务 要同时发起几十个任务、然后逐个收结果?保存一堆 `JoinHandle` 太笨拙,`JoinSet` 是专门为此设计的集合: ```rust use tokio::task::JoinSet; use std::time::Duration; #[tokio::main] async fn main() { let mut set = JoinSet::new(); // 批量 spawn 5 个任务:每个睡不同时长后报数 for i in 1..=5 { set.spawn(async move { // 越小睡得越短,越先完成 tokio::time::sleep(Duration::from_millis(600 - i * 100)).await; format!("任务 {} 完成", i) }); } // join_next() 返回"下一个完成的任务"的结果 —— 谁先好收谁! // 全部收完后返回 None,循环自然结束 while let Some(res) = set.join_next().await { println!("收到:{}", res.unwrap()); } } // 输出顺序是按完成时间:任务 5、4、3、2、1 —— 而不是按 spawn 顺序 ``` `JoinSet` 的另外几个必会操作: ```rust use tokio::task::JoinSet; use std::time::Duration; async fn more_ops() { let mut set = JoinSet::new(); set.spawn(async { 1 }); set.spawn_blocking(|| 2); // 阻塞任务也能进 JoinSet 统一管理 // 搭配超时做"竞速收尾":50ms 内收多少算多少 while let Ok(Some(res)) = tokio::time::timeout(Duration::from_millis(50), set.join_next()).await { println!("限时内收到:{}", res.unwrap()); } set.abort_all(); // 剩余任务统一取消 set.detach_all(); // 或者:全部放手不管(它们继续跑完) assert!(set.is_empty() || true); // len()/is_empty() 查询库存 } ``` 杀手级组合:**`JoinSet` + `timeout(join_next)`** 就是"并发请求,限时收集,过期放弃"的标准实现,第 20 章爬虫会实战应用。 ## 4.6 yield_now:主动礼让 `yield_now().await` 让当前任务主动让出线程一次,给同线程的其他任务运行机会: ```rust use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() { let flag = Arc::new(AtomicBool::new(false)); // 一个"慢半拍"的任务:50ms 后把标志位置真 tokio::spawn({ let flag = flag.clone(); async move { tokio::time::sleep(Duration::from_millis(50)).await; flag.store(true, Ordering::SeqCst); } }); // ❌ 危险的忙等(演示用):不带 yield 的循环里, // 单线程运行时下其他任务根本没机会跑,永远等不到置位 // while !flag.load(Ordering::SeqCst) {} // 死循环! // ✅ 带 yield 的忙等:每圈让位一次,其他任务得以运行 let mut spins = 0; while !flag.load(Ordering::SeqCst) { tokio::task::yield_now().await; spins += 1; } println!("等到了!让位了 {} 次", spins); } ``` > 💡 `yield_now` 的正经用途:①在无 IO 的长计算循环里定期喘口气(每 N 次迭代 yield 一次),避免饿死同线程任务;②修复跨任务标志位的忙等。正经的"等事件"应该用通道/Notify/锁,忙等只是急救包。 > 💡 **进阶:协作式调度预算与 `unconstrained`**。Tokio 给每次 poll 一笔"预算":任务在一次 poll 里连续完成太多小操作(几十上百个 await 都不真正挂起)会被强制让位,防止个别任务霸占线程。这通常是好事;但个别"循环里全是不真正等待的 await"的任务(如一口气清空攒满的队列、逐行解析巨型内存日志)会因此明显变慢。确认无碍后,可用 `tokio::task::unconstrained` 为它关闭预算: ```rust use tokio::sync::mpsc; /// 一口气清空队列:默认调度下每次 poll 只允许"干有限的活", /// 会被反复强制让位;unconstrained 让它放开手脚跑完 async fn drain_queue(mut rx: mpsc::Receiver) { tokio::task::unconstrained(async { while let Some(item) = rx.recv().await { std::hint::black_box(item); // 处理…… } }).await; } # async fn demo() { # let (tx, rx) = mpsc::channel(1); # drop(tx); # drain_queue(rx).await; # } ``` ## 4.7 task_local!:任务级局部变量 类似线程局部变量(`thread_local!`),但作用域是**任务**——日志追踪、请求上下文的标配: ```rust use std::time::Duration; // 声明一个任务局部变量(可以是任意类型) tokio::task_local! { static REQUEST_ID: u64; static TRACE_CTX: String; } #[tokio::main] async fn main() { // scope(值, future):在 future 内,随处可读这个值 let h1 = REQUEST_ID.scope(1001, async { log("开始处理"); tokio::time::sleep(Duration::from_millis(50)).await; log("处理中"); // 跨越 await 之后依然能读到 log("处理完成"); }); // 两个任务的局部变量互不干扰 let h2 = REQUEST_ID.scope(2002, async { log("另一个请求"); }); tokio::join!(h1, h2); } fn log(msg: &str) { // 在任意深处取出当前任务的 ID —— 不用层层传参! REQUEST_ID.try_with(|id| println!("[请求 {}] {}", id, msg)).ok(); } ``` 对比一下就明白它的价值:没有 task_local,`request_id` 要作为参数穿透每一层函数;有了它,中间层完全无感。`tracing` 库的 span 机制正是这一思想的工业化实现。 ## 4.8 LocalSet:非 Send 任务的容身之所 铁律二说任务必须 `Send`,但如果就是想用 `Rc`/`RefCell`(比如复用某个只支持单线程的库)?`LocalSet` 提供"单线程任务池": ```rust use std::rc::Rc; #[tokio::main(flavor = "current_thread")] // LocalSet 必须配合单线程运行时 async fn main() { let local = tokio::task::LocalSet::new(); local.run_until(async { let data = Rc::new(String::from("只属于这条线程")); // spawn_local:不要求 Send、不要求 'static(可借用局部数据) let handle = local.spawn_local(async move { println!("非 Send 任务正常跑:{}", data); }); handle.await.unwrap(); }).await; } ``` > 💡 使用限制:`spawn_local` 的任务只能被调度到创建 LocalSet 的那条线程上;因此 LocalSet 通常配 `current_thread` 运行时使用。需要"多线程 + 非 Send"的复杂组合时,查 `LocalPoolHandle`(tokio-util)。 ## 4.9 实战:并发"下载器"雏形 综合本章:8 个模拟下载任务,并发度限制 3(用 Semaphore,第 13 章详解),`JoinSet` 按**完成顺序**收割: ```rust use std::time::Duration; use tokio::sync::Semaphore; use tokio::task::JoinSet; use std::sync::Arc; /// 模拟下载一个文件:耗时与"文件大小"成正比,返回 (名字, 耗时) async fn fake_download(name: &str, size_mb: u64) -> (String, u64) { tokio::time::sleep(Duration::from_millis(size_mb * 100)).await; (name.to_string(), size_mb * 100) } #[tokio::main] async fn main() { let files = vec![ ("a.zip", 5), ("b.zip", 2), ("c.zip", 8), ("d.zip", 1), ("e.zip", 3), ("f.zip", 6), ("g.zip", 2), ("h.zip", 4), ]; let start = std::time::Instant::now(); let semaphore = Arc::new(Semaphore::new(3)); // 同时最多 3 个下载 let mut set = JoinSet::new(); for (name, size) in files { let sem = semaphore.clone(); set.spawn(async move { let _permit = sem.acquire().await.unwrap(); // 没拿到"工位"就排队 println!("▶ 开始下载 {}", name); fake_download(name, size).await }); } // 按完成顺序收割 while let Some(res) = set.join_next().await { let (name, ms) = res.unwrap(); println!("✔ {} 下载完成(耗时 {}ms,总耗时 {}ms)", name, ms, start.elapsed().as_millis()); } println!("全部完成,总耗时 {}ms(串行需 3100ms)", start.elapsed().as_millis()); } ``` 观察输出:开始顺序是 a、b、c(前 3 个拿到许可),d 要等有人完成才开工;完成顺序是 d(100ms)、b(200ms)、e(300ms)……**开始顺序由许可决定,完成顺序由耗时决定**——这就是并发系统里最常见的"两个顺序"。 ## 本章要点小结 - `spawn` 立即启动任务并返回 `JoinHandle`:await 取结果、丢弃即放养、`abort()` 取消、`is_finished()` 探测。 - 任务三铁律:`move` 拿所有权(共享用 `Arc`)、Future 必须 `Send`(非 Send 找 `LocalSet`)、数据必须 `'static`。 - 同步重活用 `spawn_blocking` 丢进阻塞线程池(默认 512 线程上限),别堵工作线程。 - 批量任务用 `JoinSet`:`join_next()` 按**完成顺序**收结果,配 timeout 实现"限时收割",`abort_all()` 统一止损。 - `yield_now` 治忙等;`task_local!` 提供任务级上下文(不传参的"全局变量")。 # 第 5 章 运行时:runtime 模块 > 本章目标:把"发动机"拆开——两种运行时型号的取舍、Builder 的每一项配置、`block_on` 的准确语义、`Handle` 的跨线程用法、运行时的关闭流程,以及嵌套运行时这个经典大坑的所有解法。 ## 5.1 两种型号:多线程与单线程 Tokio 运行时有两种类型(flavor),选型是写 Tokio 程序的第一个架构决策: | | 多线程(multi_thread,默认) | 单线程(current_thread) | |---|---|---| | 线程模型 | N 条工作线程(N 默认 = CPU 核心数),工作窃取均衡负载 | 全部任务跑在一条线程上 | | 任务要求 | 必须 `Send` + `'static` | 只要求 `'static`(不跨线程就无需 Send) | | 并行能力 | 真并行,用满多核 | 无并行,只有并发(靠 await 交替) | | 调度开销 | 略高(跨线程队列、原子操作) | 最低(本地队列 + LIFO 槽) | | 典型场景 | 网络服务、任何吃多核的程序 | CLI 工具、测试、嵌入式、GUI 辅助线程 | ```rust use std::time::Duration; // 写法一:默认就是多线程运行时(等价于 flavor = "multi_thread") #[tokio::main] async fn main_a() { // 任务可能分布在不同工作线程上执行 tokio::spawn(async { tokio::time::sleep(Duration::from_millis(10)).await; }).await.unwrap(); } // 写法二:显式指定单线程运行时 #[tokio::main(flavor = "current_thread")] async fn main_b() { // 所有任务都跑在主线程上 tokio::spawn(async { println!("我也在主线程上跑"); }).await.unwrap(); } // 写法三:多线程 + 指定工作线程数量 #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main_c() { println!("4 条工作线程为我服务"); } ``` > 💡 可以用 `tokio::runtime::Handle::current().runtime_flavor()` 在程序里查询当前运行时类型;排查"我的任务到底跑在哪条线程"用 `std::thread::current().name()`。 **为什么默认是多线程?** 因为服务端程序几乎总想用满多核。但注意:**加了工作线程 ≠ 每个任务独占一条线程**,任务依旧分时复用线程;多线程带来的是"并行的 CPU 能力 + 更高的调度吞吐",代价是任务必须 `Send`(第 4 章铁律二的来源)。 ## 5.2 Builder:手动定制运行时 `#[tokio::main]` 的参数只能定制少数几项,想要更细的控制,用 `Builder` 手动搭。先给全参数表: | Builder 方法 | 作用 | 默认值 | |---|---|---| | `new_multi_thread()` / `new_current_thread()` | 选择型号 | —— | | `worker_threads(n)` | 工作线程数 | CPU 核心数 | | `max_blocking_threads(n)` | 阻塞线程池上限 | 512 | | `thread_name(name)` | 工作线程名前缀(日志排查利器) | "tokio-runtime-worker" | | `thread_stack_size(bytes)` | 线程栈大小 | 2 MB | | `enable_io()` | 启用 IO 驱动(epoll/IOCP) | 关 | | `enable_time()` | 启用定时器驱动 | 关 | | `enable_all()` | 上面两个一起开 | 关 | | `build()` | 构建运行时 | —— | 完整示例: ```rust use std::time::Duration; use tokio::runtime::Builder; fn main() { // new_multi_thread():搭一个多线程运行时(单线程用 new_current_thread()) let rt = Builder::new_multi_thread() .worker_threads(4) // 4 条工作线程 .max_blocking_threads(64) // 阻塞线程池上限压到 64(省资源) .thread_name("my-worker") // 线程名前缀,排查问题时在日志里很好认 .thread_stack_size(3 * 1024 * 1024) // 每条线程的栈大小:3MB .enable_all() // 启用 IO 和时间驱动(必须!) .build() // 构建运行时,返回 io::Result .unwrap(); // block_on:让调用线程阻塞,直到传入的 future 完成 rt.block_on(async { println!("跑在定制运行时上"); tokio::time::sleep(Duration::from_millis(100)).await; // 验证线程名:打印当前线程名(任务可能落在任意 worker 上) println!("当前线程:{:?}", std::thread::current().name()); }); rt.shutdown_timeout(Duration::from_secs(1)); // 关闭(见 5.6) } ``` > ⚠️ 常见坑:手动 Builder 忘记 `.enable_all()` 是新手高频翻车点——运行时能启动,但一 `sleep` 或一开网络就 panic(`A Tokio 1.x context was found, but IO and time drivers are disabled`)。记不住就永远 `.enable_all()`。 > 💡 `Runtime::new()` 是 `Builder::new_multi_thread().enable_all().build()` 的快捷方式,两者等价。 ## 5.3 block_on:同步世界的大门 `block_on` 的准确语义:**调用线程停下手头一切,亲自驱动传入的 future 直到完成**(在多线程运行时里,任务可被分给 worker 线程,调用线程主要起"等待"作用;在单线程运行时里,调用线程就是唯一的工作线程)。 典型场景:**main 函数不想用宏**,或者**在同步代码库里临时调用一个异步函数**: ```rust use tokio::runtime::Runtime; fn main() { let rt = Runtime::new().unwrap(); // 快捷创建默认多线程运行时 // 场景 1:运行异步 main 逻辑 rt.block_on(async_main_logic()); // 场景 2:在普通同步函数里,调用某个异步 API(例如异步库提供的接口) let data = rt.block_on(fetch_config()); println!("配置内容:{}", data); // 可以多次调用 block_on,依次驱动多个顶层 future } async fn async_main_logic() { println!("业务逻辑跑在运行时里"); } async fn fetch_config() -> String { tokio::fs::read_to_string("Cargo.toml").await.unwrap_or_default() } ``` 使用规则: - **只能在非异步上下文调用**(main、普通线程、同步函数里); - **绝对不要在异步任务里调用**——那是 5.5 节的大坑; - `block_on` 期间,调用线程"占着"运行时上下文(`enter`),所以它期间创建的异步资源都归属于这个运行时。 ## 5.4 enter 与 Handle:运行时的句柄与上下文 两个配套概念: - **上下文(enter)**:某条线程"当前关联着哪个运行时"的登记状态。`tokio::spawn`、`TcpStream::connect` 等需要运行时的 API,都要求调用线程处于上下文中; - **句柄(Handle)**:运行时的轻量遥控器,可以克隆、可以跨线程传递,凭它就能往运行时里投递任务。 ```rust use std::thread; use tokio::runtime::Runtime; fn main() { let rt = Runtime::new().unwrap(); // enter() 把当前线程标记为"运行时上下文", // 返回的 guard 在 drop 前保持登记状态 let _guard = rt.enter(); // Handle::current():获取当前上下文对应运行时的句柄,可克隆、可跨线程传 let handle = tokio::runtime::Handle::current(); // 开一条完全普通的系统线程(它不属于运行时) thread::spawn(move || { // 在这条普通线程上,用句柄把任务"远程投递"给运行时执行 handle.spawn(async { println!("我从普通线程被投递进运行时执行"); }); }); thread::sleep(std::time::Duration::from_millis(100)); // 等投递的任务打印完 } ``` 三个常用姿势: - `Handle::current()`:取当前运行时的句柄(必须在运行时上下文中调用); - `Handle::try_current()`:返回 `Result`,可以安全探测"我在不在运行时里"; - `handle.spawn(future)`:把任务投递给句柄指向的运行时,不管你现在在哪条线程。 > 💡 典型架构用法:库作者在初始化时保存一个 `Handle`(而非整个 `Runtime`),后续任何线程都能凭它投递任务;或者在库的构造函数里 `Handle::try_current()` 探测——"调用方已有运行时就复用,没有就自己建",这是异步库兼容同步/异步调用双方的通用手法。 ## 5.5 嵌套运行时:经典大坑与正确姿势 ```rust // ❌ 错误:在 async fn 里创建并 block_on 新运行时 async fn broken() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { /* ... */ }); // panic: Cannot start a runtime from within a runtime } ``` 为什么禁止套娃?`block_on` 的实现要求"接管当前线程直到 future 完成"——而当前线程此刻正在为外层运行时执行任务,若被内层运行时接管,外层的其他任务将无人驱动,整个运行时可能死锁。Tokio 干脆在运行时里检测到这种情况直接 panic。 解法按需求分三种: ```rust // ✅ 姿势 1:想等一个异步调用?直接 await 就好,根本不需要新运行时 async fn fixed1() { do_something_async().await; } async fn do_something_async() {} // ✅ 姿势 2:要跑的是同步阻塞代码?用 spawn_blocking async fn fixed2() { let out = tokio::task::spawn_blocking(|| std::fs::read_to_string("a.txt")).await; # let _ = out; } // ✅ 姿势 3:库函数是同步签名、内部却 block_on 了? // 换它的异步版本;或在同步线程里调用它(不占用 worker); // 万不得已用 Handle::block_on 也不行——它在异步上下文里同样 panic。 // 正确思路:把异步调用 spawn 出去,用通道等结果(第 12 章)。 ``` 一句话总结:**运行时只进不出,异步栈里没有 block_on 的位置**。同步入口(main)可以 block_on 一次;进去之后全靠 await / spawn / spawn_blocking。 ## 5.6 运行时的关闭与优雅退场 运行时是"值",它的关闭语义值得了解: ```rust use std::time::Duration; use tokio::runtime::Runtime; fn main() { let rt = Runtime::new().unwrap(); rt.block_on(async { /* 业务 */ }); # rt.block_on(async {}); // 方式一:直接 drop(默认行为) // drop(rt); // 等待:阻塞线程池里的任务跑完,后台任务直接被丢弃 // 方式二:限时关闭 —— 给阻塞任务 N 秒收尾,超时强杀 rt.shutdown_timeout(Duration::from_secs(5)); // 方式三:后台关闭 —— 立即返回,运行时在别的线程慢慢收尾 // rt.shutdown_background(); } ``` 但注意:**业务任务的优雅收尾不该靠 drop 运行时**,而应该在 `block_on` 内部完成(关闭信号 → 任务清场 → 自然退出,第 11、16、21 章的完整方案)。`shutdown_*` 只是"进程退出前的最后清道夫"。 ## 5.7 实战:在同步项目中调用异步代码 把本章知识串起来——假设你维护着一个同步项目(比如老的命令行工具),要接入一个只有异步 API 的库,标准做法是封装一层: ```rust use tokio::runtime::{Handle, Runtime}; use std::sync::Mutex; /// 同步外壳:内部持有运行时,对外暴露同步 API pub struct AsyncBridge { runtime: Mutex, } impl AsyncBridge { pub fn new() -> Self { Self { runtime: Mutex::new( tokio::runtime::Builder::new_current_thread() // 同步项目里单线程就够 .enable_all() .build() .unwrap(), ), } } /// 对外暴露的同步接口:内部 block_on 驱动异步逻辑 pub fn fetch(&self, url: &str) -> String { let rt = self.runtime.lock().unwrap(); rt.block_on(async move { // 这里可以使用任何异步库(演示用 sleep 模拟网络请求) tokio::time::sleep(std::time::Duration::from_millis(100)).await; format!("{} 的响应", url) }) } } fn main() { // 完全同步的世界,用起来毫无异步痕迹 let bridge = AsyncBridge::new(); println!("{}", bridge.fetch("https://example.com")); println!("{}", bridge.fetch("https://rust-lang.org")); } ``` 要点:同步项目集成异步能力,常用 **current_thread 运行时 + Mutex 保护 + 每次 block_on**;如果需要真并发,再换多线程运行时并在 block_on 内部 spawn。 ## 本章要点小结 - 两种 flavor:多线程(默认,用满多核,任务须 Send)与 current_thread(单线程,开销最低,可用非 Send)。 - `Builder` 定制运行时:线程数、线程名、栈大小、阻塞池上限——**别忘了 `.enable_all()`**。 - `Runtime::block_on` 是同步世界进入异步世界的唯一大门,只能在非异步上下文调用。 - `Handle` 是运行时句柄:可跨线程投递任务、可探测运行时上下文、可在库初始化时保存。 - 运行时不能套娃:任务里不要 `block_on`、不要新建运行时;运行时关闭用 `shutdown_timeout` 兜底,业务收尾靠关闭信号。 # 第 6 章 异步 IO:io 模块 > 本章目标:拿下 Tokio 的 IO 抽象体系——四大 trait 与 Ext 扩展、每个读写方法的准确语义、缓冲的正确姿势、`copy` 家族的妙用、`split` 与 `duplex` 的分工,最后用它们搭一个 TCP 端口转发器。 ## 6.1 trait 地图:四个 trait 与两把"瑞士军刀" 无论读文件、读网络还是读标准输入,背后都是同一套抽象: ``` AsyncRead(可读) ─┐ AsyncWrite(可写) ─┼─→ 实现者:TcpStream、UnixStream、File、Stdin/Stdout/Stderr、 AsyncSeek(可定位)─│ DuplexStream、ChildStdin/ChildStdout…… AsyncBufRead(有内部缓冲,可按行)─┘ AsyncReadExt → 给 AsyncRead 加便捷方法(read/read_to_end/read_u32…) AsyncWriteExt → 给 AsyncWrite 加便捷方法(write_all/flush/shutdown…) AsyncBufReadExt→ 给 AsyncBufRead 加按行方法(read_line/lines/split…) AsyncSeekExt → 给 AsyncSeek 加 seek(移动读取位置) ``` 一个值得记住的心智模型:**"类型"(TcpStream 等)负责"我是谁","Ext trait"负责"我能干什么"**。所以忘记 `use ...Ext` 时,类型还在、方法全没了——这是初学者第一大报错来源。 > 💡 两个补充:只需要**单向**管道时用 `tokio::io::simplex`(见 6.9);想把**非 Tokio 类型**的文件描述符/套接字接入反应器(例如 C 库交出来的 fd),用 `tokio::io::AsyncFd`,见附录 E。 ## 6.2 AsyncReadExt:读方法全览 ```rust use tokio::io::{AsyncReadExt, duplex}; use tokio::io::AsyncWriteExt; #[tokio::main] async fn main() -> std::io::Result<()> { // duplex(64):创建一对内存中相连的管道,写进一端的数据能从另一端读出 let (mut client, mut server) = duplex(64); client.write_all(b"hello tokio").await?; // ---- read:读到多少算多少 ---- let mut buf = vec![0u8; 16]; let n = server.read(&mut buf).await?; // 返回本次实际读到的字节数(可能不满) println!("read 读到 {} 字节:{}", n, String::from_utf8_lossy(&buf[..n])); // ---- read_exact:精确读满 ---- let mut exact = [0u8; 5]; client.write_all(b"12345678").await?; server.read_exact(&mut exact).await?; // 死等 5 字节,不够就继续等 println!("read_exact 读到:{}", String::from_utf8_lossy(&exact)); // ---- read_to_end / read_to_string:读到 EOF ---- let mut whole = Vec::new(); let _ = server.read_to_end(&mut whole).await?; // 一直读到对端关闭 println!("read_to_end 累计 {} 字节", whole.len()); // ---- 整数族:按字节序读写整数,写网络协议时非常好用 ---- client.write_u32(0x01020304).await?; // 默认大端序(big-endian)写入 4 字节 let num = server.read_u32().await?; println!("读回 u32:{:#x}", num); // 同族还有 read_u16/u64/i32/f32/read_u32_le(小端)/read_u8…… // ---- read_buf:读进 BytesMut(零拷贝场景;BytesMut 详见第 16.4 节) ---- use tokio_util::bytes::BytesMut; // 经 tokio-util 重导出,保证与生态版本一致 let mut bmut = BytesMut::with_capacity(32); client.write_all(b"zero-copy").await?; let n = server.read_buf(&mut bmut).await?; println!("read_buf 读到 {} 字节", n); Ok(()) } ``` ## 6.3 三种 read 的辨析(高频考点) | 方法 | 返回时机 | 读到 EOF 时 | 典型用途 | |---|---|---|---| | `read(&mut buf)` | 拿到**任意数量**(≥1)字节就返回 | 返回 `Ok(0)` | 事件循环逐段读流 | | `read_exact(&mut buf)` | 精确读满 buf 长度 | buf 未满则报 `UnexpectedEof` | 按协议定长读(如 4 字节长度头) | | `read_to_end(&mut v)` | 读到 EOF | 正常返回总字节数 | 一次性收完整响应(小数据) | 再补一条 `read_to_string`:同 `read_to_end` 但校验 UTF-8 并输出 String,非合法 UTF-8 会报错。 > ⚠️ 经典错误:用 `read` 做"读一条完整消息"——TCP 一次 read 可能只到半条消息(第 7 章专题)。要读"定长结构"用 `read_exact`,要读"直到结束"用 `read_to_end`,要读"一条变长消息"用第 16 章的 codec。 ## 6.4 AsyncWriteExt:写方法全览 ```rust use tokio::io::{AsyncWriteExt, duplex}; #[tokio::main] async fn main() -> std::io::Result<()> { let (mut a, mut b) = duplex(64); // write:尽力写,返回"实际写出"的字节数(可能只写了一半!) let n = a.write(b"partial?").await?; println!("write 写出 {} 字节", n); // write_all:保证全部写完才返回(99% 的场景用它) a.write_all(b"the rest of the message").await?; // flush:冲刷内部缓冲(包了 BufWriter 或对方有缓冲时必须) a.flush().await?; // shutdown:结束写入方向 —— 对读端表现为"流结束(EOF)" a.shutdown().await?; // 此后 b.read_to_end 会自然返回 let mut rest = Vec::new(); b.read_to_end(&mut rest).await?; println!("EOF 前收到 {} 字节", rest.len()); Ok(()) } ``` `shutdown` 的语义值得强调:它**只关闭写方向**,相当于"我说完了"。对 TCP 就是发送 FIN,对方读端收到 EOF。这是"优雅结束请求"的标准动作(对比直接 drop 连接)。 ## 6.5 标准输入输出:stdin / stdout / stderr ```rust use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[tokio::main] async fn main() -> std::io::Result<()> { // stdout().write_all:异步写标准输出 let mut stdout = tokio::io::stdout(); stdout.write_all(b"请输入你的名字:").await?; stdout.flush().await?; // 别忘了冲刷,否则提示可能滞留在缓冲区 // 标准输入要按"行"读,需要包一层 BufReader(见 6.6) let mut stdin = BufReader::new(tokio::io::stdin()); let mut line = String::new(); stdin.read_line(&mut line).await?; // 读一行(含换行符) println!("你好,{}!", line.trim()); // trim 去掉换行符 // stderr 用法相同 tokio::io::stderr().write_all(b"这条消息去了标准错误流\n").await?; Ok(()) } ``` > ⚠️ 常见坑:tokio 的 `Stdin` 底层是阻塞线程池在搬数据,**不要在多个任务里同时读 stdin**(数据会被随机分走)。需要多任务消费输入时,先单独一个任务读 stdin,再用通道(第 12 章)分发给其他任务。 ## 6.6 BufReader / BufWriter:缓冲的艺术 直接对小对象频繁 read/write,每次都有 syscall 开销。`BufReader`/`BufWriter` 在中间垫一层缓冲区: ```text 无缓冲:read(1字节) → syscall ─┐ read(1字节) → syscall ─┐ ... 100 次 syscall 有缓冲:一次 syscall 搬 8KB 进内存缓冲,后续 read 全在内存里取 ``` ```rust use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::duplex; #[tokio::main] async fn main() -> std::io::Result<()> { let (mut client, server) = duplex(256); client.write_all(b"第一行\n第二行\n第三行(没有换行符结尾)").await?; drop(client); // 关闭写入端 => 读端会读到 EOF // 自定义容量:大吞吐场景调大(默认 8KB) let mut reader = BufReader::with_capacity(64 * 1024, server); loop { let mut line = String::new(); // read_line:读到换行符为止;返回 Ok(0) 表示流已结束 let n = reader.read_line(&mut line).await?; if n == 0 { break; } // EOF,退出循环 println!("读到一行({} 字节):{}", n, line.trim_end()); } Ok(()) } ``` `AsyncBufReadExt` 全家福: | 方法 | 作用 | |---|---| | `read_line(&mut s)` | 读一行(含 `\n`)进字符串,返回字节数 | | `lines()` | 整个流变成"行流"(`tokio::io::Lines`,可直接 next()) | | `split(b'\n')` | 按任意分隔符切分 | | `fill_buf()` / `consume(n)` | 手动消费缓冲区(高性能解析用) | 写入端对称地有 `BufWriter::new(w)`,配合 `write_all` + `flush` 使用——**用 BufWriter 就要记得 flush**,否则程序结束时可能有数据还躺在缓冲区里。 ## 6.7 copy 家族:数据搬运工 **`io::copy`**:把一个读端的数据原样搬到一个写端,返回总字节数,内部自带缓冲与循环: ```rust use tokio::io::{self, duplex}; use tokio::io::AsyncWriteExt; #[tokio::main] async fn main() -> std::io::Result<()> { let (mut a, mut b) = duplex(64); tokio::spawn(async move { a.write_all(b"顺流而下的数据").await.unwrap(); }); // 把 b 的数据全部搬到标准输出,直到 b 读到 EOF let bytes = io::copy(&mut b, &mut io::stdout()).await?; println!("\n共搬运了 {} 字节", bytes); Ok(()) } ``` **`io::copy_bidirectional`**:两个"既能读又能写"的流之间双向对拷——TCP 代理/隧道的心脏: ```rust use tokio::io::copy_bidirectional; use tokio::net::TcpStream; /// 把 client 与 upstream 之间的数据双向透传,任一方向结束即结束 async fn proxy_connection(client: TcpStream, upstream: TcpStream) -> std::io::Result<()> { let (mut client, mut upstream) = (client, upstream); // 返回 (client→upstream 字节数, upstream→client 字节数) let (a2b, b2a) = copy_bidirectional(&mut client, &mut upstream).await?; println!("转发结束:↑ {} B,↓ {} B", a2b, b2a); Ok(()) } # async fn demo(c: TcpStream, u: TcpStream) { let _ = proxy_connection(c, u).await; } ``` ## 6.8 split 与 reunite:读写分工 一个对象同时要读又要写(典型:TCP 连接)时,把它拆成独立的读半、写半,分给两个"角色": ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; async fn use_split(stream: TcpStream) -> std::io::Result<()> { // 拆成 (ReadHalf, WriteHalf);对 TcpStream 还可用 reunite() 合并回去 let (mut read_half, mut write_half) = tokio::io::split(stream); let read_task = tokio::spawn(async move { let mut buf = vec![0u8; 1024]; loop { let n = read_half.read(&mut buf).await.unwrap_or(0); if n == 0 { break; } // 对端关闭 println!("读半边收到:{}", String::from_utf8_lossy(&buf[..n])); } }); let write_task = tokio::spawn(async move { write_half.write_all(b"写半边发出的消息\n").await.unwrap(); write_half.shutdown().await.unwrap(); // 发送 EOF }); read_task.await.unwrap(); write_task.await.unwrap(); Ok(()) } ``` 三种拆分方式对比: | 方式 | 用法 | 特点 | |---|---|---| | `tokio::io::split(stream)` | 通用,任何 AsyncRead+Write | 两半可进不同任务, TcpHalf 可 reunite | | `TcpStream::into_split()` | TCP 专用 | 产生的半边是 `'static` 的(可直接 spawn,无需 move 限制) | | 手动用 `&mut` | 同一任务内 | 不拆也能"先读后写",只是不能并行 | ## 6.9 duplex 与 simplex:内存里的管道 `duplex(n)` 创建一对连通的 `DuplexStream`(n 是缓冲区大小),一端写、另一端读。它是**单元测试网络代码的利器**——不用真开端口就能模拟对端(第 18 章实战应用): ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex}; #[tokio::main] async fn main() -> std::io::Result<()> { // 一对管道,缓冲区 32 字节 let (mut side_a, mut side_b) = duplex(32); // 两个任务互相"对话" let t1 = tokio::spawn(async move { side_a.write_all(b"你好,对面!").await.unwrap(); let mut buf = vec![0u8; 32]; let n = side_a.read(&mut buf).await.unwrap(); println!("A 听到:{}", String::from_utf8_lossy(&buf[..n])); }); let t2 = tokio::spawn(async move { let mut buf = vec![0u8; 32]; let n = side_b.read(&mut buf).await.unwrap(); println!("B 听到:{}", String::from_utf8_lossy(&buf[..n])); side_b.write_all(b"你好,A!").await.unwrap(); }); t1.await.unwrap(); t2.await.unwrap(); Ok(()) } ``` **单工版:`simplex`** —— 数据只往一个方向流(如单向日志管道、进度上报)时,`tokio::io::simplex(n)` 比 duplex 意图更明确:返回的两半,一端只实现 `AsyncWrite`、另一端只实现 `AsyncRead`,写错方向直接编译报错: ```rust let (mut writer, mut reader) = tokio::io::simplex(64); // 单向管道 // writer 只实现 AsyncWrite —— write_all 可用,read 方法不存在(编译期就拦住) // reader 只实现 AsyncRead —— read 可用,write_all 方法不存在 // 用法与 duplex 完全一致:writer.write_all(..).await / reader.read(..).await ``` ## 6.10 AsyncSeek:随机访问 ```rust use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; #[tokio::main] async fn main() -> std::io::Result<()> { let mut file = File::open("Cargo.toml").await?; // 移动读取位置:从文件开头(Start)跳过 100 字节 file.seek(SeekFrom::Start(100)).await?; // 其他基准:End(负数往前偏移)、Current(相对当前位置) let mut buf = vec![0u8; 32]; let n = file.read(&mut buf).await?; println!("第 100 字节处的内容:{}", String::from_utf8_lossy(&buf[..n])); Ok(()) } ``` ## 6.11 实战:TCP 端口转发器(30 行的"内网穿透"雏形) 综合 `net` + `copy_bidirectional`:把本地 8100 端口的所有流量转发到目标地址——代理、隧道、调试抓包的基础积木: ```rust use tokio::io::copy_bidirectional; use tokio::net::{TcpListener, TcpStream}; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8100").await?; println!("转发器:127.0.0.1:8100 → 127.0.0.1:8080"); loop { let (client, addr) = listener.accept().await?; // 每个连接一个任务:连上游 → 双向搬运 tokio::spawn(async move { match TcpStream::connect("127.0.0.1:8080").await { Ok(upstream) => { if let Err(e) = forward(client, upstream).await { eprintln!("会话异常({}):{}", addr, e); } } Err(e) => eprintln!("上游连接失败({}):{}", addr, e), } }); } } /// 单个会话:client 与 upstream 之间双向透传,任一方向结束即结束 async fn forward(mut client: TcpStream, mut upstream: TcpStream) -> std::io::Result<()> { let (up, down) = copy_bidirectional(&mut client, &mut upstream).await?; println!("会话结束:↑{}B ↓{}B", up, down); // ↑=client 发出的量,↓=回程的量 Ok(()) } ``` 测试方法:先在 8080 端口跑任意服务(比如第 7 章的 Echo 服务器),再 `nc 127.0.0.1 8100` 连接转发器——数据会穿过转发器到达 8080,原路返回。给它加上"目标地址从命令行参数读取"和"连接数上限",就是一个可用的调试代理。 ## 6.12 实战二:命令行问答机器人 综合 stdin/stdout、BufReader、异步任务: ```rust use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use std::time::Duration; #[tokio::main] async fn main() -> std::io::Result<()> { let mut stdout = tokio::io::stdout(); let mut stdin = BufReader::new(tokio::io::stdin()); stdout.write_all(b"问答机器人上线!输入 q 退出\n").await?; stdout.flush().await?; let mut line = String::new(); loop { stdout.write_all(b"> ").await?; stdout.flush().await?; line.clear(); // 阻塞点 1:等用户输入一行 if stdin.read_line(&mut line).await? == 0 { break; } // EOF(按下 Ctrl+D) let question = line.trim().to_string(); if question == "q" { break; } if question.is_empty() { continue; } // 模拟"思考" 0.5 秒:异步睡眠,期间程序并未卡死 tokio::time::sleep(Duration::from_millis(500)).await; // 极其朴素的"智能" let answer = if question.contains("名字") { "我叫 Tokio 机器人,由异步线程驱动".to_string() } else if question.contains("天气") { "异步的世界里永远是晴天".to_string() } else { format!("你说的是「{}」吗?这个话题有点深奥", question) }; stdout.write_all(format!("{}\n", answer).as_bytes()).await?; } println!("再见!"); Ok(()) } ``` ## 本章要点小结 - 一切异步 IO 都是 `AsyncRead`/`AsyncWrite`(+`AsyncBufRead`/`AsyncSeek`);便捷方法来自四个 `*Ext` trait——**忘了 use 就报"方法不存在"**。 - `read` 读到多少算多少(EOF=Ok(0));`read_exact` 精确读满;`read_to_end` 读到 EOF;`write_all` 保证写完;`shutdown` 只关写方向(发 EOF)。 - 按行读用 `BufReader` + `read_line`/`lines()`;写入频繁配 `BufWriter`,**记得 flush**。 - `io::copy` 单向搬运,`copy_bidirectional` 双向透传(TCP 代理的心脏);`split` 拆读写,`duplex` 是内存管道、测试神器。 - stdin 不要多任务共享读,先读后分发。 # 第 7 章 网络编程:net 模块 > 本章目标:用 Tokio 写出像样的网络程序——TCP 服务器/客户端的完整细节、UDP 的两种模式、"消息边界"这个 TCP 头号陷阱的专题讨论,最后手写一个迷你 HTTP 服务器。 ## 7.1 模块总览:与标准库的对应关系 `tokio::net` 把操作系统的套接字 API 全部异步化,类型与 std 一一同构(附录 B 有完整对照): | std(同步) | tokio(异步) | 平台 | |---|---|---| | `std::net::TcpListener/Stream` | `tokio::net::TcpListener/Stream` | 全平台 | | `std::net::UdpSocket` | `tokio::net::UdpSocket` | 全平台 | | `std::os::unix::net::UnixListener/Stream` | `tokio::net::UnixListener/Stream` | Unix | | `std::os::windows::net::named_pipe` | `tokio::net::windows::named_pipe` | Windows | 它们全都实现了 `AsyncRead`/`AsyncWrite`,所以第 6 章的所有工具(BufReader、copy、split)直接可用。 ## 7.2 TCP 服务器的骨架:TcpListener + spawn TCP 服务器的固定三步:**绑定端口 → 循环接受连接 → 每个连接丢给一个任务**: ```rust use tokio::net::TcpListener; #[tokio::main] async fn main() -> std::io::Result<()> { // 第 1 步:绑定监听地址。"0.0.0.0" 表示接受来自所有网卡的连接 let listener = TcpListener::bind("127.0.0.1:8080").await?; println!("服务器已启动:http://127.0.0.1:8080"); // 第 2 步:循环 accept,每来一个连接返回一个 (TcpStream, SocketAddr) loop { let (socket, addr) = listener.accept().await?; println!("新连接来自:{}", addr); // 第 3 步:为每个连接 spawn 一个独立任务并发处理 // 注意 socket 被 move 进任务,每个连接互不干扰 tokio::spawn(async move { if let Err(e) = handle_connection(socket).await { eprintln!("处理 {} 出错:{}", addr, e); } }); } } // 具体的连接处理逻辑 async fn handle_connection(mut socket: tokio::net::TcpStream) -> std::io::Result<()> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut buf = vec![0u8; 1024]; loop { // 读数据:Ok(0) 表示对端关闭了连接 let n = socket.read(&mut buf).await?; if n == 0 { return Ok(()); } // 连接结束 socket.write_all(&buf[..n]).await?; // 原样写回(下一节升级) } } ``` `cargo run` 启动后,另开一个终端验证:`echo hi | nc 127.0.0.1 8080`(或用 7.5 的客户端),你会收到原样的 `hi`。 **并发模型说明**:每个连接一个任务,1 万个连接就是 1 万个任务——得益于任务只有几百字节,完全没问题。如果担心恶意连接耗尽资源,给 accept 循环加一个 `Semaphore` 上限(7.9 节)。 ## 7.3 完整精讲:Echo 回显服务器 把骨架的 `handle_connection` 打磨成生产级写法,每行都有讲究: ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use std::time::Duration; async fn handle_connection(mut socket: TcpStream) -> std::io::Result<()> { let mut buf = vec![0u8; 4096]; // 4KB 接收缓冲区:太小则 syscall 频繁,太大则浪费内存 loop { // 给单次读操作加 10 秒超时:防止恶意客户端连上不发数据,一直占着资源 let n = match tokio::time::timeout(Duration::from_secs(10), socket.read(&mut buf)).await { Ok(Ok(0)) => break, // 对端正常关闭(读到 EOF) Ok(Ok(n)) => n, // 正常读到 n 字节 Ok(Err(e)) => return Err(e), // 读出错 Err(_) => break, // timeout 包裹返回 Err(Elapsed):超时踢掉 }; // write_all 确保全部写回;read 一次读到多少就回多少(不保证一次=一条完整消息) if socket.write_all(&buf[..n]).await.is_err() { break; // 写失败(对端可能已断开),结束处理 } } // 优雅收尾:关闭写方向(向对端发送 EOF) let _ = socket.shutdown().await; Ok(()) } ``` 三个生产级细节: 1. **读超时**:裸的 `read` 可能永远等下去;套 `timeout` 后,静默连接会被定期清理; 2. **三态返回**:`Ok(0)`/`Ok(n)`/`Err` 各有含义,千万别把 `Ok(0)` 当错误处理(它是最正常的"对端说完了"); 3. **退出前 shutdown**:让对方读到明确的流结束,而不是等到 TCP 超时。 ## 7.4 专题:TCP 消息边界(半包与粘包) TCP 是**字节流**,没有"消息"概念——`read` 一次返回的字节数与对方"一次 write"毫无对应关系: ```text 发送方依次 write:A(5字节) B(3字节) C(4字节) 接收方 read 实际可能收到: 情形 1:[A 完整 5 字节] —— 正常 情形 2:[A 的前 3 字节] —— 半包(A 被拆开) 情形 3:[A+B 共 8 字节] —— 粘包(两条消息粘在一起) 情形 4:[B 的后 1 字节 + C 的 2 字节] —— 跨消息拼接 ``` 原因:TCP 会根据 MSS(最大报文段)、Nagle 算法、接收窗口等把字节流重新分段。**这不是 bug,是 TCP 的设计**。 三种划分消息的方案: | 方案 | 做法 | 优缺点 | |---|---|---| | 分隔符 | 消息以 `\n` 结尾,读到分隔符为一条 | 简单;内容不能含分隔符,需转义 | | 长度前缀 | 头部 4 字节 = 消息体长度,先读头再读体 | 二进制友好;主流协议做法 | | 定长 | 每条消息固定 N 字节 | 最简单;浪费带宽 | 手写"长度前缀"读取的核心逻辑(感受一下麻烦程度): ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; /// 按长度前缀协议读取一条消息:4 字节大端长度 + 消息体 async fn read_message(socket: &mut TcpStream) -> std::io::Result> { let mut len_buf = [0u8; 4]; socket.read_exact(&mut len_buf).await?; // 1. 精确读 4 字节长度头 let len = u32::from_be_bytes(len_buf) as usize; if len > 10 * 1024 * 1024 { // 2. 防御:限制单条消息上限,防止恶意"长度"耗尽内存 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "消息过大")); } let mut body = vec![0u8; len]; socket.read_exact(&mut body).await?; // 3. 精确读消息体 Ok(body) } ``` 写起来处处是细节(缓冲复用、半包续读、上限校验)——这正是第 16 章 codec 要替你解决的事。现在先建立认知:**在 TCP 上传"消息",必须显式设计边界**。 ## 7.5 TCP 客户端:TcpStream::connect ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use std::time::Duration; #[tokio::main] async fn main() -> std::io::Result<()> { // 连接也建议加超时:默认的连接超时可能长达数十秒 let mut stream = tokio::time::timeout( Duration::from_secs(3), TcpStream::connect("127.0.0.1:8080"), ) .await .expect("连接超时了") // 外层 Err(Elapsed) ?; // 内层 io::Result,用 ? 透传 println!("已连接到:{}", stream.peer_addr()?); // 对端地址 println!("本地地址:{}", stream.local_addr()?); // 本地地址(系统分配) stream.set_nodelay(true)?; // 禁用 Nagle 算法:小包立即发,适合交互式协议 // 发送一条消息 stream.write_all(b"hello, echo!\n").await?; // 读取回显 let mut buf = vec![0u8; 1024]; let n = stream.read(&mut buf).await?; println!("服务器回显:{}", String::from_utf8_lossy(&buf[..n])); Ok(()) } ``` `connect` 接受 `"host:port"` 字符串,内部自动做域名解析并**逐个尝试**解析出的所有地址(IPv4/IPv6),一般无需手动解析。需要精细控制(绑定本地 IP、TTL)时,用 `TcpSocket` 构建器: ```rust use tokio::net::TcpSocket; use std::net::SocketAddr; async fn advanced_connect() -> std::io::Result<()> { let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let socket = TcpSocket::new_v4()?; socket.set_reuseaddr(true)?; // 允许重用本地地址(重启服务不等待 TIME_WAIT) socket.bind("0.0.0.0:0".parse()?)?; // 绑定本地端口(0 = 系统分配) let stream = socket.connect(addr).await?; # let _ = stream; Ok(()) } ``` ## 7.6 UDP:UdpSocket UDP 无连接、不保证送达,但开销极小,适合 DNS、日志上报、游戏同步、服务发现等场景: ```rust use tokio::net::UdpSocket; // UDP 服务端:收到什么就回什么 async fn udp_server() -> std::io::Result<()> { // 绑定固定端口(UDP 服务端必须绑定,客户端可绑 0 让系统自动分配) let socket = UdpSocket::bind("127.0.0.1:9000").await?; let mut buf = [0u8; 1024]; loop { // recv_from 同时拿到"数据"和"发送方地址",回信时要用这个地址 let (n, peer) = socket.recv_from(&mut buf).await?; println!("收到来自 {} 的 {} 字节", peer, n); socket.send_to(&buf[..n], peer).await?; // 原样回给对方 } } // UDP 客户端 async fn udp_client() -> std::io::Result<()> { let socket = UdpSocket::bind("127.0.0.1:0").await?; // 端口 0:系统随机分配 socket.send_to(b"ping", "127.0.0.1:9000").await?; let mut buf = [0u8; 1024]; let (n, _) = socket.recv_from(&mut buf).await?; println!("UDP 回显:{}", String::from_utf8_lossy(&buf[..n])); Ok(()) } #[tokio::main] async fn main() -> std::io::Result<()> { let server = tokio::spawn(udp_server()); tokio::time::sleep(std::time::Duration::from_millis(100)).await; // 等服务器就绪 udp_client().await?; server.abort(); Ok(()) } ``` **两种模式**: - `send_to`/`recv_from`(无连接模式):每个包带地址,可同时与多个对端通信; - `connect(addr)` + `send`/`recv`(已连接模式):锁定单一对端——内核会过滤掉来源不符的包,API 也更简洁。 > ⚠️ UDP 每个数据报最大约 64KB(理论值),实际受 MTU 影响建议 ≤ 1400 字节;要"可靠 UDP"就得上应用层协议(如 QUIC)或换 TCP。 > 💡 **组播(multicast)**:`UdpSocket` 还提供 `join_multicast_v4(addr, interface)` / `join_multicast_v6` / `set_multicast_loop_v4(false)` 等方法,可实现局域网"一发多收"(如服务发现):先 `bind`,再加入组播组,之后 `recv_from` 即可收到组播包。 ## 7.7 Unix 域套接字与 Windows 命名管道 同一台机器上的进程间高效通信(不走网络栈,更快更安全):Linux/macOS 用 **Unix 域套接字**,Windows 用**命名管道**。API 与 TCP 几乎一模一样,把 `Tcp` 换成对应类型即可: ```rust // ===== Linux / macOS:Unix 域套接字(用 #[cfg(unix)] 条件编译隔离) ===== #[cfg(unix)] async fn unix_socket_demo() -> std::io::Result<()> { use tokio::net::{UnixListener, UnixStream}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; // 先删掉残留的 socket 文件(文件系统里的一个特殊文件) let path = "/tmp/my_socket.sock"; let _ = tokio::fs::remove_file(path).await; let listener = UnixListener::bind(path)?; println!("Unix socket 服务端就绪:{}", path); loop { let (mut stream, _addr) = listener.accept().await?; tokio::spawn(async move { // UnixStream 同样实现 AsyncRead/AsyncWrite,用法与 TcpStream 完全一致 let mut buf = vec![0u8; 256]; if let Ok(n) = stream.read(&mut buf).await { let _ = stream.write_all(&buf[..n]).await; // echo } }); } } // ===== Windows:命名管道 ===== #[cfg(windows)] async fn named_pipe_demo() -> std::io::Result<()> { use tokio::net::windows::named_pipe::ServerOptions; use tokio::io::{AsyncReadExt, AsyncWriteExt}; let pipe_name = r"\\.\pipe\my_pipe"; // 创建一个命名管道服务端实例 let mut server = ServerOptions::new() .first_pipe_instance(true) .create(pipe_name)?; println!("命名管道服务端就绪:{}", pipe_name); loop { server.connect().await?; // 等待客户端接入 // 接入后处理当前实例,并立即创建下一个实例等待新客户端 let mut current = server; server = ServerOptions::new().create(pipe_name)?; tokio::spawn(async move { let mut buf = vec![0u8; 256]; if let Ok(n) = current.read(&mut buf).await { let _ = current.write_all(&buf[..n]).await; } }); } } #[tokio::main] async fn main() -> std::io::Result<()> { // 按平台调用对应演示;客户端分别用 UnixStream::connect / ClientOptions::new().open(路径) #[cfg(unix)] { unix_socket_demo().await?; } #[cfg(windows)] { named_pipe_demo().await?; } Ok(()) } ``` > 💡 基于这些 API 写的协议代码可以**在 TCP / Unix socket / 命名管道之间无痛切换**——很多数据库客户端支持 `unix socket 连接`,实现基础正在于此。 **补充**:`tokio::net::UnixDatagram` 是 Unix 域的"UDP 风格"数据报套接字(`bind(path)` + `send_to`/`recv_from`),API 与 `UdpSocket` 同构,适合本机进程间的轻量消息传递。 ## 7.8 域名解析 ```rust use std::net::SocketAddr; #[tokio::main] async fn main() -> std::io::Result<()> { // 域名解析:返回所有解析结果的迭代器(IPv4/IPv6 都可能有) let addrs: Vec = tokio::net::lookup_host("example.com:80").await?.collect(); println!("example.com 解析到 {} 个地址", addrs.len()); // TcpStream::connect 直接接受 "域名:端口" 字符串,内部自动解析并逐个尝试, // 一般无需手动 lookup_host let _stream = tokio::net::TcpStream::connect("example.com:80").await?; Ok(()) } ``` > ⚠️ 注意:Tokio 的 `lookup_host` 底层走的是标准库的阻塞解析(在线程池中执行),解析本身不是真异步;高频解析场景建议自建 DNS 缓存或用 `hickory-resolver`(原 trust-dns)。 ## 7.9 并发连接的上限控制 "每个连接一个任务"无限扩张可能被资源耗尽攻击,标准防御是 `Semaphore`: ```rust use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::Semaphore; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; // 最多同时处理 100 个连接 let semaphore = Arc::new(Semaphore::new(100)); loop { let (socket, addr) = listener.accept().await?; // try_acquire:拿不到许可就立刻拒绝(新连接直接关闭),避免排队堆积 match semaphore.clone().try_acquire_owned() { Ok(permit) => { tokio::spawn(async move { // ... 处理连接(permit 在任务里,连接结束自动归还) drop(permit); }); } Err(_) => { eprintln!("连接数已达上限,拒绝 {}", addr); drop(socket); // 关闭连接 } } } } ``` ## 7.10 实战:手写迷你 HTTP 服务器 不借助任何 Web 框架,用 `TcpListener` + `BufReader` 解析 HTTP 请求、按路径返回内容。把本章与第 6 章知识全部串起来: ```rust use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:3000").await?; println!("迷你 HTTP 服务器:http://127.0.0.1:3000"); loop { let (socket, _) = listener.accept().await?; tokio::spawn(async move { if let Err(e) = handle_http(socket).await { eprintln!("请求处理失败:{}", e); } }); } } async fn handle_http(socket: TcpStream) -> std::io::Result<()> { // 用 BufReader 按行读取 HTTP 请求(HTTP 的行以 \r\n 结尾) let mut reader = BufReader::new(socket); // 1. 请求行,例如 "GET /about HTTP/1.1" let mut request_line = String::new(); reader.read_line(&mut request_line).await?; let path = request_line .split_whitespace() // 按空白切成 [方法, 路径, 协议版本] .nth(1) // 取第 2 段:路径 .unwrap_or("/"); // 2. 逐行读取请求头(只打印 Content-Length,其余丢弃),直到空行(\r\n) loop { let mut header = String::new(); reader.read_line(&mut header).await?; if header == "\r\n" || header == "\n" || header.is_empty() { break; } if header.to_lowercase().starts_with("content-length:") { println!("请求体大小:{}", header.trim()); } } // (本服务器只处理 GET,GET 无请求体,故不必读取 body) // 3. 简单路由 let (status, body) = match path { "/" => ("200 OK", "欢迎来到首页!试试访问 /about".to_string()), "/about" => ("200 OK", "本页面由纯 Tokio 手工打造".to_string()), _ => ("404 Not Found", "页面不存在".to_string()), }; // 4. 拼装 HTTP 响应:状态行 + 头部 + 空行 + 响应体 let response = format!( "HTTP/1.1 {}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", status, body.len(), // Content-Length 必须与响应体字节数一致 body ); // 5. 通过 reader.get_mut() 拿回底层的 socket 来写响应(BufReader 包住了它) reader.get_mut().write_all(response.as_bytes()).await?; reader.get_mut().flush().await?; Ok(()) // 函数返回时连接关闭(Connection: close 模式) } ``` `cargo run` 后浏览器访问 `http://127.0.0.1:3000` 和 `/about`、`/nope` 感受路由。这个玩具与真正的 Web 框架(axum)之间,只差"更完整的 HTTP 解析"这一层——而那一层,正是 hyper 库存在的意义(第 22 章有对照表)。 ## 本章要点小结 - TCP 服务器三板斧:`bind` → `accept` 循环 → 每连接 `spawn` 一个任务;连接数上限用 `Semaphore` + `try_acquire_owned` 拒绝式防御。 - read 三态:`Ok(0)`=对端关闭、`Ok(n)`=数据、`Err`=故障;给读加 `timeout` 防慢速攻击。 - **TCP 无消息边界**:半包粘包是常态,协议设计要么加分隔符、要么加长度前缀(记得校验上限),要么交给 codec。 - 客户端:`connect` 记得配超时;`set_nodelay` 换低延迟;精细控制用 `TcpSocket`。 - UDP:`bind` + `send_to`/`recv_from`;固定对端可 `connect` 后用 `send`/`recv`;单包别超 1400 字节。 - Unix 套接字 / Windows 命名管道与 TCP API 同构,可平滑替换。 # 第 8 章 时间管理:time 模块 > 本章目标:掌握异步程序所有"和时间有关"的写法——睡眠、周期任务、超时、计时,理解 Tokio 定时器为什么能廉价支撑海量定时,以及 `MissedTickBehavior` 的三种策略如何选。 ## 8.1 定时器为什么"便宜" 先回答一个好奇宝宝问题:同时挂 100 万个 `sleep`,Tokio 怎么做到毫无压力? 传统做法是"一个定时器一个线程/一个系统调用"——100 万个根本不可行。Tokio 用的是**时间轮(timing wheel)**思想:把时间切成大量"槽位",所有定时器按到期时刻挂到对应槽上,运行时只需维护一个"最近到期时间",每次时钟推进批量检查槽位。于是: - 内存:每个定时器只是轮子上的一个小节点(几十字节); - 调度:到点检查是批量操作,与定时器总数无关; - 精度:毫秒级(足够 IO 场景),**不保证纳秒级实时**——这是异步定时器的定位。 ## 8.2 sleep:异步睡眠 ```rust use std::time::Duration; #[tokio::main] async fn main() { println!("开始睡觉"); // 异步睡眠 2 秒:期间当前任务暂停,线程转去跑其他任务(对比 std::thread::sleep) tokio::time::sleep(Duration::from_secs(2)).await; println!("睡醒了"); // 也可以"睡到指定时刻":sleep_until 接收一个 Instant 截止时间 // 适合"与起点对齐"的场景:不管前面耗时多少,都睡到绝对时刻 let deadline = tokio::time::Instant::now() + Duration::from_secs(1); tokio::time::sleep_until(deadline).await; println!("到点了"); // ⚠️ sleep 的取消安全性很好:被取消(比如在 select! 中落选)直接丢弃即可, // 不会有"睡了一半"的副作用 } ``` `sleep` vs `std::thread::sleep` 的对照实验,直观感受"让不让出线程"的区别: ```rust use std::time::Duration; #[tokio::main] async fn main() { // 后台任务每 100ms 报一次数 let ticker = tokio::spawn(async { for i in 1..=10 { tokio::time::sleep(Duration::from_millis(100)).await; println!("滴答 {}", i); } }); // 主任务用 std::thread::sleep 阻塞 500ms(错误示范): // 多线程运行时下 ticker 还能跑(在别的线程),但这条线程被白白占死; // 换成单线程运行时运行本例,ticker 会整整卡 500ms! std::thread::sleep(Duration::from_millis(500)); ticker.await.unwrap(); println!("结束"); } ``` 结论重申一遍:**异步代码里永远用 `tokio::time::sleep`**。它和 `std::thread::sleep` 的差别,在单线程运行时下是"卡不卡别人",在多线程下是"白白浪费一条线程"。 ## 8.3 interval:周期性任务 固定间隔重复执行,用 `interval` 而不是"循环里 sleep"——前者以固定节拍打点,后者会因任务耗时不断漂移: ```rust use std::time::Duration; use tokio::time::{interval, MissedTickBehavior}; #[tokio::main] async fn main() { // 创建一个每 500ms 一跳的定时器 let mut ticker = interval(Duration::from_millis(500)); // 错过节拍怎么办?三种策略(默认 Burst): ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); // Burst(默认):错过的节拍立刻补跑几次(适合"必须保证次数"的场景) // Delay :错过就顺延,重新对齐间隔(适合"心跳"类场景,最常用) // Skip :跳过错过的,等下一个整点(适合统计打点) let mut count = 0; loop { // tick().await:等待下一个节拍,返回本次节拍的时刻 // ⚠️ 第一次 tick 立即完成!interval 的节拍从"创建时刻"对齐 ticker.tick().await; count += 1; println!("第 {} 次心跳", count); if count >= 5 { break; // 只跳 5 次 } } } ``` **MissedTickBehavior 的三种策略,用时间线看懂**:假设每秒一跳,但某次任务执行花了 3.5 秒: ```text Burst(补跑): tick|work 3.5s|tick! tick! tick! tick| ← 补上错过的 3 次,一口气全跑 Delay(顺延): tick|work 3.5s|.........tick|work... ← 从"上次完成"重新对齐 1s Skip(跳过): tick|work 3.5s|......tick| ← 错过的全丢,等下一个整点 ``` 选型:心跳/监控用 `Delay`(频率比次数重要);计数打点用 `Skip`(节拍比及时重要);`Burst` 仅在"每跳都必须执行"时用(慎用,容易在恢复后突然涌入一批执行)。 还有个进阶 API `interval_at(start, period)`:指定**第一次** tick 的时刻(比如"启动后 30 秒再开始心跳"),避开"首跳立即完成"的默认行为。 > ⚠️ 对比错误写法 `loop { sleep(500ms).await; work(); }`:如果 work 耗时 100ms,实际周期变成 600ms 且不断漂移;`interval` 则始终保持 500ms 一跳。 ## 8.4 timeout:给未来上闹钟 `timeout` 包裹任何 future:它在限时内完成就返回 `Ok(结果)`,超时就把它**就地取消**,返回 `Err(Elapsed)`: ```rust use std::time::Duration; use tokio::time::timeout; async fn slow_api() -> String { // 模拟一个慢接口:要 3 秒才返回 tokio::time::sleep(Duration::from_secs(3)).await; "接口数据".to_string() } #[tokio::main] async fn main() { // 给 slow_api 限时 1 秒 match timeout(Duration::from_secs(1), slow_api()).await { Ok(result) => println!("拿到结果:{}", result), // 在限时内完成 Err(_) => println!("超时!请求已被取消"), // Err(tokio::time::error::Elapsed) } // 套娃:多层超时(总闸 5 秒,单次尝试 2 秒) let result = timeout(Duration::from_secs(5), async { loop { match timeout(Duration::from_secs(2), slow_api()).await { Ok(data) => return data, // 成功,把结果带出去 Err(_) => continue, // 单次超时,重试(注意配合退避,见 8.6) } } }).await; println!("最终:{:?}", result.err().map(|_| "总超时,放弃")); } ``` 要点: - `timeout_at(deadline)`:睡到**绝对时刻**的版本,与 `sleep_until` 对应; - 超时会**取消**被包裹的 future——如果里面是"正在读一半的网络数据"这类不可安全打断的操作,取消时数据就丢了(取消安全问题,第 17 章详解)。对"读一条完整消息"要接受"超时即丢弃本次读取"的语义,或用 codec 隔离; - `Elapsed` 错误不含任何信息(它只表示"到点了"),需要"超时了多久"就自己在前后打点。 ## 8.5 Instant:精确计时 ```rust use std::time::Duration; #[tokio::main] async fn main() { // tokio::time::Instant 与 std 的 Instant 用法一致, // 但它在"暂停时间测试"(第 18 章)中会跟随虚拟时钟,推荐异步代码用它 let start = tokio::time::Instant::now(); tokio::time::sleep(Duration::from_millis(123)).await; let elapsed = start.elapsed(); println!("这段代码耗时约:{} 毫秒", elapsed.as_millis()); // 与系统时间区分:Instant 是"单调时钟"(只会前进,不受系统改时间影响),适合测耗时; // std::time::SystemTime 是"墙上时钟"(可能被改),适合打时间戳进日志 } ``` ## 8.6 实战:心跳检测器 + 指数退避重试 **实战一:心跳检测器**。典型场景:监控一条连接,若 3 秒内没有任何数据就判死。核心是 `select!` 让"等数据"与"等超时"赛跑(第 14 章详解 select,这里先照抄结构): ```rust use std::time::Duration; use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<&str>(8); // 模拟"连接上有数据到达" // 模拟对端:2 秒后发一条数据 tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(2)).await; let _ = tx.send("数据包").unwrap(); }); let mut missed = 0; loop { // 每轮最多等 1 秒数据 match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await { Ok(Some(data)) => { println!("收到:{}", data); missed = 0; // 有数据,计数清零 } Ok(None) => { println!("连接关闭"); break; } Err(_) => { // 1 秒没数据 missed += 1; println!("⚠️ {} 秒无数据", missed); if missed >= 3 { println!("判定连接死亡"); break; } } } } } ``` **实战二:带指数退避的重试**。网络编程的日常:失败别立刻重试,而是 1s、2s、4s……并给每次尝试加超时: ```rust use std::time::Duration; use tokio::time::timeout; /// 模拟一个不稳定接口:前几次都会失败 async fn flaky_api(attempt: u32) -> Result { tokio::time::sleep(Duration::from_millis(200)).await; // 模拟网络延迟 if attempt < 3 { Err(format!("第 {} 次尝试失败", attempt)) } else { Ok("终于成功了!".to_string()) } } #[tokio::main] async fn main() { let mut attempt = 1; let mut backoff = Duration::from_millis(500); // 初始退避 500ms loop { // 每次尝试限时 1 秒 match timeout(Duration::from_secs(1), flaky_api(attempt)).await { Ok(Ok(data)) => { println!("✅ 成功:{}", data); break; } Ok(Err(e)) => println!("❌ 接口报错:{}", e), // 接口明确返回失败 Err(_) => println!("❌ 本次尝试超时"), // 1 秒没响应 } // 指数退避:500ms → 1s → 2s → 4s... println!("⏳ 退避 {:?} 后重试", backoff); tokio::time::sleep(backoff).await; backoff *= 2; attempt += 1; if attempt > 5 { println!("🛑 重试次数用尽,放弃"); break; } } } ``` 重试三板斧齐活:**单次超时 + 指数退避 + 总次数上限**。生产中可再加"抖动"(jitter,给退避时间加随机偏移),避免大量客户端同时重试造成惊群。 ## 本章要点小结 - Tokio 定时器基于时间轮,百万级 `sleep` 无压力,精度毫秒级。 - `sleep`/`sleep_until` 异步睡眠,取消安全;**禁用 `std::thread::sleep`**(阻塞工作线程)。 - 周期任务用 `interval`(首次 tick 立即完成;`interval_at` 可指定首跳时刻),按场景选 `MissedTickBehavior`:心跳用 Delay、打点用 Skip。 - `timeout` 给任意 future 上闹钟,超时即取消;可嵌套实现"单次超时 + 总预算"。 - 计时用 `tokio::time::Instant`(单调时钟,兼容虚拟时间测试);日志时间戳才用 SystemTime。 - 重试三板斧:单次超时 + 指数退避(+抖动)+ 总次数上限。 # 第 9 章 文件操作:fs 模块 > 本章目标:`tokio::fs` 的全部能力——便捷函数、流式 File、OpenOptions、seek、目录与元数据;以及一个独特的底层故事:文件 IO 为什么"特殊",什么时候该绕开它。 ## 9.1 模块总览:与 std 的对应关系 `tokio::fs` 提供了与 `std::fs` 几乎一一对应的异步 API: | 用途 | std(同步) | tokio(异步) | |---|---|---| | 一次读/写整个文件 | `fs::read` / `fs::write` | 同名,返回 Future | | 读成字符串 | `fs::read_to_string` | 同名 | | 文件句柄 | `fs::File` | `tokio::fs::File` | | 打开选项 | `fs::OpenOptions` | 同名 | | 建目录 | `fs::create_dir(_all)` | 同名 | | 删除 | `fs::remove_file / remove_dir(_all)` | 同名 | | 重命名/移动 | `fs::rename` | 同名 | | 复制 | `fs::copy` | 同名 | | 元数据 | `fs::metadata` | 同名 | | 遍历目录 | `fs::read_dir` | 同名(条目异步取出) | 绝大多数场景,你只需要把 std 的调用加上 `.await`。但两个 API 家族的适用边界完全不同——先看便捷函数: ```rust use tokio::fs; #[tokio::main] async fn main() -> std::io::Result<()> { // 写文件:不存在则创建,存在则覆盖(内容整体替换) fs::write("demo.txt", "Hello, Tokio 文件系统!\n第二行内容").await?; // 读整个文件为字节流 let bytes = fs::read("demo.txt").await?; println!("读到 {} 字节", bytes.len()); // 读整个文件为 UTF-8 字符串(文件不是合法 UTF-8 时报错) let text = fs::read_to_string("demo.txt").await?; println!("文件内容:\n{}", text); // 复制、重命名、删除,都是"路径进、Future 出" fs::copy("demo.txt", "demo_backup.txt").await?; // 返回复制的字节数 fs::rename("demo_backup.txt", "demo_new.txt").await?; // 重命名/移动 fs::remove_file("demo_new.txt").await?; // 删除文件 Ok(()) } ``` > ⚠️ 注意:`fs::read` / `read_to_string` 是**一次性读整个文件进内存**,读几 GB 的日志文件会直接把内存打爆。大文件请用 9.2 的 `File` 流式读写。 ## 9.2 File:流式读写大文件 `File` 实现了第 6 章的 `AsyncRead`/`AsyncWrite`,所有 IO 工具(`io::copy`、`BufReader`、`split`)都能直接用在文件上: ```rust use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[tokio::main] async fn main() -> std::io::Result<()> { // 打开(只读) let mut file = File::open("Cargo.toml").await?; let mut head = vec![0u8; 64]; let n = file.read(&mut head).await?; // 流式读前 64 字节 println!("文件开头:{}", String::from_utf8_lossy(&head[..n])); // 创建并写入 let mut out = File::create("numbers.txt").await?; for i in 0..1000 { // write_all 逐条写入 out.write_all(format!("第 {} 行\n", i).as_bytes()).await?; } out.flush().await?; // 确保缓冲数据落盘 out.shutdown().await?; // 结束写入 // 高频小写入建议手动加 BufWriter,合并 syscall: use tokio::io::BufWriter; let f = File::create("buffered.txt").await?; let mut bw = BufWriter::with_capacity(64 * 1024, f); // 64KB 缓冲 for i in 0..10_000 { bw.write_all(format!("行 {}\n", i).as_bytes()).await?; } bw.flush().await?; // 别忘了冲刷! Ok(()) } ``` ## 9.3 OpenOptions:精细控制打开模式 ```rust use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; #[tokio::main] async fn main() -> std::io::Result<()> { // 追加模式写日志(最常见的 OpenOptions 用法) let mut log = OpenOptions::new() .create(true) // 不存在则创建 .append(true) // 追加模式(写永远加在末尾) .open("app.log") .await?; log.write_all(b"[info] 服务启动\n").await?; log.write_all(b"[info] 一切正常\n").await?; // 读写模式:read(true) + write(true);截断:truncate(true); // 不存在则失败:全部 false 时,文件必须已存在 let _rw = OpenOptions::new().read(true).write(true).open("app.log").await?; Ok(()) } ``` 常用组合速查:`create+write`(覆盖写)、`create+append`(追加)、`read+write`(原地读写)、`create_new`(仅当不存在时创建,可当"锁文件"用)。 ## 9.4 目录操作与元数据 ```rust use tokio::fs; #[tokio::main] async fn main() -> std::io::Result<()> { // 创建目录:create_dir 只建一层,create_dir_all 递归建全路径 fs::create_dir_all("data/2026/10").await?; // 在里面放几个文件 fs::write("data/2026/10/a.txt", "A").await?; fs::write("data/2026/10/b.txt", "BB").await?; // 遍历目录:next_entry 逐个取出条目(每条目自带路径/名字/元数据) let mut entries = fs::read_dir("data").await?; while let Some(entry) = entries.next_entry().await? { let meta = entry.metadata().await?; let kind = if meta.is_dir() { "目录" } else { "文件" }; println!("发现:{}({}, {} 字节)", entry.path().display(), kind, meta.len()); } // 元数据:大小、类型、时间戳 let meta = fs::metadata("data/2026/10/a.txt").await?; println!( "a.txt:{} 字节,是文件:{},是目录:{}", meta.len(), meta.is_file(), meta.is_dir() ); // 规范化路径(解析出绝对真实路径,符号链接会被展开)、硬链接 println!("绝对路径:{}", fs::canonicalize("data/2026/10/a.txt").await?.display()); fs::hard_link("data/2026/10/a.txt", "data/a_link.txt").await?; // 清理:remove_dir 只能删空目录,remove_dir_all 递归删除(慎用!) fs::remove_dir_all("data").await?; Ok(()) } ``` > ⚠️ `remove_dir_all` 是全书最危险的 API:路径算错就是"删库"。生产代码里务必先 `canonicalize` 校验目标确实在预期目录内(第 22 章静态文件服务演示了这个防御)。 ## 9.5 实战:带进度的大文件复制 第 6 章的 `io::copy` 一步到位但看不到进度;这里手写"分块复制 + 进度条",顺便练习 seek 与缓冲: ```rust use tokio::fs::File; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; #[tokio::main] async fn main() -> std::io::Result<()> { let src_path = "large_source.bin"; let dst_path = "large_target.bin"; // 准备一个 32MB 的测试文件(实际使用时换成你自己的大文件) { let mut f = File::create(src_path).await?; let chunk = vec![0xABu8; 1024 * 1024]; // 1MB 的数据块 for _ in 0..32 { f.write_all(&chunk).await?; } f.flush().await?; } // 分块复制:每 4MB 打印一次进度 let mut src = File::open(src_path).await?; let mut dst = File::create(dst_path).await?; let total_len = src.metadata().await?.len(); let mut copied: u64 = 0; let mut buf = vec![0u8; 1024 * 1024]; // 1MB 缓冲 let start = tokio::time::Instant::now(); loop { let n = src.read(&mut buf).await?; // 读一块 if n == 0 { break; } // 读完了 dst.write_all(&buf[..n]).await?; // 写一块 copied += n as u64; print!("\r进度:{}/{} 字节({}%)", copied, total_len, copied * 100 / total_len); } dst.flush().await?; println!("\n✅ 复制完成,耗时 {} 毫秒", start.elapsed().as_millis()); Ok(()) } ``` > 💡 如果不需要进度,`io::copy(&mut src, &mut dst).await?` 一行即可,内部就是同样的循环且经过优化。两者的内存占用都与文件大小无关——这是"流式"的意义。 ## 9.6 进阶认知:文件 IO 为什么"特殊" 一个诚实的科普:操作系统至今没有普及真正的**异步文件 IO**(Linux 的 io_uring 还年轻,AIO 半残)。所以 Tokio 对文件的做法是:**把同步文件操作丢到阻塞线程池里跑**,再包装成 async API。 这带来两个推论: 1. `tokio::fs` 的吞吐量上限是阻塞线程池的吞吐量,**大量并发小文件操作时,它不会比多线程快**,只是写法统一、不堵工作线程; 2. 小文件用便捷函数没问题;但如果你发现文件 IO 成为瓶颈,正确姿势是: ```rust // 大量文件操作时,与其开 1 万个并发 fs 任务,不如批量丢给 spawn_blocking let summary = tokio::task::spawn_blocking(|| -> std::io::Result<(usize, u64)> { // 在阻塞线程池里用同步 API 一口气处理,反而更快更省 let mut count = 0; let mut total = 0; for entry in std::fs::read_dir(".")? { let entry = entry?; if entry.file_type()?.is_file() { total += entry.metadata()?.len(); count += 1; } } Ok((count, total)) }).await.unwrap()?; println!("{} 个文件,共 {} 字节", summary.0, summary.1); ``` > 💡 记住这个决策口诀:**并发少、单文件大 → tokio::fs;数量多、颗粒小 → spawn_blocking 批处理**。 ## 本章要点小结 - `fs::read/read_to_string/write` 覆盖日常小文件场景;大文件必须用 `File` 流式读写(内存占用与文件大小无关)。 - `File` 实现标准 IO trait:`io::copy`、`BufReader/BufWriter`(记得 flush)、`seek` 全部可用。 - `OpenOptions` 控制"追加、创建、读写"等打开模式,与 std 语义一致。 - 目录:`create_dir_all`、`read_dir` + `next_entry`、`canonicalize`;`remove_dir_all` 慎用,先做路径校验。 - `tokio::fs` 本质是阻塞线程池:海量小文件操作时改用 `spawn_blocking` 批处理。 # 第 10 章 进程管理:process 模块 > 本章目标:在异步程序里指挥外部进程——执行命令、流式收发、管道串联、限时强杀,以及所有跨平台差异的汇总。 ## 10.1 模块总览与平台差异 `tokio::process` 与 `std::process::Command` API 同构,但所有等待(启动、结束、IO)都是异步的。先记一张平台差异表,本章示例全部据此兼容双平台: | | Unix | Windows | |---|---|---| | 执行 shell 内建命令 | `sh -c "..."` | `cmd /C "..."` | | 回显文本 | `echo hello` | `cmd /C echo hello` | | 限次 ping | `ping -c 4 host` | `ping -n 4 host` | | 睡眠 N 秒 | `sleep N` | `cmd /C ping -n (N+1) 127.0.0.1` | | 读取示例 | `cat file` | `cmd /C type file` | > 💡 示例代码里用 `if cfg!(windows) { ... } else { ... }` 选择命令;`cfg!(windows)` 是编译期常量判断,两个分支都会被编译但只执行一个。 ## 10.2 快速执行:output 与 status ```rust use tokio::process::Command; #[tokio::main] async fn main() -> std::io::Result<()> { // output():等子进程跑完,一次性拿回 stdout/stderr(注意会缓冲整个输出) let output = if cfg!(windows) { Command::new("cmd").args(["/C", "echo", "hello"]).output().await? } else { Command::new("echo").arg("hello").output().await? }; println!("退出码:{}", output.status.code().unwrap_or(-1)); println!("stdout:{}", String::from_utf8_lossy(&output.stdout)); println!("stderr:{}", String::from_utf8_lossy(&output.stderr)); // status():只要退出码,输出直接打印到当前终端(适合 git status 这类交互命令) let status = if cfg!(windows) { Command::new("cmd").args(["/C", "ver"]).status().await? } else { Command::new("uname").arg("-a").status().await? }; println!("成功了吗?{}", status.success()); Ok(()) } ``` 选型:`output()` 要"程序化处理输出";`status()` 要"给用户看输出"。前者适合 `ls`/`git log`,后者适合 `vim`/`git status`。 > ⚠️ `output()` 会把子进程输出**全部攒在内存里**。输出巨大(如日志刷屏)时请用 10.3 的流式读取。 ## 10.3 流式读取子进程输出 以 `ping` 为例,边跑边读,每行输出实时处理: ```rust use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; #[tokio::main] async fn main() -> std::io::Result<()> { // spawn():不等子进程结束,立刻返回 Child 句柄 let mut child = if cfg!(windows) { Command::new("ping").args(["-n", "4", "127.0.0.1"]).stdout(std::process::Stdio::piped()).spawn()? } else { Command::new("ping").args(["-c", "4", "127.0.0.1"]).stdout(std::process::Stdio::piped()).spawn()? }; // take():把子进程的 stdout 管道"拿走",变成一个可异步读的 ChildStdout // ⚠️ 不 take 的话,默认继承父进程输出,无法程序化读取 let stdout = child.stdout.take().expect("子进程 stdout 未开启管道"); let mut reader = BufReader::new(stdout).lines(); // 按行读 while let Some(line) = reader.next_line().await? { println!("[ping 输出] {}", line); // 实时逐行处理(过滤、告警、转发都行) } // 流读完后,等待子进程退出,拿到退出状态 let status = child.wait().await?; println!("ping 退出,成功:{}", status.success()); Ok(()) } ``` `Stdio` 三件套(适用于 stdin/stdout/stderr 任意一端): | 设置 | 效果 | |---|---| | `Stdio::piped()` | 开管道,程序化读写(take 后使用) | | `Stdio::inherit()` | 直通父进程终端(默认) | | `Stdio::null()` | 丢弃(如压掉子进程的告警输出) | ## 10.4 向子进程写入数据 ```rust use tokio::io::AsyncWriteExt; use tokio::process::Command; use std::process::Stdio; #[tokio::main] async fn main() -> std::io::Result<()> { // Unix 用 cat(原样回显输入);Windows 用 more let mut child = if cfg!(windows) { Command::new("more").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()? } else { Command::new("cat").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()? }; // 拿走 stdin 管道 let mut stdin = child.stdin.take().expect("stdin 未开启管道"); // 写入数据后必须关闭 stdin:告诉子进程"输入完了",它才会退出 stdin.write_all(b"line one\nline two\n").await?; stdin.flush().await?; drop(stdin); // 关闭写入端,发送 EOF // 收集子进程的输出与退出码(wait_with_output 会把两侧管道都收干净) let output = child.wait_with_output().await?; println!("子进程回显:{}", String::from_utf8_lossy(&output.stdout)); Ok(()) } ``` > ⚠️ 经典死锁:如果子进程输出很多、而你不读它的 stdout,管道缓冲区塞满后**子进程会被卡死**,你也等不到它退出。原则:**开了管道就负责读完**(或明确丢弃)。`wait_with_output()` 会帮你读完两边,是"写完收工"场景的省心选择。 ## 10.5 进程管道串联:自己当"shell" 把进程 A 的输出接到进程 B 的输入(`A | B` 的效果),用 `io::copy` 桥接: ```rust use tokio::io::{AsyncWriteExt, self}; use tokio::process::Command; use std::process::Stdio; #[tokio::main] async fn main() -> std::io::Result<()> { // 进程 1:产生多行输出(Unix: seq;Windows: cmd 循环 echo 简化为一条多行输出) let mut p1 = if cfg!(windows) { Command::new("cmd").args(["/C", "echo", "apple\nbanana\ncherry"]) .stdout(Stdio::piped()).spawn()? } else { Command::new("seq").args(["1", "5"]).stdout(Stdio::piped()).spawn()? }; // 进程 2:排序(Unix: sort;Windows: cmd /C sort 从 stdin 读) let mut p2 = if cfg!(windows) { Command::new("cmd").arg("/C").arg("sort") .stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()? } else { Command::new("sort").stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()? }; // 桥接:p1 的 stdout → p2 的 stdin let mut p1_out = p1.stdout.take().unwrap(); let mut p2_in = p2.stdin.take().unwrap(); let bridge = tokio::spawn(async move { io::copy(&mut p1_out, &mut p2_in).await.unwrap(); p2_in.shutdown().await.unwrap(); // 关键:关闭 p2 的输入,p2 才会结束 }); bridge.await.unwrap(); let out = p2.wait_with_output().await?; // 收 p2 的结果 println!("排序结果:\n{}", String::from_utf8_lossy(&out.stdout)); Ok(()) } ``` 这段代码就是 `seq 1 5 | sort` 的手动实现——理解了它,你就理解了 shell 管道的本质:进程间的字节流。 ## 10.6 生命周期管理:id、kill、超时 ```rust use std::time::Duration; use tokio::process::Command; #[tokio::main] async fn main() -> std::io::Result<()> { // 一个"永远不退出"的命令 let mut child = if cfg!(windows) { Command::new("cmd").args(["/C", "ping", "-n", "100", "127.0.0.1"]).spawn()? } else { Command::new("sleep").arg("1000").spawn()? }; println!("子进程 PID:{}", child.id().unwrap_or(0)); // 方式一:限时等待,超时强杀 match tokio::time::timeout(Duration::from_secs(2), child.wait()).await { Ok(status) => println!("子进程自己退出了:{}", status.map(|s| s.success()).unwrap_or(false)), Err(_) => { println!("超时,强制终止"); child.kill().await?; // 发送终止信号(Windows: TerminateProcess;Unix: SIGKILL) child.wait().await?; // 回收子进程,避免僵尸进程 } } // 方式二:kill_on_drop(true) —— 忘记 wait 也会在 Child 被 drop 时自动杀掉 let mut sloppy = Command::new(if cfg!(windows) { "cmd" } else { "sleep" }) .args(if cfg!(windows) { vec!["/C", "ping", "-n", "10", "127.0.0.1"] } else { vec!["10"] }) .kill_on_drop(true) .spawn()?; drop(sloppy); // drop 即杀,防止"孤儿进程"长期占用资源 println!("演示完毕"); Ok(()) } ``` > 💡 Unix 下要"温柔地"优雅终止(先 SIGTERM 让进程清理现场),`kill()` 的 SIGKILL 太粗暴。可以用 `tokio::signal::unix` 给子进程发 SIGTERM(结合 `libc` 的 `kill` 系统调用),或查询 `child.id()` 后调用系统命令。跨平台优雅终止是复杂话题,先记住:`kill()` = 立即强杀。 ## 10.7 实战:并发跑批 + 超时终止 用 `JoinSet` 并发执行一批命令,每个限时 3 秒,超时自动杀掉: ```rust use std::time::Duration; use tokio::process::Command; use tokio::task::JoinSet; #[tokio::main] async fn main() -> std::io::Result<()> { let mut set = JoinSet::new(); // 要执行的命令清单(这里用两个快速命令 + 一个慢命令演示) let commands: Vec<(String, Vec)> = if cfg!(windows) { vec![ ("cmd".into(), vec!["/C".into(), "echo".into(), "task-1".into()]), ("cmd".into(), vec!["/C".into(), "echo".into(), "task-2".into()]), ("cmd".into(), vec!["/C".into(), "ping".into(), "-n".into(), "100".into(), "127.0.0.1".into()]), ] } else { vec![ ("echo".into(), vec!["task-1".into()]), ("echo".into(), vec!["task-2".into()]), ("sleep".into(), vec!["1000".into()]), ] }; // 并发启动全部命令,每个包一层 3 秒超时 for (idx, (program, args)) in commands.into_iter().enumerate() { set.spawn(async move { match tokio::time::timeout(Duration::from_secs(3), async { Command::new(&program).args(&args).output().await }).await { Ok(Ok(output)) => (idx, format!( "成功:{}", String::from_utf8_lossy(&output.stdout).trim() )), Ok(Err(e)) => (idx, format!("启动失败:{}", e)), Err(_) => (idx, "超时!已被取消(子进程一并终止)".into()), } }); } // 谁先完成收谁 while let Some(res) = set.join_next().await { let (idx, msg) = res.unwrap(); println!("[任务 {}] {}", idx, msg); } Ok(()) } ``` 两个快任务立刻返回,慢任务 3 秒后被 timeout 取消(其子进程也随之终止)。这套"并发 + 超时"模式可直接用于批量调用脚本、批量转码、批量数据同步等生产场景。 ## 本章要点小结 - `output()` 一次拿全输出(占内存,适合短命令);`spawn()` + `take()` 管道流式处理(省内存、实时,适合长任务)。 - `Stdio::piped/inherit/null` 三选一;**开了管道就必须读完**,否则可能死锁;`wait_with_output()` 是"写完收工"的省心选择。 - 写完 stdin 要 `shutdown`/`drop` 关闭,子进程才知道输入结束;进程管道 = `io::copy` 桥接两个管道。 - `child.wait()` 等退出,`child.kill()` 强杀;`kill_on_drop(true)` 防孤儿进程;配合 `timeout` 实现限时执行。 - 批量任务用 `JoinSet` 并发跑,单任务套 `timeout`,完成一个收一个。 # 第 11 章 信号处理:signal 模块 > 本章目标:让程序听懂操作系统的"话"——Ctrl+C、SIGTERM 等信号的异步监听;并给出生产环境优雅关闭的完整设计模式(两级关闭:先优雅,再强制)。 ## 11.1 信号是什么 信号(signal)是操作系统发给进程的"紧急通知":用户按了 Ctrl+C、管理员执行了 `kill`、子进程退出了……每个信号都有默认行为(通常是"直接终止进程")。 先认识几个最重要的 Unix 信号: | 信号 | 谁发出 | 默认行为 | 生产语义 | |---|---|---|---| | SIGINT | 用户按 Ctrl+C | 终止 | "我想退出" | | SIGTERM | `kill` 命令 / Kubernetes 停容器 | 终止 | "请求你退出"(必听!) | | SIGHUP | 终端断开 | 终止 | 约定俗成:"重读配置" | | SIGUSR1/2 | 开发者自定义 | 终止 | 应用自定义开关(如切日志级别) | | SIGKILL | `kill -9` | **无法捕获,立即终止** | "不管你愿不愿意都得死" | 关键认知:**SIGKILL 无法拦截**。所以"优雅关闭"的真正含义是:在收到 SIGTERM/SIGINT 后、系统失去耐心(或被 kill -9)之前,尽快把状态落盘、把连接收尾。 ## 11.2 最常用的信号:Ctrl+C ```rust use tokio::signal; #[tokio::main] async fn main() { println!("程序运行中,按 Ctrl+C 退出…"); // ctrl_c() 返回一个 future:在用户按下 Ctrl+C(控制台的 SIGINT)时完成 // 阻塞在这里等待,不占 CPU —— 信号等待是完全异步的 signal::ctrl_c().await.expect("注册 Ctrl+C 监听失败"); // 走到这里说明用户按了 Ctrl+C:进程并没有立刻死掉, // 默认的"立即退出"行为已被接管,现在轮到你决定怎么收场 println!("\n收到 Ctrl+C!开始保存数据、关闭连接…"); } ``` 关键认知:一旦你 `await` 了 `ctrl_c()`,Tokio 就替你**接管了默认终止行为**——进程不会"啪"地死掉,而是等你处理完再退出。这正是优雅关闭的入口。 三个细节: - **可以多次触发**:`ctrl_c()` 产生的 future 丢弃后,可以重新 `await` 一个新的——比如第二次 Ctrl+C 用于强制退出(见 11.6); - **全局只能一份监听**:同一信号同时注册两个监听器会 panic。多个模块都需要响应关闭时,用"监听一次 + 广播"模式(11.5); - **Ctrl+C 也是数据**:在行编辑场景(如 REPL)里,你可能想捕获 Ctrl+C 当作普通输入处理——`ctrl_c().await` 正是这么用的。 ## 11.3 Unix 信号:SIGTERM 与一众信号 Linux/macOS 上信号丰富:`kill` 命令默认发的是 **SIGTERM**(而不是 Ctrl+C 的 SIGINT),Kubernetes 停容器也发 SIGTERM,生产服务器必须监听它: ```rust #[cfg(unix)] async fn unix_signals_demo() { use tokio::signal::unix::{signal, SignalKind}; // 注册想要监听的信号,返回一个可反复 recv() 的流 let mut sigterm = signal(SignalKind::terminate()).expect("注册 SIGTERM 失败"); // kill 命令 let mut sighup = signal(SignalKind::hangup()).expect("注册 SIGHUP 失败"); // 常用于"重载配置" let mut sigusr1 = signal(SignalKind::user_defined1()).expect("注册 SIGUSR1 失败"); // 自定义用途 // 用 select! 同时监听多个信号,谁先来处理谁(select! 详见第 14 章) tokio::select! { _ = sigterm.recv() => println!("收到 SIGTERM:请求退出"), _ = sighup.recv() => println!("收到 SIGHUP:重读配置文件"), _ = sigusr1.recv() => println!("收到 SIGUSR1:切换日志级别"), _ = tokio::signal::ctrl_c() => println!("收到 Ctrl+C(SIGINT)"), } // 注意:信号流注册一次、可反复 recv, // 同一个信号来了多次,recv 会依次返回 —— 所以"重读配置"可以这样循环着做 } #[cfg(unix)] #[tokio::main] async fn main() { println!("试试:kill -TERM {}", std::process::id()); // 打印自己的 PID 方便实验 unix_signals_demo().await; } #[cfg(not(unix))] #[tokio::main] async fn main() { println!("本示例仅在 Unix 平台运行"); } ``` `SignalKind` 覆盖了常用信号:`terminate`(SIGTERM)、`interrupt`(SIGINT)、`hangup`(SIGHUP)、`user_defined1/2`、`child`(子进程退出)、`alarm`、`pipe`、`quit` 等;冷门信号用 `SignalKind::from_raw(编号)` 构造。 ## 11.4 Windows 控制台事件 Windows 没有完整的 Unix 信号体系,Tokio 提供的是一组控制台事件:`ctrl_c`、`ctrl_break`、`ctrl_close`、`ctrl_logoff`、`ctrl_shutdown`: ```rust #[cfg(windows)] async fn windows_signals_demo() { use tokio::signal::windows::ctrl_break; let mut brk = ctrl_break().expect("注册 CTRL_BREAK 失败"); tokio::select! { _ = tokio::signal::ctrl_c() => println!("收到 Ctrl+C"), _ = brk.recv() => println!("收到 Ctrl+Break"), } } #[cfg(windows)] #[tokio::main] async fn main() { println!("试试按 Ctrl+Break 或 Ctrl+C"); windows_signals_demo().await; } #[cfg(not(windows))] #[tokio::main] async fn main() { println!("本示例仅在 Windows 平台运行"); } ``` > 💡 Windows 的 `ctrl_close`(用户点 X 关窗口)只给程序约 5 秒清理时间——所以"优雅关闭要快"是跨平台共识。 ## 11.5 设计模式:单点监听 + 令牌广播 直接把信号监听塞进每个模块是行不通的(同一信号只能一份监听)。标准架构: ```text ┌─────────────┐ cancel() ┌──────────────────────┐ │ 信号监听任务 │ ──────────► │ CancellationToken │ │ (全局唯一) │ │ (树形广播给所有模块) │ └─────────────┘ └──────────┬───────────┘ │ cancelled().await ┌──────────────┬───────────┼────────────┐ ▼ ▼ ▼ ▼ HTTP 接收循环 后台定时任务 连接任务 缓存刷新任务 (停止接客) (停止出活) (断开连接) (停止刷新) ``` 信号只在一个地方 `await`,然后把"关闭"这个事实通过 `CancellationToken`(第 16 章)或 `broadcast` 通道(第 12 章)广播出去——每个模块自己决定怎么收尾。这套模式贯穿第 21、22 章实战。 ## 11.6 实战:优雅关闭的 Echo 服务器(带强制退出) 把信号监听嵌进服务器主循环,并加上"第二次 Ctrl+C 强制退出"的完整语义: ```rust use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::signal; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; println!("服务器已启动,按 Ctrl+C 优雅关闭(再按一次强制退出)…"); let in_flight = Arc::new(AtomicUsize::new(0)); // 正在处理的连接数 // 两个 future 竞速: // A. 服务器主循环(永远跑下去,除非出错) // B. Ctrl+C 信号(用户按下时完成) tokio::select! { res = run_server(&listener, &in_flight) => { res?; // 主循环意外退出(如端口被占用的 IO 错误) println!("服务器主循环退出"); } _ = signal::ctrl_c() => { println!("\n收到 Ctrl+C:停止接受新连接,等待存量连接收尾…"); } } // 优雅关闭阶段:等存量连接归零(最多 10 秒) let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); while in_flight.load(Ordering::SeqCst) > 0 { if tokio::time::Instant::now() > deadline { println!("超时!仍有 {} 个连接未收尾,强制退出", in_flight.load(Ordering::SeqCst)); break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } println!("再见!"); Ok(()) } async fn run_server(listener: &TcpListener, in_flight: &Arc) -> std::io::Result<()> { loop { let (socket, addr) = listener.accept().await?; in_flight.fetch_add(1, Ordering::SeqCst); // 接客 +1 let counter = in_flight.clone(); tokio::spawn(async move { if let Err(e) = echo(socket).await { eprintln!("连接 {} 出错:{}", addr, e); } counter.fetch_sub(1, Ordering::SeqCst); // 送客 -1 }); } } async fn echo(mut socket: TcpStream) -> std::io::Result<()> { let mut buf = vec![0u8; 1024]; loop { let n = socket.read(&mut buf).await?; if n == 0 { return Ok(()); } // 对端关闭 socket.write_all(&buf[..n]).await?; } } ``` 运行后用浏览器开几个连接(会立刻断开但计数器有痕迹),再按 Ctrl+C:服务器不再接受新连接、等存量归零后退出。如果存量连接迟迟不结束,10 秒后强制退出——**"先优雅、再强制"** 是所有服务的关闭范式(Kubernetes 的 `terminationGracePeriodSeconds` 就是给这个流程的时间预算)。 > ⚠️ 本章用计数器做演示,生产中更常用 `TaskTracker`(第 16 章)替代手写计数——它就是"计数器 + 等待"的现成封装。 ## 本章要点小结 - `signal::ctrl_c().await` 接管 Ctrl+C,进程是否退出、何时退出由你决定。 - Unix 生产环境必监听 SIGTERM(`SignalKind::terminate()`),多信号用 `select!` 分发;信号流注册一次可反复 recv。 - 同一信号只能一份监听;广播"关闭事件"交给 CancellationToken / broadcast。 - Windows 提供控制台事件系列 API,用法同构;`ctrl_close` 只有约 5 秒清理时间。 - 优雅关闭范式:单点监听 → 令牌广播 → 停止接纳 → 等存量归零(限时)→ 退出。 # 第 12 章 同步原语 sync(上):通道 > 本章目标:掌握任务间通信的第一手段——四种通道的完整语义(容量、背压、关闭)、选型决策,以及用通道组织代码的终极模式:Actor。 ## 12.1 为什么是通道,而不是共享内存 任务之间传数据有两条路:**共享内存 + 锁**(下一章)与**消息传递(通道)**。Rust 的所有权系统让后者格外优雅: ```text 共享内存:谁都能碰数据 → 必须用锁护住 → 锁的粒度、顺序、持有时间全是坑 消息传递:数据从"发送者"移动/共享给"接收者" → 同一时刻只有一个任务持有它 → 无锁无竞争 ``` Go 语言的格言 "Don't communicate by sharing memory; share memory by communicating"(不要通过共享内存来通信,而要通过通信来共享内存)在 Rust 里体现得更彻底——所有权转移让"通信中的共享"天然安全。`tokio::sync` 提供四种通道,各有各的脾气。 ## 12.2 mpsc:多生产者,单消费者 最常用的通道:多个发送端(**M**ulti **P**roducer),一个接收端(**S**ingle **C**onsumer)。 ```rust use tokio::sync::mpsc; use std::time::Duration; #[tokio::main] async fn main() { // 创建容量为 32 的通道:缓冲区最多积压 32 条消息 // 满了之后 send().await 会暂停等待 —— 这就是"背压",防止生产过快撑爆内存 let (tx, mut rx) = mpsc::channel::(32); // clone 出 3 个生产者任务(原始 tx 被我们 move 进第一个任务) for worker_id in 1..=3 { let tx = tx.clone(); tokio::spawn(async move { for i in 0..3 { let msg = format!("工人 {} 的第 {} 号产品", worker_id, i); // send().await:通道满则等待,接收端关闭则返回 Err if tx.send(msg).await.is_err() { break; // 接收端没了,别再生产 } tokio::time::sleep(Duration::from_millis(50)).await; } // tx 在任务结束时被丢弃:这是"该生产者已下线"的信号 }); } // ⚠️ 关键一步:丢弃主线程手里最后的 tx 克隆。 // 否则 rx 永远等不到"所有生产者都下班",recv 永不返回 None drop(tx); // recv():取一条消息;返回 None 表示所有发送端都已关闭、通道彻底枯竭 while let Some(msg) = rx.recv().await { println!("收到:{}", msg); } println!("所有生产者已完成,通道关闭"); } ``` mpsc 的完整语义清单: | API | 行为 | 用途 | |---|---|---| | `channel(n)` | 有界通道,容量 n | **默认选择**,自带背压 | | `unbounded_channel()` | 无界,send 永不等待 | 仅限"生产必然慢于消费"场景 | | `send(v).await` | 满则等待;接收端关闭返回 Err | 正常发送 | | `try_send(v)` | 满则立即返回 `TrySendError::Full` | 想丢就丢的遥测数据 | | `recv().await` | 空则等待;全部发送端关闭返回 None | 正常接收 | | `try_recv()` | 空则立即返回 Err | 非阻塞轮询 | | `recv_many(&mut vec, limit).await` | 一次批量收取最多 limit 条,返回实际条数(0 = 通道已关闭且无消息) | 攒批处理(micro-batch) | | `tx.blocking_send(v)` / `rx.blocking_recv()` | **同步上下文**版收发(严禁在异步任务里调用,会阻塞工作线程) | 同步代码与任务互操作 | | `tx.downgrade()` → `WeakSender` | 弱引用,`upgrade()` 才能发送 | 缓存"可能已关闭"的发送端 | | `tx.max_capacity() / tx.capacity()` | 查容量 | 调试背压 | > ⚠️ `unbounded_channel` 在生产环境等于定时炸弹:一旦消费速度长期低于生产速度,内存无限增长。确要用它,请先回答"消费端为什么永远更快"。 ## 12.3 oneshot:一问一答 只需要**传一个值**(往往还是一次 RPC 的响应)时,oneshot 最轻量: ```rust use tokio::sync::oneshot; use std::time::Duration; #[tokio::main] async fn main() { // oneshot 只能传一个值,无需容量参数 let (tx, rx) = oneshot::channel::(); // 派一个任务去"算答案" tokio::spawn(async move { let answer = "42".to_string(); // 模拟耗时的计算 // 注意:oneshot 的 send 不需要 .await!传完这个通道就自动关闭 if tx.send(answer).is_err() { println!("接收方已经不等了"); } }); // 给"等答案"也加个超时:timeout 与 rx.await 组合 match tokio::time::timeout(Duration::from_secs(1), rx).await { Ok(Ok(answer)) => println!("问题答案:{}", answer), // Ok(答案) Ok(Err(_)) => println!("对方没回复就消失了"), // 发送端被 drop Err(_) => println!("等答案超时了"), // timeout 的 Elapsed } } ``` 典型用途:工作线程回传结果、请求-响应式 RPC、任务间"回调"。注意它的错误是**双层**的:外层 `Result` 来自 timeout,内层来自通道本身——初学容易拆错层。 ## 12.4 broadcast:广播给所有人 一条消息,所有订阅者都能收到——聊天室、日志总线、配置热更新的标配: ```rust use tokio::sync::broadcast; #[tokio::main] async fn main() { // 创建容量 16 的广播通道;broadcast 只有"一个 Sender" let (tx, mut rx1) = broadcast::channel::<&str>(16); let mut rx2 = tx.subscribe(); // 新订阅者向 Sender 讨要一个 Receiver let mut rx3 = tx.subscribe(); println!("当前订阅者:{} 人", tx.receiver_count()); // 发布者广播 3 条消息 tx.send("第 1 条:大家好").unwrap(); tx.send("第 2 条:明天开会").unwrap(); tx.send("第 3 条:散会").unwrap(); drop(tx); // 发送完关闭发布端 // 三个订阅者各自独立收取,互不影响 for (name, rx) in [("订阅者1", &mut rx1), ("订阅者2", &mut rx2), ("订阅者3", &mut rx3)] { loop { match rx.recv().await { Ok(msg) => println!("{} 收到:{}", name, msg), Err(broadcast::error::RecvError::Closed) => break, // 通道关闭,收完了 Err(broadcast::error::RecvError::Lagged(n)) => { // 该订阅者取消息太慢,缓冲区被冲刷,丢了 n 条! println!("{} 落后了,错过 {} 条消息", name, n); } } } } } ``` > ⚠️ broadcast 的两个特性必须刻在脑子里: > 1. **容量有限**:消息是"环形队列",落后的订阅者会收到 `Lagged` 错误而不是无限积压——设计上就是允许丢; > 2. **接收端人数未知**:`recv()` 返回 `Result`,消息本身包在 `Ok` 里;`send()` 只有当"零个订阅者"时才返回 Err。 ## 12.5 watch:只关心最新值 状态监控场景:"我不在乎中间改了几次,告诉我**现在**是什么。"watch 是单发送端、多接收端、只保留最新值的通道: ```rust use tokio::sync::watch; use std::time::Duration; #[tokio::main] async fn main() { // watch 必须带一个初始值 let (tx, mut rx) = watch::channel("启动中".to_string()); // 一个"状态监控员"任务 tokio::spawn(async move { loop { // changed():等待值发生变化(与上次看到的不同) if rx.changed().await.is_err() { break; // 发送端被 drop,监控结束 } // changed() 返回后用 borrow() 借出最新值的只读引用 println!("状态更新为:{}", *rx.borrow()); } }); // 主任务模拟服务状态流转 for state in ["连接数据库", "初始化完成", "对外服务中"] { tokio::time::sleep(Duration::from_millis(100)).await; tx.send(state.to_string()).unwrap(); // 修改值,监控员会被唤醒 } drop(tx); // 结束监控 tokio::time::sleep(Duration::from_millis(50)).await; println!("演示结束"); } ``` watch 的 API 细节: | API | 说明 | |---|---| | `rx.changed().await` | 等待"值变了";首次调用前应先建立基线(见下) | | `rx.borrow()` | 借出当前值的只读引用(持有期间发送端照常更新,互不阻塞) | | `rx.borrow_and_update()` | 借出值**并**把"已读"标记推到最新(避免 changed() 立即误报) | | `rx.mark_seen()` | 手动标记已读 | | `tx.send_modify(f)` | 原地修改值并通知 | | `tx.subscribe()` | 从发送端再加一个接收端 | > 💡 经典坑:连续 `send` 两次会被合并(接收端只看到最新值)——这是特性不是缺陷。若不想错过"订阅之前"的值,先 `rx.borrow_and_update()` 建立基线。 ## 12.6 通道选型指南 | 通道 | 生产者 | 消费者 | 语义 | 典型场景 | |---|---|---|---|---| | `mpsc` | 多个 | **一个** | 队列,有背压 | 任务池收活儿、日志汇聚、结果回收 | | `oneshot` | 1 个 | 1 个 | 一次性的承诺 | RPC 响应、子任务回传结果 | | `broadcast` | 1 个 | 多个 | 广播,允许 Lag | 聊天室、事件总线、优雅关闭通知 | | `watch` | 1 个 | 多个 | 只看最新值 | 状态监控、配置热更新 | 选型口诀:**干活排队 mpsc,讨要结果 oneshot,广而告之 broadcast,只看现状 watch**。 ## 12.7 Actor 模式:把状态藏进任务里 通道最大的价值,是支撑 **Actor 模式**——并发架构的万能钥匙:把所有可变状态锁进一个专属任务(Actor),其他任务只能通过通道给它"发消息"。没有锁、没有竞争、状态永远被单线程串行访问: ```rust use tokio::sync::{mpsc, oneshot}; // Actor 能听懂的"指令"集合 enum Command { Increment, // 计数 +1 Add(u32), // 计数 +n Get(oneshot::Sender), // 查询当前值(带"回执单") } /// 计数器 Actor:全世界唯一有权碰 counter 的人 async fn counter_actor(mut rx: mpsc::Receiver) { let mut counter: u64 = 0; // 状态被"藏"在这里,外界永远摸不到 while let Some(cmd) = rx.recv().await { match cmd { Command::Increment => counter += 1, Command::Add(n) => counter += n as u64, Command::Get(reply) => { // 通过 oneshot 把结果发回给"提问的人" let _ = reply.send(counter); } } } println!("Actor 收到关闭信号,最终计数:{}", counter); } #[tokio::main] async fn main() { let (tx, rx) = mpsc::channel::(16); tokio::spawn(counter_actor(rx)); // 启动 Actor // 外界只能"发消息",哪怕 10 个任务并发加,也不会有数据竞争 let mut handles = Vec::new(); for i in 0..10 { let tx = tx.clone(); handles.push(tokio::spawn(async move { tx.send(Command::Add(i + 1)).await.unwrap(); })); } for h in handles { h.await.unwrap(); } // 查询:发一条带 oneshot 回执的指令 let (reply_tx, reply_rx) = oneshot::channel(); tx.send(Command::Get(reply_tx)).await.unwrap(); println!("最终计数:{}", reply_rx.await.unwrap()); // 1+2+...+10 = 55 drop(tx); // 关闭通道,Actor 自然退出 } ``` Actor 模式的三要素:**状态私有 + 指令枚举 + oneshot 回执**。它把"锁的正确性"转化为"消息的正确性",代码天然可测试(直接构造指令喂给 Actor)。数据库连接池、缓存服务、状态机,都可以用这个骨架搭建。 ## 12.8 实战:用 mpsc 搭建"工作队列" 生产架构里最常用的模式:主任务派活,多个 worker 抢活干,结果汇总回来: ```rust use tokio::sync::{mpsc, oneshot}; use std::time::Duration; #[tokio::main] async fn main() { // 工作队列:主任务 -> workers let (job_tx, job_rx) = mpsc::channel::<(u32, oneshot::Sender)>(16); // ⚠️ 多个 worker 要抢同一个 job_rx:把 Receiver 包进 Arc + 异步 Mutex, // 谁锁到谁取活(mpsc 本身单消费者,这是标准扩展手法) let job_rx = std::sync::Arc::new(tokio::sync::Mutex::new(job_rx)); // 启动 3 个 worker for id in 1..=3 { let job_rx = job_rx.clone(); tokio::spawn(async move { loop { // 锁住队列取一个活,立刻释放锁(关键:锁绝不包住处理过程) let (job_id, reply_tx) = { let mut rx = job_rx.lock().await; match rx.recv().await { Some(job) => job, None => break, // 队列彻底关闭,worker 下班 } }; // MutexGuard 在这里析构,锁已释放 // 处理任务(耗时操作,不持锁) tokio::time::sleep(Duration::from_millis(100)).await; let result = format!("任务 {} 已由工人 {} 完成", job_id, id); // 用 oneshot 把结果发回给"下单"的主任务 let _ = reply_tx.send(result); } }); } // 主任务:下 5 个单,并收集每个的回执 let mut handles = Vec::new(); for i in 1..=5 { let job_tx = job_tx.clone(); let (reply_tx, reply_rx) = oneshot::channel(); // 每单一张"回执单" job_tx.send((i, reply_tx)).await.unwrap(); handles.push(tokio::spawn(async move { match reply_rx.await { Ok(result) => println!("✔ {}", result), Err(_) => println!("✘ 任务 {} 的工人消失了", i), } })); } drop(job_tx); // 不再派活:所有单发完后关闭队列,worker 们才能收工 for h in handles { h.await.unwrap(); } println!("全部任务完成"); } ``` 这个"mpsc 派活 + oneshot 回执"的组合拳,就是无数连接池、任务队列的雏形,务必亲手跑一遍。 ## 本章要点小结 - 通道优于共享内存:所有权转移天然避免数据竞争;四种通道背口诀——mpsc(多对一队列)、oneshot(一次性)、broadcast(一对多广播)、watch(最新值)。 - 有界通道自带背压,无界通道会吃内存,默认用有界;`try_send`/`try_recv` 处理"想丢就丢"的遥测类数据。 - 关闭语义:发送端全 drop → 接收端 `recv` 得 `None`/`Closed`;接收端 drop → `send` 得 `Err`。**多余的 Sender 克隆记得 drop**,否则永远等不到关闭。 - broadcast 会 Lag(丢旧消息),watch 会合并中间状态——都是特性不是缺陷。 - Actor 模式(状态私有 + 指令枚举 + oneshot 回执)是用通道组织复杂并发逻辑的万能骨架。 - 多消费者抢 mpsc:`Arc>`,锁只包住取活动作。 # 第 13 章 同步原语 sync(下):锁与协调 > 本章目标:通道负责"传数据",本章的原语负责"护数据"和"集合排队"——互斥锁(与 std 锁的完整对比)、读写锁、信号量(限流与连接池)、通知器(防竞态唤醒)、屏障,以及"限流并发"的标准骨架。 ## 13.1 两种哲学:锁数据 vs 传消息 上一章的通道是"传消息";当你确有"多个任务要读写同一份数据"的需求(计数器、配置表、连接池),就轮到本章的原语。先给决策框架: ```text 需要共享可变状态? ├── 状态很小、更新频率低(计数器、开关) → 原子类型 std::sync::atomic(最快,无需 await) ├── 多任务要读/写复杂数据 → Mutex / RwLock(本章) ├── 本质是"排队限流"(连接数、并发度) → Semaphore ├── 本质是"等事件" → Notify / watch / 通道 └── 其实可以用 Actor 模式重构(第 12 章) → 优先考虑! ``` ## 13.2 Mutex:互斥锁 多个任务要改同一份数据,必须加锁防止互相踩踏: ```rust use std::sync::Arc; use tokio::sync::Mutex; use std::time::Duration; #[tokio::main] async fn main() { // Arc + Mutex 是"多任务共享可变数据"的标准组合: // Arc 解决"多个任务都持有它",Mutex 解决"同一时刻只有一个能改" let counter = Arc::new(Mutex::new(0u64)); let mut handles = Vec::new(); for _ in 0..10 { let counter = Arc::clone(&counter); handles.push(tokio::spawn(async move { for _ in 0..100 { // lock().await:拿锁(别人拿着就等);返回 MutexGuard 智能指针 let mut num = counter.lock().await; *num += 1; // guard 在作用域结束/被 drop 时自动解锁,没有手动 unlock } // <- 这里解锁 })); } for h in handles { h.await.unwrap(); } println!("最终计数:{}", *counter.lock().await); // 必然是 1000,无竞态 } ``` **`tokio::sync::Mutex` 与 `std::sync::Mutex` 的本质区别**(这是面试高频题): | | `tokio::sync::Mutex` | `std::sync::Mutex` | |---|---|---| | 拿锁 | `lock().await`,等待时**让出线程**(任务挂起) | `lock()`,等待时**阻塞线程**(自旋/内核等待) | | 跨 await 持有 | ✅ 可以(guard 是 Send) | ❌ 持 std 的 guard 跨 await 编译报错(非 Send) | | 开销 | 较大(内部有队列、waker) | 极小(无人竞争时几乎免费) | | 阻塞工作线程 | 不会 | **会!** 拿不到锁时整条线程卡住 | 选型建议(也是 Tokio 官方文档的建议):**竞争少、临界区短、不跨 await → 用 `std::sync::Mutex`**(在异步代码里完全可以用!);**需要跨 await 持有锁(比如锁着连接做 IO)→ 用 `tokio::sync::Mutex`**。 ```rust use std::sync::Arc; use tokio::sync::Mutex; use std::time::Duration; // 跨 await 持锁的典型场景:锁住"整段会话",别的任务不许插手 struct MockConn; impl MockConn { async fn send(&mut self, _d: &[u8]) {} async fn recv(&mut self) -> u8 { 0 } } async fn exclusive_session(conn: Arc>) { let mut conn = conn.lock().await; // 拿到连接 conn.send(b"AUTH").await; // —— 这些 IO 期间锁一直握在手里 conn.recv().await; // —— 别的任务无法抢用这条连接 } // 会话做完才释放 // ⚠️ 死锁警示:持锁 A 等 B,同时另一个任务持锁 B 等 A —— 永久互等。 // 规避三原则: // 1. 多把锁永远按固定顺序获取(全局排序); // 2. 锁内不做可能"反向要锁"的操作(尤其别在持锁时调用未知回调); // 3. 锁的临界区越小越好,取完数据立刻放锁再慢慢处理。 async fn deadlock_demo(a: Arc>, b: Arc>) { // 任务 1:a.lock → b.lock;任务 2:b.lock → a.lock ⇒ 死锁! // 修复:两个任务都先 a 后 b let mut _x = a.lock().await; tokio::time::sleep(Duration::from_millis(1)).await; let _y = b.lock().await; } ``` ## 13.3 RwLock:读写锁 读多写少的数据(配置表、路由表),让多个读者并行、写者独占: ```rust use std::sync::Arc; use tokio::sync::RwLock; #[tokio::main] async fn main() { // 与 Mutex 同样需要 Arc 跨任务共享 let config = Arc::new(RwLock::new(String::from("版本 1"))); // 多个读者:read().await 返回只读 guard,彼此互不阻塞 let mut readers = Vec::new(); for _ in 0..3 { let config = Arc::clone(&config); readers.push(tokio::spawn(async move { let cfg = config.read().await; // 读锁:可多个共存 format!("读到:{}", cfg) })); } // 一个写者:write().await 独占(等所有读者放掉才进得来) let writer_config = Arc::clone(&config); let writer = tokio::spawn(async move { let mut cfg = writer_config.write().await; // 写锁:独占一切 *cfg = "版本 2".to_string(); println!("配置已更新"); }); for r in readers { println!("{}", r.await.unwrap()); } writer.await.unwrap(); println!("最终:{}", *config.read().await); } ``` > ⚠️ 读写锁的陷阱:**写者可能被持续的读者"饿死"**(永远有新读者进来,写者等不到清场)。如果读多写少到极致,考虑"快照 + 通道广播"(把配置做成不可变值,更新时整份替换并 broadcast)——比 RwLock 更简单可靠。 ## 13.4 Semaphore:并发限流 "同时最多 N 个任务在干活"——限流、连接池、爬虫并发控制,全靠它: ```rust use std::sync::Arc; use tokio::sync::Semaphore; use std::time::{Duration, Instant}; #[tokio::main] async fn main() { // 只发放 3 张"通行证":同一时刻最多 3 个任务在执行 let semaphore = Arc::new(Semaphore::new(3)); let mut handles = Vec::new(); for i in 1..=8 { let sem = Arc::clone(&semaphore); handles.push(tokio::spawn(async move { let start = Instant::now(); // acquire().await:领一张票(没票就排队等待) let _permit = sem.acquire().await.unwrap(); println!("任务 {} 进入(等待了 {}ms)", i, start.elapsed().as_millis()); tokio::time::sleep(Duration::from_millis(300)).await; // 模拟干活 // _permit 在这里 drop,票自动归还 —— 解锁无需手动操作 })); } for h in handles { h.await.unwrap(); } // 观察"进入"日志:任意时刻最多 3 个任务,按批进入 } ``` Semaphore 的完整 API 速览: | API | 用途 | |---|---| | `acquire().await` | 领票,没票就排队(返回 `SemaphorePermit`,drop 即还票) | | `try_acquire()` | 领不到立即返回 Err(拒绝式限流,第 7 章连接上限用的它) | | `acquire_owned()` | 配合 `Arc`,领一个 `'static` 的票(带进 spawn 的任务) | | `add_permits(n)` | 手动加票(配合 `permit.forget()` 实现动态调整) | | `available_permits()` | 查余票(监控用) | | `acquire_many(n).await` | 一次领 n 张票(重活占多个"工位",与单票任务共用同一池) | | `close()` | 关闸:所有等待者收到 `AcquireError::Closed` | **Permit 的 Drop 即归还**,这是信号量好用的一半;另一半是"permit 可以被 forget(不还票)实现永久降容,再 add_permits 升容"——动态伸缩并发度就是这几个 API 的组合。 ## 13.5 Notify:精准唤醒 通道传数据,Notify 传"信号"——一个任务说"事情好了",另一个任务从睡梦中醒来: ```rust use tokio::sync::Notify; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() { let notify = Arc::new(Notify::new()); // 等待者 let waiter = Arc::clone(¬ify); let handle = tokio::spawn(async move { println!("等待者:睡大觉,等人叫我…"); // ⚠️ 经典竞态与它的标准解法: // 如果先 notified().await,而通知在" await 前的一瞬间"已经发出,信号就丢了 // notified() 返回的 future 先 enable():登记"我要听下一次通知" let future = waiter.notified(); tokio::pin!(future); // 固定到栈上(pin! 详见第 14 章) future.as_mut().enable(); // 提前登记,不再怕信号先到 future.await; // 等待(若通知已登记过,立即返回) println!("等待者:被叫醒了!"); }); tokio::time::sleep(Duration::from_millis(100)).await; // 通知者 notify.notify_one(); // 唤醒一个等待者 println!("通知者:喂,醒醒!"); handle.await.unwrap(); } ``` `Notify` 的 API 只有三件,语义差异要分清: | API | 语义 | "通知先于等待"会怎样 | |---|---|---| | `notify_one()` | 唤醒一个等待者 | 预存一个许可,后来的第一个等待者立即醒 ✅ | | `notify_waiters()` | 唤醒**此刻所有**等待者 | 不预存,信号直接丢失 ⚠️ | | `notified().await` | 等待被唤醒 | —— | > 💡 "等待 + 条件判断"的循环(等某个标志变真)建议优先用 `watch` 通道;`Notify` 适合纯粹的"一次性的开工信号"。 ## 13.6 OnceCell:异步惰性初始化 "第一次用到时才初始化,而且全局只初始化一次"——同步世界靠 `once_cell`/`lazy_static`,异步世界用 **`tokio::sync::OnceCell`**,因为初始化过程本身可以 `.await`(读文件、查数据库都行): ```rust use tokio::sync::OnceCell; use std::time::Duration; static CONFIG: OnceCell = OnceCell::const_new(); /// 惰性加载配置:无论多少任务并发调用,加载逻辑只执行一次, /// 其余任务等待同一份结果(而不是各自加载一遍) async fn get_config() -> &'static String { CONFIG .get_or_init(|| async { println!("🔐 真正加载配置(只出现一次)"); tokio::time::sleep(Duration::from_millis(100)).await; // 模拟读文件/查库 "env=prod&timeout=30".to_string() }) .await } #[tokio::main] async fn main() { // 5 个任务同时首次访问:只有一个执行初始化,其余等它 let mut handles = Vec::new(); for _ in 0..5 { handles.push(tokio::spawn(async { get_config().await.clone() })); } for h in handles { println!("{}", h.await.unwrap()); } // 全部打印 env=prod&timeout=30;"真正加载配置"只打印一次 } ``` 典型用途:全局连接池、全局配置、服务句柄等"首次访问时异步构建、之后直接复用"的资源。初始化可能失败时用 `get_or_try_init`(其 Future 返回 `Result`)。它也常与 `static` 组合,充当异步版的"全局单例"。 ## 13.7 Barrier:集合等待 一群任务"人到齐再一起开跑",类似比赛发令枪: ```rust use std::sync::Arc; use tokio::sync::Barrier; #[tokio::main] async fn main() { let barrier = Arc::new(Barrier::new(3)); // 等 3 个"运动员" let mut handles = Vec::new(); for i in 1..=3 { let barrier = Arc::clone(&barrier); handles.push(tokio::spawn(async move { println!("选手 {} 完成准备", i); let result = barrier.wait().await; // 到起跑线集合(等满 3 人才放行) // wait 返回本次是否是"最后一个到达的"(is_leader) println!("选手 {} 开跑!(我是最后到的吗:{})", i, result.is_leader()); })); } for h in handles { h.await.unwrap(); } } ``` 注意 Barrier 是可复用的(每轮 wait 互相独立),但多轮同步的轮次对齐很容易出错;复杂编排建议直接用通道或 join!。 ## 13.8 实战:限流并发抓取 综合本章与第 12 章:用 `Semaphore` 限流 + `mpsc` 汇总,这是爬虫、批量接口调用的标准骨架(第 20 章会把它扩展成完整爬虫): ```rust use std::sync::Arc; use tokio::sync::{mpsc, Semaphore}; use std::time::{Duration, Instant}; async fn fetch(url: String) -> String { tokio::time::sleep(Duration::from_millis(200)).await; // 模拟网络请求 format!("{} 的响应内容", url) } #[tokio::main] async fn main() { let urls: Vec = (1..=10).map(|i| format!("https://example.com/page/{}", i)).collect(); let semaphore = Arc::new(Semaphore::new(3)); // 最多 3 个并发请求 let (tx, mut rx) = mpsc::channel::(16); // 结果汇总通道 for url in urls { let sem = Arc::clone(&semaphore); let tx = tx.clone(); tokio::spawn(async move { let _permit = sem.acquire_owned().await.unwrap(); // 排队领票(acquire_owned 便于移动) let start = Instant::now(); let result = fetch(url.clone()).await; println!("[余票 {}] {} 耗时 {}ms", sem.available_permits(), url, start.elapsed().as_millis()); tx.send(result).await.unwrap(); // 回传结果 }); } drop(tx); // 发送完毕:允许 rx 结束 // 收齐全部 10 条结果 for _ in 0..10 { if let Some(result) = rx.recv().await { println!("✔ 收到:{}", result); } } } ``` > 💡 运行后注意观察:请求永远是 3 个一批进出,这就是限流的效果。把 `Semaphore::new(3)` 改成 10,耗时立刻从"4 批 × 200ms"变成"1 批 × 200ms"——并发度的杠杆作用一目了然。 ## 本章要点小结 - 共享可变状态优先考虑 Actor 模式(第 12 章)重构;确需锁时:短临界区用 std 锁,跨 await 持锁用 tokio 锁。 - `Arc>` 是标配;`RwLock` 让读者并行、写者独占,但要防写者饿死。 - `Semaphore` 限流神器:`acquire().await` 领票,Permit drop 自动还票;`try_acquire` 做拒绝式限流。 - `Notify` 传唤醒信号,先 `enable()` 再 await 防竞态;`notify_waiters` 不预存信号。 - `OnceCell::get_or_init().await` 异步惰性初始化:并发首次访问共享同一次初始化,是异步版"全局单例"。 - `Barrier` 人齐发令;死锁三原则:固定加锁顺序、锁内不做反向要锁的操作、临界区尽量小。 - "Semaphore 限流 + mpsc 汇总"是批量并发任务的通用骨架。 # 第 14 章 并发宏:join!、select!、pin! > 本章目标:掌握任务内并发的全部语法——`join!`(等全部)、`try_join!`(错误短路)、`select!`(先到先得,含 `if`/`else`/`biased` 完整语法)、`pin!`(钉住 future),以及 select 循环的三大生产模式与取消安全速查。 ## 14.1 三个宏各自的领地 第 3 章说过:在一个任务内部想"同时推进多件事",靠的是 Tokio 的并发宏。三者的分工: | 宏 | 语义 | 类比 | |---|---|---| | `join!(a, b, c)` | 并发推进所有分支,**等全部**完成,按声明顺序返回元组 | 等所有人交卷 | | `try_join!(a, b, c)` | 同上,但任一分支返回 `Err` 就**立刻整体失败** | 谁交白卷全员解散 | | `select!` | 并发推进所有分支,**谁先完成执行谁**,其余分支被取消 | 谁先举手叫谁 | > 💡 注意区分:`join!`/`select!` 的分支仍是**同一个任务**里的并发(不跨线程、不要求 Send);`tokio::spawn` 才是真并行(多线程)。临时并发两三个 IO 操作,`join!` 最轻便。 ## 14.2 join! 与 try_join! ```rust use std::time::{Duration, Instant}; async fn task_a() -> u32 { tokio::time::sleep(Duration::from_millis(300)).await; 1 } async fn task_b() -> u32 { tokio::time::sleep(Duration::from_millis(200)).await; 2 } async fn task_err() -> Result { tokio::time::sleep(Duration::from_millis(100)).await; Err("任务出错了".to_string()) } #[tokio::main] async fn main() { let start = Instant::now(); // join!:并发推进所有分支,等【全部】完成后按声明顺序返回元组 // 三个任务各睡不同时长,总耗时 = 最长的那个(约 300ms),而不是相加 let (a, b) = tokio::join!(task_a(), task_b()); println!("join! 结果:a = {},b = {},耗时 {}ms", a, b, start.elapsed().as_millis()); // try_join!:失败短路版 —— 任一分支返回 Err 就立刻整体返回 Err // (task_err 100ms 就失败,task_a/b 都不再等) let result: Result<(u32, u32, u32), String> = tokio::try_join!(task_a(), task_b(), task_err()); println!("try_join! 结果:{:?}", result.err()); // 有的 Err("任务出错了") } ``` 细节辨析: - **表达式立刻求值**:`join!(f1(), f2())` 里的 `f1()`、`f2()` 在宏展开处立即调用并创建 Future,随后被并发轮询——不存在"先跑完 f1 才开始 f2"的问题; - **借用也能进**:`join!(a.read().await, b.write().await)` 这种带可变借用的分支合法,因为它们仍是同一个任务(第 4 章 spawn 做不到这点); - **try_join! 的错误类型必须一致**:三个分支的 `Err` 类型要相同(或能统一转换); - **想要"并发 + 可 spawn + 可中途取消"**:用 `JoinSet`(第 4 章)或 `FuturesUnordered`(第 15 章提及)。 ## 14.3 select!:语法全解 `select!` 监听多个分支,**谁先完成就执行谁的分支,其余分支的 future 被就地取消**。先看基础形态: ```rust use std::time::Duration; use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<&str>(8); // 分支 1:睡 2 秒后发消息 tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(2)).await; let _ = tx.send("来自任务的消息").unwrap(); }); tokio::select! { // 语法:模式 = async 表达式 => 处理代码 msg = rx.recv() => { // 分支 1:先收到消息(2 秒后) println!("收到:{}", msg.unwrap()); } _ = tokio::time::sleep(Duration::from_secs(5)) => { // 分支 2:5 秒兜底超时(本例中不会走到) println!("等待超时"); } } // 每轮 select! 只会走【一个】分支; // recv 分支完成后,sleep 分支的 future 被直接丢弃(取消) } ``` **模式三件套:`if` 前置条件、`else` 兜底、`biased` 定序。** ```rust use std::time::Duration; use tokio::sync::mpsc; #[tokio::main] async fn main() { let mut authenticated = false; // 模拟"连接认证"状态 let (tx, mut rx) = mpsc::channel::<&str>(8); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(50)).await; let _ = tx.send("认证令牌").unwrap(); }); for _ in 0..3 { tokio::select! { // 分支 1:带 ", if !authenticated" 前置条件 —— // 条件为假时,该分支本轮【根本不参与竞速】 msg = rx.recv(), if !authenticated => { let msg = msg.unwrap(); println!("认证阶段收到:{}", msg); if msg.contains("认证") { authenticated = true; // 认证完成,切换状态 } } // 分支 2:只有认证之后才让"业务心跳"参与竞速 _ = tokio::time::sleep(Duration::from_millis(30)), if authenticated => { println!("❤️ 业务心跳(认证后才有)"); } } } // 关于 else:当【所有分支都被 if 禁用】且没有无条件分支时,select! 没东西可等—— // 写了 else => { ... } 就执行它;没写 else 则会 panic。 // 所以"全条件 select!"必须兜一个 else。 } ``` ```rust use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::(8); tx.send(1).await.unwrap(); tx.send(2).await.unwrap(); tokio::select! { biased; // 严格按书写顺序从上到下轮询(默认是每轮随机打乱,防止某分支饿死) _ = async {} => { println!("优先分支"); } msg = rx.recv() => { println!("消息:{:?}", msg); } } } ``` > ⚠️ `biased` 模式下若第一个分支几乎总是就绪(比如一个攒满消息的通道),后面的分支可能被饿死——这是"确定性"换来的代价。典型正当用途:关闭信号分支永远写在第一位,保证"喊停永远最先被听见"。 **select 循环的三大生产模式**(后面章节反复出现): ```rust // 模式一:服务器主循环 = accept + 关闭信号 竞速(第 11、21、22 章) // 模式二:消息循环 + 心跳/超时(第 8.6 节) // 模式三:状态机式监听(if 切换认证/收数据阶段,上面刚演示) // 它们的共同骨架: // loop { // tokio::select! { // _ = shutdown.cancelled() => break, // ① 关闭 // msg = rx.recv() => { match msg { ... } } // ② 业务 // _ = heartbeat.tick() => { ... } // ③ 周期性 // } // } ``` ## 14.4 pin!:把 future 钉在栈上 `select!`/`join!` 内部会自动处理 `Pin`,但某些场景(如第 13 章 `Notify` 的先 `enable()` 再 await)需要手动固定。`pin!` 宏把 future 固定到**当前栈帧**,再也不用 `Box::pin` 堆分配: ```rust use std::time::Duration; #[tokio::main] async fn main() { // 声明一个 future 变量(暂时不 await) let mut timer = tokio::time::sleep(Duration::from_millis(100)); // pin! 把它固定到栈上:变量变成 Pin<&mut Sleep>,可以安全地拿到可变借用 tokio::pin!(timer); // 现在可以先把"引用"交给别人(比如登记 Waker),稍后再 await // &mut timer 就是 &mut Pin<&mut Sleep>,不会发生移动 timer.as_mut().await; // 通过 as_mut() 拿 &mut Pin 再 await println!("定时器触发"); } ``` 两个 pin 的选择: | | `tokio::pin!(x)` | `std::pin::pin!(x)`(Rust 1.68+) | |---|---|---| | 来源 | Tokio 宏,老工具链也能用 | 标准库,新项目优先 | | 结果 | `Pin<&mut T>`(shadow 原变量) | `Pin<&mut T>` | 口诀:**future 被"钉住"之前不能重复借用,钉住之后就可以传 `&mut` 了**。底层缘由回顾第 3 章:自引用结构不能被 move,`Pin` 就是"禁止搬家"的标记。 ## 14.5 取消安全初识 `select!` 与 `timeout` 共同的隐藏规则:**落选/超时的分支会被取消**——future 在 await 点被丢弃,不再恢复。那么问题来了:被丢弃时,操作进行到一半的状态怎么办? **✅ 取消安全的操作:** 打断等于"完全没做",落选不丢数据: ```rust use std::time::Duration; use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<&str>(8); tx.send("一条完整的消息").await.unwrap(); tokio::select! { // 假设这一轮 sleep 先完成,recv 落选被取消 —— 没关系! // recv 的取消安全性:消息仍好好待在通道里,下一轮还能收到 _ = tokio::time::sleep(Duration::from_millis(1)) => { println!("这轮超时分支赢了"); } msg = rx.recv() => { println!("收到:{}", msg.unwrap()); } } // 下一轮照常收到刚才那条消息,什么都没丢 let msg = rx.recv().await.unwrap(); println!("下一轮收到:{}", msg); } ``` **❌ 取消不安全的操作:** 打断等于"做了一半",状态已损坏: ```rust // 危险模式(伪代码):在 select! 循环里直接 read loop { tokio::select! { // 假设 read 刚从网络读了半条消息(比如只读到协议头的一半), // 此时 shutdown 分支获胜,read 被取消 —— // 已经读出的半条消息被丢弃,下次连接的数据流将从"半条消息"中间开始解析! n = stream.read(&mut buf) => { parse_message(&buf[..n]); } _ = shutdown_signal() => break, } } ``` 结论速查: | 取消安全 ✅ | 取消不安全 ❌ | |---|---| | `mpsc::recv()` / `send()` | `AsyncReadExt::read()`(可能读到半截) | | `oneshot::Receiver::await` | `read_exact()` | | `broadcast::recv()` / `watch::changed()` | `BufReader::read_line()`(可能已消费部分字节) | | `tokio::time::sleep()` / `timeout()` | `AsyncWriteExt::write_all()`(可能写了一半) | | `TcpListener::accept()` | 自定义的"多步骤事务" future | `read_line` 这类操作的正确姿势:改用第 16 章 codec 的 `FramedRead`——它内部按"完整消息"为单位产出,天然取消安全;或者利用 `BufReader` 的内部缓冲把未消费的字节"重塞"回去。先记住原则:**在 select! 循环里轮询的读操作,必须确认它的取消安全性**。 ## 14.6 实战:多任务竞速 + 慢客户端踢除 **竞速**:同时向 3 个镜像源要数据,用最先响应的那个: ```rust use std::time::{Duration, Instant}; use tokio::sync::oneshot; async fn download_from(name: &str, delay_ms: u64) -> String { tokio::time::sleep(Duration::from_millis(delay_ms)).await; format!("来自 {} 的数据", name) } #[tokio::main] async fn main() { let start = Instant::now(); // 三个镜像源,延迟不同 let sources = vec![("镜像 A", 900), ("镜像 B", 300), ("镜像 C", 600)]; // 每个源派一个任务,用 oneshot 回传结果 let mut rxs = Vec::new(); for (name, delay) in sources { let (tx, rx) = oneshot::channel(); rxs.push(rx); tokio::spawn(async move { let data = download_from(name, delay).await; let _ = tx.send(data); // 即便没人接收也不报错(接收端可能已丢弃) }); } // 用 select! 逐个"赛马":收一个就够了,其余被取消 let winner = tokio::select! { data = &mut rxs[0] => data.unwrap(), data = &mut rxs[1] => data.unwrap(), data = &mut rxs[2] => data.unwrap(), }; println!("🏆 最快的源返回:{}(总耗时 {}ms)", winner, start.elapsed().as_millis()); } ``` **心跳踢人**:连接 5 秒没动静就断开——把 select 循环模式用到底: ```rust use std::time::Duration; use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::(8); tokio::spawn(async move { // 模拟客户端:先活跃后沉默 tx.send("我还在".to_string()).await.unwrap(); tokio::time::sleep(Duration::from_millis(600)).await; tx.send("我还还在".to_string()).await.unwrap(); tokio::time::sleep(Duration::from_secs(10)).await; // 沉默 10 秒 }); let mut idle = tokio::time::interval(Duration::from_millis(300)); // 检查周期 let mut silent_rounds = 0; loop { tokio::select! { msg = rx.recv() => { match msg { Some(m) => { println!("活跃:{}", m); silent_rounds = 0; } None => { println!("连接关闭"); break; } } } _ = idle.tick() => { silent_rounds += 1; if silent_rounds > 5 { // 约 1.5 秒(首跳立即,忽略)没消息 println!("长时间沉默,踢除!"); break; } } } } } ``` > 💡 对固定分支数,`select!` 足够;分支数动态(任意多个任务竞速)时用 `JoinSet` 收 `join_next()` 的"先完成"语义(第 4 章)。 ## 本章要点小结 - `join!` 并发等全部,`try_join!` 错误短路;它们不跨线程,天然无需 Send,分支表达式立即求值。 - `select!` 先到先得,其余分支被取消;完整语法 = 模式 `=` future `=>` 处理,可选 `, if 条件` 与 `else`、`biased` 定序;全分支被禁且无 else 会 panic。 - select 循环骨架:关闭信号(可 biased 置顶)+ 业务消息 + 周期任务,三大生产模式由此而来。 - `pin!` 把 future 钉在栈上,使其可被重复可变借用;新项目优先 `std::pin::pin!`。 - select!/timeout 会取消分支:recv/send/sleep/accept 安全,read/write_all/read_line 不安全(第 17 章深挖)。 # 第 15 章 流处理:tokio-stream > 本章目标:拿下"异步迭代器"——`Stream` trait 的定位与生态、`StreamExt` 全部常用适配器、五种造流方式(含手写 Stream)、通道/监听器与流的桥接、`timeout`/`throttle`,以及实时数据流水线实战。 ## 15.1 Stream:异步版的 Iterator `Future` 是"将来会有一个值",那"将来会陆续来很多个值"呢?那就是 **Stream**(异步迭代器):每 5 秒一条行情、连接上源源不断的请求、传感器不停上报的数据……都是流。Tokio 把流生态放在配套 crate **`tokio-stream`** 里。 ```toml [dependencies] tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["full"] } # 流处理全家桶 ``` ```rust // Stream 与 Future、Iterator 的关系(简化定义): pub trait Stream { type Item; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; // Ready(Some(x)) = 又来一个值 // Ready(None) = 流结束(等价于迭代器耗尽) // Pending = 还没有新值,稍后再问 } ``` 可以这么类比:`Iterator` 的 `next()` 是同步"要一个值",`Stream` 的 `poll_next` 是异步"等一个值"——等的时候让出线程。与 Future 一样,Stream 也是**惰性**的:没人驱动它就不产生数据。 > 💡 `Stream` trait 本身定义在 `futures` 生态里(tokio-stream 重新导出了它)。Rust 语言层尚在推进 `AsyncIterator`,现阶段异步代码里说的 Stream 都指这个 trait。Tokio 把它放进独立 crate 的原因:主 crate 保持精简,流生态(适配器、桥接)随需引入。 ## 15.2 StreamExt 适配器全表 `StreamExt` 让流像迭代器一样链式加工,与 `std::iter::Iterator` 的同名方法语义一致,只是全部变成了异步等待: | 分类 | 适配器 | 作用 | |---|---|---| | 变换 | `map` / `filter_map` | 逐项转换 | | 筛选 | `filter` / `take` / `take_while` / `skip` | 过滤与截断 | | 组合 | `merge`(交织合并)/ `chain`(串接)/ `zip` | 多流合流 | | 聚合 | `fold` / `collect` / `for_each` / `any` / `all` | 折叠为单值 | | 时间 | `timeout`(每项限时)/ `throttle`(限速) | 流量整形 | | 探测 | `next().await` | 取下一个值(最常用) | ## 15.3 基础适配器实战 ```rust use tokio_stream::{iter, StreamExt}; #[tokio::main] async fn main() { // iter():从一个普通迭代器创建流(最简单的流来源) let numbers = iter([1, 2, 3, 4, 5, 6, 7, 8]); // 链式加工:过滤偶数 -> 每项平方 -> 只取前 3 个 let mut processed = numbers .filter(|n| n % 2 == 0) // 2,4,6,8 .map(|n| n * n) // 4,16,36,64 .take(3); // 4,16,36 // next():异步地一个一个取 while let Some(n) = processed.next().await { println!("产出:{}", n); } // 多流组合:merge 交织 —— 谁先有值就先出谁 let a = tokio_stream::iter(vec!["A1", "A2"]); let b = tokio_stream::iter(vec!["B1", "B2"]); let mut merged = a.merge(b); while let Some(v) = merged.next().await { print!("{} ", v); } println!(); } ``` ## 15.4 聚合:fold / collect / for_each ```rust use tokio_stream::{iter, StreamExt}; #[tokio::main] async fn main() { // fold:带状态折叠(异步版 reduce) let sum = iter(1..=100).fold(0, |acc, x| async move { acc + x }).await; println!("1 加到 100 = {}", sum); // collect:收成集合 let all: Vec = iter([1, 2, 3]).collect().await; println!("收集成 Vec:{:?}", all); // for_each:逐项消费(注意返回 Future 要 await) iter(["处理", "每一条", "数据"]) .for_each(|item| async move { println!("→ {}", item); }) .await; } ``` ## 15.5 造流的五种方式 ```rust use tokio_stream::{unfold, repeat, pending, once, StreamExt}; use std::time::Duration; #[tokio::main] async fn main() { // ① iter:迭代器转流(上一节用过) // ② unfold(state, f):带着状态造流 —— 万能手工流构造器 // 闭包接收当前状态,异步地返回 (值, 新状态) 或 None(结束) let mut countdown = unfold(3u32, |n| async move { if n > 0 { tokio::time::sleep(Duration::from_millis(100)).await; Some((n, n - 1)) // 产出 3、2、1 } else { None // 流结束 } }); while let Some(n) = countdown.next().await { println!("倒计时:{}", n); } // ③ repeat(v):无限重复同一个值(危险但有用,通常配合 take 截断) let mut three_ones = repeat(1).take(3); while let Some(v) = three_ones.next().await { print!("{} ", v); } println!(); // ④ once(v):只产出一个值就结束 let mut single = once("就一个值"); println!("{}", single.next().await.unwrap()); // ⑤ pending():永远不产出任何值(测试"卡住的流"很有用) // let _ = pending::(); // 谁等它谁挂起 } ``` **手写一个 Stream**(理解 poll_next 的最后一课)——实现一个"每 200ms 滴答一次"的定时流: ```rust use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; use tokio::time::Sleep; use tokio_stream::Stream; use std::future::Future; /// 每隔 period 产出一个递增编号的滴答流 struct TickStream { period: Duration, count: u64, // 当前正在进行的睡眠(None = 需要新建) sleep: Option>>, } impl Stream for TickStream { type Item = u64; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { // 保证"正在睡"这个状态存在(第一次 poll 时创建) let sleep = self.sleep.get_or_insert_with(|| { Box::pin(tokio::time::sleep(self.period)) }); match sleep.as_mut().poll(cx) { Poll::Ready(()) => { // 睡醒了:产出一个编号,准备下一轮 self.count += 1; self.sleep = None; // 复位,下次 poll 重新睡 Poll::Ready(Some(self.count)) } Poll::Pending => Poll::Pending, // 还没到点,保持挂起 } } } #[tokio::main] async fn main() { use tokio_stream::StreamExt; let mut ticks = TickStream { period: Duration::from_millis(200), count: 0, sleep: None }; // 用 while + next 取 3 个就收手 let mut got = 0; while let Some(n) = ticks.next().await { println!("滴答 {}", n); got += 1; if got >= 3 { break; } } } ``` 这个例子展示了 poll_next 的标准姿势:**把内部状态(睡眠 Future)存在结构体里**(而不是存进 next 的 Future 里),所以它**天然取消安全**——select 里落选也不会丢状态。 ## 15.6 桥接:通道、监听器与流的互转 Tokio 生态里大量 API(通道、监听器)不是 Stream,`wrappers` 模块提供了一层"转接头": ```rust use tokio::sync::{mpsc, broadcast}; use tokio_stream::wrappers::{ReceiverStream, BroadcastStream, IntervalStream}; use tokio_stream::StreamExt; use std::time::Duration; #[tokio::main] async fn main() { // 1. ReceiverStream:把 mpsc 的 Receiver 变成流 let (tx, rx) = mpsc::channel::(8); tokio::spawn(async move { for i in 1..=3 { tx.send(i * 10).await.unwrap(); } }); let mut s = ReceiverStream::new(rx); while let Some(v) = s.next().await { println!("mpsc 流出:{}", v); // 10,20,30(发送端 drop 后自动结束) } // 2. BroadcastStream:把 broadcast 的 Receiver 变成流 // ⚠️ 元素类型是 Result:落后丢消息会以 Err 呈现 let (btx, brx) = broadcast::channel(16); tokio::spawn(async move { let _ = btx.send("公告一"); let _ = btx.send("公告二"); }); let mut bs = BroadcastStream::new(brx); while let Some(item) = bs.next().await { match item { Ok(msg) => println!("广播:{}", msg), Err(e) => println!("落后了:{}", e), // e 是 Lagged(_) } } // 3. IntervalStream:把 interval 的 tick 变成流(周期任务的最简写法) let mut ticks = IntervalStream::new(tokio::time::interval(Duration::from_millis(50))); let mut n = 0; while let Some(_tick_instant) = ticks.next().await { // 每个元素是一次节拍的时刻 n += 1; println!("第 {} 跳", n); if n >= 3 { break; } // 跳 3 次就收工 } } ``` 桥接工具全表: | 桥 | 方向 | 用途 | |---|---|---| | `ReceiverStream` / `UnboundedReceiverStream` | mpsc Receiver → Stream | 消费任务结果 | | `BroadcastStream` | broadcast Receiver → Stream(元素为 Result) | 事件总线 | | `WatchStream` | watch Receiver → Stream | 状态变化序列 | | `IntervalStream` | interval → Stream | 周期事件 | | `TcpListenerStream` | TcpListener → Stream(`net` 特性) | "连接流"式写服务器 | | `ReaderStream` / `WriterStream` | AsyncRead ↔ Stream(tokio-util,见第 16 章) | IO 与流互转 | ## 15.7 timeout 与 throttle:流量整形 ```rust use std::time::Duration; use tokio_stream::{StreamExt, iter}; #[tokio::main] async fn main() { // 模拟一个忽快忽慢的数据源 let source = iter(vec![("快数据", 10), ("慢数据", 500), ("又是快数据", 10)]); let mut stream = source.then(|(name, delay)| async move { tokio::time::sleep(Duration::from_millis(delay)).await; name }) .timeout(Duration::from_millis(100)); // 每个元素限时 100ms while let Some(item) = stream.next().await { match item { Ok(name) => println!("按时到达:{}", name), Err(_) => println!("超时!这个元素的等待被放弃了"), } } // throttle:限速 —— 让流最多每周期产出一个元素 // let mut slow = some_fast_stream.throttle(Duration::from_millis(100)); // 每 100ms 最多放行一个元素,API 限速/防御下游过载的好工具 } ``` > ⚠️ `timeout` 是**取消不安全**的:超时元素的等待被放弃,元素本体被丢弃(这正是语义)。对"丢失即业务错误"的数据,用整块 `timeout` 包裹读取逻辑而非流适配器。 ## 15.8 实战:实时数据流水线 综合运用:传感器数据走 broadcast 通道 → 流式消费 → 过滤异常 → 统计。这是监控系统数据链路的微缩版: ```rust use std::time::Duration; use tokio::sync::broadcast; use tokio_stream::{wrappers::BroadcastStream, StreamExt}; #[tokio::main] async fn main() { // 传感器总线:一个发布者,多个分析者 let (tx, rx) = broadcast::channel::(64); // 模拟传感器:每 50ms 上报一次温度 tokio::spawn(async move { for i in 0..20u32 { // 后 5 次故意"发烧",制造异常数据 let temp = if i >= 15 { 42.0 + i as f64 } else { 36.0 + (i % 5) as f64 * 0.1 }; if tx.send(temp).is_err() { break; } // 没有订阅者了就收工 tokio::time::sleep(Duration::from_millis(50)).await; } }); // 分析流水线:广播接收端 -> 流 -> 过滤 -> 统计 let mut readings = BroadcastStream::new(rx) .filter_map(|r| r.ok()) // 丢掉 Lagged 错误,只要数据 .filter(|t| *t > 40.0) // 只关注发烧数据(> 40°C) .take(5); // 最多统计 5 条异常 let mut count = 0; while let Some(temp) = readings.next().await { count += 1; println!("🚨 检测到异常温度 {:.1}°C(第 {} 条)", temp, count); } println!("监控结束,共发现 {} 条异常", count); } ``` 这段代码的精髓在中间那串链式调用:**通道 → 流 → filter_map → filter → take**,每一步都是惰性的、异步的、可组合的。熟悉之后,你会发现它就是 Rust 版的"响应式编程"。 ## 本章要点小结 - Stream = 异步迭代器:`Ready(Some)` 出值、`Ready(None)` 结束、`Pending` 等待;惰性,与 Future 同源。 - `StreamExt` 提供与 Iterator 同名的异步适配器(map/filter/take/fold/collect/merge...),外加 `timeout`/`throttle` 两个时间类适配器。 - 造流五式:`iter`、`unfold`(万能)、`repeat`/`once`/`pending`;手写 Stream 时**把状态存进结构体**(天然取消安全)。 - 桥接:`ReceiverStream`、`BroadcastStream`(元素是 Result,注意 Lagged)、`IntervalStream`、`TcpListenerStream` 等。 - 流水线写法(通道 → 流 → 链式加工)是处理连续数据的标准架构。 # 第 16 章 工具集:tokio-util 与 bytes 字节缓冲 > 本章目标:吃透 Tokio 的"瑞士军刀箱"——取消令牌(优雅关闭的标配)、任务追踪器(等所有后台任务收尾)、bytes 字节缓冲四件套(`Buf`/`BufMut`/`Bytes`/`BytesMut`)、编解码器 codec(把字节流变成消息流,粘包/半包/取消安全一揽子解决),最后沉淀一个可直接复用的优雅关闭模板。 ```toml [dependencies] tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["full"] } tokio-stream = { version = "0.1" } # 需要 Sink 的 send 方法(FramedWrite 发送用) futures-util = { version = "0.3", default-features = false, features = ["sink"] } # bytes 字节缓冲四件套(16.4 节);也可不直接依赖,统一用 tokio_util::bytes 重导出 bytes = "1" ``` ## 16.1 crate 地图 | 模块 | 内容 | 本书章节 | |---|---|---| | `tokio_util::sync` | CancellationToken、PollSemaphore、PollSender | 16.2 | | `tokio_util::task` | TaskTracker、LocalPoolHandle | 16.3 | | `bytes`(经 `tokio_util::bytes` 重导出) | Bytes、BytesMut、Buf、BufMut | 16.4 | | `tokio_util::codec` | Encoder/Decoder、FramedRead/FramedWrite、现成编解码器 | 16.5-16.7 | | `tokio_util::io` | ReaderStream/WriterStream/StreamReader | 16.8 | | `tokio_util::time` | DelayQueue 延时队列 | 16.9 | | `tokio_util::net` |各类监听器的 Stream 化 | 了解即可 | ## 16.2 CancellationToken:协作式取消令牌 第 4 章的 `abort()` 是"硬取消"——任务在 await 点直接被终结,没机会清理现场。生产环境更常用**软取消**:发一个"该收工了"的信号,让任务自己优雅退出。`CancellationToken` 就是这个信号: ```rust use tokio_util::sync::CancellationToken; use std::time::Duration; #[tokio::main] async fn main() { let token = CancellationToken::new(); // 后台 worker:每 200ms 干一次活,直到收到取消信号 let worker_token = token.clone(); // 令牌可以随意克隆,大家共享同一个"开关" let worker = tokio::spawn(async move { // run_until_cancelled(异步块):执行它,期间被取消则返回 None —— 循环自然退出 while worker_token.run_until_cancelled(async { println!("worker:干活中…"); tokio::time::sleep(Duration::from_millis(200)).await; }).await.is_some() {} println!("worker:收到取消信号,清理现场后收工"); }); // 子任务用 child_token():父令牌一取消,所有子令牌跟着取消(树形传播) let child = token.child_token(); let child_task = tokio::spawn(async move { // cancelled().await:睡到取消发生为止 child.cancelled().await; println!("child:父令牌取消了,我也收工"); }); tokio::time::sleep(Duration::from_secs(1)).await; println!("主任务:发出全局关闭信号!"); token.cancel(); // 一键通知所有持有者(父与全部子令牌) worker.await.unwrap(); child_task.await.unwrap(); println!("全部优雅退出"); } ``` API 全览与语义: | API | 语义 | |---|---| | `clone()` | 克隆的是"同一个开关"(任意一个 cancel 全体生效) | | `cancel()` | 触发取消,**不可逆**;重复调用无副作用 | | `cancelled().await` | 睡到取消发生(可在 select! 里当分支) | | `run_until_cancelled(fut)` | 执行 fut;被取消则返回 None(循环退出的惯用法) | | `run_until_cancelled_owned(fut)` | 同上,但拥有 fut('static) | | `child_token()` | 创建子令牌:父取消 ⇒ 子取消;子取消不影响父 | | `is_cancelled()` | 非阻塞查询 | | `drop_guard()` | 返回一个守卫:**它被 drop 时触发 cancel**(作用域取消) | 与第 11 章信号处理的标准组合:**`ctrl_c` 监听一次 → `token.cancel()` → 所有模块通过 `select! { _ = token.cancelled() => ... }` 自行退出**。这避免了"每个模块都注册信号监听"的冲突,是 Tokio 生态公认的优雅关闭范式。 ## 16.3 TaskTracker:任务追踪与收尾等待 `tokio::spawn` 出来的任务没人"记账",想等服务下线前**等所有存量任务跑完**(第 11 章遗留的问题),用 `TaskTracker`: ```rust use tokio_util::task::TaskTracker; use std::time::Duration; #[tokio::main] async fn main() { let tracker = TaskTracker::new(); // spawn:用法同 tokio::spawn,但任务自动被追踪记账 for i in 1..=5 { tracker.spawn(async move { tokio::time::sleep(Duration::from_millis(i as u64 * 100)).await; println!("连接 {} 处理完毕", i); }); } tracker.close(); // 宣告:以后不会再有新任务加入(必须先 close,wait 才有尽头) tracker.wait().await; // 等到所有被追踪的任务全部结束 println!("✅ 存量任务全部收尾,现在退出不会丢数据了"); } ``` `TaskTracker` + `CancellationToken` 就是"优雅关闭"的完整拼图:**令牌喊停,追踪器等清**。第 21、22 章的实战项目会完整运用。 ## 16.4 bytes crate:字节缓冲"四件套" Tokio 生态的高性能 IO,几乎都建立在 **bytes** crate 之上(同样由 Tokio 团队维护):codec 的缓冲区、`ReaderStream` 产出的数据块、`read_buf` 的接收容器,全是它的类型。这一节把它的四个核心类型一次讲透——`use bytes::{Buf, BufMut, Bytes, BytesMut};`,这行导入就是本节的目录。 先给类比,建立直觉: - **`BytesMut`** = 可反复涂改的**草稿纸**:往里攒字节、在中间解析,攒够了"定稿"; - **`Bytes`** = 定稿后的**打印件**:不可变,复印多少份(克隆)都不重新排版(零拷贝); - **`Buf` / `BufMut`** = 你的**手指(游标)**:指着"读到哪了 / 写到哪了",读写整数、跳过头部都不用手动数偏移量。 与"老朋友"`Vec` 对比一下,就知道为什么异步 IO 场景偏爱它: | 能力 | `Vec` / `&[u8]` | `BytesMut` / `Bytes` | |---|---|---| | 扩容 | realloc,可能整块搬家 | `reserve` 精准申请(为"等够字节再解析"设计) | | 多任务共享 | 要包 `Arc>` | `clone()` 即共享,引用计数、零拷贝 | | 切片 | `&v[..]` 借用,受生命周期约束 | `slice(..n)` 得到**独立所有权**的视图 | | 可变/不可变切换 | 始终可变(靠 Mutex 护) | `freeze()`/`unfreeze()` 严格的模式切换 | | 与 Tokio 集成 | 无 | `read_buf`/`write_buf`、Framed、ReaderStream 原生支持 | > 💡 依赖写法二选一:`bytes = "1"` 直接依赖;或**不直接依赖**,统一用 `tokio_util::bytes::...` 重导出路径(推荐)——它保证与 tokio-util 用的 bytes 版本完全一致,避免"两个 bytes 版本类型互不相认"的经典编译错误。 ### BytesMut:可变草稿纸 ```rust use tokio_util::bytes::{Bytes, BytesMut}; #[tokio::main] async fn main() { // 创建可变缓冲区(给足容量,避免中途反复扩容) let mut buf = BytesMut::with_capacity(64); // 方式一:extend_from_slice 直接追加 buf.extend_from_slice(b"hello "); // 方式二:BufMut 的游标式写入(下一小节详讲) use tokio_util::bytes::BufMut; buf.put_slice(b"bytes!"); // 预留空间:告诉它"接下来还要写 N 字节"(内部只 reserve 不拷贝) let mut another = BytesMut::new(); another.reserve(100); // 定稿:freeze() 把可变草稿变成不可变打印件(值被移动走,buf 之后不可再用) let frozen: Bytes = buf.freeze(); println!("定稿:{}", String::from_utf8_lossy(&frozen)); // slice:切出独立所有权的视图(零拷贝,与 frozen 共享同一块内存) let head = frozen.slice(..6); println!("前 6 字节:{}", String::from_utf8_lossy(&head)); } ``` ### Bytes:零拷贝的打印件 `Bytes` 的核心卖点是**克隆即共享**——第 19 章 19.4 节用它广播 10MB 数据;这里补充它的另一半能力:**切片视图**。`slice(..n)` 返回的也是 `Bytes`,拥有独立所有权、可以随意 move 进别的任务,但底层内存只有一份: ```rust use tokio_util::bytes::Bytes; #[tokio::main] async fn main() { // 一份较大的"响应体"(实际场景换成 10MB 也一样,内存里只有这一份) let payload = Bytes::from(vec![0u8; 1024]); // 切三个视图:零拷贝,共享同一块底层内存 let head = payload.slice(..256); let mid = payload.slice(256..512); let tail = payload.slice(512..); // 各视图可独立 move 进不同任务,clone 只 +1 引用计数 let h1 = tokio::spawn(async move { head.len() }); let h2 = tokio::spawn(async move { mid.len() }); let h3 = tokio::spawn(async move { tail.len() }); println!("三段视图:{} / {} / {} 字节", h1.await.unwrap(), h2.await.unwrap(), h3.await.unwrap()); // 输出:三段视图:256 / 256 / 512 字节 —— 而底层内存始终只有一份 } ``` ### Buf / BufMut:会自动前进的手指 `Buf` 是"可读游标",`BufMut` 是"可写游标"。`Bytes`、`BytesMut`、`&[u8]` 都实现了它们。游标的全部意义:**读写之后指针自动前进,不再手动维护偏移量**: | `Buf`(读) | 作用 | `BufMut`(写) | 作用 | |---|---|---|---| | `remaining()` | 还剩多少可读 | `remaining_mut()` | 还能写多少 | | `chunk()` | 游标处字节切片(偷看,不前进) | `chunk_mut()` | 游标处可写区域 | | `advance(n)` | 游标跳过 n 字节 | `put_slice(&[u8])` | 写入字节 | | `get_u32()` / `get_u32_le()` | 读整数,**游标自动前进**(大端/小端) | `put_u32(v)` / `put_u32_le(v)` | 写整数(大端/小端) | | `copy_to_slice(&mut buf)` | 读出指定长度 | `put_bytes(0, n)` | 写 n 个指定字节 | ```rust use tokio_util::bytes::{Buf, BufMut, Bytes, BytesMut}; // 若直接依赖 bytes crate,则写作:use bytes::{Buf, BufMut, Bytes, BytesMut}; —— 两者完全等价 #[tokio::main] async fn main() { // ---- Buf:游标式解析 ---- // 手工构造字节流:[4 字节大端长度 = 3] + "abc" + 尾巴 [0xFF, 0xFE] let mut data = Bytes::from_static(&[0, 0, 0, 3, 0x61, 0x62, 0x63, 0xFF, 0xFE]); println!("总长:{}", data.remaining()); // 9 let len = data.get_u32(); // 读 4 字节大端整数,游标自动前进! println!("长度 = {},剩余 {}", len, data.remaining()); // 3, 5 let mut body = vec![0u8; len as usize]; data.copy_to_slice(&mut body); // 读出消息体,游标继续前进 println!("消息体 = {}", String::from_utf8_lossy(&body)); // abc println!("尾巴 = {:?}", data.chunk()); // [255, 254] // ---- BufMut:游标式拼装 ---- let mut pkt = BytesMut::with_capacity(64); pkt.put_u16(0xCAFE); // 魔数:大端 2 字节 pkt.put_u8(1); // 版本号:1 字节 pkt.put_slice(b"payload"); // 载荷 pkt.put_bytes(0, 3); // 3 个 0 填充 println!("数据包:{:02X?}", &pkt[..]); // [CA, FE, 01, 70, 61, 79, 6C, 6F, 61, 64, 00, 00, 00] } ``` ### 实战:Buf/BufMut 版"长度前缀协议"(半包粘包照样拼得回来) 把三个类型合体:第 7 章手写过长度前缀读取,第 16.7 节有 codec 版——这一版是"手工 + bytes"的正统写法,也是自定义 codec 内部的样子: ```rust use tokio_util::bytes::{Buf, BufMut, Bytes, BytesMut}; /// 编码一条消息:[4 字节大端长度][消息体] fn encode_frame(msg: &str, dst: &mut BytesMut) { dst.reserve(4 + msg.len()); // 精准预留空间 dst.put_u32(msg.len() as u32); // 长度头,游标自动前进 dst.put_slice(msg.as_bytes()); // 消息体 } /// 从缓冲区解析一条消息;数据没到齐返回 None(下次字节到达再试) fn try_decode_frame(src: &mut BytesMut) -> Option { // 先确认"长度头 + 消息体"都到齐了,再开始消费(否则游标一去不回!) if src.len() < 4 { return None; // 连长度头都没有 } let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]) as usize; // 只偷看,不动游标 if src.len() < 4 + len { src.reserve(4 + len - src.len()); // 提示缓冲区:去申请还差的字节数 return None; } // 到齐了,开始消费:游标式读取 let _header = src.get_u32(); // 长度头(游标 +4) let mut body = vec![0u8; len]; src.copy_to_slice(&mut body); // 消息体(游标 +len) Some(String::from_utf8_lossy(&body).to_string()) } #[tokio::main] async fn main() { // 发送端:把两条消息编进一段字节流 let mut stream_bytes = BytesMut::new(); encode_frame("hello", &mut stream_bytes); encode_frame("tokio bytes!", &mut stream_bytes); let full = stream_bytes.freeze(); // 定稿:模拟"网络上的一段字节流"(共 25 字节) // 接收端:模拟恶劣网络 —— 字节被拆成三段、断续到达(半包 + 粘包混合) let chunks = [full.slice(..5), full.slice(5..20), full.slice(20..)]; let mut rx = BytesMut::new(); for chunk in chunks { println!("网络到达 {} 字节", chunk.len()); rx.extend_from_slice(&chunk); // 收进缓冲区 // 每收一段,就把"能拼出来的完整消息"全部解析出来 while let Some(msg) = try_decode_frame(&mut rx) { println!(" ✅ 解析出完整消息:{}", msg); } } println!("缓冲区剩余 {} 字节(应为 0)", rx.len()); } ``` 运行输出: ```text 网络到达 5 字节 网络到达 15 字节 ✅ 解析出完整消息:hello 网络到达 5 字节 ✅ 解析出完整消息:tokio bytes! 缓冲区剩余 0 字节(应为 0) ``` 无论分包多么零碎,半包等一等、粘包逐条解,永远拼得回来——这段代码与 16.7 节的自定义 codec 逻辑同源,读懂它,codec 对你而言就没有黑盒了。 ### 与 Tokio 生态的集成点 bytes 四件套在本书出现过的所有位置,现在可以串成一张网: | 集成点 | 用法 | 出处 | |---|---|---| | `read_buf(&mut BytesMut)` / `write_buf(&Bytes)` | 直接读写进 bytes 缓冲 | 第 6 章 | | `FramedRead`/`FramedWrite` 的 decode/encode | 参数就是 `BytesMut`(见 16.7) | 16.5-16.7 | | `ReaderStream` 产出的数据块 | `Stream>`(见 16.8) | 16.8 | | 任务间共享大块数据 | `Bytes::clone()` 零拷贝 | 第 19 章 | > ⚠️ 常见坑: > 1. **`get_u32` 会消费**(游标前进)!必须先确认"整个消息都到齐"再调 `get_*`,否则长度头被消费掉、数据没到齐时就没法退回了——上面 `try_decode_frame` 的"先偷看、再消费"顺序是铁律; > 2. **大小端要两端一致**:`put_u32` 是大端(网络序),`put_u32_le` 是小端;协议文档写哪个就用哪个; > 3. **`freeze()` 之后原 `BytesMut` 已被移动**,不能再写;要改就 `unfreeze()`(仅当没有其他克隆共享时成功); > 4. **不要混用两个 bytes 版本**:直接依赖请锁 `bytes = "1"`,或干脆只用 `tokio_util::bytes` 重导出。 ## 16.5 codec:为什么需要它 第 7 章说过:TCP 是字节流,**没有消息边界**,半包、粘包是家常便饭。手写"读到 `\n` 为止 / 读够长度头再读消息体"的逻辑既繁琐又容易踩取消安全的坑。codec 用"读端 + 编解码器"一次性解决(它的工作台,正是 16.4 节的 `BytesMut`): ``` 字节流 ──> FramedRead(Decoder) ──> 一条条完整的消息(Stream) 消息 ──> FramedWrite(Encoder) ──> 字节流(自动加协议头) 状态(未凑齐的字节)存在 codec 对象里,不在 next() 的 Future 里 ⇒ 在 select! 里落选也不丢字节 ⇒ 天然取消安全(第 14 章伏笔的答案) ``` ## 16.6 现成编解码器:LinesCodec 回显服务器 现成的解码器:`LinesCodec`(按行)、`BytesCodec`(按块)、`LengthDelimitedCodec`(长度前缀)、`AnyDelimiterCodec`(自定义分隔符)。看 `LinesCodec` 的回显服务器——对比第 7 章手写版本,注意代码短了多少: ```rust use futures_util::SinkExt; // 提供 Sink::send use tokio::net::TcpListener; use tokio_stream::StreamExt; // 提供 Stream::next use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec}; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; println!("行协议回显服务器:nc 127.0.0.1 8080"); loop { let (socket, _) = listener.accept().await?; tokio::spawn(async move { // 拆读写两半,分别套上"行编解码器" let (r, w) = tokio::io::split(socket); let mut reader = FramedRead::new(r, LinesCodec::new()); // Stream> let mut writer = FramedWrite::new(w, LinesCodec::new()); // Sink // 关键:next().await 拿到的永远是【完整的一行】! // 半包、粘包、跨网络包的拆分,全被 FramedRead 在内部处理掉了 while let Some(Ok(line)) = reader.next().await { if writer.send(format!("echo: {}", line)).await.is_err() { break; // 对端关闭 } } }); } } ``` `FramedRead` 实现的是 `Stream>`:每项都是 Result,消息解析失败(如一行超过上限、非法 UTF-8)会以 Err 流出而不断流。 ## 16.7 自定义 Encoder / Decoder:长度前缀协议 当协议是自定义的二进制格式(如 `[4 字节长度][消息体]`),实现两个 trait 即可接入整个 Framed 体系: ```rust use tokio_util::codec::{Decoder, Encoder}; use tokio_util::bytes::{Buf, BufMut, BytesMut}; // tokio-util 重新导出了 bytes crate use std::io; /// 协议:4 字节大端长度头 + UTF-8 消息体 #[derive(Default)] pub struct LengthPrefixedCodec; impl Decoder for LengthPrefixedCodec { type Item = String; // 解出来的消息类型 type Error = io::Error; fn decode(&mut self, src: &mut BytesMut) -> io::Result> { // 返回 Ok(None) 表示"数据还不够,拼出一条完整消息后再叫我" if src.len() < 4 { return Ok(None); // 连长度头都没凑齐 } // 读出 4 字节长度头 let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]) as usize; if src.len() < 4 + len { src.reserve(4 + len - src.len()); // 提示缓冲区:去申请需要的空间 return Ok(None); // 消息体没到齐,继续等 } src.advance(4); // 吃掉长度头 let payload = src.split_to(len); // 切下消息体 String::from_utf8(payload.to_vec()) // 转字符串 .map(Some) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } } impl Encoder for LengthPrefixedCodec { type Error = io::Error; fn encode(&mut self, item: String, dst: &mut BytesMut) -> io::Result<()> { dst.reserve(4 + item.len()); dst.put_u32(item.len() as u32); // 写 4 字节长度头 dst.put_slice(item.as_bytes()); // 写消息体 Ok(()) } } ``` 理解 `decode` 的契约是关键:框架会**反复调用**它——每收到一批新字节调一次;你返回 `Ok(None)` 表示"还拼不齐",返回 `Ok(Some(x))` 表示"拼出一条 x"(框架随后从缓冲区移除已消费字节,继续调你想不想再拼一条)。**解析状态存在 `self`(codec)里**,所以它可处理任意跨包状态,且取消安全。 用 `duplex` 在内存里演示这套协议(不占端口,单元测试同款): ```rust use futures_util::SinkExt; use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, FramedWrite}; #[tokio::main] async fn main() -> std::io::Result<()> { let (client, server) = tokio::io::duplex(64); // "服务端":按自定义协议逐条收消息 tokio::spawn(async move { let mut framed = FramedRead::new(server, LengthPrefixedCodec); while let Some(msg) = framed.next().await { println!("服务端收到:{}", msg.unwrap()); } }); // "客户端":按协议发消息(长度头自动加好) let mut framed = FramedWrite::new(client, LengthPrefixedCodec); framed.send("第一条:你好".to_string()).await?; framed.send("第二条:协议解析交给 codec".to_string()).await?; drop(framed); // 关闭写端,服务端的流随之结束 tokio::time::sleep(std::time::Duration::from_millis(100)).await; Ok(()) } ``` ## 16.8 ReaderStream / WriterStream:IO 与流的互转 第 15 章的流世界与第 6 章的 IO 世界之间也需要桥: ```rust use tokio_util::io::{ReaderStream, StreamReader}; use tokio_stream::StreamExt; use std::io::Cursor; #[tokio::main] async fn main() -> std::io::Result<()> { // ReaderStream:把 AsyncRead 变成 Stream> // 场景:文件/网络下载以"数据块流"的形式逐块处理(如边下边算哈希) let file = tokio::fs::File::open("Cargo.toml").await?; let mut chunks = ReaderStream::new(file); while let Some(chunk) = chunks.next().await { println!("读到 {} 字节", chunk?.len()); } // StreamReader:反向,把"产出字节块的流"变回 AsyncRead // 场景:解密流、解压流等"以流产出数据"的库,接入需要 AsyncRead 的 API let data = vec![Ok(bytes::Bytes::from("hello")), Ok(bytes::Bytes::from(" world"))]; let stream = tokio_stream::iter(data); let mut reader = StreamReader::new(stream); let mut buf = String::new(); tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut buf).await?; println!("拼回的字符串:{}", buf); # let _ = Cursor::new(vec![0u8]); // 占位:Cursor 常与这些工具同场出现 Ok(()) } ``` ## 16.9 其他值得认识的工具 | 工具 | 用途 | |---|---| | `PollSender` | 把 `mpsc::Sender` 变成可 poll 的 Sink,把通道接入自己的 poll 逻辑(进阶) | | `PollSemaphore` | 把 `Semaphore::acquire` 变成可 poll 的 Future(同上) | | `tokio_util::time::DelayQueue` | 带过期时间的延时队列:每元素可单独定时取出,缓存过期清理的经典实现 | | `LocalPoolHandle` | 在多线程运行时里,把任务固定派发到特定线程池(非 Send 任务的多线程方案) | | `JoinMap` | 带 key 的任务集:`insert(key, fut)` 后 `join_next()` 返回 `(key, 结果)` —— 每个客户端一个任务、且要按 key 找回结果时正好用 | | `CancellationToken::drop_guard()` | RAII 式取消:守卫 drop 时自动 cancel(作用域取消) | ## 16.10 实战:优雅关闭完整模板(可直接抄走) 把 16.2、16.3 与第 11 章组装成生产级关闭骨架——任何服务把业务填进去就能用: ```rust use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; use std::time::Duration; #[tokio::main] async fn main() { let token = CancellationToken::new(); let tracker = TaskTracker::new(); // ① 关闭源:Ctrl+C(生产中再加 SIGTERM,见第 11 章) { let token = token.clone(); tokio::spawn(async move { if tokio::signal::ctrl_c().await.is_ok() { println!("\n收到关闭信号"); token.cancel(); } }); } // ② 后台任务:既听令牌又干活 for i in 1..=3 { let token = token.clone(); tracker.spawn(async move { while token.run_until_cancelled(async { println!("[任务 {}] 工作中…", i); tokio::time::sleep(Duration::from_millis(200)).await; }).await.is_some() {} println!("[任务 {}] 清理完毕,退出", i); }); } // ③ 主循环(演示为睡 1 秒;真实服务是 accept 循环,见第 21/22 章) tokio::time::sleep(Duration::from_secs(1)).await; println!("模拟服务收到停止指令"); token.cancel(); // ④ 收尾:等全部后台任务退出(限时兜底) tracker.close(); match tokio::time::timeout(Duration::from_secs(5), tracker.wait()).await { Ok(()) => println!("服务已优雅退出"), Err(_) => println!("⚠️ 收尾超时,强制退出"), } } ``` 运行后按 Ctrl+C,观察输出顺序:收到关闭信号 → 各任务打印"清理完毕" → 服务已优雅退出。**这个骨架(信号 → 令牌 → 追踪器)与第 21、22 章完全一致**,建议背下来。 ## 本章要点小结 - `CancellationToken` 是协作式优雅关闭的标准件:clone 广播、树形传播、`cancelled().await` 或 `run_until_cancelled` 退出;`drop_guard()` 提供 RAII 式取消。 - `TaskTracker` 记账后台任务:`close()` + `wait().await` 保证退出前任务清零。 - bytes 四件套:`Bytes` 克隆即零拷贝共享、`slice()` 出独立所有权视图;`BytesMut` 是可变草稿、`freeze()` 定稿;`Buf`/`BufMut` 游标读写——**`get_u32` 会前进游标,先确认到齐再消费**。 - codec 把"字节流"升维成"消息流":`FramedRead`(Stream)/`FramedWrite`(Sink);解析状态存 codec ⇒ 半包续读 + 取消安全一并解决。 - 自定义协议只需实现 `Decoder::decode`(不够返回 `Ok(None)`)与 `Encoder::encode`。 - `ReaderStream`/`StreamReader` 让 IO 与 Stream 两个世界互通。 - 优雅关闭模板:单点监听信号 → 令牌广播 → tracker.close() + wait() → 退出。 # 第 17 章 错误处理与取消安全 > 本章目标:把异步代码的"结构性错误"全部照亮——任务 panic 去哪了、取消从哪来、为什么取消会丢数据、三种正解、Drop 守卫的清理时序,最后沉淀一套放之四海的"安全循环"模板。 ## 17.1 业务错误:? 照常工作 async fn 里的错误传播与同步函数完全一致:`Result` + `?`。这里补一版"正经的"错误类型实践: ```rust use std::fmt; // 自定义错误枚举(thiserror 库可以帮你省掉下面这些模板代码) #[derive(Debug)] enum AppError { UserNotFound(u32), Timeout, Network(String), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::UserNotFound(id) => write!(f, "用户 {} 不存在", id), AppError::Timeout => write!(f, "请求超时"), AppError::Network(msg) => write!(f, "网络错误:{}", msg), } } } impl std::error::Error for AppError {} async fn load_user(id: u32) -> Result { if id == 0 { return Err(AppError::UserNotFound(id)); } // 模拟查库耗时 tokio::time::sleep(std::time::Duration::from_millis(50)).await; Ok(format!("用户 {}", id)) } async fn main_logic() -> Result<(), AppError> { // ? 在 async fn 中照常工作:Err 时立刻从当前函数返回 let user = load_user(1).await?; println!("加载成功:{}", user); Ok(()) } #[tokio::main] async fn main() { if let Err(e) = main_logic().await { eprintln!("出错了:{}", e); } } ``` 工程实践对照: ```rust // 库:用 thiserror 派生错误类型(自动实现 Display/Error/From) // use thiserror::Error; // #[derive(Debug, Error)] // pub enum StoreError { // #[error("用户 {0} 不存在")] NotFound(u32), // #[error("数据库错误: {0}")] Db(#[from] sqlx::Error), // } // 应用:用 anyhow 聚合任意错误,? 自动转换,附上下文 // use anyhow::{Context, Result}; // async fn boot() -> Result<()> { // let cfg = tokio::fs::read_to_string("app.toml").await // .context("读取配置文件失败")?; // context 给错误加"案发现场" // Ok(()) // } ``` > 💡 经验法则:**库**用 `thiserror` 定义语义化错误类型;**应用程序**用 `anyhow` 聚合任意错误。这两者与 Tokio 无关,但与异步代码天天见面。 ## 17.2 JoinError:任务的 panic 与取消去哪了 子任务的 panic **不会**直接炸掉主任务——它被捕获后打包进 `JoinError`: ```rust #[tokio::main] async fn main() { // 情形一:任务 panic let h = tokio::spawn(async { panic!("任务里炸了!"); // 这个 panic 不会传染给 main #[allow(unreachable_code)] 42 }); match h.await { Ok(v) => println!("结果:{}", v), Err(e) if e.is_panic() => { // into_panic() 可把 panic 携带的值取回(进而 resume_unwind 重新炸出去) println!("检测到任务 panic:{}", e); } Err(e) => println!("其他 JoinError:{}", e), } // 情形二:任务被取消(abort / select 落选 / 超时) let h2 = tokio::spawn(async { loop { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } }); h2.abort(); match h2.await { Err(e) if e.is_cancelled() => println!("任务确认已被取消"), _ => unreachable!(), } } ``` > ⚠️ 关键区别:`handle.await` 拿到的 `Err` 说明**任务没跑完**(JoinError:panic 或取消);而任务内部返回的 `Err(T)` 是**正常业务结果**。两套体系不要混为一谈——这也是为什么 `h.await.unwrap()` 在生产代码里要三思:它把"任务崩了/被取消了"和"没任务"都变成了无声的 panic。 ## 17.3 取消从哪来:五种来源 取消不是罕见事件,它无处不在: | 来源 | 触发方式 | 典型场景 | |---|---|---| | 显式 abort | `handle.abort()` | 主动止损 | | select! 落选 | 其他分支先完成 | 消息循环、竞速 | | timeout 超时 | 包裹的 future 到点未完成 | 请求限时 | | 优雅关闭 | `CancellationToken` / shutdown 信号 | 服务下线 | | 任务自己 return / drop | 外层 async 块提前返回 | 函数中途 `?` 返回 | 含义:**你写的每个 await 点,都随时可能"睡过去就不再醒来"**。所以每段跨 await 的逻辑都要问一句:"如果在这里被取消,会发生什么?" ## 17.4 取消安全深挖:为什么会丢数据 第 14 章给过速查表,这里讲透**为什么**。future 在 await 点被 drop 时,已经发生的副作用**不会回滚**: ```text 分析一个"读消息"的取消过程(协议:[4 字节长度][消息体]): 时刻 1:read() 从网络收到 2 字节(长度头的一半)→ 暂存后返回 Pending (那 2 个字节存在哪里?存在这个 read future 自己的状态机里!) 时刻 2:select! 的另一个分支完成,这个 read future 被 drop 时刻 3:那 2 个字节随 future 一起被销毁 —— 对端却以为它们已经送达,接下来的字节流从此错位! 这就是"取消不安全":部分完成的操作 + 副作用没有回滚 = 协议错位 ``` 对照:为什么 `mpsc::recv()` 是安全的?因为**消息存在通道里,不存在 future 里**——future 被 drop 只是"这次取货失败",货还在架上,下轮再取。判别口诀:**状态在 future 里 ⇒ 危险;状态在共享对象(通道/codec)里 ⇒ 安全**。 ## 17.5 三种正解 **正解一:把不安全的读隔离到专职任务里(最常用)。** 读的细节永远在一个独立任务里进行,主循环只 select 取消安全的通道: ```rust use tokio::io::AsyncRead; use tokio::sync::mpsc; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_stream::StreamExt; /// 把"逐行读流"隔离成专职任务,行消息经通道转发(通道 recv 是取消安全的) fn spawn_line_reader( stream: impl AsyncRead + Unpin, ) -> mpsc::Receiver { let (tx, rx) = mpsc::channel::(16); tokio::spawn(async move { let mut lines = FramedRead::new(stream, LinesCodec::new()); while let Some(Ok(line)) = lines.next().await { if tx.send(line).await.is_err() { break; } // 主循环没了,收工 } }); rx // 主循环只管 recv,永远不碰"读一半"的问题 } ``` **正解二:用 codec / 状态机处理"部分完成"。** `FramedRead` 把已收到的字节缓存在**不受取消影响的内部缓冲区**里(缓冲在 codec 对象中,而不是在被丢弃的 future 中),下次继续拼,天然取消安全。 **正解三:接受"丢就丢了"的语义。** 心跳、行情推送这类数据,本就允许丢弃,直接在 select 里用即可,不必过度设计。 ## 17.6 守卫模式:取消时的自动清理 任务被取消时,它的局部变量会**依次 drop**——利用 `Drop` 可以实现"无论正常结束还是中途被取消,都自动清理": ```rust use std::time::Duration; /// 守卫:创建时登记,drop 时清理(无论怎么退出都会执行) struct JobGuard(u32); impl Drop for JobGuard { fn drop(&mut self) { // 同步的清理动作(写日志、扣减计数器等) println!("[清理] 任务 {} 退出,计数器减一", self.0); } } #[tokio::main] async fn main() { // 场景一:正常结束,守卫照常触发 { let _guard = JobGuard(1); println!("任务 1 正常执行"); } // <- 守卫在此 drop // 场景二:任务被取消,守卫依然触发! let handle = tokio::spawn(async { let _guard = JobGuard(2); println!("任务 2 开始长睡眠"); tokio::time::sleep(Duration::from_secs(60)).await; // 永远睡不到头 println!("这行永远不会执行"); }); tokio::time::sleep(Duration::from_millis(100)).await; handle.abort(); // 取消任务 let _ = handle.await; // 观察输出:任务 2 的守卫在取消时被 drop,清理照样执行 ✅ } ``` 要点:**`Drop` 清理必须是同步操作**。如果清理本身是异步的(比如"通知服务器断开"),两种策略: ```rust // 策略 A:Drop 里 spawn 一个收尾任务(简单,但清理时序不受控) struct ConnGuard(tokio::sync::mpsc::Sender); impl Drop for ConnGuard { fn drop(&mut self) { let _ = self.0.try_send("connection-closed".to_string()); // 非阻塞通知 } } // 策略 B(推荐):不用 Drop,在任务内部用令牌监听取消,主动做异步清理 async fn task_with_cleanup(token: tokio_util::sync::CancellationToken) { while token.run_until_cancelled(async { // ... 干活 ... }).await.is_some() {} // 走到这里 = 收到取消令牌,可以安全地做异步清理了 println!("异步清理:保存状态、关闭连接……"); } ``` ## 17.7 一套放之四海的"安全循环"模板 把本章与前面章节组合,给出生产级"既响应关闭、又不丢数据"的主循环模板: ```rust use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use std::time::Duration; async fn worker(token: CancellationToken, mut rx: mpsc::Receiver) { loop { tokio::select! { // 分支 1:取消令牌(取消安全)→ 优雅退出 _ = token.cancelled() => { println!("worker:收到关闭信号,处理完手头数据后退出"); break; } // 分支 2:收消息(取消安全)→ 处理业务 msg = rx.recv() => { match msg { Some(m) => { println!("worker:处理 {}", m); // 业务逻辑…… } None => { println!("worker:消息源关闭,正常退出"); break; } } } } } } #[tokio::main] async fn main() { let token = CancellationToken::new(); let (tx, rx) = mpsc::channel::(16); let handle = tokio::spawn(worker(token.clone(), rx)); for i in 1..=3 { tx.send(format!("消息 {}", i)).await.unwrap(); tokio::time::sleep(Duration::from_millis(50)).await; } drop(tx); // 消息发完 token.cancel(); // 或者随时喊停 handle.await.unwrap(); } ``` 这个模板的每个部件都是取消安全的(recv、cancelled),所以**任何时刻打断都不会丢消息、不会破坏状态**。把它背下来,你已经超过大多数初学者了。 ## 17.8 实战:带自动清理的资源管理 综合守卫与令牌:管理一个"临时文件 + 工作连接",无论成功、失败还是取消,资源都干净退场: ```rust use std::time::Duration; use tokio_util::sync::CancellationToken; /// 守卫:负责删除临时文件(同步清理) struct TempFile(String); impl Drop for TempFile { fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); // 同步删除,Drop 里只能同步 println!("[守卫] 已删除临时文件 {}", self.0); } } async fn job(token: CancellationToken) { let path = format!("/tmp/job_{}.tmp", std::process::id()); std::fs::write(&path, b"工作数据").unwrap(); let _guard = TempFile(path.clone()); // 从这一刻起,无论怎么退出都会清理 // 工作 + 可被取消 while token.run_until_cancelled(async { println!("[任务] 工作中……"); tokio::time::sleep(Duration::from_millis(200)).await; }).await.is_some() {} // 取消后:主动做一段"异步清理"(保存元数据之类) println!("[任务] 异步清理:登记 {} 的处理结果", path); } #[tokio::main] async fn main() { let token = CancellationToken::new(); let h = tokio::spawn(job(token.clone())); tokio::time::sleep(Duration::from_millis(500)).await; token.cancel(); // 中途取消 h.await.unwrap(); println!("主流程结束(注意临时文件已被守卫删除)"); } ``` ## 本章要点小结 - 业务错误照常 `Result` + `?`;库用 thiserror,应用用 anyhow;`context` 给错误加现场。 - 任务 panic 被打包进 `JoinError`(`is_panic`/`is_cancelled`/`into_panic`),不传染主任务;`h.await.unwrap()` 要三思。 - 取消有五种来源,每个 await 点都可能是"永别点";判别取消安全:状态在 future 里危险,在共享对象(通道/codec)里安全。 - 取消不安全的正解:隔离读任务、用 codec、或接受可丢语义。 - `Drop` 守卫在任务被取消时依然触发,适合同步清理;异步清理在令牌取消后主动做。 - "取消令牌 + 通道 + select!" 是放之四海的安全主循环模板。 # 第 18 章 测试异步代码 > 本章目标:让异步代码"测得动、测得快、测得稳"——`#[tokio::test]` 的全部形态、虚拟时间(让 1 小时的测试 1 毫秒跑完)、duplex 替代真实网络、通道/超时逻辑的确定性验证。 ## 18.1 测试基础设施 ```toml [dev-dependencies] # 只在测试时编译 tokio-test = "0.4" ``` ```rust // 加在 #[test] 的位置,Tokio 会为每个测试函数自动创建一个 // 单线程(current_thread)运行时并执行它 —— 用完即弃,测试间互不干扰 #[tokio::test] async fn 加法还是成立的() { assert_eq!(1 + 1, 2); } // 需要真正并行的测试:切多线程运行时 #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn 并发任务正常完成() { let h1 = tokio::spawn(async { 10 }); let h2 = tokio::spawn(async { 32 }); assert_eq!(h1.await.unwrap() + h2.await.unwrap(), 42); } ``` 要点: - **每个测试独立运行时**:`#[tokio::test]` 为每个测试函数新建一个运行时,不存在"共享运行时串扰"; - **默认单线程**:确定性最好;测真实并行/工作窃取行为时才用 `flavor = "multi_thread"`; - 属性参数与 `#[tokio::main]` 一致(`start_paused` 等),`#[tokio::main(flavor = ...)]` 的知识全部通用。 ## 18.2 虚拟时间:让 1 小时的测试 1 毫秒跑完 真实地 `sleep(60s)` 再断言,测试会慢到没法用。Tokio 的**时间暂停**模式解决了它:启用后,运行时的时钟由你控制,`sleep` 不再真等,而是等"虚拟时钟"走到: ```rust use std::time::Duration; // start_paused = true:测试一开始就暂停虚拟时钟 #[tokio::test(start_paused = true)] async fn 一小时的休眠瞬间完成() { let real_start = std::time::Instant::now(); // 真实世界的时间 let virtual_start = tokio::time::Instant::now(); // 虚拟时钟 // 真实世界里要等 1 小时;虚拟时间里,运行时发现 // "所有任务都在睡",直接把时钟拨到最近的唤醒点 —— 立刻继续! tokio::time::sleep(Duration::from_secs(3600)).await; // 虚拟时钟确实流逝了整整 1 小时(业务视角的时间语义正确) assert_eq!(virtual_start.elapsed(), Duration::from_secs(3600)); // 而真实耗时不足 1 秒 —— 测试飞快 assert!(real_start.elapsed() < Duration::from_secs(1)); } // 手动快进:控制更精细的时间线 #[tokio::test(start_paused = true)] async fn 手动快进时钟() { let deadline_hit = tokio::spawn(async { tokio::time::sleep(Duration::from_secs(10)).await; println!("虚拟 10 秒后醒来"); }); // 任务们 sleep 时,测试代码掌握主动权: tokio::time::advance(Duration::from_secs(5)).await; // 快进 5 秒 assert!(!deadline_hit.is_finished()); // 还没到点 tokio::time::advance(Duration::from_secs(5)).await; // 再快进 5 秒 deadline_hit.await.unwrap(); // 这次它真的醒来了 } ``` 工作原理一句话:**暂停模式下,当所有任务都在等待定时器时,运行时把虚拟时钟直接跳到最近的定时器到期点**。所以"一小时休眠"是零成本跳转;而 `advance` 让你"快进指定时长"以观察中间状态。 配套断言宏(tokio-test 提供): ```rust #[tokio::test] async fn 断言耗时区间() { let start = tokio::time::Instant::now(); tokio::time::sleep(Duration::from_millis(50)).await; // 断言"距离 start 已过约 50ms"(允许调度误差) tokio_test::assert_elapsed!(start, Duration::from_millis(50)); } ``` > ⚠️ 使用条件:虚拟时间只影响 `tokio::time` 的定时器。如果代码里混入了 `std::thread::sleep` 或真实系统调用,时间暂停救不了它们——这也是"必须用异步 API"的又一理由。另外测试代码本身要真正"睡过去":如果 future 在 sleep 之前就完成了,时间跳跃不会发生。 ## 18.3 用 duplex 测试网络服务 真实端口在测试里又慢又易冲突。第 6 章的 `duplex` + **泛型函数**是标准解法:让服务函数接受"任何读写流",生产传 `TcpStream`,测试传 `DuplexStream`: ```rust use tokio::io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt}; /// 服务函数:不写死 TcpStream,而是接受泛型流 —— 可测试性设计的关键一步 async fn echo_service(mut stream: impl AsyncRead + AsyncWrite + Unpin) -> std::io::Result<()> { let mut buf = vec![0u8; 1024]; let n = stream.read(&mut buf).await?; // 读一次 stream.write_all(&buf[..n]).await?; // 原样回写 stream.flush().await?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn echo_应原样返回() { // 建一对内存管道:server 端给服务函数,client 端模拟真实客户端 let (mut client, server) = tokio::io::duplex(64); tokio::spawn(echo_service(server)); // 模拟客户端:发一条消息 client.write_all(b"hello").await.unwrap(); // 断言收到的回显 let mut buf = vec![0u8; 64]; let n = client.read(&mut buf).await.unwrap(); assert_eq!(&buf[..n], b"hello"); } #[tokio::test] async fn echo_空消息也不崩() { let (mut client, server) = tokio::io::duplex(64); tokio::spawn(echo_service(server)); client.write_all(b"").await.unwrap(); let mut buf = vec![0u8; 64]; let n = client.read(&mut buf).await.unwrap(); assert_eq!(n, 0); // 空进空出 } } ``` 这个"泛型服务函数 + duplex"的组合拳能覆盖绝大多数协议逻辑测试,不需要真实网络、不需要端口分配、跑得飞快。 ## 18.4 测试超时与重试逻辑 把 18.2 的虚拟时间用在真实业务上——验证"带指数退避的重试"(第 8.6 节)确实会重试且最终成功: ```rust use std::time::Duration; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; /// 被测逻辑:重试直到成功(与 8.6 节同款,抽成了可测函数) async fn retry_until_success( fail_times: u32, counter: Arc, ) -> u32 { let mut attempt = 0; loop { attempt += 1; tokio::time::sleep(Duration::from_millis(50)).await; // 模拟请求 if counter.fetch_add(1, Ordering::SeqCst) >= fail_times { return attempt; // 终于成功 } tokio::time::sleep(Duration::from_millis(100)).await; // 退避(简化) } } #[tokio::test(start_paused = true)] // 虚拟时间:所有 sleep 瞬间跳过 async fn 重试三次后成功() { let counter = Arc::new(AtomicU32::new(0)); // 前 2 次失败,第 3 次成功 —— 断言重试次数与尝试次数一致 let attempts = retry_until_success(2, counter).await; assert_eq!(attempts, 3); assert_eq!(counter.load(Ordering::SeqCst), 3); // 真实耗时:几毫秒(虚拟时间里"睡"了 300ms) } // 再来一个:验证"慢消费会触发 broadcast 的 Lagged"(第 12 章语义的回归测试) #[tokio::test(start_paused = true)] async fn 慢订阅者会收到_Lagged() { use tokio::sync::broadcast; let (tx, mut rx) = broadcast::channel::(2); // 容量只有 2 tx.send(1).unwrap(); tx.send(2).unwrap(); tx.send(3).unwrap(); // rx 还没取,环形队列被覆盖 => rx 落后 1 条 assert!(matches!(rx.recv().await, Err(broadcast::error::RecvError::Lagged(1)))); assert_eq!(rx.recv().await.unwrap(), 3); // 之后从最新消息继续 } ``` 注意虚拟时间的好处不止"快":**行为完全确定性**——测试不会因为机器慢而偶发超时失败(俗称 flaky test)。 ## 18.5 tokio-test:同步测试环境里的异步代码 给同步测试框架(比如某些 `#[test]`)临时执行异步代码,用配套的 `tokio-test` crate: ```rust #[test] // 普通同步测试 fn 在同步测试里跑异步代码() { // block_on:临时创建运行时执行这个 future tokio_test::block_on(async { let v = async { 7 }.await; assert_eq!(v, 7); }); } // 另一个实用宏:断言 future 的结果为 Ok #[test] fn 断言结果是_ok() { tokio_test::assert_ok!(tokio_test::block_on(async { Ok::(1) }), 1); } ``` ## 18.6 测试策略:测什么、怎么测、不测什么 | 层 | 测法 | 示例 | |---|---|---| | 纯业务逻辑(无 IO) | 普通 `#[tokio::test]` | 消息解析、状态机流转 | | 协议编解码 | `duplex` 泛型函数 | echo/帧协议 | | 时间行为 | `start_paused` + 断言虚拟流逝 | 重试、超时、心跳 | | 并发正确性 | `multi_thread` flavor + 大量并发压测式测试 | 计数器无丢失 | | 组件交互 | 少量真实端口集成测试(`#[ignore]` 标注,CI 手动跑) | 端到端冒烟 | 组织建议:单元测试放 `src` 内 `#[cfg(test)] mod tests`;集成测试放 `tests/` 目录(每个文件独立二进制);把"能抽象成泛型流/可注入时间"作为可测试性设计的核心手段——**生产代码里多用 `impl AsyncRead + AsyncWrite` 泛型参数,测试时就免费获得 duplex 测试能力**。 ## 18.7 实战:为聊天室核心逻辑写测试 提前为第 21 章的聊天室打样:核心的"消息广播"逻辑抽成纯异步函数,测试它"多人收发、断线不影响他人": ```rust use tokio::sync::broadcast; use std::time::Duration; /// 聊天室核心:把一条消息转发给所有在线成员 async fn broadcast_message( tx: &broadcast::Sender, msg: &str, ) -> Vec { let mut received = Vec::new(); // 简化:两个订阅者各收一条 let mut r1 = tx.subscribe(); let mut r2 = tx.subscribe(); tx.send(msg.to_string()).unwrap(); received.push(r1.recv().await.unwrap()); received.push(r2.recv().await.unwrap()); received } #[tokio::test] async fn 广播消息全员可见() { let (tx, _rx) = broadcast::channel::(16); let got = broadcast_message(&tx, "大家好").await; assert_eq!(got, vec!["大家好", "大家好"]); } #[tokio::test(start_paused = true)] async fn 空闲时收不到消息_不挂死() { let (_tx, mut rx) = broadcast::channel::(16); // 用虚拟超时验证"没有消息时 recv 会一直等" —— 若实现错误提前返回,测试会失败 let result = tokio::time::timeout(Duration::from_secs(3600), rx.recv()).await; assert!(result.is_err()); // 3600 虚拟秒后仍在等(且真实耗时毫秒级) } ``` > 💡 一句话总结测试心法:**协议逻辑用泛型 + duplex;时间行为用虚拟时间;并发正确性用多线程 flavor;组件交互才用真实端口(尽量少)**。 ## 本章要点小结 - `#[tokio::test]` 给每个测试一个独立运行时(默认单线程);`flavor = "multi_thread"` 测真并行。 - `start_paused = true` 开启虚拟时间:`sleep` 瞬间完成、`advance` 手动快进、`assert_elapsed!` 断言耗时,测试快且稳定(消灭 flaky test)。 - 服务函数写成泛型 `impl AsyncRead + AsyncWrite`,测试用 `duplex` 替代真实网络——可测试性来自抽象,不来自 mock 框架。 - 重试/超时/通道语义(Lagged 等)都可以在虚拟时间下确定性验证。 - `tokio_test::block_on` 让同步测试临时执行异步代码。 # 第 19 章 性能调优与最佳实践 > 本章目标:异步程序的性能问题很少出现在"语言不够快",而几乎总是"用错了模式"。本章给出:阻塞问题的完整诊断流程、运行时与任务的调优参数、内存与锁的优化手法、观测工具三件套,以及一份可打印的陷阱自查清单。 ## 19.1 性能心智模型:异步优化的是什么 先校准预期:**异步不加速单个请求,它提升的是"同时服务很多请求"的吞吐量**。所以调优第一步是分清你面对的问题: - **单请求延迟高**:瓶颈多半在下游(数据库慢查询、下游 API)或算法——异步帮不了,去优化 SQL 和算法; - **并发上不去/吞吐塌方**:瓶颈多半在"等待被浪费"(阻塞调用)或"资源争用"(锁、连接池)——这是本章的主战场; - **内存爆炸**:瓶颈多半在无界积压(通道、任务)——见 19.4。 ## 19.2 头号杀手:阻塞运行时(诊断与治理) 再次回到这个主题,因为它值得用最严肃的程度对待。症状对照表: | 症状 | 典型原因 | |---|---| | 整个服务所有请求同时变慢/卡死 | 某任务在工作线程上做同步 IO 或大计算 | | 偶发性、周期性的停顿(pause) | 定时任务里有阻塞调用(如同步日志落盘) | | `tokio::time::timeout` 频繁假超时 | 任务被阻塞,定时器醒来却轮不到执行 | | CPU 占用低但吞吐量也低 | 线程在"等待"而不是"工作",事件循环被饿死 | **决策流程**:遇到一段"可能会慢"的代码,依次问: 1. 有异步版本吗?(tokio::fs / tokio::net / 异步 SDK)→ **用异步版**; 2. 是纯 CPU 计算(>约 10ms)或必须用同步库?→ **`spawn_blocking`**; 3. 需要长驻线程(数据库连接、GUI)?→ **`std::thread` + 通道通信**; 4. 只是几微秒的计算、字符串拼接?→ **原地做,别过度设计**。 ```rust // 三个反例与正解,一分钟自查 use std::time::Duration; async fn examples() { // ❌ 反例 1:同步网络调用(如某些 SDK 的 blocking 客户端) // let resp = blocking_client.get(url).send().unwrap(); // 堵死工作线程! // ❌ 反例 2:同步文件 IO 处理大文件 // let big = std::fs::read_to_string("huge.log").unwrap(); // ❌ 反例 3:在 async 里做重计算 // let hash = compute_sha256_huge_file(); // ✅ 正解:统统丢给 spawn_blocking(或换异步库) let size = tokio::task::spawn_blocking(|| { // 这个闭包在阻塞线程池里,堵多久都无所谓 std::fs::read("Cargo.toml").map(|d| d.len()).unwrap_or(0) }).await.unwrap(); println!("阻塞线程池算出:{}", size); # let _ = Duration::from_secs(0); } ``` **如何确认"确实存在阻塞"?** 用 tokio-console(19.7 节):被阻塞的工作线程会显示为一长段 "Busy" 且没有 await 切换;或者临时加一个"看门狗任务"——每秒 tick 一次,若 tick 间隔异常拉长,说明有线程被堵: ```rust // 简易阻塞看门狗:tick 间隔本应 1s,若持续远大于 1s,说明工作线程被阻塞 async fn watchdog() { let mut tick = tokio::time::interval(std::time::Duration::from_secs(1)); let mut last = tokio::time::Instant::now(); loop { tick.tick().await; let now = tokio::time::Instant::now(); let gap = (now - last).as_secs_f64(); if gap > 1.5 { println!("⚠️ 调度延迟 {:.2}s,可能存在阻塞调用!", gap); } last = now; } } # async fn demo() { watchdog().await; } ``` ## 19.3 运行时配置调优 - **worker 数默认 = CPU 核心数**,适合 99% 的 IO 密集服务;**不用加线程**——异步的意义就是少线程高并发,加线程只增加切换开销; - **CPU 密集与 IO 混合**:把 CPU 活赶进 `spawn_blocking`(独立 512 线程池)或独立进程,别调大 worker 数; - **current_thread 的适用场景**:CLI 工具、单用户应用、嵌入式、需要对调度做确定性推理的测试;它没有跨线程同步开销,小并发下反而更快; - **`max_blocking_threads`**:阻塞池默认上限 512;阻塞任务极多时排队即可,盲目调大只是把内存问题换成线程切换问题; - **`thread_stack_size`**:任务本身不占线程栈,但深递归的同步代码会;默认 2MB 一般够用。 ```rust // 一个诊断小技巧:看看任务实际跑在哪条线程上 #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { for i in 0..4 { tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(10 * i as u64)).await; // 输出线程名,你会看到 my-worker-1/2/3/4 轮流出现 println!("任务 {} 跑在 {:?}", i, std::thread::current().name()); }); } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } ``` ## 19.4 内存管理:积压与零拷贝 - **有界通道是内存防线**:`mpsc::channel(1024)` 的容量就是你的"最大积压预算",无界通道在生产环境等于定时炸弹; - **`bytes::Bytes` 共享零拷贝**:多个任务要引用同一段大数据时,克隆 `Bytes` 只增加引用计数,不复制内存(bytes 四件套的系统讲解见 16.4 节); ```rust // Bytes 的零拷贝共享:对比 Vec 的深拷贝 use bytes::Bytes; #[tokio::main] async fn main() { let big_payload = Bytes::from(vec![0u8; 10 * 1024 * 1024]); // 10MB // 广播给 10 个任务:每次 clone 只 +1 引用计数,O(1) let mut handles = Vec::new(); for _ in 0..10 { let payload = big_payload.clone(); // 不复制那 10MB! handles.push(tokio::spawn(async move { payload.len() // 使用数据…… })); } for h in handles { println!("任务看到 {} 字节", h.await.unwrap()); } } // Cargo.toml: bytes = "1" ``` - **缓冲区大小别拍脑袋**:高频小消息场景,`BufReader::with_capacity(64 * 1024, reader)` 起; - **Future 也占内存**:await 之间夹带大数组会让每个任务膨胀(第 3 章)——缓冲尽量放 `BufReader`/结构体字段里复用,不要在 async 块里每次新建。 ## 19.5 任务粒度:太多与太少 - **粒度太细**(每个循环迭代都 spawn):spawn 本身有开销(几百字节的分配、入队、waker),百万级小任务会撑爆内存和调度器; - **粒度太粗**(一个任务从头干到尾):失去并发性,await 点之间的大段计算还会阻塞调度; - **经验值**:任务 = "一个独立的等待源"(一个连接、一个请求、一个文件),内部循环不 spawn。 ```rust // 反例:为每个元素 spawn(100 万元素 = 100 万个任务) // for item in million_items { tokio::spawn(process(item)); } // ❌ // 正解 1:JoinSet 分批处理,并用 Semaphore 控制在合理并发度 // 正解 2:小任务直接在循环里 await 串行(如果它们本来就很快) // 正解 3:消息攒批(micro-batch):凑够 N 条或超时 100ms 就处理一批 ``` ## 19.6 锁与争用 ```rust use std::sync::{Arc, Mutex}; // 反例:所有任务抢一把"全局大锁",锁内做重活 —— 并行系统被串行化 // async fn bad(state: Arc>) { // let mut s = state.lock().unwrap(); // 拿锁 // let result = heavy_compute(&s); // 锁内重算!别人全在等 // } // 正解 1:锁内只取快照,锁外慢慢算 // async fn good(state: Arc>) { // let snapshot = { state.lock().unwrap().clone() }; // 临界区极小 // let result = heavy_compute(&snapshot); // 无锁计算 // } // 正解 2:热点计数器换原子类型(无锁) // static REQ_COUNT: AtomicU64 = AtomicU64::new(0); REQ_COUNT.fetch_add(1, Ordering::Relaxed); // 正解 3:热点状态分片(shard):按 key 哈希到 N 份小锁,冲突概率降为 1/N // 正解 4:本质是"状态服务"的,直接 Actor 化(第 12 章),单任务持有状态天然无竞争 ``` ## 19.7 观测三件套 **tokio-console**(官方异步调试器):实时显示每个任务的状态(运行/空闲/阻塞)与"唤醒-执行"耗时,阻塞元凶一目了然: ```toml [dependencies] console-subscriber = "0.4" # 服务端接入 ``` ```rust #[tokio::main] async fn main() { // 初始化订阅器(要在业务开始前尽早调用) console_subscriber::init(); // ... 正常业务 ... // 另开终端:cargo install tokio-console && tokio-console // 即可看到任务面板:哪个任务 Busy 太久、哪个 Await 长眠不醒 } ``` **metrics**:启用 tokio 的 `metrics` 特性(需 `--cfg tokio_unstable`,夜间编译器场景)后,运行时暴露任务总数、调度延迟等指标,可对接 metrics-exporter 输出到监控系统——先用 console,有需求再上 metrics。 **tracing**:结构化日志 + span 耗时,配合 `tracing-subscriber` 是 Rust 服务的标配: ```rust // use tracing::{info, instrument}; // // #[instrument] // 自动记录函数进出与耗时,参数成为日志字段 // async fn handle_request(req_id: u64) { // info!("开始处理"); // 日志自动带上 req_id 与 span 耗时 // } ``` ## 19.8 性能陷阱清单(自查 Checklist) 1. ❌ `async fn` 里调用同步阻塞 API(最大杀手,没有之一); 2. ❌ 无界通道 + 慢消费者 = 内存爆炸; 3. ❌ `Arc>` 锁跨整个请求处理流程,把并行系统串行化; 4. ❌ 大量小任务(每次迭代 spawn)或全部任务塞一个任务里; 5. ❌ 忘了 BufReader,小消息高频读写 syscall 翻倍; 6. ❌ 在每请求路径上做昂贵的分配(能用 `Bytes` 共享就共享); 7. ❌ 把 CPU 密集工作留在工作线程上,还调大 worker 数试图"解决"; 8. ❌ select! 循环里使用取消不安全的读(第 17 章),数据错位后表现成"灵异性能问题"; 9. ❌ 每请求都新建运行时/连接(应复用:连接池、Handle); 10. ❌ 忙等轮询标志位(用通道/Notify/watch 替代)。 ## 本章要点小结 - 先分清问题:异步优化吞吐不优化单请求;延迟问题查下游与算法,吞吐问题查阻塞与争用。 - 阻塞工作线程是头号杀手:能异步就异步,不能就 `spawn_blocking`;用 console 或看门狗确认。 - worker 数默认即最优;CPU 密集活赶进阻塞线程池,而不是加 worker。 - 有界通道是内存防线;`Bytes` 零拷贝共享;BufReader/Writer 省 syscall;Future 里别夹带大缓冲。 - 任务粒度 = 一个独立等待源;锁要小、要无争用(原子/分片/Actor 化)。 - 工具链:tokio-console 看任务,metrics 看运行时,tracing 看链路。 # 第 20 章 实战项目一:并发网络爬虫 > 本章目标:把前面所学全部串起来,完成第一个完整项目:**Semaphore 限流、timeout 超时、指数退避重试、HashSet 去重、JoinSet 批量管理、mpsc 结果汇总**。不引入任何 HTTP 库,直接用第 7 章的 TCP 知识手写一个极简 HTTP 客户端——顺便看清楚"框架替你做了什么"。 ## 20.1 需求与架构 需求: 1. 从种子 URL 出发抓取页面,并跟进页面里发现的链接(两层); 2. 同一时刻最多 3 个请求在飞(礼貌爬取); 3. 单个请求限时 5 秒,失败自动重试 2 次; 4. 同一 URL 只抓一次; 5. 结果实时打印,最后汇总耗时。 架构图: ``` 种子 URL ──> crawl_batch(第 1 层)──> 发现的链接 ──> crawl_batch(第 2 层) │ │ └── JoinSet 批量任务 ──┐ └── 同左 │ Semaphore(限 3 个)──> fetch_with_retry(重试) │ http_get(超时包裹的 TCP+HTTP) │ 结果 ──> mpsc 通道 ──> 打印任务(实时输出) ``` ## 20.2 完整代码 只依赖 `tokio = { version = "1", features = ["full"] }`,可直接运行(需联网): ```rust //! 并发网络爬虫:仅依赖 tokio,支持 http:// 页面 use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::sync::{mpsc, Semaphore}; use tokio::task::JoinSet; const CONCURRENCY: usize = 3; // 最大并发请求数 const TIMEOUT: Duration = Duration::from_secs(5); // 单次请求限时 const MAX_RETRIES: u32 = 2; // 失败重试次数 const MAX_LINKS_PER_PAGE: usize = 5; // 每页最多跟进的链接数 /// 极简 HTTP GET:仅支持 http://,返回 (状态码, 响应体文本) async fn http_get(url: &str) -> Result<(u16, String), String> { // ---- 解析 URL(玩具级):http://host[:port]/path ---- let rest = url .strip_prefix("http://") .ok_or_else(|| "仅支持 http://".to_string())?; let (host_port, path) = match rest.find('/') { Some(i) => (&rest[..i], &rest[i..]), None => (rest, "/"), }; let addr = if host_port.contains(':') { host_port.to_string() } else { format!("{}:80", host_port) // 没写端口就补默认 80 }; // ---- 连接、发请求、收响应(作为一个整体,供超时包裹) ---- let request_fut = async { let mut stream = TcpStream::connect(&addr) .await .map_err(|e| format!("连接失败: {}", e))?; let req = format!( "GET {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: tokio-book-crawler\r\nConnection: close\r\n\r\n", path, host_port ); stream .write_all(req.as_bytes()) .await .map_err(|e| format!("发送失败: {}", e))?; let mut resp = Vec::new(); // Connection: close => 服务器回完就断开,read_to_end 能自然读到 EOF stream .read_to_end(&mut resp) .await .map_err(|e| format!("读取失败: {}", e))?; Ok(resp) }; // 整个"连接+发送+接收"限时 5 秒,超时即取消全部中间步骤 let resp = tokio::time::timeout(TIMEOUT, request_fut) .await .map_err(|_| "请求超时".to_string())??; // 两个 ? 依次拆掉 Elapsed 与内层 String // ---- 解析状态行:HTTP/1.1 200 OK ---- let header_end = resp .windows(4) .position(|w| w == b"\r\n\r\n") // 找到头部结束标志(空行) .unwrap_or(resp.len()); let head = String::from_utf8_lossy(&resp[..header_end]); let code = head .lines() .next() .and_then(|l| l.split_whitespace().nth(1)) .and_then(|s| s.parse::().ok()) .unwrap_or(0); let body = String::from_utf8_lossy(&resp[header_end..]).to_string(); Ok((code, body)) } /// 从 HTML 朴素提取 http:// 链接(演示用,不是严格 HTML 解析) fn extract_links(html: &str) -> Vec { let mut links = Vec::new(); let mut rest = html; while let Some(pos) = rest.find("href=\"") { rest = &rest[pos + 6..]; if let Some(end) = rest.find('"') { let link = &rest[..end]; if link.starts_with("http://") && !links.contains(&link.to_string()) { links.push(link.to_string()); } } else { break; } } links } /// 单次抓取 + 指数退避重试 async fn fetch_with_retry(url: &str) -> Result<(u16, String), String> { let mut last_err = String::new(); for attempt in 0..=MAX_RETRIES { if attempt > 0 { // 第 1 次重试等 200ms,第 2 次等 400ms…… tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await; } match http_get(url).await { Ok((code, body)) if (200..300).contains(&code) => return Ok((code, body)), Ok((code, _)) => last_err = format!("HTTP {}", code), // 状态异常也计入重试 Err(e) => last_err = e, } } Err(format!("重试 {} 次后仍失败:{}", MAX_RETRIES, last_err)) } /// 抓取一批 URL:去重入队、限流并发、结果回传,返回本批"发现的链接" async fn crawl_batch( urls: Vec, visited: Arc>>, sem: Arc, res_tx: mpsc::Sender<(String, Result<(u16, usize), String>)>, ) -> Vec { let mut set = JoinSet::new(); for url in urls { // 去重要发生在"入队前":insert 返回 false 说明 URL 已被抓过 if !visited.lock().unwrap().insert(url.clone()) { continue; } let sem = Arc::clone(&sem); let res_tx = res_tx.clone(); set.spawn(async move { let _permit = sem.acquire().await.unwrap(); // 限流:第 4 个请求在这里排队 let outcome = fetch_with_retry(&url).await; // 提取链接(只在成功时) let links = match &outcome { Ok((_, body)) => extract_links(body), Err(_) => Vec::new(), }; // 把精简后的结果发回打印任务 let _ = res_tx.send((url.clone(), outcome.map(|(c, b)| (c, b.len())))).await; (url, links) // 任务返回值:本页发现的链接 }); } // 收割:谁先完成收谁(完成顺序无关紧要,这里只求"全部收齐") let mut discovered = Vec::new(); while let Some(joined) = set.join_next().await { let (_url, links) = joined.unwrap(); discovered.extend(links); } discovered } #[tokio::main] async fn main() { let start = Instant::now(); let visited = Arc::new(Mutex::new(HashSet::new())); // 全局去重表 let sem = Arc::new(Semaphore::new(CONCURRENCY)); // 全局限流 let (res_tx, mut res_rx) = mpsc::channel(64); // 结果通道 // 打印任务:专职消费结果,主流程专心编排(解耦输出与抓取) let printer = tokio::spawn(async move { while let Some((url, result)) = res_rx.recv().await { match result { Ok((code, size)) => println!("✅ [{}] {} ({} 字节)", code, url, size), Err(e) => println!("❌ {} —— {}", url, e), } } }); // ---- 第 1 层:抓种子页 ---- let layer1: Vec = vec!["http://info.cern.ch".to_string()]; // 世界第一个网站,链接着实不少 let links = crawl_batch(layer1, Arc::clone(&visited), Arc::clone(&sem), res_tx.clone()).await; // ---- 第 2 层:跟进发现的链接(最多 5 条) ---- let layer2: Vec = links.into_iter().take(MAX_LINKS_PER_PAGE).collect(); let _ = crawl_batch(layer2, visited, sem, res_tx.clone()).await; drop(res_tx); // 结果通道不再有新数据 printer.await.unwrap(); // 等打印任务收尾 println!("⏱ 爬取结束,总耗时 {:?}", start.elapsed()); } ``` ## 20.3 预期运行输出 网络状况不同输出会不同,节奏大致如下(观察"3 个一批"的限流效果): ```text ✅ [200] http://info.cern.ch (798 字节) ✅ [200] http://info.cern.ch/hypertext/WWW/TheProject.html (3365 字节) ✅ [200] http://info.cern.ch/hypertext/WWW/WhatIs.html (2489 字节) ✅ [200] http://info.cern.ch/hypertext/WWW/Helping.html (2140 字节) ✅ [200] http://info.cern.ch/hypertext/WWW/Terms.html (1189 字节) ✅ [200] http://info.cern.ch/hypertext/WWW/Protocols/Overview.html (3271 字节) ⏱ 爬取结束,总耗时 1.32s ``` ## 20.4 逐段精讲:每个决策背后的"为什么" **为什么用 `JoinSet` 而不是无限 spawn?** 批量任务的生命周期必须"可收敛":`crawl_batch` 返回时,这批任务必然全部结束,才能安全进入下一层。配合 `Semaphore`,实现了"总量不限、同时最多 3 个"的流控。 **去重为什么放在 spawn 之前?** 抓取是最贵的操作,去重要发生在"花钱"之前。`visited.lock().unwrap().insert(url)` 利用 `HashSet::insert` 的返回值一步完成"去重 + 登记"。 **超时为什么包裹整个请求?** 连接、发送、接收任何一环卡住都该超时。`timeout(整体 async 块)` 的取消语义正好把所有中间步骤一并清理——这是"整体限时 + 单步不做局部超时"的实战化。 **为什么结果走 mpsc 而不是直接 println?** 抓取任务与输出解耦:打印慢不会拖住抓取(通道满时背压回抓取,自然限速),而且把打印任务换成"写数据库/上报"时,抓取代码一行不改。 **两层 BFS 如何终止?** 每层都是有限的 `JoinSet`,天然收敛。若要做"无限深度"的持续爬取,终止条件需要换成"在飞任务数 == 0 且队列空",配合 `CancellationToken` 做整体喊停——留给读者作为练习(第 16 章工具已备齐)。 **生产的爬虫怎么写?** 用 `reqwest`(TLS、重定向、压缩全都有)、尊重 `robots.txt`、限速用 `governor` 或 `throttle` 流、调度用持久化队列。本章的价值在于:HTTP 客户端剥开皮就是这几行 TCP 代码,你已经看清了它的骨架。 ## 20.5 扩展练习(按难度递增) 1. **深度控制**:给 crawl_batch 加 `depth` 参数,支持"最多跟进 N 层"的持续抓取,并用"在飞计数"解决终止判定; 2. **持久化去重**:把 `visited` 换成磁盘文件/sqlite,支持断点续爬; 3. **限速器**:用 `throttle` 或令牌桶,把并发限制换成"每秒最多 2 个请求"; 4. **robots.txt**:抓取每个站点前先请求 `/robots.txt`,解析 Disallow 规则; 5. **换 reqwest**:把 http_get 换成 reqwest::get(对比代码量,体会 TLS/重定向的复杂度都藏在哪)。 ## 本章要点小结 - 一个请求的全部等待(连接/发送/接收)应作为一个整体被 timeout 包裹。 - 限流用 `Semaphore`,批量收敛用 `JoinSet`,去重发生在入队前。 - 结果与业务解耦:mpsc 回传,专职任务消费。 - 手写 HTTP 客户端 = TCP 连接 + 文本协议 + 读到 EOF,框架并不神秘。 # 第 21 章 实战项目二:多人聊天室 > 本章目标:第二个项目——一个支持多人同时聊天的 TCP 服务器。它把全书最精华的部件组装在一起:**broadcast 通道(消息广播)、FramedRead/FramedWrite(行协议)、CancellationToken + TaskTracker(优雅关闭)**,并补上第 18 章的测试。 ## 21.1 需求与架构 需求: 1. 多个客户端同时连接(`nc 127.0.0.1 8080` 即可当客户端); 2. 任何客户端发送一行文字,广播给其他所有人(不回显自己); 3. 上线、下线有系统公告; 4. 输入 `/quit` 主动退出; 5. Ctrl+C 时服务器**优雅关闭**:不再接受新连接、通知所有在线客户端、等所有连接处理完再退出。 架构: ``` ┌────────────── broadcast 频道(tx) ──────────────┐ │ │ 连接 A(读任务:收一行 → tx.send) 连接 B(同左) │ │ 连接 A(写任务:rx.recv → 写回 socket) 连接 B(同左) │ │ CancellationToken ←── Ctrl+C ──→ 主循环停止 accept → TaskTracker.wait 收尾 ``` 每个连接一个任务,任务内部用 `select!` 同时盯三件事:自己的新消息、广播频道、全局关闭信号。 ## 21.2 服务器完整代码 依赖:`tokio`(full)、`tokio-util`(full)、`tokio-stream`、`futures-util`(sink 特性): ```rust //! 多人聊天室服务器(行协议广播) use futures_util::SinkExt; use tokio::net::{TcpListener, TcpStream}; use tokio::signal; use tokio::sync::broadcast; use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec}; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; println!("聊天室已启动:nc 127.0.0.1 8080(Ctrl+C 优雅关闭)"); // 广播频道:所有连接的"聊天大厅"(容量 256 条,落后者会收到 Lagged) let (tx, _) = broadcast::channel::(256); // 取消令牌:全局关闭开关(克隆即广播) let token = CancellationToken::new(); // 任务追踪器:记录所有在线连接,退出前等它们清零 let tracker = TaskTracker::new(); // 关闭监听:Ctrl+C => 按下总闸(令牌取消,所有等待者都会知道) let watch_token = token.clone(); tokio::spawn(async move { if signal::ctrl_c().await.is_ok() { println!("\n收到 Ctrl+C,开始优雅关闭…"); watch_token.cancel(); } }); // 主循环:接受新连接 与 关闭信号 竞速 loop { tokio::select! { // 令牌被取消 => 停止接受新连接,进入收尾阶段 _ = token.cancelled() => break, accepted = listener.accept() => { let (socket, addr) = accepted?; println!("连接进入:{}", addr); // 每个连接一个任务,纳入追踪器记账 tracker.spawn(handle_connection(socket, addr, tx.clone(), token.clone())); } } } // ---- 优雅收尾三步曲 ---- tracker.close(); // 1. 宣告不再有新连接任务 tracker.wait().await; // 2. 等所有连接任务自行退出(它们会收到令牌取消) println!("所有连接已收尾,服务器退出。再见!"); Ok(()) } /// 单个连接的生命周期:读与写合在一个 select 循环里 async fn handle_connection( socket: TcpStream, addr: std::net::SocketAddr, tx: broadcast::Sender, token: CancellationToken, ) { // 读写分家,各自套上行编解码器 let (read_half, write_half) = tokio::io::split(socket); let mut lines = FramedRead::new(read_half, LinesCodec::new()); // Stream> let mut out = FramedWrite::new(write_half, LinesCodec::new()); // Sink let mut rx = tx.subscribe(); // 这个连接在聊天大厅的"收音机" // 上线公告 let _ = tx.send(format!(">> {} 加入了聊天室", addr)); loop { tokio::select! { // ① 全局关闭:退出循环(后续清理代码会执行) _ = token.cancelled() => break, // ② 本连接发来一行消息(注意:FramedRead 的 next 是取消安全的, // 内部缓冲在 codec 对象里,即使本分支落选也不丢已收字节) line = lines.next() => { match line { Some(Ok(text)) => { if text == "/quit" { break; // 主动退出 } // 广播给所有订阅者(包括自己,下方写回时会跳过) if tx.send(format!("[{}] {}", addr, text)).is_err() { break; // 频道已关闭(服务器正在退出) } } _ => break, // 协议错误或对端断开(Ok(Err)/None) } } // ③ 聊天大厅来新消息了:写给本客户端 msg = rx.recv() => { match msg { Ok(text) => { // 简单的"不回显自己":消息格式固定为 [地址] 内容, // 是自己发的就跳过(生产做法:广播结构体并按发送者 ID 过滤) if !text.starts_with(&format!("[{}]", addr)) { if out.send(text).await.is_err() { break; // 写失败:对端可能断开了 } } } // 落后于频道(错过 n 条):如实告知 Err(broadcast::error::RecvError::Lagged(n)) => { let _ = out.send(format!("(提醒:你落后了,错过 {} 条消息)", n)).await; } Err(broadcast::error::RecvError::Closed) => break, // 服务器关闭 } } } } // 无论正常退出、断线还是被关闭信号打断,都会执行到这里 let _ = tx.send(format!(">> {} 离开了聊天室", addr)); println!("连接退出:{}", addr); } ``` ## 21.3 客户端(测试用) 一个约 30 行的客户端:把终端输入发给服务器,同时把服务器消息打印出来: ```rust //! 聊天室客户端:stdin 输入 => 服务器;服务器 => stdout use futures_util::SinkExt; use tokio::net::TcpStream; use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec}; #[tokio::main] async fn main() -> std::io::Result<()> { let socket = TcpStream::connect("127.0.0.1:8080").await?; println!("已连接,输入消息回车发送,/quit 退出"); let (r, w) = tokio::io::split(socket); let mut to_server = FramedWrite::new(w, LinesCodec::new()); let mut from_server = FramedRead::new(r, LinesCodec::new()); // 有趣的事实:stdin 套上 FramedRead 也变成"行消息流" —— 同一套抽象处处可用 let mut stdin = FramedRead::new(tokio::io::stdin(), LinesCodec::new()); loop { tokio::select! { line = stdin.next() => { match line { Some(Ok(text)) => to_server.send(text).await?, // 发给服务器 _ => break, // stdin 结束(Ctrl+D) } } msg = from_server.next() => { match msg { Some(Ok(text)) => println!("{}", text), // 显示服务器消息 _ => break, // 服务器断开 } } } } println!("客户端退出"); Ok(()) } ``` ## 21.4 预期运行效果 开 3 个终端:先跑服务器,再跑两个客户端(A、B)。交互时间线: ```text 服务器 : 聊天室已启动 A 上线 : >> 127.0.0.1:52001 加入了聊天室 ← B 也收到这条 A 发送 : [127.0.0.1:52001] 大家好 ← B 收到,A 不回显 B 上线 : >> 127.0.0.1:52002 加入了聊天室 ← A 收到 A 发送 : [127.0.0.1:52001] 又是我 ← B 收到 A /quit : >> 127.0.0.1:52001 离开了聊天室 ← B 收到 服务器Ctrl+C: 收到 Ctrl+C,开始优雅关闭… (主循环停止 accept) 连接退出:127.0.0.1:52002 ← 令牌通知 B 的任务退出 所有连接已收尾,服务器退出。再见! ``` 重点观察优雅关闭的日志顺序:**停止 accept → 存量连接收到令牌取消 → 各自打印退出 → tracker.wait 返回 → 进程退出**——这就是第 16 章模板的完整落地。 ## 21.5 设计要点复盘 **为什么读写合在一个 select 循环,而不是拆两个任务?** 拆任务就必须处理"读任务结束后如何通知写任务退出"(oneshot 或 abort)。单任务 + 三路 select 天然同生共死,状态(`out`、`rx`)不用跨任务共享,是更简单的选择。教训:**能在一个任务里用 select! 解决的,就不要拆任务**。 **为什么用 FramedRead 而不是裸 read?** 两点:一是省去手工处理 TCP 半包/粘包;二是取消安全——select 里 `lines.next()` 落选时,已收的字节安全躺在 codec 缓冲区里,下轮继续(第 14、17 章伏笔的闭环)。 **为什么 broadcast 容量要有限?** 慢客户端(比如网络很差的手机)会拖后取消息的速度;`Lagged` 机制宁可让他丢几条,也不让服务器的内存被一个慢客户端拖爆。这与 mpsc 有界通道是同一个哲学:**背压与丢弃,好过无限积压**。 **"不回显自己"为什么用地址前缀比较?** 演示从简。正确做法是把广播的消息换成结构体: ```rust // 改进方向:broadcast::channel::(256) // enum ChatEvent { UserJoined(u64), Message { from: u64, text: String }, UserLeft(u64) } // 写回时按 from != my_id 过滤,顺便解决了"两个客户端地址相同"的极端情况 ``` ## 21.6 用测试保护核心逻辑 按第 18 章的方法,把"广播 + 过滤自己"抽成可测函数,用虚拟时间验证关闭行为: ```rust #[cfg(test)] mod tests { use super::*; use std::time::Duration; #[tokio::test] async fn 消息应广播给所有订阅者() { let (tx, _rx) = broadcast::channel::(16); let mut a = tx.subscribe(); let mut b = tx.subscribe(); tx.send("房间广播".to_string()).unwrap(); assert_eq!(a.recv().await.unwrap(), "房间广播"); assert_eq!(b.recv().await.unwrap(), "房间广播"); } #[tokio::test] async fn 过滤自己发出的消息() { let my_id = "127.0.0.1:52001"; let msg = format!("[{}] 你好", my_id); // 复刻服务器里的过滤逻辑:是自己发的就跳过 let should_skip = msg.starts_with(&format!("[{}]", my_id)); assert!(should_skip); } #[tokio::test(start_paused = true)] async fn 关闭信号能让循环退出() { let token = CancellationToken::new(); let handle = tokio::spawn(async move { // 模拟连接任务的主循环 loop { tokio::select! { _ = token.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(3600)) => {} } } }); tokio::time::advance(Duration::from_millis(10)).await; token.cancel(); handle.await.unwrap(); // 能走到这里说明循环正确退出 } } ``` ## 21.7 扩展练习(按难度递增) 1. **昵称系统**:上线第一条消息作为昵称,广播与过滤全部改用昵称; 2. **消息结构体**:按 21.5 的改进方向,广播 `ChatEvent` 枚举(顺便练习 codec 对自定义类型的使用); 3. **私聊**:`@昵称 内容` 只发给目标用户(需要"昵称 → 发送端"的注册表,想想放哪、谁来锁); 4. **心跳踢人**:接入第 14 章的慢客户端检测,10 秒无消息自动断开; 5. **房间制**:多个 broadcast 频道 + 加入/切房指令,把单房间变多房间。 ## 本章要点小结 - 聊天室 = broadcast 大厅 + 每连接一个 select 循环(自己的消息 / 大厅 / 关闭信号)。 - 广播消息"不回显自己"需要发送者标识;`Lagged` 要向用户如实提示。 - FramedRead 在 select 中落选不丢字节,是聊天类长连接的标准读法。 - CancellationToken(喊停)+ 主循环 select(停接客)+ TaskTracker(等收尾)= 优雅关闭闭环,可直接复用到任何项目。 # 第 22 章 实战项目三:迷你 Web 服务与全书总结 > 本章目标:最后一个项目——不依赖任何 Web 框架,手写一个具备**路由、JSON 健康检查、查询参数解析、静态文件服务(防目录穿越)、优雅关闭**的 HTTP 服务。写完它,再回头看 axum 这类框架,每一层都清清楚楚。 ## 22.1 需求与路由设计 | 路由 | 功能 | |---|---| | `GET /health` | 健康检查,返回 JSON `{"status":"ok"}` | | `GET /hello?name=张三` | 问候接口,演示查询参数解析 | | `GET /files/<文件名>` | 静态文件服务(限定 `./public` 目录) | | 其他 | 404 | 关闭行为:Ctrl+C → 停止接受新连接 → 等存量请求处理完 → 退出(与第 21 章同一套闭环)。 ## 22.2 完整代码 依赖只有 `tokio`(full)与 `tokio-util`(full): ```rust //! 迷你 Web 服务:手写 HTTP 路由 + 静态文件 + 优雅关闭 use tokio::fs; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::signal; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; #[tokio::main] async fn main() -> std::io::Result<()> { // 准备静态文件目录,放两个示例文件 fs::create_dir_all("public").await?; fs::write("public/readme.txt", "这是 public 目录里的静态文件").await?; let listener = TcpListener::bind("127.0.0.1:3000").await?; println!("服务运行中:http://127.0.0.1:3000/health"); let token = CancellationToken::new(); let tracker = TaskTracker::new(); // Ctrl+C => 总闸(与聊天室完全相同的关闭骨架) let watch_token = token.clone(); tokio::spawn(async move { let _ = signal::ctrl_c().await; println!("\n收到 Ctrl+C,停止接受新请求…"); watch_token.cancel(); }); // 主循环:accept 与关闭竞速 loop { tokio::select! { _ = token.cancelled() => break, accepted = listener.accept() => { let (socket, _) = accepted?; // 每个请求连接一个任务;tracker 记账,退出前等它们清零 tracker.spawn(handle_connection(socket)); } } } tracker.close(); tracker.wait().await; println!("所有请求已处理完毕,服务退出"); Ok(()) } /// 处理一个 HTTP 连接(本服务采用 Connection: close,一次连接一个请求) async fn handle_connection(socket: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(socket); // 1. 读请求行:GET /hello?name=x HTTP/1.1 let mut request_line = String::new(); reader.read_line(&mut request_line).await?; let path = request_line .split_whitespace() .nth(1) .unwrap_or("/") .to_string(); // 2. 丢弃其余请求头(读到空行为止) loop { let mut line = String::new(); reader.read_line(&mut line).await?; if line == "\r\n" || line.is_empty() { break; } } // 3. 路由分发 let (status, content_type, body) = route(&path).await; // 4. 组装并发送响应 let head = format!( "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", status, content_type, body.len() // 字节数!中文按 UTF-8 字节计算 ); reader.get_mut().write_all(head.as_bytes()).await?; reader.get_mut().write_all(body.as_bytes()).await?; reader.get_mut().flush().await?; Ok(()) // 返回即关闭连接 } /// 路由函数:路径 => (状态行, Content-Type, 响应体) async fn route(path: &str) -> (&'static str, &'static str, String) { // 拆出纯路径与查询串:/hello?name=张三 => ("/hello", Some("name=张三")) let (path_only, query) = match path.split_once('?') { Some((p, q)) => (p, Some(q)), None => (path, None), }; match path_only { // 健康检查:返回 JSON "/health" => ( "200 OK", "application/json", "{\"status\":\"ok\"}".to_string(), ), // 问候接口:解析 name= 参数 "/hello" => { let name = query .and_then(|q| q.split('&').find(|kv| kv.starts_with("name="))) .map(|kv| kv[5..].to_string()) .unwrap_or_else(|| "世界".to_string()); ( "200 OK", "text/plain; charset=utf-8", format!("你好,{}!", name), ) } // 静态文件服务:只允许访问 public 目录下的文件 p if p.starts_with("/files/") => { let file_name = p.trim_start_matches("/files/"); let full = std::path::Path::new("public").join(file_name); // 安全检查:canonicalize 解析出真实绝对路径, // 必须仍在 public 目录内 —— 否则攻击者用 ../ 读取任意文件(目录穿越)! let is_safe = fs::canonicalize(&full) .await .map(|real| real.starts_with("public")) .unwrap_or(false); if !is_safe { ("403 Forbidden", "text/plain; charset=utf-8", "禁止访问".to_string()) } else { match fs::read(&full).await { Ok(bytes) => ( "200 OK", "application/octet-stream", // 文件可能是二进制,这里按 UTF-8 丢失性转换(演示从简) String::from_utf8_lossy(&bytes).to_string(), ), Err(_) => ( "404 Not Found", "text/plain; charset=utf-8", "文件不存在".to_string(), ), } } } _ => ( "404 Not Found", "text/plain; charset=utf-8", "404:页面不存在".to_string(), ), } } ``` ## 22.3 预期运行效果 ```bash $ curl http://127.0.0.1:3000/health {"status":"ok"} $ curl "http://127.0.0.1:3000/hello?name=Tokio" 你好,Tokio! $ curl http://127.0.0.1:3000/files/readme.txt 这是 public 目录里的静态文件 $ curl --path-as-is http://127.0.0.1:3000/files/../Cargo.toml 禁止访问 ← 目录穿越被 403 拦截 # 按 Ctrl+C 后: 收到 Ctrl+C,停止接受新请求… 所有请求已处理完毕,服务退出 ``` ## 22.4 从玩具到框架:距离在哪? 对照这个 150 行的玩具,真正生产级的差距恰好就是**框架的分层**: | 玩具的短板 | 生产方案 | |---|---| | 手撕 HTTP 解析(头、体、编码、分块) | **hyper**:Tokio 官方 HTTP 实现,axum 的底座 | | match 字符串路由,无路径参数 | **axum**:路由、提取器、中间件(tower 生态) | | 手拼响应头、字符串 body | axum 的 `Json`/`Html` 等响应类型 | | 无认证、日志、压缩 | tower 中间件 + **tracing** 链路追踪 | | 手写 SQL | **sqlx** / **sea-orm**:异步数据库访问 | | 无 TLS | rustls / axum-server | 换句话说:**你已经掌握了这些框架的"地板"**。学习路线建议: 1. **axum**:Rust 最主流 Web 框架,API 与 Tokio 概念一一对应(它的 handler 就是 async fn,State 就是 Arc 共享,优雅关闭就用 CancellationToken + with_graceful_shutdown); 2. **tonic**:gRPC 框架(基于 hyper); 3. **sqlx**:编译期校验 SQL 的异步数据库库; 4. **tracing + tokio-console**:生产观测双件套(第 19 章); 5. 阅读 Tokio 官方教程与源码:`tokio::sync::Notify` 的 waker 管理是极佳的进阶读物。 ## 22.5 扩展练习(按难度递增) 1. **POST 支持**:读取 Content-Length 并收 body,实现一个 `/echo` 接口原样返回 POST 内容; 2. **真正的 JSON**:引入 `serde_json`,health 返回带版本号/时间戳的动态 JSON; 3. **keep-alive**:去掉 `Connection: close`,循环处理同一连接上的多个请求(注意判断"读不到更多请求"的时刻); 4. **访问日志**:给每个请求加一行"方法 路径 状态 耗时"日志(对比第 19 章 tracing 的做法); 5. **限流中间件**:全局限流 `Semaphore` + 每 IP 计数,把第 7、13 章的防御手段搬进来。 ## 22.6 全书知识点总回顾 22 章,最后用一张地图收束全书: | 能力 | 对应章节 | 关键 API | |---|---|---| | 异步心智模型(Future/await/运行时) | 第 1-3 章 | `async fn`、`.await`、`Future::poll` | | 任务创建与取消 | 第 4 章 | `spawn`、`JoinHandle`、`spawn_blocking`、`JoinSet` | | 运行时形态与配置 | 第 5 章 | `#[tokio::main]`、`Builder`、`block_on`、`Handle` | | 异步 IO 与缓冲 | 第 6 章 | `AsyncRead/Write(Ext)`、`BufReader`、`copy`、`duplex` | | 网络编程 | 第 7 章 | `TcpListener/Stream`、`UdpSocket` | | 时间控制 | 第 8 章 | `sleep`、`interval`、`timeout` | | 文件系统 | 第 9 章 | `tokio::fs`、`File`、`spawn_blocking 批处理` | | 子进程 | 第 10 章 | `Command`、`Child`、管道流式读写 | | 信号与优雅关闭 | 第 11 章 | `ctrl_c`、`SignalKind` | | 通道选型 | 第 12 章 | `mpsc`/`oneshot`/`broadcast`/`watch`、Actor 模式 | | 锁与限流 | 第 13 章 | `Mutex`、`RwLock`、`Semaphore`、`Notify` | | 任务内并发 | 第 14 章 | `join!`、`select!`、`pin!`、取消安全 | | 流处理 | 第 15 章 | `Stream`、`StreamExt`、通道↔流桥接 | | 工具箱 | 第 16 章 | `CancellationToken`、`TaskTracker`、`codec`、`Bytes/Buf` | | 错误与取消 | 第 17 章 | `JoinError`、守卫模式、安全循环模板 | | 测试 | 第 18 章 | `#[tokio::test]`、虚拟时间、duplex 测试 | | 调优 | 第 19 章 | 阻塞检测、worker 调优、陷阱清单 | | 综合实战 | 第 20-22 章 | 爬虫、聊天室、Web 服务 | 如果只能带走一句话,那就是贯穿全书的这句: > **异步的一切,都是关于"在等待的时候,把线程让给别人"。** 理解了这句话,`spawn` 是它、`await` 是它、通道是它、select 是它、优雅关闭还是它。 祝你在异步的世界里,永不阻塞。🦀 # 附录 ## 附录 A Tokio feature 特性开关全解 主 crate `tokio` 的特性(在 `Cargo.toml` 的 `features = [...]` 中启用): | 特性 | 内容 | 何时需要 | |---|---|---| | `full` | 以下全部特性之和 | 学习阶段、工具型程序,一把梭 | | `rt` | 基础运行时(单线程 current_thread) | 任何 Tokio 程序的最低要求 | | `rt-multi-thread` | 多线程运行时 | 需要多核并行的服务(含 `#[tokio::main]` 默认形态) | | `macros` | `#[tokio::main]`、`#[tokio::test]`、`join!`、`select!`、`try_join!`、`pin!` | 几乎总是要 | | `net` | TCP / UDP / Unix 套接字 / Windows 命名管道 | 网络程序 | | `io-util` | `AsyncReadExt`/`AsyncWriteExt`/`BufReader` 等工具 | 任何做读写的程序 | | `io-std` | `stdin`/`stdout`/`stderr` | 交互式程序 | | `time` | `sleep`、`interval`、`timeout`、定时器 | 几乎总是要 | | `sync` | `mpsc`/`oneshot`/`broadcast`/`watch`/`Mutex`/`RwLock`/`Semaphore`/`Notify`/`Barrier` | 任务间通信 | | `fs` | `tokio::fs` 异步文件系统 | 文件操作 | | `process` | `tokio::process` 子进程 | 执行外部命令 | | `signal` | `ctrl_c`、Unix 信号、Windows 控制台事件 | 优雅关闭 | | `parking_lot` | 用 parking_lot 替换内部同步原语(性能略优) | 可选优化 | | `tracing` | 让运行时内部事件接入 tracing(诊断) | 深度排查时 | | `test-util` | 时间暂停/快进(`start_paused`) | 测试 | 配套 crate 常用特性: - **tokio-stream**:`full` 或分选 `time`(默认含 timeout)/`sync`(`BroadcastStream` 等)/`net`(`TcpListenerStream`); - **tokio-util**:`full` 或分选 `codec`(编解码器)/`sync`(CancellationToken)/`task`(TaskTracker)/`io`(ReaderStream 等)/`time`(DelayQueue)。 > 💡 裁剪技巧:发布前用 `cargo tree` 查看依赖,把 `full` 换成实际用到的特性,可缩短编译时间。功能裁剪不影响运行时行为,只影响"哪些代码被编译进来"。 ## 附录 B Tokio 与标准库 API 对照表 | 标准库(sync) | Tokio(async) | 备注 | |---|---|---| | `std::thread::sleep` | `tokio::time::sleep` | 后者等待时让出线程 | | `std::thread::spawn` | `tokio::spawn` | 任务比线程轻几个数量级 | | `std::net::TcpListener` | `tokio::net::TcpListener` | API 同构 | | `std::net::TcpStream` | `tokio::net::TcpStream` | 配 AsyncRead/WriteExt | | `std::net::UdpSocket` | `tokio::net::UdpSocket` | | | `std::os::unix::net::UnixListener` | `tokio::net::UnixListener` | 同构 | | `std::fs::File` | `tokio::fs::File` | 底层走阻塞线程池 | | `std::fs::read/write` | `tokio::fs::read/write` | | | `std::io::BufReader` | `tokio::io::BufReader` | | | `std::io::stdin/stdout` | `tokio::io::stdin/stdout` | | | `std::process::Command` | `tokio::process::Command` | | | `std::sync::mpsc` | `tokio::sync::mpsc` | Tokio 版 send 是 async | | `std::sync::Mutex` | `tokio::sync::Mutex` | 短临界区仍推荐 std 版 | | `std::sync::RwLock` | `tokio::sync::RwLock` | | | `std::sync::Barrier` | `tokio::sync::Barrier` | | | `std::sync::atomic` | 原样使用 | 原子操作无需异步化 | | `std::time::Instant` | `tokio::time::Instant` | 后者兼容虚拟时间测试 | | `thread_local!` | `tokio::task_local!` | 任务级局部变量 | ## 附录 C 常见报错与 FAQ **1. `future cannot be sent between threads safely`** 原因:多线程运行时要求任务可跨线程(`Send`),你的任务跨 `.await` 持有了非 `Send` 类型(典型:`Rc`、`RefCell`、普通引用、`std::sync::MutexGuard`)。 解法:改用 `Arc`(代替 Rc)、`tokio::sync::Mutex`(代替持 std 锁跨 await)、或把任务挪到 `current_thread` 运行时/`LocalSet`(第 4.8 节)。 **2. `borrowed data escapes outside of closure` / `may not live long enough`** 原因:`tokio::spawn` 的任务要求 `'static`,不能借用局部变量。 解法:给 async 块加 `move` 并转移所有权;多处共享用 `Arc`;字符串传 `String` 而非 `&str`。 **3. `unused ... future that must be used` 警告,程序"没反应"** 原因:创建了 Future 却没 `.await` 也没 `spawn`,惰性 Future 永远不会执行。 解法:补 `.await`(要结果)或 `tokio::spawn`(要并发)。 **4. `Cannot start a runtime from within a runtime`** 原因:在异步上下文里又调用了 `Runtime::new().block_on()`(套娃)。 解法:异步栈里没有 block_on 的位置——直接 `.await`;要跑同步代码用 `spawn_blocking`(第 5 章)。 **5. `A Tokio 1.x context was found, but IO and time drivers are disabled`** 原因:手动 `Builder` 构建运行时时忘了 `.enable_all()`。 解法:补上 `.enable_io().enable_time()` 或直接 `.enable_all()`。 **6. `Cannot drop a runtime in a context where blocking is not allowed`** 原因:在异步任务里 drop 了一个 Runtime(常见于把运行时当普通字段存)。 解法:运行时只应在同步世界创建与销毁;跨线程共享用 `Handle` 而不是 Runtime 本体。 **7. 程序莫名卡住不动(死锁排查顺序)** 依次检查:① 任务是否真的被 spawn/await;② 是否用 std Mutex 且跨 await 持锁(改 tokio Mutex 或缩短临界区);③ 两把锁是否交叉等待(固定加锁顺序);④ 通道是否"发送端没关闭导致 recv 永等"(记得 drop 多余的 Sender 克隆);⑤ 是否在等一个永远不会 notify 的 Notify/watch;⑥ 是否有无 yield 的忙等循环饿死了同伴(第 4.6 节)。 **8. `select!` 报 `all branches are disabled and there is no else branch`** 原因:所有分支都被 `if` 前置条件禁用,而宏没有兜底分支可执行。 解法:加 `else => { ... }` 分支,或保证任一时刻至少有一个分支可用。 **9. `panicked at there is no reactor running...`** 原因:在运行时之外创建了需要反应器/定时器的对象(如在同步 main 里直接创建异步 TCP 连接)。 解法:异步资源必须在运行时上下文内创建使用;或先 `Handle::enter()`。 **10. 同一信号注册两次监听器 panic** 原因:同一信号(signal)的全局监听器只能存在一份。 解法:一处监听 + `CancellationToken`/broadcast 广播给所有模块(第 11.5 节模式)。 **11. trait 里的 `async fn` 在 `tokio::spawn` 中报 Send 错误** 原因:trait 的 async fn 编译后的 future 是否 Send 取决于实现者,trait 层面无法保证。 解法:库作者用 `async_trait` 宏注解(生成的 future 强制 Send),或为 trait 手动添加 `fn boxed<'a>(...) -> Pin + Send + 'a>>` 类方法。 **12. Windows 下 Ctrl+C 似乎"无效"?** 原因:`cargo run` 的某些终端(如部分 IDE 内置终端)对 Ctrl+C 的转发方式不同。 解法:用独立终端(Windows Terminal)测试;或用 `tokio::signal::windows::ctrl_close()` 等补充监听。 ## 附录 D 常用 API 速查表 **任务与运行时** ```rust tokio::spawn(fut) -> JoinHandle // 并发任务 tokio::task::spawn_blocking(closure) // 阻塞线程池 tokio::task::yield_now().await // 礼让 handle.abort() / .is_finished() // 取消 / 查询 #[tokio::main] / #[tokio::test] // 入口宏 Builder::new_multi_thread().worker_threads(n).enable_all().build() rt.block_on(fut) / Handle::current().spawn(fut) ``` **时间** ```rust tokio::time::sleep(dur).await tokio::time::interval(dur) -> ticker.tick().await // 首跳立即 tokio::time::timeout(dur, fut).await // Result tokio::time::Instant::now() // 计时(兼容虚拟时间) ``` **IO** ```rust reader.read(&mut buf) / read_exact / read_to_end / read_to_string writer.write(&buf) / write_all / flush / shutdown // shutdown = 发 EOF BufReader::new(r).read_line(&mut s) / .lines() io::copy(&mut r, &mut w) // 单向搬运 io::copy_bidirectional(&mut a, &mut b) // 双向透传 io::split(stream) / io::duplex(n) ``` **网络** ```rust TcpListener::bind("host:port").await?.accept().await? TcpStream::connect("host:port").await? stream.set_nodelay(true) / peer_addr() / shutdown() UdpSocket::bind(addr).await? .send_to / recv_from / send / recv tokio::net::lookup_host("example.com:80").await? ``` **文件与进程** ```rust fs::read / read_to_string / write // 便捷函数 File::open / File::create / OpenOptions::new().append(true).open(p) fs::create_dir_all / remove_dir_all / rename / metadata / canonicalize Command::new(prog).args([...]).output().await? / .spawn()? -> Child child.stdin.take() / stdout.take() / kill().await / wait().await ``` **同步与信号** ```rust mpsc::channel(n) -> (Sender, Receiver); tx.send(v).await; rx.recv().await oneshot::channel() -> (tx, rx); tx.send(v); rx.await broadcast::channel(n) -> (tx, rx); tx.subscribe(); rx.recv().await // 可能 Lagged watch::channel(init) -> (tx, rx); tx.send_modify; rx.changed().await Mutex/RwLock: .lock().await / .read().await / .write().await Semaphore::new(n); sem.acquire().await -> permit(drop 即归还) Notify: notify_one() / notified().await signal::ctrl_c().await; unix::signal(SignalKind::terminate()) ``` **宏与流** ```rust join!(a, b) / try_join!(a, b) // 并发等全部 / 错误短路 select! { pat = fut => {...}, ... } // 先到先得(+ if 条件、else、biased) pin!(fut) // 栈上固定 StreamExt: .next().await / map / filter / merge / fold / collect / timeout wrappers: ReceiverStream / BroadcastStream / IntervalStream / TcpListenerStream ``` **tokio-util** ```rust CancellationToken: .clone() / .cancel() / .cancelled().await / .child_token() / run_until_cancelled TaskTracker: .spawn(task) / .close() / .wait().await FramedRead::new(r, LinesCodec::new()) // Stream> FramedWrite::new(w, LinesCodec::new()) // Sink(需 SinkExt::send) ReaderStream::new(file) / StreamReader::new(stream) ``` **bytes 字节缓冲**(经 `tokio_util::bytes` 使用,详见 16.4 节) ```rust BytesMut: with_capacity(n) / extend_from_slice / reserve(n) / split_to(at) / freeze() -> Bytes Bytes: .clone()(零拷贝)/ .slice(..n)(独立所有权视图)/ from_static / from(Vec) Buf(读游标): remaining() / chunk()(偷看) / advance(n) / get_u32() / get_u32_le() / copy_to_slice(&mut buf) BufMut(写游标): reserve(n) / put_u32(v) / put_u32_le(v) / put_slice(&[u8]) / put_bytes(0, n) // ⚠️ get_* 会前进游标:先确认"整条消息到齐"再消费 ``` --- # 附录 E 易漏 API 补遗(全景清单) 前面章节按"主线"组织,还有一些**小而美、特定场景救急**的 API 没有独立成节。本附录一次列全:一部分已在正文章节内嵌(标注出处),其余在此配齐示例或入表。读完这一页,对 Tokio 全家桶的覆盖才算真正闭环。 ## E.1 已内嵌进正文的补遗(快速索引) | API | 一句话 | 出处 | |---|---|---| | `tokio::task::unconstrained(fut)` | 关闭协作式调度预算,适合"循环里全是不真正等待的 await"的任务 | 4.6 节 | | `mpsc::Receiver::recv_many(&mut vec, limit)` | 一次批量收取最多 limit 条消息 | 12.2 节 | | `tx.blocking_send` / `rx.blocking_recv` | 同步上下文版的通道收发 | 12.2 节 | | `Semaphore::acquire_many(n)` | 一次领 n 张票(重活占多个工位) | 13.4 节 | | `tokio::sync::OnceCell` | 异步惰性初始化,`get_or_init(async || ...).await` | 13.6 节 | | `tokio::io::simplex(n)` | 单向内存管道(一端只写、一端只读) | 6.9 节 | | `tokio_util::task::JoinMap` | 带 key 的任务集,`join_next()` 返回 `(key, 结果)` | 16.9 节 | ## E.2 io 的小件 ```rust // copy_buf:从"已有缓冲的读端"搬运数据,复用 BufReader 的内部缓冲,少一次分配 tokio::io::copy_buf(&mut buf_reader, &mut dst).await?; // write!/writeln!:格式化写入(由 AsyncWriteExt::write_fmt 支撑,和 println! 一样顺手) use tokio::io::AsyncWriteExt; writeln!(stream, "user {} joined", uid).await?; // stream_position:查询当前读取位置(与 seek 搭配使用) let pos = file.stream_position().await?; ``` **AsyncFd:把"非 Tokio 类型"接入反应器**(进阶)。第三方库交给你一个同步的 `std::net::TcpStream` 或裸 fd,想异步等它的可读/可写事件,用 `AsyncFd` 包装——它替你向反应器注册事件,你只在就绪时做真实 IO: ```rust use tokio::io::AsyncFd; // 同时支持 Unix 的 fd 与 Windows 的 SOCKET #[tokio::main] async fn main() -> std::io::Result<()> { // 场景:某个第三方库只给你一个同步的 TcpStream let std_stream = std::net::TcpStream::connect("127.0.0.1:8080")?; std_stream.set_nonblocking(true)?; // ① 必须先设为非阻塞 let fd = AsyncFd::new(std_stream)?; // ② 包装:接入 Tokio 反应器 // ③ 固定套路:先异步等"就绪"事件,再在 try_io 里做真实读写 loop { let mut guard = fd.readable().await?; // 异步等待"可读"(等待时不占线程) let result = guard.try_io(|inner| { let mut buf = [0u8; 64]; inner.get_ref().read(&mut buf) // 用 std 的同步 read 做真实读取 }); match result { Ok(Ok(0)) => break, // 对端关闭 Ok(Ok(_n)) => { /* 处理数据 */ } Ok(Err(e)) => return Err(e), // 真实 IO 错误 Err(_would_block) => continue, // WouldBlock 已被 try_io 自动过滤到这里 } # break; // 演示只读一次 } Ok(()) } ``` ## E.3 net 的小件 ```rust // Unix 数据报:本机进程间的"UDP 风格"通信(仅 Unix,API 与 UdpSocket 同构) use tokio::net::UnixDatagram; let sock = UnixDatagram::bind("/tmp/app.sock").await?; sock.send_to(b"ping", "/tmp/other.sock").await?; // UDP 组播:join_multicast_v4 / join_multicast_v6 / set_multicast_loop_v4(见 7.6 节) // peek:偷看数据但不消费(下次 read 还能读到) let n = stream.peek(&mut buf).await?; // 所有权式读写半:into_split 产出 OwnedReadHalf/OwnedWriteHalf, // 各自是 'static 的,可直接 move 进不同任务(对比 6.8 节的 split) let (owned_r, owned_w) = stream.into_split(); ``` ## E.4 sync / runtime / process / fs 的小件 | API | 一句话 | |---|---| | `Handle::block_on(fut)` | 在普通线程上阻塞执行异步代码(勿在异步上下文内调用,见 5.5 节) | | `watch::Sender::send_if_modified(\|v\| ...)` | 原地修改,仅当闭包返回 true 才通知接收端(减少无效唤醒) | | `watch::Receiver::has_changed()` | 非阻塞查询"有没有新值" | | `Command::env("K", "v")` / `.envs(..)` / `.current_dir(..)` | 设置子进程的环境变量与工作目录 | | `File::into_std().await` | 把 tokio File 转回 `std::fs::File`(与其他同步库互操作) | | `fs::symlink_metadata` | 读元数据但不跟随符号链接(判断"这是个链接"用) | | `broadcast::Sender::receiver_count()` | 查询当前订阅者数量(无人订阅时跳过工作) | ## E.5 测试与工具 crate 的小件 | API | 一句话 | |---|---| | `tokio_test::task::spawn(fut)` | 不建运行时,手动 `poll` future,精确断言状态机"被唤醒前/后"的行为 | | `tokio_stream::wrappers::UnixListenerStream` | Unix 监听器 → 连接流(对应 15.6 节的 TcpListenerStream) | | `tokio-metrics`(crate) | 任务级指标聚合:运行时长、轮询次数、排队延迟的统计报表 | | `console-subscriber` | tokio-console 的接入端(第 19 章已演示) | > 💡 未列入本书的还有两类:**不稳定 API**(需 `--cfg tokio_unstable`,如 `Builder::on_task_spawn` 钩子、`LocalRuntime`)和**底层内部**(如 `mio`、调度器私有结构)。它们随时可能变动,生产代码等转正再学即可。 --- ## 模块覆盖核对清单 本书对 Tokio 家族的覆盖情况(写完最后一页,与你核对一遍): | 模块 | 覆盖章节 | 状态 | |---|---|---| | `tokio::runtime` | 第 5 章 | ✅ | | `tokio::task` | 第 4 章(+16 章 TaskTracker) | ✅ | | `tokio::io`(含 io-util、io-std) | 第 6 章(+7、9 章综合运用) | ✅ | | `tokio::net` | 第 7 章(+20、21、22 章实战) | ✅ | | `tokio::time` | 第 8 章(+18 章虚拟时间) | ✅ | | `tokio::fs` | 第 9 章(+22 章静态文件) | ✅ | | `tokio::process` | 第 10 章 | ✅ | | `tokio::signal` | 第 11 章(+21、22 章优雅关闭) | ✅ | | `tokio::sync` | 第 12、13 章(+21 章 broadcast 实战) | ✅ | | `tokio::macros`(main/test/join/select/try_join/pin) | 第 2、14 章 | ✅ | | `tokio::prelude` | 第 2.5 节 | ✅ | | `tokio-stream` | 第 15 章(+21 章实战) | ✅ | | `tokio-util`(codec/sync/task/io) | 第 16 章(+17、21、22 章实战) | ✅ | | `bytes` crate(`Buf`/`BufMut`/`Bytes`/`BytesMut`) | 第 16.4 节(+6、19 章综合运用) | ✅ | | 零散补遗(`unconstrained`/`OnceCell`/`recv_many`/`simplex`/`AsyncFd`/组播/`JoinMap` 等) | 各章内嵌 + 附录 E 全景清单 | ✅ | | `tokio-test` / test-util | 第 18 章 | ✅ | | feature 特性系统 | 第 1.6 节 + 附录 A | ✅ | ## 后记 写完这本书,你已经从"不知道异步是什么"走到了"能独立设计并发服务"。下一步的最好方式,是把第 20-22 章的项目改造出你自己的版本:给爬虫加上 URL 去重的持久化、给聊天室加上房间与私聊、给 Web 服务接上 sqlx 数据库——每章末尾的"扩展练习"就是为此准备的台阶。 遇到问题时,回到三样东西:官方文档([docs.rs/tokio](https://docs.rs/tokio))、tokio-console(看任务)、以及本书附录 C(排查坑)。 Happy async! 🦀