本页定位 · Rust 进阶

《Rust 语言》讲清了所有权、生命周期、并发、unsafe 基础,能写"能编译的小程序"。本页进入把它用在真实系统里的深度:所有权/借用/生命周期的实战坑、Tokio 异步运行时与 Send/Sync、Unsafe 与 FFI(含 pyo3 调 Python)、声明宏与过程宏、trait 系统设计与零成本抽象、Axum + tonic(gRPC) + 中间件的 Web 进阶、以及可观测、测试、基准、嵌入式 no_std 与 WASM 方向。我会像《Java 进阶》那样,把每个点拆成"它是什么 → 为什么这样设计 → 怎么用 → 踩过什么坑"。基线:Rust 1.8x、Tokio 1、Axum 0.7、tonic 0.12、tracing、criterion。

1

所有权 / 借用 / 生命周期深入

Ownership · Borrowing · Lifetime · Drop

Rust 的核心卖点是"编译期消灭数据竞争与悬垂指针",靠的就是所有权系统。基础篇讲了规则,进阶要懂为什么这些规则长这样、报错时怎么改、以及借检查卡住时的脱困手段。

移动语义:值的所有权唯一转移

在 Rust 里,大多数类型(非 Copy)的赋值或传参是移动——所有权从旧变量转移到新变量,旧变量随即失效。这是"一个值同一时刻只有一位主人"的具象化,从根上杜绝双释放与悬垂。

fn main() { let s = String::from("hello"); let t = s; // 所有权从 s 移到 t;此后 s 不可用 // println!("{}", s); // 编译错误:borrow of moved value println!("{}", t); }

论为什么不像 GC 语言那样"共享引用"

GC 语言靠运行时追踪谁还在用对象,有运行时开销、还有 STW。Rust 把"谁负责释放"在编译期就定死:唯一所有者,离开作用域自动 drop。零运行时负担,但代价是你要适应"移动"思维——好在 Clone 和 & 借用随时可兜底。

借用检查:&T 与 &mut T 的规则

不想转移所有权时,用借用:&T 是不可变借用(可多个共存),&mut T 是可变借用(同一时刻只能有一个,且与不可变借用互斥)。这条"共享 XOR 可变"规则,正是无数据竞争的来源。

fn len(s: &String) -> usize { s.len() } // 不可变借用,不拿所有权 fn main() { let mut s = String::from("hi"); let r = &s; // 不可变借用 let r2 = &s; // 多个不可变借用 OK // let m = &mut s; // 错误:已有不可变借用时不能有可变借用 println!("{} {}", r, r2); }
经典死锁式报错:借用活得比作用域长

在函数里返回"指向局部变量的引用"会报错:局部变量在函数结束就 drop,返回的引用会变成悬垂。解决:返回所有权(返回 String 而非 &String),或让调用者提供存储(借用检查的生命周期参数)。别试图用 'static 糊弄,那是另一回事。

生命周期标注 'a:为什么需要、怎么写

多数时候编译器能自己推断出引用的存活范围。但当函数返回值的引用,编译器无法判断它来自哪个参数,就需要你用 'a 显式声明"返回的引用至少和某个输入活得一样长"。

// 返回值引用指向 x 或 y,必须标注它们共享同一个生命周期 'a fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } // 调用时编译器按实参的实际存活范围去校验

论生命周期不是"运行时概念"

生命周期 'a 完全在编译期消掉,运行时没有任何痕迹。它的唯一作用是让借用检查器能证明"返回的引用不会悬垂"。新手常以为要手动管理存活时间——不用,你只是"告诉编译器约束",证明工作它来做。

Drop 与 RAII:资源自动释放

Rust 用 RAII(资源获取即初始化):任何类型实现 Drop 后,值离开作用域会自动调用 drop。文件、锁、socket、堆内存都靠它释放——不用 free、不用 try/finally。

struct Guard; impl Drop for Guard { fn drop(&mut self) { println!("释放资源"); } } fn main() { let _g = Guard; // 离开作用域自动 drop,无需手动 free } // 此处打印 "释放资源"
Drop 顺序与"部分移动"的坑

同一作用域多个值按逆声明顺序 drop(后声明先释放)。若你在某字段被 move 走后调用 drop,会因"部分移动"报错——Rust 不允许只释放结构体的一部分。需要精细控制释放时机时,用 std::mem::drop 提前交还所有权即可。

实战清单:常见借用报错与解法

把高频报错和对应思路列成表,遇到红字先对号入座,能省大量抓瞎时间。

报错信号含义典型解法
borrow of moved value变量已被移动用 & 借用、或 .clone()
cannot borrow as mutable与已有借用冲突缩小借用作用域、先用完再可变借
missing lifetime specifier返回引用需标注加 <'a> 生命周期参数
temporary value dropped返回了临时量的引用把值存进变量再返回其引用

内部可变性:RefCell 在"看起来共享"下安全改

有时你需要"通过不可变引用去修改内部数据"(比如多个地方共享一个计数器)。RefCell<T> 把借用检查从编译期推迟到运行期:同一时刻仍只允许一种借用,违反就在运行时 panic,但语法上借用的是 & 不可变引用。

use std::cell::RefCell; let data = RefCell::new(vec![1, 2, 3]); data.borrow_mut().push(4); // 运行期借用检查,非编译期 println!("{:?}", data.borrow()); // 不可变借用读取

论RefCell 与 Mutex 的分工

单线程内用 RefCell(运行期借用检查,无锁开销);跨线程共享可变状态用 Mutex(靠锁保证安全)。二者都实现了"内部可变性"模式:对外暴露 &T 却能在内部改。选错会编译不过或被 Send 拒绝——这正是 Rust 在帮你。

Copy 类型:自动按位复制 vs 移动

为什么 i32、bool 赋值后原变量还能用,而 String 会移动?因为前者实现了 Copy trait——赋值时按位复制一份,不涉及所有权转移;后者没有,于是走移动。理解 Copy 的边界,能解释很多"为什么这里没报错"。

类型赋值行为原因
i32 / f64 / bool复制,原值仍可用实现了 Copy
&T(引用)复制(复制指针)引用本身 Copy
String / Vec移动,原值失效含堆指针,需唯一 owner

论所有权系统是一套"编译期资源管理"

把本章串起来看:移动保证唯一 owner、借用用"共享 XOR 可变"防数据竞争、生命周期证明引用不悬垂、Drop 自动释放、Copy/内部可变性补上特例。它们不是零散规则,而是同一套"在编译期确定资源归属"的设计——这正是 Rust 不用 GC 也能安全的根。

2

并发:Tokio、async/.await、Pin/Stream、Send/Sync

Tokio · async · Pin · Stream · Send/Sync

Rust 的无畏并发建立在所有权之上。但真正要写高并发服务,绕不开异步运行时 Tokio。这一章讲清 Future 为什么不自己跑、async 里为什么不能阻塞、以及那两个让人头大的概念 Pin 和 Send/Sync。

Future 与运行时:async 不自己跑

async fn 返回的是一个 Future——它只是"待办计划",本身停在第一步。必须放进运行时(Tokio 的 executor)被轮询(poll)才会真正推进。理解了这点,才能解释"为什么光写 async 没并发效果"。

use tokio::time::{sleep, Duration}; async fn fetch() { sleep(Duration::from_secs(1)).await; } // fetch() 返回 Future,不调用就什么都不发生 // 必须交给运行时:tokio::spawn / .await / block_on

论零成本抽象意味着什么

Rust 的 Future 是状态机,编译期展开成普通 struct,没有堆分配、没有虚函数。相比 Go 的 goroutine(每次 2KB 栈 + 调度器开销),Rust async 的每任务开销极小——这是它能在单机扛百万连接的根本。

#[tokio::main] 与 spawn:把任务并发跑起来

#[tokio::main] 帮你包了一层"启动运行时 + 执行 async main"。在运行时里用 tokio::spawn 派生子任务,它们才真正并发执行。

#[tokio::main] async fn main() { let h = tokio::spawn(async { fetch().await; }); let _ = h.await; // 等待子任务完成(JoinHandle) }
spawn 的 Future 必须是 'static

tokio::spawn 要求任务不借用任何外部局部变量(得是 'static)。想传数据进去,用 move 把所有权移进闭包:tokio::spawn(async move { ... })。新手常因"借了外面的 &s"而编译失败,记住 move 进去就好。

别阻塞运行时线程:spawn_blocking

Tokio 多线程运行时靠少量 worker 线程驱动大量任务。在 async 里调同步阻塞(std 锁、耗时 CPU、文件 IO)会占住 worker,饿死同运行时的其他任务。重活要用 spawn_blocking 丢到专用阻塞线程池。

let res = tokio::task::spawn_blocking(|| { std::thread::sleep(std::time::Duration::from_secs(2)); // 同步阻塞,但在专用线程 42 }).await.unwrap();

论为什么 IO 一定要用异步版

标准库的文件/网络调用会阻塞线程,在 async 里是禁区。Tokio 提供了异步的 TcpStream、fs、net 等——它们把等待交还给运行时去调度别的任务,而不是干等。规则一句话:async 体内只做"等",重活和阻塞都外包。

Pin 与 Stream:自引用与流式

async 块生成的 Future 可能包含自引用(引用自己内部的局部变量),一旦内存地址动了引用就失效,所以必须 Pin 住地址才能安全轮询。绝大多数情况 .await 自动处理;Stream 则是"异步版的 Iterator",一边产生一边消费。

use tokio_stream::wrappers::ReceiverStream; use tokio::sync::mpsc; let (tx, rx) = mpsc::channel(8); let mut stream = ReceiverStream::new(rx); // Stream 包裹接收端 while let Some(v) = stream.next().await { // 逐个异步取 println!("got {}", v); }
Pin 别硬刚

99% 的场景你从不需要手写 Pin 或 unsafe 的 poll。只有写自定义 Future、实现流式适配器时才碰。理解"它为什么存在"即可,真要手动 Pin 再查文档——别为了显得高级去 Pin 一个普通值。

Send / Sync:哪些类型能跨线程、被共享

这两个 auto trait 是 Rust 并发安全的"宪法"。Send:类型能安全地转移到另一线程;Sync:类型能安全地被多线程共享引用。跨 .await 持有的数据必须 Send,否则编译器直接拒绝你 spawn。

// 跨 .await 持有的数据必须 Send // 典型非 Send:Rc(引用计数非原子)、带 !Send 字段的结构体 fn needs_send<T: Send>(_: T) {} // 需要共享可变状态跨线程 → 用 Arc<Mutex<T>>,它同时是 Send + Sync use std::sync::{Arc, Mutex}; let shared = Arc::new(Mutex::new(0)); let s2 = Arc::clone(&shared); // 克隆 Arc(只增引用计数),可移入新线程

论编译器替你拦下的并发 bug

在别的语言里,把非线程安全对象甩给新线程要等到运行时才炸;Rust 在编译期就因 Send 不满足而拒绝编译。这不是刁难,是把"数据竞争"从运行期错误变成编译期错误——这正是 Rust 敢说"无畏并发"的底气。

组合多个异步:join! 与 select!

真实逻辑常要"并发跑几个、等全部"或"谁先到用谁"。join! 并发执行多个 Future 并等全部完成;select! 竞速,哪个先 ready 就走哪个分支(常用于超时、取消)。

use tokio::join; // 并发跑两个,等全部完成,拿到两个结果 let (a, b) = join!(fetch_a(), fetch_b()); // select! 竞速:超时优先 tokio::select! { r = fetch() => println!("got {:?}", r), _ = sleep(Duration::from_secs(3)) => println!("timeout"), }

背压与流控:有界 channel 与取消传播

用 mpsc::channel(N) 创建有界通道,生产者发满 N 条后会被阻塞,天然形成背压,防止消费者被冲垮。tokio 的任务并非"结构化并发":drop 一个 JoinHandle 只是把任务脱离(detach),任务仍会在后台继续运行,并不会随之取消。要真正取消子任务,需使用 select! / JoinSet,或在任务内主动检查取消信号(如 tokio::task::yield_now 配合停止标志,或 tokio_util::sync::CancellationToken)。

论为什么无界 channel 是隐患

无界通道让生产者永远不阻塞,一旦消费者慢了,内存就一路涨到 OOM。生产里默认用有界通道 + 合理容量,把背压尽早暴露给上游。取消传播同理:用 select! 或 JoinSet 管理子任务,父退出时自动清理,避免僵尸任务空转。

论异步的本质是"协作式多任务"

Tokio 的 worker 在同一线程上切换不同任务,靠的是每个 Future 在 .await 点主动让出。所以"一个任务长时间不让出(阻塞/死循环)"会卡住整条线程——这反过来解释了为什么阻塞是大忌、为什么 spawn_blocking 要另起炉灶。理解协作式,才能写好异步。

3

unsafe 与 FFI:和别的语言握手

Unsafe Rust · FFI · C ABI · pyo3

Rust 的卖点是安全,但和系统/其他语言打交道必须越过边界。unsafe 不是"不安全代码",而是"安全责任由我承担"——编译器不再替你保证那几条最硬的规则。

unsafe 的边界:5 种超能力

进入 unsafe 块,你就获得了编译器不再审查的五种能力。清楚它们才能写对——大部分 Rust 代码一辈子不需要碰 unsafe。

超能力风险
解引用裸指针 *const/*mut可能悬垂、UB
调用 unsafe fn(如 FFI)函数本身不安全
读写可变静态变量全局可变 = 数据竞争
访问 union 字段读错类型 = UB
实现 unsafe trait违反约定 = 未定义行为

调用 C:extern "C" 与 ABI

用 extern "C" 声明外部 C 函数,Rust 按 C 的调用约定去链接。注意参数/返回类型要对应,且调用发生在 unsafe 块内(编译器没法验证 C 函数真的符合声明)。

extern "C" { fn abs(input: i32) -> i32; } let x = unsafe { abs(-3) }; // 调用约定走 C ABI // 真实项目用 bindgen 从 .h 自动生成这些声明,别手写

论bindgen 比手写声明靠谱

手敲 extern "C" 极易错一个类型就 UB。生产用 bindgen 读 C 头文件自动生成 Rust 绑定,类型、常量、宏都对齐。同理调 C++ 用 cbindgen 反向生成 C 头。让工具保住边界正确性。

pyo3:把 Rust 写成 Python 扩展

性能热点常从 Python 下沉到 Rust。用 pyo3 把 Rust 函数编译成 .so,Python 直接 import 调用,既保留 Python 生态又拿到 Rust 速度——正是 LLM 推理、数据处理里的常见组合。

use pyo3::prelude::*; #[pyfunction] fn fast_compute(n: i64) -> i64 { n * 2 } // 编译成扩展,给 Python 调用 #[pymodule] fn myext(m: &Bound<PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(fast_compute, m)?)?; Ok(()) }
跨语言边界的 GIL 与异常

持有 Python 对象(Py<T>)跨越 await 点时要注意 GIL;Rust 的 panic 不能泄漏进 Python,要让它转成 Python 异常(pyo3 默认会)。另外跨边界传大数据用零拷贝视图(如 PyBytes),别无脑 clone 整个数组。

不安全边界纪律:封装成安全 API

unsafe 块要尽量小、加注释说明"为什么这里安全",把不安全封装成安全的对外 API。外部永远碰不到裸指针,所有安全保证由你的封装负责。

// 把 unsafe 包成安全的函数,外部无需 unsafe 即可调用 pub fn safe_abs(x: i32) -> i32 { unsafe { abs(x) } // 为何安全:abs 是纯函数,无副作用、不触碰非法内存 }

论unsafe 的最小化原则

每多一行 unsafe,就多一块"编译器不管、靠你头铁保证"的雷区。业界做法是把 unsafe 压到最小、集中、配详尽的 Safety 注释,并用 miri 这类 UB 检测器在 CI 里跑。能不用 unsafe 解决的,绝不上。

内存布局与 repr(C)

Rust 默认对结构体字段重排以优化对齐,这在纯 Rust 里无所谓,但跨 FFI 时必须固定布局。#[repr(C)] 让字段顺序/对齐与 C 一致,对方才能正确解读同一块内存。

#[repr(C)] struct Point { x: f64, y: f64 } // 字段顺序/对齐与 C 一致,FFI 传递才正确 // 跨语言传结构体前务必标 repr(C),否则读到错位字段 = 静默错误

FFI 的所有权边界:谁分配谁释放

跨语言传数据最易错的是"内存归谁管"。C 侧 malloc 的内存应由 C 侧 free;Rust 分配的 Box 转成裸指针交出去时,必须提供对应的 Rust 释放函数,绝不能让对方用错释放器——跨分配器的 free 是未定义行为。

场景正确做法
Rust 分配 → C 使用提供 Rust 侧的 free 函数,C 回调它释放
C 分配 → Rust 使用Rust 用完后调用 C 的 free,绝不 Rust 的 drop
只读字符串传 C用 CString 并以 as_ptr() 传,明确生命周期

把 unsafe 藏起来的一个真实例子

设想封装一个"从裸指针读定长数组"的安全函数:unsafe 块里只做解引用,并对"空指针 / 长度"做前置校验,把"为什么安全"写进注释。外部调用方永远拿不到裸指针,于是整个不安全面被压缩到几行。

论安全封装的验收标准

好的封装满足:① unsafe 块尽量小且每段都有 Safety 注释;② 不变量在入口被检查(非空、长度合法、对齐正确);③ 对外 API 无需 unsafe 即可正确调用;④ 配合 miri 在 CI 跑 UB 检测。做到这四点,unsafe 就只是"可控的小火苗"。

unsafe 不是"性能开关"

新手常误以为标了 unsafe 会变快——完全不是。unsafe 不改变生成的机器码,它只是"关掉编译器的某些检查"。该慢还是慢,风险却多了。需要性能时用 perf/火焰图找热点,而不是往代码上糊 unsafe。

4

宏与 trait:声明宏、过程宏、trait 系统设计

macro_rules! · proc-macro · trait · zero-cost

Rust 的"魔法"大半来自两样编译期能力:宏(写代码的代码)和 trait(定义行为契约)。这一章讲清它们怎么用、怎么不滥用,以及"零成本抽象"到底省了什么。

声明宏:macro_rules! 模式匹配

声明宏是"模式匹配式"的代码模板:按你写的规则匹配输入 token,展开成代码。适合消除重复、做小 DSL。它的能力比过程宏弱,但更安全、更易读。

macro_rules! vec_of { ($($x:expr),* $(,)?) => { vec![$($x),*] }; } let v = vec_of![1, 2, 3]; // 展开成 vec![1, 2, 3]
别为了炫技写宏

宏让代码难读、报错信息指向展开后的代码、调试抓狂。能用函数/泛型解决的,不要上宏。声明宏留给"重复代码模板",过程宏留给"机械生成样板"(序列化、ORM 映射、CLI 解析),且优先复用 serde/clap 等成熟库。

过程宏:derive / attribute / function-like

过程宏是操作 AST 的"真·编译器插件",分三类。最常用的是 derive 宏——你写的 #[derive(Serialize)] 就是 serde 提供的过程宏,编译期自动生成 Serialize/Deserialize 实现。

// derive 宏在编译期生成样板(如 serde) #[derive(Debug, Clone, Serialize, Deserialize)] struct User { name: String, age: u32 } // 也可写自定义 derive(独立 proc-macro crate)生成 boilerplate // attribute 宏如 #[tokio::main]、function-like 如 sqlx::query!

论为什么过程宏要单独成 crate

过程宏必须在独立的 proc-macro = true crate 里实现,因为编译器要在最早阶段加载它去展开代码。这也意味着宏 crate 不能依赖你的业务 crate(会循环)。设计库时把 derive 拆成 xxx + xxx-derive 两个包是惯例。

trait 系统设计:定义行为契约

trait 是 Rust 的"接口"——定义一组方法签名,任何类型都可以实现。优秀的 trait 设计把"能做什么"抽象出来,让函数对行为而非具体类型编程,是解耦的核心。

trait Repository<T> { fn get(&self, id: u64) -> Option<T>; fn save(&mut self, item: T); } struct MemRepo<T> { items: std::collections::HashMap<u64, T> } impl<T> Repository<T> for MemRepo<T> { fn get(&self, id: u64) -> Option<T> { self.items.get(&id).cloned() } fn save(&mut self, item: T) { /* ... */ } }

零成本抽象:泛型单态化

"零成本抽象"指:你用高级写法(泛型、trait、迭代器)得到的运行时性能,和不写抽象、手写的等价代码完全一样——抽象不收运行时税。

fn max<T: Ord>(a: T, b: T) -> T { if a > b { a } else { b } } // 编译期对 i32/f64/... 各生成一份专用代码(单态化) // 比等价的虚函数调用更快:没有 vtable 间接跳转

论单态化 vs 动态分发

泛型默认是静态分发(编译期展开,最快、但代码体积变大)。若想缩小体积、用同一份代码处理多种类型,可走 dyn Trait 动态分发(运行时查 vtable,略有开销)。默认用泛型,性能敏感且类型有限时它最香;类型极多、体积敏感时再考虑 dyn。

关联类型 / 默认方法 / 运算符重载

trait 还能携带关联类型(如 Iterator::Item)、提供默认实现(实现者按需覆盖)、以及通过标准库 trait 重载运算符(+、== 等),让自定义类型用起来像内建类型。

use std::ops::Add; #[derive(Clone, Copy)] struct Vec2 { x: f64, y: f64 } impl Add for Vec2 { type Output = Vec2; // 关联类型 fn add(self, o: Vec2) -> Vec2 { Vec2 { x: self.x + o.x, y: self.y + o.y } } } // 现在 Vec2 + Vec2 直接可用

trait object:dyn Trait 与对象安全

当你需要在运行时存"多种实现同一 trait 的类型"(如一个集合里塞不同形状的控件),用 Box<dyn Trait> 做动态分发。但只有"对象安全"的 trait 才能变 dyn——简单说,不能有泛型方法、Self 不能出现在返回值等。

方式分发代价
泛型 <T: Trait>静态(编译期展开)代码体积大,最快
Box<dyn Trait>动态(vtable)稍慢、灵活、体积小

孤儿规则与 sealed trait

Rust 的孤儿规则规定:实现 trait 时,trait 或类型至少有一个定义在当前 crate——这防止两个库对同一类型实现同一 trait 产生冲突。想禁止别人给你的 trait 加实现(稳定 API 边界),可用 sealed trait 模式(把 supertrait 设为私有模块里的 trait)。

论为什么孤儿规则保护你

没有它,依赖 A 和依赖 B 都能给 String 实现 Display,编译器该听谁的?孤儿规则把"实现权"绑定到定义方,杜绝这种歧义,也让标准库的 trait 不会被随意污染。设计公共库时,靠它守住 API 稳定性。

论宏和 trait 是 Rust 表达力的两极

宏负责"编译期生成代码"(消灭样板),trait 负责"静态/运行时的行为抽象"(定义契约)。二者配合:用 derive 宏(过程宏)自动生成 trait 实现,既少了手写样板,又保住了类型安全。serde、tokio、axum 全是这套路。

5

Web 服务进阶:Axum、tonic(gRPC)、中间件、错误处理

Axum · tonic · middleware · error handling

基础篇讲了 Axum 路由。生产级还要补:共享状态怎么注入、横切逻辑怎么用中间件、内部服务间用什么通信、以及错误怎么统一成结构化响应。

Axum 路由与 Handler

Axum 的 Handler 就是个异步函数,参数用"提取器(extractor)"声明要什么(路径参数、查询、JSON 体)。路由用 Router 把路径与方法绑到 Handler。

use axum::{routing::get, Router}; async fn hello() -> &'static str { "hi" } async fn show(Path(id): Path<u32>) -> String { format!("user {}", id) } let app = Router::new() .route("/", get(hello)) .route("/u/:id", get(show)); // Path 提取器拿 :id

共享状态与中间件

数据库连接、配置这类共享状态用 with_state 注入;鉴权、日志、耗时统计这类横切逻辑用中间件(layer)包裹,不必污染每个 Handler。

use axum::{extract::State, middleware}; async fn handler(State(db): State<Db>) -> String { // 从 state 取出共享 Db format!("rows={}", db.count()) } let app = Router::new() .route("/u/:id", get(handler)) .with_state(db) .layer(middleware::from_fn(auth_mw)); // 全局鉴权中间件

论中间件顺序 = 洋葱模型

Layer 是从外到内一层层包的:请求先经过最外层(如日志/追踪),再到鉴权,最后到 Handler;响应再原路返回。顺序影响行为——鉴权中间件要放在"真正处理"之前,但放在"追踪/日志"之后以便记录每次调用。用 layer(全路由)还是 route_layer(单路由)要分清。

gRPC(tonic):内部服务间高吞吐 RPC

对外用 REST+JSON,内部服务间用 gRPC 更优:基于 HTTP/2 + Protobuf,强类型、包体小、支持流式。用 tonic-build 从 .proto 生成客户端/服务端桩代码。

tonic::include_proto!("greet"); // 生成 Greeter trait 与消息类型 struct GreeterSvc; #[tonic::async_trait] impl Greeter for GreeterSvc { async fn say(&self, req: Request<HelloReq>) -> Result<Response<HelloRes>, Status> { let name = req.into_inner().name; Ok(Response::new(HelloRes { msg: format!("hi {}", name) })) } }
gRPC 不是银弹

gRPC 对浏览器不友好(需 grpc-web 网关),调试也没 JSON 直观。别为了"先进"把对外公开 API 全改成 gRPC。经典架构:外部 REST 网关,内部 gRPC 通信,两者并存互补。

REST 还是 gRPC:选型对照

维度REST + JSONgRPC
适用对外 / 浏览器 / 第三方内部服务间高吞吐
类型弱(JSON 自由结构)强(Protobuf schema)
性能文本解析,较重二进制,体积小、快
流式靠 SSE/WebSocket 补原生支持

统一错误处理:让 ? 自动转 HTTP 响应

Handler 里用 ? 传播错误很爽,但错误类型要能转成 HTTP 响应。做法是定义自己的错误枚举(thiserror),再为它实现 IntoResponse,于是一行 ? 就变成带状态码的 JSON。

#[derive(thiserror::Error, Debug)] enum AppError { #[error("not found")] NotFound, #[error("db error: {0}")] Db(#[from] sqlx::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (code, msg) = match self { AppError::NotFound => (StatusCode::NOT_FOUND, "not found"), AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db error"), }; (code, msg).into_response() } }

论错误分层:库用 thiserror,应用用 anyhow

thiserror 给库定义稳定的错误枚举(调用方能 match 出分支);anyhow 在应用层用 ? 链式传播、保留上下文,不必枚举每种失败。Handler 这一层用 thiserror 实现 IntoResponse,业务内部可用 anyhow,到边界再转成 AppError。

自动 API 文档:utoipa 生成 OpenAPI

手写接口文档很快过时。用 utoipa 给类型加注解,编译期生成 OpenAPI/Swagger 文档,配合 utoipa-swagger-ui 直接在浏览器里可调试。文档与代码同源,改代码文档自动跟着变。

论文档即类型

既然 Handler 的入参/出参都是 Rust 类型,文档本就不该另写一份。从类型派生 schema,既保证"文档与实际响应一致",也省去维护成本。对外公开 API 时,这份 OpenAPI 还能直接喂给前端代码生成器。

健康检查与优雅关闭

生产服务要能被编排系统正确管理:暴露 /healthz 让 K8s 探活,收到终止信号时停止接新请求、把在途请求处理完再退出(graceful shutdown)。Axum 的 with_graceful_shutdown 原生支持。

端点/信号作用
/healthz存活探针,挂了就重启容器
/readyz就绪探针,依赖没好就不接流量
SIGTERM触发优雅关闭,处理完在途请求

论优雅关闭是"对用户负责"

接到 SIGTERM 立刻杀进程,正在处理的请求会失败、客户端拿到 5xx。优雅关闭先摘掉流量(不再接新请求),等在途请求跑完或超时才退出——滚动发布时用户几乎无感。配合 readiness 探针,K8s 会在你退出前就把新流量导向新副本。

6

可观测 / 测试 / 基准、嵌入式与 no_std、WASM

tracing · test · benchmark · no_std · WASM

能编译只是开始,能测、能看、能优化、能跑在奇怪的地方才是生产级。这一章把工程化尾巴补齐,并指几条 Rust 特别能打的方向。

tracing:结构化、带 span 的可观测日志

tracing 比 println! 强在"span"——一段有起止的逻辑区间,区间内所有事件自动带上上下文(如 user_id)。可导出到 OpenTelemetry / 日志系统,和指标、追踪三件套对齐。

use tracing::{info_span, Instrument}; let span = info_span!("handle", user_id); async { // 这段逻辑里的所有日志都带 user_id tracing::info!("processing order"); }.instrument(span).await; // 进入 span

论日志 / 指标 / 追踪 各管一摊

日志(tracing 结构化):事后查细节。指标(metrics / Prometheus):QPS、耗时分位、错误率,配告警。追踪(OpenTelemetry):一次跨服务请求全链路耗时。三者互补,缺一个排障就少一只眼。Rust 的 tracing 生态能一站式打通这三样。

测试:单元、集成与 async 测试

Rust 内置测试框架,#[test] 标单元测试、#[tokio::test] 标异步测试、tests/ 目录放集成测试(只能调用公开 API,像外部用户一样)。

#[cfg(test)] mod tests { use super::*; #[test] fn it_adds() { assert_eq!(add(2, 3), 5); } #[tokio::test] async fn it_fetches() { let r = fetch().await; assert!(r.is_ok()); } }
异步测试别忘了 tokio::test

直接在 #[test] 里 .await 会编译失败(普通测试函数不是 async)。要么用 #[tokio::test],要么在测试里 tokio::runtime::Runtime::new().unwrap().block_on(...)。Mock 外部依赖用 mockall 或手写 trait 的假实现,别在单测里真连数据库。

基准测试与火焰图:用数据定位热点

性能不能靠"我觉得"。criterion 做统计严谨的微基准(多次采样、给出置信区间);配合 perf / 火焰图定位 CPU 热点函数。

use criterion::{criterion_group, criterion_main, Criterion}; fn bench(c: &mut Criterion) { c.bench_function("parse", |b| b.iter(|| parse_input(black_box(DATA)))); } criterion_group!(benches, bench); criterion_main!(benches);

论先测再优化,别微优化错地方

开发者对"哪慢"的直觉常常错。先用基准/火焰图找到真正的热点(往往是某次不该发生的克隆、某处同步阻塞、某个 O(n²)),再针对性改。Rust 默认就快,大部分瓶颈在算法与 IO,不在语言——别把时间花在纠结 & 还是 &mut 的微优化上。

嵌入式与 no_std:在没有 OS 的芯片上跑

Rust 能编译掉标准库(#![no_std]),直接跑在 MCU 上——没有堆、没有 OS、靠静态内存和中断。这正是它进军嵌入式/IoT 的优势:内存安全又不依赖 GC。

// lib.rs 顶部:去掉标准库,只留 core + alloc #![no_std] extern crate alloc; // 在 MCU 上:用 #[entry] 定义复位向量,操作寄存器与外设 // 没有 main、没有堆,资源全靠静态分配与中断驱动
no_std 下没有 String / Vec

标准库的 String、Vec 依赖堆分配器,no_std 默认没有。要使用得引入 alloc crate 并提供全局分配器(如 cortex-m-rt 的堆)。写固件时优先用固定大小数组/静态缓冲,避免动态分配带来的不确定性。

WASM 方向:把 Rust 带到浏览器与边缘

编译到 WebAssembly 后,Rust 能跑在浏览器(替代/加速 JS)和边缘运行时(Cloudflare Workers)。wasm-bindgen 打通 Rust 与 JS 的互调,Leptos / Yew 让你用 Rust 写整页前端。

use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn greet(name: &str) -> String { format!("hi {}", name) } // 构建:wasm-pack build → 生成 JS 包装,前端 import 调用 // 框架:Leptos / Yew 写整页前端,包体远小于 JS 框架

论为什么 Rust+WASM 值得关注

对计算密集的前端场景(图像/音频处理、加密、游戏、数据可视化),JS 太慢、手写 C+WASM 又难维护,Rust+WASM 兼顾性能与安全性:编译产物体积小、内存安全、类型友好。配合你的 MJ Nexus OS 这类本地优先应用,浏览器里的重活完全可以用 Rust 下沉。

日志聚合与 Metrics 接入

tracing 的事件要落到一个后端才有用。用 tracing-subscriber 同时接:控制台(开发)、OTLP(导出到 OpenTelemetry Collector → 进 Jaeger/Loki/Prometheus)。业务指标用 metrics crate 定义计数器/直方图,暴露给 Prometheus 抓取。

论可观测三件套的落地链路

追踪经 OTLP 进分布式追踪系统;日志带 trace_id 进 Loki/ES;指标进 Prometheus 配 Grafana 与告警。三者用同一个 trace_id 关联:告警里点开指标异常 → 下钻到具体 trace → 看那条 trace 的日志。这才是"能定位根因"的闭环。

CI 质量门禁:fmt / clippy / audit

Rust 的工具链让"提交即检查"很顺:cargo fmt --check 保证格式统一,cargo clippy 抓常见反模式,cargo audit 扫依赖漏洞,cargo test 跑全部测试。把它们接进 CI,不通过不让合入。

命令拦什么
cargo fmt --check格式不一致,diff 噪声
cargo clippy可疑写法、性能/正确性问题
cargo audit依赖中的已知 CVE
cargo test回归、行为破坏
结
本页要点

所有权靠移动 + 借用(共享 XOR 可变)+ 生命周期标注 + Drop/RAII 在编译期消灭悬垂与数据竞争;RefCell/Mutex 提供内部可变性(单线程运行期检查 / 跨线程加锁)。Tokio 用 worker 驱动 Future,async 内别阻塞(重活 spawn_blocking)、跨 .await 数据须 Send、Pin 只在写自定义 Future/Stream 时碰;Send/Sync 是并发安全宪法。unsafe 是"责任转移",尽量缩小并封装成安全 API,FFI(C/pyo3)靠 repr(C) 与 bindgen 保边界。宏分声明/过程两类,trait 定义行为契约,泛型单态化实现零成本抽象。Web 进阶用 Axum 提取器+with_state+中间件、tonic 做内部 gRPC、统一错误 IntoResponse。可观测用 tracing+指标+追踪;测试/基准靠内置框架与 criterion;no_std 进嵌入式、WASM 进浏览器与边缘。

自测 · 看你是否真懂

1.在 Tokio 的 async 里调用同步阻塞函数有什么后果?怎么避免?

查看答案

会占住 worker 线程,饿死同运行时的其他任务。重活应用 spawn_blocking 丢到专用阻塞线程池;IO 一律用异步版本(tokio 的 TcpStream/fs 等)。

2.unsafe 关键字意味着"这段代码有 bug / 不安全"吗?

查看答案

不意味"坏代码",而是"编译器不再保证、安全责任转移给开发者"(解引用裸指针、调 unsafe fn、可变静态等 5 种超能力)。应把 unsafe 块尽量缩小、注释为何安全,对外封装成安全 API。

3.为什么 &mut T 和 &T 不能同时存在?这解决了什么问题?

查看答案

"共享 XOR 可变"规则:同一数据要么被多个不可变借用、要么被唯一可变借用,二者互斥。它从根上消灭了"一边读一边写"导致的数据竞争与悬垂,是 Rust 无畏并发的基石。

4.内部服务间通信,什么时候选 gRPC 而非 REST?

查看答案

高吞吐、强类型、需要流式或低延迟的内部 RPC 选 gRPC(HTTP/2 + Protobuf);对外/浏览器仍用 REST+JSON 更方便。两者可并存:外部 REST 网关,内部 gRPC 通信。

5.tokio::spawn 常因"Future 不是 Send"而编译失败,为什么?怎么修?

查看答案

spawn 要求任务可跨线程(Send)。若闭包借用了非 Send 的局部变量(如 Rc),就会失败。修法:用 move 把所有权移入闭包;需要共享跨线程可变状态时改用 Arc<Mutex<T>>(它同时是 Send+Sync)。

下一步往哪走

路学完 Rust 进阶之后

① 看系统设计 / DevOps 页:Rust 服务的高可用、可观测、容器化正是这些主题的落地。

② 结合你的 MJ Nexus OS:Tauri 2 的 Rust 后端、本地 LLM 推理(llama.cpp 绑定)、浏览器里的重活用 WASM——正是 FFI、异步、no_std/WASM 的主场。

③ 动手:用 Axum + SQLx + tracing 写一个带可观测的 Rust 微服务,并把一个 Python 性能热点用 pyo3 下沉成扩展。

④ 读源码:挑 serde(过程宏)或 tokio(异步运行时)读一轮,理解"魔法"背后的真实实现。