Rust常见点
Rust 面试考点:从所有权到异步
本文按考察频率从高到低,把 Rust 面试的常见考点分成九类,每个考点给出结论、原理、代码和常见追问。写过 C++ 的读者可以重点看每题里和 C++ 的对比,第 11 节把两种语言的异同汇总成了对照表。
| 项 | 说明 |
|---|---|
| 版本基准 | 稳定版 Rust,2024 edition。依赖版本的特性标注了稳定的版本号 |
| 代码 | 全部未实测,本机没有 Rust 工具链。可以粘到 https://play.rust-lang.org/ 运行 |
| 类型大小 | 按 64 位平台。标注「实现相关」的是当前编译器的行为,语言没有保证 |
目录
- 所有权、借用与生命周期
- 智能指针与内部可变性
- trait 与泛型
- 错误处理
- 并发
- 异步与 tokio
- 内存布局与 unsafe
- 宏、集合、工程与常用库
- 工程场景题
- 高频题索引
- Rust 与 C++ 对照总表
1. 所有权、借用与生命周期
1.1 所有权的三条规则是什么?
- 每个值都有一个所有者,即持有它的变量。
- 同一时刻只有一个所有者。
- 所有者离开作用域时,值被释放。
// b 离开作用域,字符串的堆内存被释放
这三条规则让每块内存在编译期就有唯一确定的释放点,所以不需要垃圾回收,也不会重复释放。
和 C++ 的关系:C++ 的 RAII 是同一个思想,但它是一种惯用法,可以绕开。Rust 把它做成了语言规则,安全代码里绕不开。
1.2 移动、拷贝、克隆有什么区别?
| 移动 | 拷贝 Copy |
克隆 Clone |
|
|---|---|---|---|
| 触发方式 | 赋值、传参、返回的默认行为 | 类型实现了 Copy 时,赋值自动拷贝 |
显式调用 .clone() |
| 实现 | 按位拷贝,源变量失效 | 按位拷贝,源变量仍可用 | 自定义,通常是深拷贝 |
| 可否自定义 | 不可以 | 不可以,只能派生 | 可以 |
| 典型类型 | String、Vec、Box |
整数、浮点、bool、char、只含 Copy 字段的结构体 |
几乎所有类型 |
两个要点:
- 移动是按位拷贝加源失效。没有移动构造函数,移动不会失败,也不会调用任何用户代码。源变量被编译器标记为不可用,之后也不会对它调用析构。
Copy和Drop不能同时实现,否则报错 E0184。理由是:能按位拷贝的类型,拷贝出来的两份如果都要析构,就会重复释放同一资源。
和 C++ 的对比:
| 方面 | C++ | Rust |
|---|---|---|
| 默认行为 | 拷贝 | 移动 |
| 移动后的源对象 | 仍然存在,处于「有效但未指定」的状态,析构照常执行 | 不可再使用,不会被析构 |
| 移动构造函数 | 要写,要把源置成可安全析构的状态 | 不存在 |
追问:条件移动之后怎么知道要不要析构? 编译器为这个变量生成一个运行时的「析构标志」,在可能被移走的分支里清掉它,作用域结束时按标志决定是否析构。
let s = Stringfrom;
if cond // 只在这个分支被移走
// 作用域结束:按析构标志决定是否释放 s
1.3 借用和引用是什么关系?借用规则是什么?为什么这样设计?
引用是一个值,借用是创建这个值的动作以及它存活的这段时间。 写 &x 就是发起一次借用,得到一个引用;只要这个引用还会被用到,借用就没有结束,x 就处在「被借出」的状态。
| 概念 | 是什么 | 例子 |
|---|---|---|
| 引用 | 一种类型和它的值:一个不为空、保证指向有效数据的指针 | &T、&mut T,大小是一个指针 |
| 借用 | 创建引用的动作,以及从创建到最后一次使用之间的这段区间 | let r = &x; 开始借用,r 最后一次被用到时借用结束 |
| 借用检查器 | 编译器里检查各段借用之间是否冲突的部分 | 报错 E0502、E0505 的就是它 |
两种借用各产生一种引用:
| 借用 | 写法 | 得到的引用 | 同时可以有几个 |
|---|---|---|---|
| 共享借用 | &x |
&T,叫共享引用,也常叫不可变引用 |
任意多个 |
| 可变借用 | &mut x |
&mut T,叫可变引用,本质是独占引用 |
只能一个 |
借用期间,原来的所有者也受限制:
| 借用状态 | 所有者能读吗 | 能改吗 | 能移走吗 |
|---|---|---|---|
| 存在共享借用 | 能 | 不能 | 不能,报错 E0505 |
| 存在可变借用 | 不能 | 不能,只能通过那个 &mut 改 |
不能 |
let mut s = Stringfrom;
let r = &mut s; // 可变借用开始
println!; // 编译错误 E0502:s 已被可变借用,所有者自己也不能读
r.push; // r 在这里最后一次使用,借用到此结束
借用不一定要显式写 &,下面这些都会隐式地发起借用:
| 写法 | 实际发生的借用 |
|---|---|
v.push(1) |
方法调用的自动引用,等价于 Vec::push(&mut v, 1) |
for x in &v |
对 v 的共享借用,持续整个循环 |
takes_str(&s),s 是 String |
先借用得到 &String,再经解引用强制转换变成 &str,见 3.7 节 |
let Some(ref name) = opt |
模式里的 ref 借用而不是移走字段 |
有的借用不产生引用。RefCell::borrow_mut() 返回的是一个守卫 RefMut<T>,同样遵守「一个可变或多个共享」的规则,只是检查从编译期挪到了运行时,见 2.4 节。
Rust 的引用和 C++ 的引用名字相同,性质差别很大:
| 方面 | C++ 的 T& |
Rust 的 &T |
|---|---|---|
| 本质 | 别名,不是独立的对象 | 一个独立的值,本质是指针 |
| 能否指向别处 | 绑定后不能改 | 变量声明为 mut 时可以重新赋值,指向另一个值 |
| 能否放进容器 | 不能,std::vector<int&> 不合法 |
能,Vec<&str>,只要标注好生命周期 |
| 可空 | 不可空 | 不可空;需要可空时用 Option<&T>,大小不变 |
| 悬垂 | 可能,编译器不检查 | 不可能,编译期检查 |
| 更接近 C++ 的 | 无 | 非空的 const T* 加编译期的存活检查;&mut T 还额外保证独占,类似 C 的 restrict |
借用规则:同一时刻,对一个值要么只有一个可变引用 &mut T,要么有任意多个共享引用 &T,二者不能同时存在。并且引用不能比它指向的值活得长。
这条规则就是「可变不共享,共享不可变」,它在编译期消灭了两类问题:
| 问题 | C++ 里的样子 | Rust 为什么编译不过 |
|---|---|---|
| 迭代器失效 | 遍历 vector 时 push_back,迭代器悬空 |
遍历持有 &v,push 需要 &mut v,冲突 |
| 数据竞争 | 两个线程同时写同一块内存 | 跨线程共享只能是 &T,要修改必须经过锁或原子类型 |
let mut v = vec!;
for x in &v
借用的结束点是引用最后一次被使用的地方,而不是作用域的末尾。这叫非词法生命周期,2018 edition 起生效。
let mut s = Stringfrom;
let r = &s;
println!; // r 最后一次使用,借用到此结束
s.push; // 可以,不冲突
1.4 生命周期标注解决什么问题?
它告诉编译器多个引用的存活时间之间的约束关系。标注本身不会延长或缩短任何值的存活时间。
'a 的含义是:返回的引用,活得不会比 x 和 y 中较短的那个更长。编译器在调用处检查这个约束:
let a = Stringfrom;
let r;
// b 被释放
println!; // 编译错误 E0597:b 活得不够长
不写标注时,编译器不知道返回值借的是 x 还是 y,无法检查调用方,所以要求写出来。
1.5 生命周期的省略规则是什么?
大部分函数不用写生命周期,是因为编译器按三条规则自动补全:
| 规则 | 内容 | 例子 |
|---|---|---|
| 一 | 每个输入引用各自获得一个独立的生命周期参数 | fn f(a: &str, b: &str) 等价于 fn f<'a, 'b>(a: &'a str, b: &'b str) |
| 二 | 只有一个输入生命周期时,它被赋给所有输出引用 | fn first(s: &str) -> &str 返回值借自 s |
| 三 | 方法有 &self 或 &mut self 时,self 的生命周期被赋给所有输出引用 |
fn name(&self, key: &str) -> &str 返回值借自 self |
三条规则用完仍然无法确定输出的生命周期时,编译器报错,要求手写。longest 就属于这种情况:两个输入,没有 self。
1.6 'static 是什么意思?
要区分两种用法:
| 写法 | 含义 |
|---|---|
&'static T |
引用指向的数据活到程序结束,比如字符串字面量、静态变量 |
T: 'static |
类型 T 不含任何非 'static 的引用 |
第二种最容易误解。String、Vec<u8>、i32 都满足 T: 'static,因为它们自己拥有数据,不借用任何东西。这不表示值要活到程序结束,它随时可以被释放。
典型场景是 std::thread::spawn:
闭包要求 'static,是因为新线程可能比当前函数活得更久,闭包不能借用当前栈上的数据。解决办法是用 move 把数据的所有权交给闭包,或者用 5.5 节的作用域线程。
1.7 String、&str、&String 有什么区别?
| 类型 | 是什么 | 布局 | 大小 |
|---|---|---|---|
String |
拥有的、可增长的 UTF-8 字符串 | 堆指针、容量、长度 | 24 字节,实现相关 |
&str |
借用的字符串切片 | 指针、长度 | 16 字节 |
&String |
对 String 的引用 |
一个指针 | 8 字节 |
String &str
┌─────┬─────┬─────┐ ┌─────┬─────┐
│ ptr │ cap │ len │ │ ptr │ len │
└──┬──┴─────┴─────┘ └──┬──┴─────┘
▼ ▼
堆上的 UTF-8 字节 任何地方的 UTF-8 字节:堆、栈、静态区
使用原则:
- 函数参数用
&str。传&String会通过解引用强制转换自动变成&str,传字面量也可以。 - 需要拥有或修改时用
String。 - 不要写
&String作为参数类型,它比&str更受限,没有任何好处。
三个容易出错的地方:
s.len()是字节数,不是字符数。字符数用s.chars().count()。- 不能用
s[0]取字符。UTF-8 是变长编码,按下标取字符不是 O(1),所以标准库不提供。 - 按字节范围切片
&s[0..3]时,如果边界落在一个多字节字符中间,运行时 panic。
和 C++ 的对应关系:String 对应 std::string,&str 对应 std::string_view。区别是 &str 保证内容是合法 UTF-8,而且有借用检查,不会悬垂。
1.8 借用检查器拒绝了逻辑上正确的代码,怎么办?
借用检查是保守的,有些正确的程序也通不过。常见的几种情况和解法:
| 情况 | 解法 |
|---|---|
| 同时可变借用结构体的两个字段,经过方法调用时报错 | 直接访问字段,编译器能分开追踪不同字段;或者把方法拆成只借用需要的字段 |
| 同时可变借用切片的两部分 | split_at_mut、chunks_mut |
| 查一个键,不存在再插入 | 用 HashMap 的 entry 接口,一次借用完成 |
| 图、双向链表这类互相引用的结构 | 用下标代替引用,节点放在 Vec 里;或者用 Rc 加 RefCell |
| 借用的范围太大 | 缩小作用域,或者先取出需要的值再修改 |
| 实在绕不开 | .clone(),用一次拷贝换代码简单 |
let mut v = ;
let = v.split_at_mut; // 两段不重叠的 &mut
left += right;
下一代借用检查器 Polonius 能接受更多正确的程序,目前还没有默认启用。
2. 智能指针与内部可变性
2.1 Box<T> 什么时候用?
Box<T> 把值放到堆上,独占所有权,相当于 C++ 的 std::unique_ptr<T>。四种典型用途:
| 用途 | 原因 | 例子 |
|---|---|---|
| 递归类型 | 类型大小必须在编译期确定,递归类型直接嵌套是无限大 | enum List { Cons(i32, Box<List>), Nil } |
| trait 对象 | dyn Trait 大小未知,必须放在指针后面 |
Vec<Box<dyn Shape>> |
| 大的值 | 移动时只拷贝一个指针,而不是整块数据 | 几 KB 的结构体 |
| 转移所有权到不确定的生命周期 | 堆上的值不依赖当前栈帧 | 构建树、跨线程传递 |
和 unique_ptr 的一个区别:Box<T> 不可能为空。需要可空时用 Option<Box<T>>,它和 Box<T> 一样大,见 7.2 节。
2.2 Rc 和 Arc 有什么区别?为什么 Rc 不能跨线程?
两者都是引用计数的共享所有权,相当于 C++ 的 std::shared_ptr。区别只在计数的方式:
Rc<T> |
Arc<T> |
|
|---|---|---|
| 引用计数 | 普通整数加减 | 原子操作 |
| 跨线程 | 不能,既不是 Send 也不是 Sync |
能,要求 T: Send + Sync |
| 开销 | 低 | 每次克隆和析构都有一次原子操作 |
Rc 不能跨线程的原因:两个线程同时克隆同一个 Rc,非原子的加一可能丢失一次,计数比实际少;之后某次析构把计数减到零,内存被释放,而另一个线程还在用。编译器通过 Rc 不实现 Send 来阻止这种情况,把 Rc 传给 thread::spawn 编译不过。
C++ 的 shared_ptr 只有一种,计数总是原子的,单线程场景也要付这个开销。Rust 把选择交给使用者,用错了编译器会拦住。
两点注意:
Rc和Arc提供的是共享的只读访问。要修改内部的值,需要配合RefCell或Mutex。- 克隆
Arc只增加计数,不拷贝数据。习惯写Arc::clone(&a)而不是a.clone(),读代码时一眼能看出不是深拷贝。
2.3 循环引用怎么处理?
用 Weak<T>。Weak 不增加强引用计数,不阻止值被释放;使用前调用 upgrade() 尝试得到一个 Rc,值已释放时返回 None。
典型场景是树的父指针:父节点用 Rc 持有子节点,子节点用 Weak 指回父节点。
use RefCell;
use ;
循环引用造成的是内存泄漏,在 Rust 里这不算不安全。Rust 的安全保证覆盖释放后使用、重复释放、数据竞争,不覆盖泄漏。
2.4 什么是内部可变性?Cell 和 RefCell 怎么用?
内部可变性指通过一个不可变引用 &T 修改值的内部状态。它是借用规则的一个受控的例外,所有实现的底层都是 UnsafeCell<T>,这是编译器唯一允许「通过共享引用修改」的原语。
| 类型 | 机制 | 适用 | 违反规则时 |
|---|---|---|---|
Cell<T> |
只能整体取出或替换,不给出内部引用 | Copy 类型,比如计数器、标志位 |
不会违反,因为从不给出引用 |
RefCell<T> |
运行时记录借用状态,borrow() 和 borrow_mut() 返回守卫 |
需要拿到内部引用的场景 | panic;用 try_borrow_mut 可以得到错误而不是 panic |
use RefCell;
let c = new;
let r1 = c.borrow; // 不可变借用计数 +1
let r2 = c.borrow_mut; // panic:已有不可变借用
RefCell 把借用检查从编译期挪到了运行时,代价是每次借用有一次计数检查,以及可能的 panic。只在编译期确实无法证明正确时使用,比如 Rc<RefCell<T>> 组成的图结构。
2.5 RefCell 和 Mutex 有什么区别?
RefCell<T> |
Mutex<T> |
|
|---|---|---|
| 线程 | 单线程,不是 Sync |
多线程 |
| 冲突时 | panic | 阻塞等待 |
| 读写区分 | 多读或一写 | 互斥,读写区分要用 RwLock |
| 开销 | 一个普通计数 | 原子操作,竞争时有系统调用 |
| 常见组合 | Rc<RefCell<T>> |
Arc<Mutex<T>> |
两者是单线程和多线程的对应物,就像 Rc 和 Arc。
2.6 Rust 的 Mutex 和 C++ 的 std::mutex 有什么不同?
最大的区别是 Rust 的锁持有数据,C++ 的锁和数据是分开的。
use Mutex;
let m = new;
// 守卫析构,自动解锁
| 方面 | C++ | Rust |
|---|---|---|
| 锁和数据的关系 | 分开声明,靠注释说明哪把锁保护哪些数据 | 数据在锁里面,不加锁拿不到 |
| 忘记加锁 | 编译通过,运行时数据竞争 | 编译不过 |
| 解锁 | lock_guard 析构时 |
守卫析构时 |
| 持锁线程 panic | 无对应概念 | 锁被标记为「中毒」,之后的 lock() 返回错误,提醒数据可能处于不一致状态 |
社区常用的 parking_lot 库提供的锁没有中毒机制,lock() 直接返回守卫。
2.7 OnceLock 和 LazyLock 是做什么的?
都是线程安全的一次性初始化,用于全局变量。
| 类型 | 稳定版本 | 用法 |
|---|---|---|
OnceLock<T> |
1.70 | 先声明,运行时第一次调用 get_or_init 时初始化 |
LazyLock<T> |
1.80 | 声明时给出初始化闭包,第一次访问时自动执行 |
use LazyLock;
use HashMap;
static CONFIG: = new;
对应 C++ 函数内的 static 局部变量,C++11 起它的初始化也是线程安全的。之前 Rust 社区用的是第三方的 lazy_static 和 once_cell,现在标准库已经覆盖。
2.8 析构的调用顺序是怎样的?
| 对象 | 顺序 |
|---|---|
| 局部变量 | 按声明的逆序 |
| 结构体字段 | 按声明的顺序 |
| 元组、数组元素 | 按下标顺序 |
| 函数参数 | 函数返回时,按声明的逆序 |
注意字段顺序和 C++ 相反,C++ 的成员按声明的逆序析构。
相关的几个工具:
| 工具 | 作用 |
|---|---|
drop(x) |
提前释放。它只是一个接收所有权、什么都不做的函数,值在函数结束时析构 |
std::mem::forget(x) |
不析构直接丢弃,是安全函数,会造成泄漏 |
ManuallyDrop<T> |
包装后编译器不再自动析构,由你决定何时析构 |
不能直接调用 x.drop(),编译器禁止,因为调用之后值还在作用域里,结束时还会再析构一次。
析构不保证一定执行:mem::forget、Rc 循环引用、进程被杀都会跳过析构。所以不能把内存安全建立在「某个析构一定会跑」的假设上。
3. trait 与泛型
3.1 trait 对应 C++ 的什么?
trait 定义一组方法和关联项,一个类型通过 impl 实现它。它在 C++ 里没有单一对应物,同时承担了三种角色:
| 用法 | 写法 | C++ 对应 |
|---|---|---|
| 泛型约束 | fn f<T: Display>(x: T) |
C++20 concept |
| 运行时多态 | Box<dyn Display> |
抽象基类加虚函数 |
| 给已有类型扩展方法 | impl MyTrait for i32 |
无直接对应 |
关键区别在于:同一个 trait 既可以做编译期约束,又可以做运行时多态,由使用者在使用处决定。C++ 里这两件事要分别用 concept 和继承来写。
3.2 静态分派和动态分派有什么区别?
| 静态分派 | 动态分派 | |
|---|---|---|
| 写法 | 泛型 T: Trait,或 impl Trait |
dyn Trait |
| 实现 | 单态化:每个具体类型生成一份代码 | 通过虚表间接调用 |
| 调用开销 | 无,可以内联 | 一次间接跳转,通常无法内联 |
| 代码体积 | 类型越多越大 | 只有一份 |
| 异构集合 | 不行,Vec<T> 里只能是同一种类型 |
可以,Vec<Box<dyn Shape>> |
| C++ 对应 | 模板 | 虚函数 |
impl Trait 有两个位置:
- 参数位置
fn f(x: impl Trait):等价于泛型,调用方决定类型。 - 返回位置
fn f() -> impl Trait:函数决定具体类型,调用方只知道它实现了这个 trait。常用于返回闭包和迭代器,避免写出复杂的类型名。
3.3 dyn Trait 在内存里长什么样?
&dyn Trait 和 Box<dyn Trait> 都是胖指针,两个字长:
&dyn Shape
┌──────────────┬──────────────┐
│ 数据指针 │ 虚表指针 │
└──────┬───────┴──────┬───────┘
▼ ▼
具体的值 虚表:析构函数、大小、对齐、area 方法的地址
use size_of;
const _: = assert!;
const _: = assert!;
和 C++ 的区别:C++ 的虚表指针存在对象内部,每个有虚函数的对象都多一个指针;Rust 的虚表指针存在引用里,对象本身不变。所以同一个值可以在需要时被当成任意一个 trait 对象使用,不需要在定义类型时就决定。
虚表里各项的具体排列是实现细节,语言没有保证。
dyn Trait 是语言内置的类型,不是标准库实现的。 dyn 是关键字:2015 edition 里是上下文关键字,2018 edition 起是严格关键字。这个写法 1.27 引入,之前直接把 trait 名当类型写,如 Box<Shape>,2021 edition 起这种旧写法是编译错误。
编译器为 dyn Trait 做的事 |
说明 |
|---|---|
| 生成虚表 | 每一对「具体类型,trait」在编译期生成一张 |
| 定义胖指针的布局 | 指向 dyn Trait 的指针自动变成两个字长 |
| 非定长强制转换 | &T 到 &dyn Trait、Box<T> 到 Box<dyn Trait> 时,把对应的虚表指针填进去 |
| 检查 dyn 兼容 | 见 3.4 节 |
| 把方法调用翻译成查表加间接调用 | 调用处不知道具体类型 |
它和 &T、[T]、fn() 是同一类东西,属于类型系统的基本构件。对比 6.5 节的 Pin:Pin 是标准库里的普通结构体,编译器只在个别地方认识它。
库也可以手写虚表来模拟同样的效果,标准库的 RawWaker 就是手写的:一个数据指针加一个函数指针表。这样做是为了不依赖 dyn 的具体布局,代价是要写 unsafe。
3.4 哪些 trait 可以做成 dyn Trait?
满足「dyn 兼容」的 trait 才行,以前叫对象安全。核心原因是:通过虚表调用时,编译器不知道具体类型,所以方法签名里不能出现需要知道具体类型才能处理的东西。
| 规则 | 违反的例子 | 原因 |
|---|---|---|
| 方法不能有泛型参数 | fn f<T>(&self, x: T) |
虚表里要为每个 T 准备一项,数量无限 |
不能按值使用 Self,接收者除外 |
fn clone(&self) -> Self |
不知道 Self 多大,无法在栈上放置 |
| 不能有关联常量 | const N: usize; |
虚表里没有常量的位置 |
不能有 async fn |
async fn f(&self) |
每个实现的 Future 类型不同、大小不同 |
trait 本身不能要求 Self: Sized |
trait T: Sized |
dyn T 不是 Sized |
个别方法不满足时,可以给它加 where Self: Sized,把它排除在虚表之外,其余方法照样能通过 dyn 调用。
Clone 不是 dyn 兼容的,所以 Box<dyn Trait> 不能直接克隆。常见的绕法是在 trait 里加一个 fn box_clone(&self) -> Box<dyn Trait>。
3.5 关联类型和泛型参数怎么选?
| 关联类型 | 泛型参数 | |
|---|---|---|
| 一个类型能实现几次 | 一次 | 每个不同的 T 各一次 |
| 例子 | 一个迭代器只产出一种元素 | String 可以从 &str、char、Box<str> 等多种类型转换 |
| 使用处 | 不用指定,fn f<I: Iterator> |
要指定,T: From<u32> |
判断标准:给定实现类型之后,这个类型参数是否唯一确定。唯一就用关联类型。
3.6 孤儿规则是什么?
写 impl Trait for Type 时,Trait 和 Type 至少要有一个是当前 crate 定义的。
它保证一个类型对一个 trait 的实现全局唯一。如果允许任意 crate 给 Vec<i32> 实现 Display,两个依赖各实现一次,编译器就不知道该用哪个。
绕法是新类型模式:用一个元组结构体把外部类型包一层,新类型是自己的,可以给它实现任何 trait。
;
新类型没有运行时开销,它和内部的值大小相同。
3.7 Deref 和自动解引用是怎么回事?
实现了 Deref<Target = U> 的类型,在需要 &U 的地方,&T 会被自动转换成 &U。这叫解引用强制转换,可以连续发生多次。
| 类型 | Deref 到 |
|---|---|
String |
str |
Vec<T> |
[T] |
Box<T>、Rc<T>、Arc<T> |
T |
MutexGuard<T> |
T |
let s = Boxnew;
takes_str; // &Box<String> → &String → &str
方法调用时还有自动引用:s.len() 会按需尝试 s、&s、&mut s,以及它们解引用之后的类型,找到第一个有 len 方法的。
不要用 Deref 模拟继承,也就是让 Derived 解引用到 Base 来「继承」它的方法。这样做语义混乱,Deref 应该只用于智能指针类的包装。
3.8 Sized 和 ?Sized 是什么?
Sized 表示类型的大小在编译期已知。绝大多数类型都是,例外是动态大小类型:
| 动态大小类型 | 只能通过什么使用 |
|---|---|
str |
&str、Box<str> |
[T] |
&[T]、Box<[T]> |
dyn Trait |
&dyn Trait、Box<dyn Trait> |
泛型参数默认带有 Sized 约束。写 T: ?Sized 才能接受动态大小类型:
Sized + >
print_len; // T = str,只有加了 ?Sized 才能这样调用
指向动态大小类型的指针都是胖指针,额外存长度或虚表。
3.9 闭包的 Fn、FnMut、FnOnce 有什么区别?
由闭包体怎么使用捕获的变量决定:
| trait | 闭包体对捕获变量做了什么 | 能调用几次 |
|---|---|---|
FnOnce |
把捕获的值移出去,比如返回它、传给接收所有权的函数 | 一次 |
FnMut |
修改捕获的值 | 多次,调用时需要 &mut |
Fn |
只读 | 多次,可以并发调用 |
三者是包含关系:实现 Fn 的也实现 FnMut,实现 FnMut 的也实现 FnOnce。所以参数类型写 FnOnce 的函数最宽松,什么闭包都能接受。
move 关键字只决定捕获方式,把变量按值移进闭包,不决定实现哪个 trait。一个 move 闭包如果只读捕获的值,仍然是 Fn。
let s = Stringfrom;
let f = move || println!; // 按值捕获,但只读:实现 Fn
f; f;
和 C++ lambda 的对比:
| 方面 | C++ | Rust |
|---|---|---|
| 捕获方式 | [=]、[&]、逐个指定 |
编译器按使用方式推断;move 强制按值 |
| 按引用捕获后悬垂 | 可能,比如返回捕获了局部变量引用的 lambda | 编译不过 |
| 每个闭包的类型 | 唯一的匿名类型 | 唯一的匿名类型 |
| 类型擦除 | std::function |
Box<dyn Fn()> |
3.10 常用的标准 trait 有哪些?
| trait | 作用 | C++ 对应 |
|---|---|---|
Clone |
显式拷贝 | 拷贝构造函数 |
Copy |
按位隐式拷贝的标记 | 可平凡拷贝的类型 |
Drop |
析构 | 析构函数 |
Debug、Display |
格式化输出,前者给开发者看,后者给用户看 | operator<< |
Default |
默认值 | 默认构造函数 |
PartialEq、Eq |
相等比较;Eq 额外保证自反性,浮点数因为 NaN 只有前者 |
operator== |
PartialOrd、Ord |
大小比较;Ord 是全序,BTreeMap 的键要求它 |
operator<=> |
Hash |
哈希;实现时必须和 Eq 一致 |
std::hash 特化 |
From、Into |
类型转换;实现 From 会自动得到反方向的 Into |
转换构造函数 |
AsRef、AsMut |
廉价的引用转换 | 无 |
Iterator、IntoIterator |
迭代;后者让类型能用于 for 循环 |
begin、end |
Send、Sync |
线程安全的标记,见 5.1 节 | 无 |
大部分可以用 #[derive(...)] 自动生成。
3.11 Rust 为什么没有继承?
Rust 用组合加 trait 替代继承,原因是继承把两件事绑在了一起:
| 继承提供的 | Rust 的替代 |
|---|---|
| 代码复用 | 组合:把另一个类型作为字段;trait 的默认方法实现 |
| 多态 | trait 对象或泛型 |
分开之后避免了继承带来的问题:脆弱的基类、菱形继承、为了复用而建立的不合理的「是一个」关系。
同类的取舍还有:
| C++ 有、Rust 没有的 | 原因与替代 |
|---|---|
| 函数重载 | 类型推断会变得复杂。用不同的函数名,或用 trait 让一个函数接受多种类型 |
| 默认参数 | 用 Option 参数、构建者模式,或 Default |
| 构造函数 | 用普通的关联函数,约定命名为 new |
| 异常 | 用 Result,见第 4 节 |
| 空指针 | 用 Option |
3.12 trait 对象和 Go 的 interface 是一回事吗?
运行时是同一类东西,语言层面不是。Go 的 interface 约等于 Rust 的 dyn Trait;Rust 的 trait 范围更大,动态分派只是它的用法之一。
相同点:内存布局和调用方式。 两者都是两个字长的胖指针,方法调用都是查表后间接跳转。
Go 的接口值 Rust 的 &dyn Trait
┌───────────┬───────────┐ ┌───────────┬───────────┐
│ itab 指针 │ 数据指针 │ │ 数据指针 │ 虚表指针 │
└─────┬─────┴───────────┘ └───────────┴─────┬─────┘
▼ ▼
类型信息 + 方法表 析构、大小、对齐 + 方法表
不同点。
| 方面 | Go 的 interface | Rust 的 trait 对象 |
|---|---|---|
| 怎么算实现了 | 隐式:类型有这组方法就自动满足,不用声明 | 显式:必须写 impl Trait for Type |
| 分派方式 | 通过接口调用就是动态分派 | 同一个 trait 可以选:泛型是静态分派,dyn 是动态分派 |
| 方法表何时生成 | 运行时第一次把某类型转成某接口时生成并缓存 | 编译期生成 |
| 值放在哪 | 语言自动处理,装进接口的值通常会被分配到堆上 | 自己写明放在什么指针后面:Box<dyn T>、&dyn T、Arc<dyn T> |
| 空值 | 接口可以是 nil |
没有空值,需要时用 Option<Box<dyn T>> |
| 取回具体类型 | 内置类型断言和 switch x.(type) |
没有内置,要借助 Any trait 做向下转型 |
| 运行时类型信息 | 接口值带完整的类型信息,可以反射 | 虚表里只有析构、大小、对齐和方法,没有反射 |
| 数据的生命周期 | 垃圾回收 | 所有权和生命周期 |
第一行是设计理念上最大的区别。Go 是结构化的,只看方法签名对不对得上,可以让别人的类型满足后定义的接口。Rust 是名义上的,必须明确声明,不会因为方法名碰巧相同而被误当成实现。
第三行是第一行的后果。Go 里任何类型都可能满足任何接口,编译期无法为所有组合预先生成方法表,只能运行时按需生成。Rust 的实现关系是显式声明的,编译期就能全部生成。
trait 比 interface 多出来的能力。
| 能力 | 例子 |
|---|---|
| 作为泛型约束,零开销的静态分派 | fn f<T: Display>(x: T),每个类型生成一份代码,可以内联 |
| 关联类型 | Iterator::Item |
关联常量和不带 self 的函数 |
Default::default() |
| 默认方法实现 | trait 里直接写方法体 |
| 给已有的类型实现自己的 trait | impl MyTrait for i32 |
| 一揽子实现 | impl<T: Display> ToString for T,给所有满足条件的类型一次性实现 |
| 标记 trait | Send、Sync、Copy,没有方法,只携带编译期的性质 |
用了其中某几项之后,这个 trait 就不能再做成 dyn,规则见 3.4 节。
Go 1.18 加入泛型后,interface 也能当类型约束用。但 Go 的泛型不是完全的单态化,带约束的方法调用仍可能经过一次查表,和 Rust 的静态分派不等价。
两个容易出错的差异。
Go 的带类型的 nil:把一个值为 nil 的具体类型指针赋给接口变量,接口本身不等于 nil,因为它的类型那一半不是空的。
fmt // false
Rust 没有空指针,不存在这个问题。
取回具体类型:Go 里 v, ok := x.(*MyType) 一行完成。Rust 的 dyn Trait 不带类型信息,要让 trait 以 Any 为父 trait 再调 downcast_ref。需要频繁判断具体类型时,通常说明这里该用枚举而不是 trait 对象。
3.13 dyn Trait 和 C++ 的虚函数、CRTP、PIMPL 是什么关系?
dyn Trait 就是 Rust 的虚函数:运行时通过虚表做间接调用。CRTP 对应的是泛型加 trait 约束,PIMPL 和多态无关。
| C++ | Rust | 分派时机 |
|---|---|---|
| 虚函数 | dyn Trait |
运行时查虚表 |
| CRTP、模板、concept | 泛型 T: Trait、impl Trait |
编译期确定,可以内联 |
| PIMPL | 基本不需要 | 没有多态 |
和虚函数表的异同。 机制相同,都是「一张按类型生成的函数指针表,调用时取出指针再间接跳转」。差别在虚表指针放在哪:
C++:虚表指针在对象里 Rust:虚表指针在引用里
Base* p ──► ┌──────────────┐ &dyn Trait
8 字节 │ vptr ────────┼──► 虚表 ┌───────────┬───────────┐
│ 字段 │ │ 数据指针 │ 虚表指针 ──┼──► 虚表
└──────────────┘ └─────┬─────┴───────────┘
每个对象多 8 字节 ▼ 16 字节
┌──────────────┐
│ 字段 │ 对象本身不变
└──────────────┘
| 方面 | C++ 虚函数 | Rust dyn Trait |
|---|---|---|
| 虚表指针的位置 | 对象内部 | 胖指针里 |
| 对象大小 | 每个有虚函数的对象多一个指针;多重继承时每个带虚函数的基类各一个 | 不变 |
| 指针大小 | 8 字节 | 16 字节 |
| 何时决定用动态分派 | 定义类时写 virtual |
使用处写 dyn,类型定义不用改 |
| 谁能参与 | 只有设计成带虚函数的类 | 任何实现了该 trait 的类型,包括 i32 |
| 一次调用的访存 | 从对象里读虚表指针,再从虚表读函数指针 | 虚表指针已经在手上,只需从虚表读函数指针 |
| 虚表里有什么 | 函数指针,外加类型信息指针和到对象顶部的偏移 | 函数指针,外加析构函数、大小、对齐;没有类型信息 |
| 析构 | 通过基类指针删除时,基类要有虚析构函数,否则是未定义行为 | 析构函数总在虚表里,自动正确 |
| 向下转型 | dynamic_cast,靠运行时类型信息 |
没有内置,借助 Any |
| 向上转型 | 派生类指针隐式转成基类指针 | &dyn Sub 转成 &dyn Super,1.86 起稳定 |
| 同时满足多个接口 | 多重继承,对象里有多个虚表指针,转换时要调整 this |
dyn A + B 只允许 B 是 Send、Sync 这类自动 trait;要组合多个 trait 就定义一个以它们为父 trait 的新 trait |
| 构造期间调用虚函数 | 分派到当前正在构造的那一层,不是最终的派生类 | 没有构造函数,不存在这个问题 |
| 布局是否有规范 | 有,如 Itanium ABI | 没有,实现相关 |
两个差别值得记住:
- Rust 把「是否多态」从类型的定义挪到了使用处。 C++ 里一个类要不要虚函数,写类的人说了算,之后每个对象都带着虚表指针。Rust 里类型本身不带任何额外数据,用
dyn的那一处才付出胖指针的代价;同一个类型在别处仍然可以走静态分派。 - 没有忘写虚析构函数这类错误。 C++ 侧的虚表布局见 memory-layout.md。
CRTP 对应什么。 CRTP 的目的是避开虚表:让基类在编译期知道派生类是谁,直接调用。
;
;
Rust 里是 trait 的默认方法加泛型:
// 相当于 CRTP:单态化,可内联
// 相当于虚函数
CRTP 里 static_cast<Derived*>(this) 的技巧在 Rust 里是语言自带的:trait 方法里的 Self 就是实现者的具体类型。
C++ 里虚函数和 CRTP 是两套互不通用的写法,定义类型时就要选定。Rust 里同一个 trait 两种都能用,上面最后两行就是同一个 Shape 的两种用法。
PIMPL 对应什么。 PIMPL 解决的是编译依赖:头文件里只留一个指向实现类的指针,改实现不用重编下游。它背后是一个确定的具体类型,没有多态。
Rust 基本不需要它:没有头文件,字段默认私有,改私有字段不影响依赖方的源码兼容性。形式上相近的做法是在公开结构体里放一个 Box<Inner>,用途是让公开类型的大小不随实现变化。
3.14 dyn Trait 的性能开销在哪?什么时候要避开?
开销的大头不是那次间接跳转,而是它挡住了内联。构成和 C++ 的虚函数完全相同。
| 来源 | 说明 | 量级 |
|---|---|---|
| 间接调用 | 从虚表读函数指针再跳转 | 分支预测命中时几个时钟周期;预测失败时十几到二十个周期 |
| 不能内联 | 编译器不知道具体类型,无法把函数体展开到调用处 | 最大的一项 |
| 胖指针 | 引用是 16 字节而不是 8 字节 | 一般可以忽略 |
| 堆分配和指针追踪 | Vec<Box<dyn T>> 的每个元素各自在堆上,遍历时内存不连续 |
缓存缺失,常比调用本身贵 |
周期数是常见的经验量级,未实测,取决于 CPU 和具体代码。
不能内联为什么最贵。 内联之后编译器才能做后续的优化:常量传播、消除重复计算、把循环向量化。一个只有一两行的小函数,通过 dyn 在循环里调用一百万次,损失的不是一百万次跳转,而是整个循环无法被优化成 SIMD。
什么时候可以不在意。
| 情况 | 原因 |
|---|---|
| 被调用的函数本身做的事很多 | 函数体耗时在几百纳秒以上,比如一次 IO、一次哈希表查找,间接调用的几纳秒可以忽略 |
| 调用频率低 | 初始化、配置加载、每个请求只调几次的路径 |
| 需要异构集合或插件 | 不同类型放进同一个容器、运行时才决定用哪个实现,这是 dyn 的本职 |
dyn 相对泛型还有两个好处:代码只生成一份,二进制更小;编译更快。泛型每多一个具体类型就多生成一份代码。
什么时候要避开。 热循环里、对每个元素调用一次、函数体又很小。四种替代办法:
| 办法 | 做法 | 适用 |
|---|---|---|
| 泛型 | fn f<T: Trait> |
类型在编译期已知 |
| 枚举分派 | 把几种实现放进一个 enum,用 match 分派 |
实现的种类固定且不多。match 是直接跳转,各分支可以内联 |
| 提高分派的粒度 | 一次 dyn 调用处理一整批数据,而不是一个元素 |
类型要运行时决定,但数据是成批的 |
| 按类型分组 | 把同类型的对象放在一起分别处理 | 异构集合的遍历 |
枚举分派的写法:
元素连续存放在 Vec<AnyShape> 里,没有逐个的堆分配。代价是每个元素按最大的变体占空间,新增一种实现要改这个枚举。
提高分派粒度的例子:特征算子的接口从「对一个候选算一个值」改成「对一整列算一批值」。
算子仍然通过 dyn Op 调用,但一次分派摊到几百个元素上,分派的开销可以忽略;每个算子内部是一个处理连续数组的紧凑循环,循环体照样能内联和向量化。
判断顺序:先用火焰图确认间接调用确实在热点里,再决定换哪一种。
4. 错误处理
4.1 Rust 为什么没有异常?
错误是返回值的一部分,写在函数签名里:
| 方面 | C++ 异常 | Rust 的 Result |
|---|---|---|
| 函数会不会失败 | 签名里看不出来,noexcept 只是承诺 |
返回类型就说明了 |
| 调用方忘记处理 | 编译通过,异常一路向上传播 | Result 带 #[must_use],不处理有警告;要拿到值必须先处理错误 |
| 控制流 | 隐式跳转,任何一行都可能抛出 | 显式,只有写了 ? 或 match 的地方才会提前返回 |
| 成功路径的开销 | 零开销模型下没有开销 | 返回值多一个判别字段,一次分支 |
| 失败路径的开销 | 栈回溯,很贵 | 和成功路径一样,一次返回 |
不可恢复的错误用 panic 处理,见 4.3 节。
4.2 ? 运算符做了什么?
遇到 Err 时提前返回,并且把错误类型通过 From 转换成函数声明的错误类型。
let n = s.?;
大致等价于:
let n = match s. ;
第二行的 From::from 是关键:只要为自己的错误类型实现了 From<ParseIntError>,各种底层错误就能被 ? 自动转换,不用每处手写映射。
? 也能用于 Option:遇到 None 时提前返回 None。
4.3 什么时候用 panic,什么时候用 Result?
| panic | Result |
|
|---|---|---|
| 含义 | 程序进入了不该进入的状态,是 bug | 可以预见的失败 |
| 例子 | 数组越界、违反了函数的前置条件、内部不变量被破坏 | 文件不存在、网络超时、用户输入不合法 |
| 调用方 | 无法处理,也不该处理 | 决定重试、降级或向上传递 |
unwrap() 和 expect() 把 Err 变成 panic。在测试、原型和「确定不会失败」的地方可以用;后一种情况用 expect("原因") 写清楚为什么不会失败。
panic 之后有两种处理方式,在 Cargo.toml 里配置:
| 方式 | 配置 | 行为 |
|---|---|---|
| 栈展开 | 默认 | 逐层析构栈上的值,线程结束;可以用 catch_unwind 捕获 |
| 直接终止 | panic = "abort" |
立即终止进程,二进制更小,没有展开的开销 |
panic 不能跨越 FFI 边界展开到 C 代码里。跨语言回调要么在边界上用 catch_unwind 拦住,要么使用 extern "C-unwind" 这个 ABI,它在 1.71 稳定。
4.4 thiserror 和 anyhow 怎么选?
两个都是第三方库,事实上的标准:
thiserror |
anyhow |
|
|---|---|---|
| 用在 | 库 | 应用程序 |
| 做什么 | 用派生宏方便地定义具体的错误枚举 | 提供一个能装下任何错误的类型,加上下文信息 |
| 调用方 | 可以按错误种类 match,分别处理 |
通常只是记录日志或展示给用户 |
#[from] 自动生成 From<std::io::Error>,所以 ? 能把 IO 错误直接转成 ConfigError::Io。
4.5 整数溢出会怎样?
取决于构建方式:
| 构建 | 行为 |
|---|---|
| debug | panic |
| release | 按二进制补码回绕,不报错 |
可以在 Cargo.toml 的 release 配置里写 overflow-checks = true,让 release 也检查。除以零在任何构建下都 panic。
需要确定的行为时,用显式的方法:
| 方法 | 溢出时 | 返回 |
|---|---|---|
checked_add |
返回 None |
Option<T> |
wrapping_add |
回绕 | T |
saturating_add |
停在最大或最小值 | T |
overflowing_add |
回绕,并告诉你是否溢出 | (T, bool) |
和 C++ 的区别:C++ 的有符号整数溢出是未定义行为,编译器可以假设它不发生并据此优化。Rust 里溢出从来不是未定义行为,只是 release 下默认不检查。
5. 并发
本节和 dex_qa.md 第 10 节有重叠,那边的回答更短,结合了撮合引擎的场景。
5.1 Send 和 Sync 是什么?
| trait | 含义 |
|---|---|
T: Send |
T 的所有权可以安全地转移到另一个线程 |
T: Sync |
&T 可以安全地在多个线程之间共享。等价于 &T: Send |
两者都是自动 trait:一个类型的所有字段都满足,它就自动满足,不用手写。少数类型手动声明自己不满足。
| 类型 | Send |
Sync |
原因 |
|---|---|---|---|
i32、String、Vec<T> |
是 | 是 | 普通的拥有型数据 |
Rc<T> |
否 | 否 | 引用计数不是原子的 |
Arc<T> |
是,要求 T: Send + Sync |
是,要求 T: Send + Sync |
原子计数 |
Cell<T>、RefCell<T> |
是,要求 T: Send |
否 | 内部可变性没有同步,两个线程同时通过 & 修改就是数据竞争 |
Mutex<T> |
是,要求 T: Send |
是,要求 T: Send |
锁提供同步,所以只要求 T 能转移 |
MutexGuard<T> |
否 | 是,要求 T: Sync |
有些平台要求在加锁的线程上解锁 |
裸指针 *const T、*mut T |
否 | 否 | 编译器无法知道它指向什么 |
记住两个只满足其中一个的类型:RefCell 是 Send 不是 Sync;MutexGuard 是 Sync 不是 Send。
Mutex<T> 只要求 T: Send 就能是 Sync,这就是它的价值:把一个不能共享的东西变成可以共享的。Arc<Mutex<RefCell<T>>> 这种组合能编译,就是因为这一点。
5.2 Rust 怎么保证没有数据竞争?
数据竞争的定义是:两个线程同时访问同一块内存,至少一个是写,并且没有同步。Rust 用三层机制排除它:
- 借用规则:同一时刻要么一个
&mut,要么多个&。所以「同时写」只可能通过共享引用&T发生。 Sync:只有Sync的类型才能通过&T被多个线程共享。能通过&T修改的类型,比如Cell,都不是Sync;是Sync的,比如Mutex和原子类型,内部都有同步。Send和'static:thread::spawn要求闭包是Send + 'static,不能把Rc带进新线程,也不能借用当前栈上会先消失的数据。
use Rc;
let rc = new;
spawn; // 编译错误:Rc<i32> 不能在线程间安全地发送
这些检查全部在编译期,没有运行时开销。
5.3 Rust 能保证没有死锁吗?
不能。Rust 保证的是没有数据竞争,下面这些都不在保证范围内:
| 问题 | 例子 |
|---|---|
| 死锁 | 两个线程以相反的顺序获取两把锁 |
| 竞态条件 | 先检查余额、再扣款,两步之间被另一个线程插入。每一步都加了锁,但组合起来不是原子的 |
| 活锁、饥饿 | 线程一直在重试,或一直抢不到锁 |
| 内存泄漏 | Arc 循环引用 |
数据竞争是未定义行为,竞态条件是逻辑错误。前者 Rust 在类型系统里排除了,后者仍然要靠设计:固定加锁顺序、缩小临界区、用消息传递代替共享状态。
5.4 共享状态加锁还是消息传递?
Arc<Mutex<T>> |
通道 | |
|---|---|---|
| 模型 | 多个线程共享一份数据,轮流加锁访问 | 数据归一个线程所有,其他线程发消息给它 |
| 适合 | 状态小、访问频繁、每次操作很短 | 流水线、生产者消费者、需要确定处理顺序 |
| 瓶颈 | 竞争激烈时锁成为热点 | 单个消费者的处理速度 |
| 确定性 | 加锁顺序取决于调度 | 消息按到达顺序处理,容易做回放 |
通道的选择:
| 实现 | 特点 |
|---|---|
std::sync::mpsc |
标准库,多生产者单消费者 |
crossbeam-channel |
多生产者多消费者,select! 同时等多个通道 |
tokio::sync::mpsc |
异步版本,接收时 .await 而不阻塞线程 |
第三种做法是分片:按键把状态切成多份,每份归一个线程,既不用锁也不用集中的消费者。撮合引擎按市场分片就是这种做法。
有界通道比无界通道安全:消费者变慢时,有界通道会让生产者阻塞,形成背压;无界通道会让内存一直涨。
5.5 作用域线程解决什么问题?
thread::spawn 要求闭包是 'static,不能借用栈上的数据,只好用 Arc 包一层。std::thread::scope 在 1.63 稳定,它保证作用域结束前所有线程都已结束,所以线程可以直接借用外面的数据:
let mut data = vec!;
scope; // 这里等待所有线程结束
println!;
chunks_mut 返回互不重叠的可变切片,每个线程拿一段。编译器能证明它们不冲突,所以不需要锁。这是 Rust 做数据并行的基本模式,rayon 库在这之上提供了并行迭代器。
5.6 原子操作的内存序怎么选?
Rust 的原子类型和内存序与 C++20 的内存模型一致:
| Rust | C++ | 保证 |
|---|---|---|
Relaxed |
memory_order_relaxed |
只保证这个变量本身的读写是原子的,不约束其他内存访问的顺序 |
Release |
memory_order_release |
用于写:之前的所有读写不会被重排到它之后 |
Acquire |
memory_order_acquire |
用于读:之后的所有读写不会被重排到它之前 |
AcqRel |
memory_order_acq_rel |
用于读改写操作,兼具两者 |
SeqCst |
memory_order_seq_cst |
在上面的基础上,所有线程看到所有 SeqCst 操作的同一个全局顺序 |
Rust 没有 C++ 的 memory_order_consume。
最常用的模式是发布数据:一个线程写好数据,用 Release 设置标志;另一个线程用 Acquire 读到标志后,一定能看到那些数据。
use ;
static READY: AtomicBool = new;
static mut DATA: u64 = 0;
// 线程 A
unsafe
READY.store;
// 线程 B
while !READY.load
assert_eq!; // 一定成立
这个例子为了直观用了 static mut,实际代码应该把数据放进原子类型或用安全的封装。2024 edition 对引用 static mut 默认报错。
选择原则:
| 场景 | 内存序 |
|---|---|
| 统计计数器,只关心最终值 | Relaxed |
| 发布数据、实现锁、无锁队列 | Release 写配 Acquire 读 |
| 拿不准、或者需要多个变量之间的全局顺序 | SeqCst |
在 x86 上,Acquire 读和 Release 写编译出来就是普通的读写指令;在 ARM 上要用带获取或释放语义的专门指令。所以只在 x86 上测试通过的弱内存序代码,到 ARM 上可能出错。
5.7 无锁数据结构的内存回收怎么做?
无锁结构里,一个线程把节点从链表上摘下来之后,不能马上释放,因为别的线程可能刚读到它的指针还没用完。常见的三种办法:
| 方法 | 原理 | Rust 库 |
|---|---|---|
| 基于纪元的回收 | 线程进入临界区时登记当前纪元,节点要等所有线程都离开它被摘下时的纪元才释放 | crossbeam-epoch |
| 危险指针 | 线程在使用指针前把它登记为「正在使用」,回收时跳过被登记的节点 | haphazard |
| 引用计数 | 每个节点带原子计数 | Arc,开销大 |
还有一个相关问题叫 ABA:线程读到指针 A,被挂起;其他线程把 A 释放,又分配了一个新节点恰好地址也是 A;原线程的比较交换会误以为没有变化。上面的回收方案保证了节点在被引用期间不会被释放,也就避免了地址被复用。
6. 异步与 tokio
6.1 Future 是什么?
一个可以被反复轮询的计算,每次轮询要么完成并返回结果,要么告诉调用方「还没好,好了会通知你」。
三个特点:
- 惰性。创建一个
Future什么都不执行,只有被轮询才推进。忘记.await的Future不会运行,编译器会警告。 - 拉模型。由执行器主动调
poll,而不是任务完成后回调。 - 零成本。
Future是一个普通的值,可以放在栈上,不需要堆分配。
6.2 async fn 编译成什么?
编译成一个返回匿名 Future 的函数。这个 Future 是一个状态机枚举:每个 .await 是一个状态,跨越 .await 还活着的局部变量是状态里的字段。
async
编译器大致生成:
每次 poll 从当前状态继续执行,遇到 Pending 就保存状态并返回。
状态机的大小是各状态里最大的那个,编译期就确定。所以 async fn 互相嵌套调用时,外层的 Future 会把内层的整个包含进来,可能变得很大。递归的 async fn 必须用 Box::pin 打断,否则大小无限。
6.3 为什么需要运行时?tokio 做了什么?
语言只定义了 Future 这个接口和 async/await 语法,不包含执行器。谁来调 poll、IO 就绪了怎么通知,要由运行时提供。
tokio 的三个部分:
| 部分 | 作用 |
|---|---|
| 执行器 | 维护任务队列,调用任务的 poll |
| 反应器 | 用 epoll、kqueue 或 IOCP 等待 IO 事件,事件到达时唤醒对应的任务 |
| 定时器 | 管理 sleep、超时 |
两种调度模式:
| 模式 | 行为 | 适用 |
|---|---|---|
| 多线程 | 每个工作线程一个本地队列,空闲时从其他线程的队列偷任务;工作线程数默认等于 CPU 核数 | 服务端默认 |
| 当前线程 | 所有任务在一个线程上执行 | 测试、嵌入到已有的事件循环里 |
tokio::spawn 要求 Future 是 Send + 'static,因为任务可能被另一个工作线程偷走继续执行。
6.4 Waker 的作用是什么?
poll 返回 Pending 之前,Future 要从 Context 里取出 Waker 并登记到会触发它的地方,比如反应器的某个套接字上。事件发生时,反应器调用 waker.wake(),执行器把这个任务重新放回就绪队列,下一次再 poll 它。
任务 poll ──► 套接字不可读 ──► 把 Waker 登记给反应器 ──► 返回 Pending
▼
执行器去跑别的任务
▼
反应器收到 epoll 事件 ──► waker.wake() ──► 任务回到就绪队列 ──► 再次 poll
如果 Future 返回 Pending 却没有登记 Waker,它就永远不会再被轮询,任务挂住。手写 Future 时这是最常见的错误。
6.5 为什么需要 Pin?Pin 和 Unpin 的原理是什么?
async 编译出的状态机会把跨 .await 的局部变量和指向它们的引用存在同一个结构体里。引用里记的是绝对地址,状态机一旦被移动,数据搬走了而引用还指着旧地址。Pin 的作用是保证这个状态机从第一次被轮询起不再换地址。
问题:自引用的状态机。
async
移动之前 按位拷贝到新地址之后
地址 0x1000 地址 0x2000
┌────────────────────┐ ┌────────────────────┐
│ buf: [0u8; 64] │◄──┐ │ buf: [0u8; 64] │
│ r: 0x1000 ───────┼───┘ │ r: 0x1000 ───────┼──► 旧地址,已经无效
└────────────────────┘ └────────────────────┘
移动是按位拷贝,r 的值原样搬过去,仍然是旧地址。
借用检查器挡不住这件事。普通的值在有活着的引用时不允许移动,这条规则靠生命周期来检查;而这里的引用指向结构体自己的字段,「借用自己」这种关系生命周期表达不了,对外看这个状态机只是一个没有任何借用的普通值。所以需要另一套机制,在类型层面禁止移动。
Pin 固定的是指针指向的值,不是指针本身。
Pin<Box<Fut>> 堆
┌──────────┐ ┌────────────────────┐
│ 指针 ────┼─────────────────────►│ buf │◄──┐
└──────────┘ │ r ─────────────────┼───┘
这个指针可以随意移动、 └────────────────────┘
传给别的函数、放进 Vec 这块内存的地址始终不变
把 Pin<Box<Fut>> 从一个变量移到另一个变量,搬的只是 8 字节的指针,堆上的状态机没有动,内部的自引用一直有效。
Pin 怎么做到的。 它没有任何运行时机制,只是一个不交出 &mut T 的包装。要移动一个值,必须先拿到它的所有权或 &mut T,再用赋值、mem::swap、mem::replace 把它换走。Pin 把这条路堵上:
| 操作 | 条件 |
|---|---|
Pin::new(ptr) |
只有指向的类型是 Unpin 时才能调用 |
Box::pin(x)、std::pin::pin!(x) |
对任何类型都可以,得到一个已经固定的指针 |
pin.get_mut(),得到 &mut T |
只有 T: Unpin 时才能调用 |
pin.as_mut() |
得到 Pin<&mut T>,仍然是固定的 |
pin.get_unchecked_mut()、Pin::new_unchecked(ptr) |
unsafe,由调用者保证之后不移动 |
对不是 Unpin 的类型,安全代码里没有任何办法从 Pin<&mut T> 得到 &mut T,也就没有办法移动它。
Unpin 是什么。 一个自动实现的标记 trait,含义是「这个类型被移动也没有问题」。
| 类型 | 是否 Unpin |
Pin 对它的效果 |
|---|---|---|
i32、String、Vec<T>、Box<T>,以及由它们组成的结构体 |
是 | 没有任何约束,Pin<&mut T> 和 &mut T 可以自由互转 |
async fn 和 async 块生成的状态机 |
否 | 真正被固定 |
含有 PhantomPinned 字段的类型 |
否 | 真正被固定,手写自引用结构时用它来声明 |
名字容易看反:Unpin 表示「不需要固定」。绝大多数类型都是 Unpin,所以平时感觉不到 Pin 的存在。
两者怎么配合。 Future::poll 的接收者是 Pin<&mut Self>。要轮询一个 Future,必须先把它固定;固定之后,如果它不是 Unpin,就再也拿不到 &mut,无法移动。于是「开始轮询之后不再移动」这条约束由类型系统保证,没有运行时开销。
是语言内置的还是标准库的。 两者都定义在标准库的 core 里,不是关键字,但都有编译器的配合:
| 定义在哪 | 是什么 | 编译器的参与 | |
|---|---|---|---|
Pin<Ptr> |
core::pin,std::pin 重新导出 |
一个只有一个字段的普通结构体,#[repr(transparent)] |
认识这个类型:允许 self: Pin<&mut Self> 作为方法接收者;.await 展开成对 poll 的调用时要构造它 |
Unpin |
core::marker,std::marker 重新导出 |
一个没有方法的标记 trait | 它是自动 trait,由编译器按字段自动推导;编译器生成 async 状态机时不为它实现 Unpin |
PhantomPinned |
core::marker |
一个零大小的、不是 Unpin 的类型 |
无 |
pin! |
core::pin |
宏,1.68 稳定 | 无 |
因为在 core 里,没有标准库的环境也能用。固定的保证来自 Pin 的接口设计,即哪些方法是安全的、哪些是 unsafe 的,而不是来自某条专门的语言规则。
什么时候会碰到。
| 场景 | 做法 |
|---|---|
普通的 async 代码,只用 .await |
碰不到,.await 会自动处理 |
把不同的 Future 放进同一个集合,或作为 trait 对象返回 |
Pin<Box<dyn Future<Output = T>>>,用 Box::pin(fut) 得到 |
在循环的 select! 里反复轮询同一个 Future |
循环外先 tokio::pin!(fut) 或 std::pin::pin!(fut) |
调用要求 Unpin 的接口,比如某些流的 next() |
先固定:let mut s = std::pin::pin!(s); |
手写 Future 或 Stream 的 poll |
要访问字段时用 pin-project 库生成安全的投影,避免手写 unsafe |
C++20 的协程帧在堆上分配,地址天然不变,所以不需要 Pin 这个概念。
6.6 异步任务怎么取消?要注意什么?
丢弃 Future 就是取消,它不会再被轮询,持有的资源按正常的析构释放。超时、select! 里没有胜出的分支,都是通过丢弃实现的。
select!
要注意的是取消安全:Future 可能在任意一个 .await 处被丢弃,两个 .await 之间做了一半的事情会留下不一致的状态。比如从套接字读了半帧数据存在局部缓冲里,被取消后这半帧就丢了。
在循环里的 select! 中使用时,要确认每个分支的操作是取消安全的。tokio 的文档为每个方法标注了它是否取消安全。
6.7 在异步代码里调用阻塞函数会怎样?
会占住整个工作线程,这个线程上的其他任务全部停下。几个线程都被占住,整个服务就没有响应了。
阻塞操作包括:同步的文件或网络 IO、std::thread::sleep、长时间的 CPU 计算、获取可能长时间等待的标准库锁。
| 做法 | 适用 |
|---|---|
tokio::task::spawn_blocking |
把阻塞调用放到专门的阻塞线程池,返回一个可以 .await 的句柄;这个池默认最多 512 个线程 |
tokio::task::block_in_place |
在多线程运行时里,把当前工作线程临时转为阻塞线程,任务迁走 |
| 独立的线程池 | 持续的 CPU 密集计算,比如撮合、验签,用自己的固定线程池并绑核,和异步运行时隔离 |
经验法则:两个 .await 之间的代码执行时间不应超过几十到一百微秒。
6.8 跨 .await 持有锁会出什么问题?
两个问题:
一,Future 不再是 Send。 标准库的 MutexGuard 不是 Send。跨 .await 持有它,它就成了状态机的字段,整个 Future 也不是 Send,不能交给 tokio::spawn。
async
// tokio::spawn(bad(...)) 编译不过:Future 不是 Send
二,可能死锁。 持锁的任务在 .await 处让出,同一个线程上的另一个任务也去获取这把锁,阻塞了线程;而持锁的任务要等这个线程空出来才能继续,于是互相等待。
| 做法 | 说明 |
|---|---|
| 缩小临界区 | 在 .await 之前把守卫释放,用一个块把加锁的代码包起来 |
tokio::sync::Mutex |
获取锁时 .await 而不是阻塞线程,守卫可以跨 .await;比标准库的锁慢,只在确实需要跨 .await 持锁时用 |
| 改用消息传递 | 状态交给一个任务独占,其他任务发消息给它 |
6.9 Rust 的异步和 C++20 协程有什么区别?
| 方面 | Rust async |
C++20 协程 |
|---|---|---|
| 有栈还是无栈 | 无栈 | 无栈 |
| 状态保存在哪 | 编译器生成的状态机,大小编译期确定 | 协程帧,通常在堆上分配,编译器能证明生命周期时可以省掉分配 |
| 何时开始执行 | 惰性,被轮询才执行 | 由 promise_type::initial_suspend 决定 |
| 唤醒机制 | 统一的 Waker 接口 |
由各库自定义 awaiter |
| 标准库提供 | Future 接口和语法,没有执行器 |
语言机制,几乎不提供库支持 |
| 生态 | tokio 一家主导 | 各家自建,比如 cppcoro、folly、asio |
| 自引用 | 需要 Pin |
帧在堆上不移动,不需要 |
C++ 侧的细节见 coroutings.md。
6.10 trait 里能写 async fn 吗?
可以,1.75 起稳定。
两个限制:
- 不能用
dyn。每个实现返回的Future类型和大小都不同,放不进虚表,见 3.4 节。 - 调用方难以约束返回的
Future是Send。泛型代码里调用store.get()再交给tokio::spawn时,编译器不知道这个Future是不是Send,trait 的定义方要预先声明,或借助trait-variant这类库生成带Send约束的版本。
需要 dyn 时,用第三方的 async-trait 宏:它把返回值改写成 Pin<Box<dyn Future + Send>>,代价是每次调用一次堆分配。
6.11 为什么不能用 for 循环遍历异步产生的序列?
for 循环背后调用的 Iterator::next 是同步函数,它没有地方放 .await。同步信道能用 for,是因为它靠阻塞线程来等待下一个元素。
for 是语法糖,编译器把它展开成对 next 的反复调用:
for x in rx
// 展开后大致是
let mut it = rx.into_iter;
loop
next 被调用后必须立刻返回 Some 或 None,没有第三种选择。两种信道在「暂时没有消息」时的处理因此不同:
同步信道 std::sync::mpsc |
异步信道 tokio::sync::mpsc |
|
|---|---|---|
| 没有消息时 | recv() 阻塞当前线程,直到有消息或发送端全部关闭 |
不能阻塞线程,否则卡住运行时的工作线程;要挂起当前任务,把线程让出去 |
| 等待靠什么 | 操作系统让线程睡眠 | .await 处让出,消息到达时由 Waker 唤醒 |
能否包进 Iterator::next |
能,next 内部调 recv() 即可 |
不能,见下表 |
异步等待需要的三样东西,Iterator::next 都提供不了:
| 需要的 | Iterator::next 的情况 |
|---|---|
| 返回「还没好,稍后再来」 | 返回值只有 Some 和 None |
| 调用处是一个挂起点 | 是一次普通函数调用,不能挂起 |
拿到 Waker,消息到了能唤醒任务 |
签名里没有 Context 参数 |
要支持异步的 for,需要一个新的 trait 加一个新的语法:
| 需要 | 现状 |
|---|---|
取下一个元素的方法是异步的 trait。社区叫 Stream,标准库叫 AsyncIterator |
AsyncIterator 只在 nightly 可用;稳定版用 futures 或 tokio-stream 里的 Stream,见 6.12 节 |
告诉编译器每次取元素处是挂起点的语法,如提案里的 for await x in stream |
没有稳定 |
AsyncIterator 的核心方法比 Iterator 多出「还没好」这个状态:
;
现在的写法是用 while let 把 .await 显式写出来:
// 异步信道
while let Some = rx.recv.await
// 实现了 Stream 的类型
use StreamExt;
while let Some = stream.next.await
效果和 for 相同,区别只是挂起点写在了明面上。
在异步代码里对同步信道写 for x in rx,编译能通过,但每次等待都会阻塞工作线程,后果见 6.7 节。
C++ 里是同一个问题:范围 for 展开成对 begin、end、operator++ 的同步调用,不能用于异步序列。协程技术规范里有过 for co_await (auto x : gen) 这个语法,在 C++20 定稿前被移除,C++20 里遍历异步生成器同样要手写循环加 co_await。
6.12 Stream 是什么?和 Iterator、Future 是什么关系?
Stream 是异步版本的 Iterator:一个会陆续产出多个值的异步序列,每取一个值都可能要等待。
四种抽象按「同步还是异步」「一个值还是多个值」排成一张表:
| 一个值 | 多个值 | |
|---|---|---|
| 同步 | 普通函数的返回值 | Iterator |
| 异步 | Future |
Stream |
trait 的定义在 futures 库里,标准库的对应物 AsyncIterator 还在 nightly:
和另外两个 trait 逐项对比:
Iterator::next |
Future::poll |
Stream::poll_next |
|
|---|---|---|---|
| 返回值 | Option<Item> |
Poll<Output> |
Poll<Option<Item>> |
| 有值 | Some(x) |
Ready(x) |
Ready(Some(x)) |
| 结束 | None |
不适用,只产出一次 | Ready(None) |
| 还没准备好 | 不能表达,只能阻塞 | Pending |
Pending |
| 接收者 | &mut self |
Pin<&mut Self> |
Pin<&mut Self> |
Stream 的返回值是 Future 的 Poll 套上 Iterator 的 Option,三种状态分别是:有一个值、序列结束、暂时没有。
怎么消费。 语言没有 for await,原因见 6.11 节。引入 StreamExt 后用 while let:
use StreamExt;
while let Some = ticks.next.await
next() 把「取下一个元素」包成一个 Future。它要求流是 Unpin 的,用 async_stream::stream! 或 unfold 造出来的流通常不是,使用前要先固定:let mut s = std::pin::pin!(s);。
怎么创建。
| 来源 | 写法 |
|---|---|
| 已有的集合 | tokio_stream::iter(vec) |
| 异步信道的接收端 | tokio_stream::wrappers::ReceiverStream::new(rx) |
| 定时器 | IntervalStream::new(tokio::time::interval(d)) |
| 一段带状态的异步逻辑 | futures::stream::unfold(state, f),f 接收当前状态,异步返回下一个元素和新状态 |
| 像写生成器一样 | async_stream::stream! { ... yield x; ... } |
| 网络连接 | WebSocket、gRPC 流式接口的读端本身就是 Stream |
写端的对应物是 Sink:一个可以异步写入多个值的目标。一条 WebSocket 连接通常被拆成一个 Stream 和一个 Sink,分别交给读任务和写任务。
生成器语法是这块的后续方向:gen 在 2024 edition 里被保留为关键字,gen 块和异步生成器目前都没有稳定。
6.13 Stream 怎么组合、怎么控制并发和背压?
StreamExt 提供的适配器和 Iterator 的一一对应,另外多出一组和时间、并发有关的:
| 适配器 | 作用 | 说明 |
|---|---|---|
map、filter、filter_map、take |
同步变换,和 Iterator 上的同名方法语义相同 |
闭包是同步的 |
then |
对每个元素执行一个异步操作,逐个进行 | 前一个完成才开始下一个 |
buffered(n) |
同时最多执行 n 个异步操作,输出保持原顺序 |
流的元素本身是 Future |
buffer_unordered(n) |
同时最多执行 n 个,谁先完成谁先输出 |
吞吐最高,顺序打乱 |
for_each_concurrent(n, f) |
以最多 n 的并发度消费整个流 |
没有输出 |
merge,或 futures::stream::select |
把两个流合并成一个,哪边有值就产出哪边的 | 多路行情合并 |
chunks(n)、chunks_timeout(n, d) |
攒够 n 个或等满 d 时间后成批输出 |
批量写库、批量下发 |
timeout(d) |
两个元素之间超过 d 则产出一个超时错误 |
心跳检测 |
throttle(d) |
相邻两个元素之间至少间隔 d |
限速 |
chunks_timeout、timeout、throttle 来自 tokio-stream,其余在 futures 和 tokio-stream 里都有。
并发抓取的典型写法:
use ;
let bodies: = iter
.map // 每个元素变成一个 Future,此时还没执行
.buffer_unordered // 同时最多 16 个在途
.collect
.await;
map 之后流里放的是还没开始的 Future,buffer_unordered(16) 才是真正驱动它们的地方。去掉这一行,后面的 collect 拿到的是一堆没执行的 Future。
背压。 Stream 是拉模型:下游调 poll_next,上游才产出下一个元素。下游处理得慢,上游自然被拖慢,这就是背压,不需要额外的机制。三处会破坏背压:
| 破坏背压的做法 | 后果 | 改法 |
|---|---|---|
| 生产者和消费者之间用无界信道 | 消费者慢时信道无限增长,内存耗尽 | 用有界信道,满了生产者的 send().await 会挂起 |
对每个元素 tokio::spawn 一个任务 |
任务数不受控 | 用 buffer_unordered(n) 或信号量限制并发 |
| 推送型数据源,比如行情 | 对方不会因为你慢而停下 | 明确选择一种策略:丢弃旧值只保留最新、合并、或断开慢的消费者 |
第三种在行情推送里最常见。只关心最新值时用 tokio::sync::watch;允许丢弃并要知道丢了多少时用 tokio::sync::broadcast,慢的接收者会收到一个带丢失数量的错误。
和 select! 一起用。 一个任务同时等多个来源时,把 stream.next() 放进 select! 的分支:
loop
tokio_stream 的 next() 是取消安全的:分支没被选中时,它返回的 Future 被丢弃,不会丢元素。取消安全的含义见 6.6 节。
6.14 tokio 的多线程调度器是怎么工作的?
每个工作线程有一个本地队列,另有一个所有线程共享的全局队列;线程优先跑自己队列里的任务,空了就去偷别的线程的。
全局队列(从运行时外部 spawn 的任务、本地队列溢出的任务)
│
┌─────────────┼─────────────┐
▼ ▼ ▼
工作线程 0 工作线程 1 工作线程 2
LIFO 槽 LIFO 槽 LIFO 槽
本地队列 本地队列 本地队列 ◄── 空闲的线程从别人的本地队列偷一半
│
└── 都没有任务时:线程挂起,等 IO 事件或定时器唤醒
| 组成 | 作用 |
|---|---|
| 本地队列 | 定长的环形队列,只有所属线程往里放,无锁;满了把一半移到全局队列 |
| LIFO 槽 | 存放最近一次被唤醒的任务,下一个就执行它。一个任务给另一个任务发消息后,接收方马上运行,数据还在缓存里 |
| 全局队列 | 多线程共享,有锁。工作线程每调度若干次就检查一次,防止里面的任务饿死 |
| 任务窃取 | 本地队列空时,随机选一个线程偷它一半的任务 |
| IO 驱动 | 基于 epoll、kqueue、IOCP,事件就绪时唤醒对应任务 |
| 时间驱动 | 分层时间轮,管理 sleep 和超时,精度 1 ms |
| 阻塞线程池 | 独立于工作线程,执行 spawn_blocking 的任务 |
队列长度、检查间隔这些具体数值是实现细节,随版本变化。
两种运行时:
| 多线程 | 当前线程 | |
|---|---|---|
| 启用方式 | #[tokio::main] 的默认行为 |
#[tokio::main(flavor = "current_thread")] |
| 工作线程 | 默认等于 CPU 核数 | 只有调用 block_on 的那个线程 |
| 任务要求 | Send,因为可能被偷到别的线程 |
配合 LocalSet 可以运行不是 Send 的任务 |
| 适用 | 服务端 | 测试、客户端、每核一个运行时的架构 |
#[tokio::main] 是一个属性宏,它把 async fn main 改写成:建一个运行时,再用 block_on 执行原来的函数体。
追问:和 Go 的调度器有什么相似。 结构基本一致:本地队列、全局队列、任务窃取。区别是 Go 的 goroutine 有栈且可被抢占,tokio 的任务无栈且只能在 .await 处让出,见 6.23 节。
6.15 spawn、spawn_blocking、block_on、block_in_place 有什么区别?
| 接口 | 做什么 | 在哪调用 | 对参数的要求 |
|---|---|---|---|
tokio::spawn(fut) |
创建一个新任务交给调度器,立即返回 JoinHandle |
运行时内 | Future 要 Send + 'static |
spawn_blocking(f) |
把一个同步闭包放到阻塞线程池执行,返回可 .await 的句柄 |
运行时内 | 闭包要 Send + 'static |
rt.block_on(fut) |
阻塞当前线程,直到 Future 完成。是同步世界进入异步世界的入口 |
运行时外的普通线程 | 无 |
block_in_place(f) |
在当前工作线程上直接执行阻塞代码,执行前先把这个线程上的其他任务交给别的线程 | 多线程运行时的任务内 | 闭包不要求 'static,可以借用局部变量 |
三个常见错误:
- 在异步代码里调
block_on。会 panic,提示不能在运行时内部再启动运行时。要等另一个Future就用.await。 - 在当前线程运行时里调
block_in_place。会 panic,因为没有别的线程可以接手任务。 - 用
spawn_blocking跑持续的 CPU 密集任务。阻塞线程池默认上限 512 个线程,是为偶发的阻塞调用准备的。长期的计算任务应该用独立的固定大小线程池,比如rayon,结果通过oneshot通道送回。
从同步代码里向运行时提交任务,用运行时的句柄:
let handle = current; // 在运行时内取得,可以克隆后带到别的线程
spawn;
6.16 任务和线程、和 Future 是什么关系?
Future 是一个待执行的计算;任务是交给调度器管理的一个顶层 Future;线程是执行任务的载体。
Future |
任务 | 线程 | |
|---|---|---|---|
| 是什么 | 一个状态机对象 | tokio::spawn 之后的 Future,加上调度需要的头部信息 |
操作系统线程 |
| 谁驱动 | 持有它的代码去 .await 或轮询它 |
调度器 | 操作系统 |
| 创建成本 | 一个栈上的值 | 一次堆分配,几十到几百字节 | 一块栈,MB 级的虚拟地址空间 |
| 并行 | 同一个任务里的多个 Future 是并发,不是并行 |
不同任务可以在不同线程上并行 | 并行 |
JoinHandle 的行为:
| 操作 | 结果 |
|---|---|
handle.await |
等任务结束,得到 Result<T, JoinError> |
丢弃 JoinHandle |
任务被分离,继续运行。这和丢弃一个普通 Future 就是取消不同 |
handle.abort() |
请求取消,任务在下一个 .await 处停止 |
| 任务内部 panic | 不会波及其他任务和运行时,handle.await 得到一个表示 panic 的 JoinError |
管理一组任务用 JoinSet:往里 spawn,用 join_next().await 逐个取结果;JoinSet 被丢弃时里面的任务全部被取消。
6.17 tokio 的通道有哪几种?怎么选?
| 通道 | 生产者与消费者 | 语义 | 典型用途 |
|---|---|---|---|
mpsc |
多对一 | 每条消息被消费一次;有界版本满了发送方挂起 | 任务间传递工作项,把状态交给一个任务独占 |
oneshot |
一对一 | 只能发一个值 | 请求和响应:把发送端随请求一起发过去,对方用它回结果 |
broadcast |
多对多 | 每个接收者都收到每一条消息;接收者太慢时最旧的消息被覆盖,它会收到一个带丢失数量的错误 | 事件通知、行情广播 |
watch |
一对多 | 只保留最新的一个值,接收者等「值变了」的通知 | 配置热更新、关闭信号、只关心最新状态 |
use ;
// 请求方
let = channel;
tx.send.await?;
let value = reply_rx.await?;
这是用消息传递代替共享状态的标准写法:状态由一个任务独占,其他任务通过 mpsc 发命令,用 oneshot 拿回结果。
追问
- 有界还是无界:默认用有界。无界通道在消费者变慢时内存会一直涨,见 6.13 节的背压。
- 容量设多大:有界通道的容量是缓冲突发的余量,不是吞吐的保证。设得很大只是把问题推迟。
- 发送端全部被丢弃会怎样:接收端的
recv()返回None,这是通知消费者结束的常用方式。
6.18 tokio 的同步原语有哪些?什么时候用 tokio::sync::Mutex?
| 原语 | 作用 |
|---|---|
Mutex、RwLock |
异步的锁:拿不到时挂起任务而不是阻塞线程,守卫可以跨 .await |
Semaphore |
限制并发度,比如最多同时处理 100 个请求、最多 16 个在途的下游调用 |
Notify |
不带数据的唤醒,一个任务等、另一个任务通知 |
OnceCell |
异步的一次性初始化 |
Barrier |
等指定数量的任务都到达再一起继续 |
锁的选择:
| 情况 | 用哪个 | 原因 |
|---|---|---|
临界区短,里面没有 .await |
标准库或 parking_lot 的 Mutex |
更快;tokio 的文档也是这样建议的 |
必须跨 .await 持锁,比如持锁期间要做 IO |
tokio::sync::Mutex |
标准库的守卫不是 Send,而且可能死锁,见 6.8 节 |
| 保护的是一个 IO 资源,比如一条共享的连接 | 改成一个任务独占它,其他任务用通道发请求 | 比锁更清晰,也没有持锁等待的问题 |
用信号量限制并发:
use Arc;
use Semaphore;
let sem = new;
for job in jobs
6.19 select! 的语义是什么?有哪些要注意的地方?
select! 在同一个任务里同时轮询多个分支,第一个完成的分支被执行,其余分支的 Future 被丢弃。
select!
| 规则 | 说明 |
|---|---|
| 并发不并行 | 所有分支在当前任务上轮询,同一时刻只有一个在执行 |
| 公平性 | 默认每次随机选一个分支开始检查,避免排在前面的分支总是赢 |
biased; |
写在第一行,改成按书写顺序检查。用于有优先级的场景,比如先看关闭信号 |
| 模式不匹配 | 分支写成 Some(x) = rx.recv() 时,结果是 None 则这个分支被禁用,继续等其他分支 |
else 分支 |
所有分支都被禁用时执行 |
| 未选中的分支 | Future 被丢弃,即被取消 |
要注意的三点:
- 取消安全。在循环里反复
select!时,每一轮没被选中的分支都会被取消重建。如果那个操作在两个.await之间持有中间状态,状态就丢了。tokio 文档为每个方法标注了是否取消安全,recv()是安全的,按长度读取固定字节数的read_exact不是。 - 想跨轮次保留同一个
Future,要在循环外创建并固定它,分支里用可变引用:
let sleep = sleep;
pin!;
loop
- 分支体里不要做长时间的事。分支体执行期间其他分支得不到轮询。
6.20 join!、select!、spawn 分别在什么时候用?
join! |
select! |
spawn |
|
|---|---|---|---|
| 等待 | 全部完成 | 第一个完成 | 不等待,返回句柄 |
| 在哪执行 | 当前任务内 | 当前任务内 | 新的任务,可能在别的线程 |
| 并行 | 否,并发 | 否,并发 | 是 |
| 能否借用局部变量 | 能 | 能 | 不能,要求 'static |
| 开销 | 无额外分配 | 无额外分配 | 一次堆分配加调度 |
| 用途 | 同时发起几个 IO,全部回来再继续 | 超时、多路等待、关闭信号 | 独立的后台工作、每个连接一个任务 |
try_join! 是 join! 的变体:任一分支返回 Err 就立即返回,其余分支被取消。
选择的依据:几个操作都是 IO 等待,用 join! 就够,没有必要 spawn。只有其中有 CPU 计算、想利用多核,或者要让它独立于当前任务的生命周期,才 spawn。
数量不固定时,join! 换成 futures::future::join_all,或用 6.13 节的 buffer_unordered 限制并发。
6.21 超时和定时器怎么用?
| 接口 | 作用 |
|---|---|
sleep(d) |
一个在 d 之后完成的 Future。不阻塞线程 |
timeout(d, fut) |
给任意 Future 加上限。超时返回 Err,同时 fut 被丢弃取消 |
interval(d) |
周期性触发,tick().await 等下一次 |
Instant |
tokio 自己的时刻类型,测试时可以被暂停和快进 |
match timeout.await
要点:
- 每个外部调用都要有超时。没有超时的
.await在对端不响应时会永远挂住,任务泄漏。 interval错过了触发点怎么办。由MissedTickBehavior决定:默认是连续补发,追上进度;Delay是从现在起重新计时;Skip是跳过错过的。周期性上报这类任务通常用Skip。- 超时只是不再等,不保证对端停下。超时后请求可能仍在下游执行,写操作要靠幂等来保证重试安全。
- 测试里不用真的等。启用测试工具后可以暂停时间,
sleep会立即推进虚拟时钟,带定时逻辑的测试毫秒级跑完。
6.22 tokio 服务怎么做优雅退出?
三步:通知、停止接收、等待完成。
use CancellationToken;
use TaskTracker;
let token = new;
let tracker = new;
// 接入循环
loop
// 3. 等在途任务结束,设上限
tracker.close;
let _ = timeout.await;
第 1 步在信号处理处:tokio::signal::ctrl_c().await 或监听 SIGTERM,收到后调用 token.cancel()。
| 部件 | 作用 |
|---|---|
CancellationToken |
可克隆、可派生子令牌的取消信号,任务在 select! 里等 cancelled() |
TaskTracker 或 JoinSet |
记录还有哪些任务没结束 |
| 最长等待时间 | 超过就强制退出,避免一个卡住的任务让进程永远退不掉 |
每个连接的处理循环里也要检查令牌:处理完当前请求后退出,而不是在请求处理到一半时被取消。
部署在 Kubernetes 上时,收到终止信号后还要先从服务发现摘除、等流量停止,再走上面的流程。
6.23 什么是协作式调度?任务会饿死别的任务吗?
会。tokio 的任务只在 .await 处让出线程,调度器不能强行打断一个正在运行的任务。一个任务如果长时间不 .await,它所在的工作线程就一直被占着。
| 情形 | 后果 | 处理 |
|---|---|---|
循环里做大量计算,没有 .await |
这个线程上的其他任务、定时器、IO 事件都得不到处理 | 计算放到独立线程池;或在循环里定期 tokio::task::yield_now().await |
| 调用同步的阻塞函数 | 同上 | spawn_blocking,见 6.7 节 |
一个任务的 .await 总是立刻就绪,比如从一个总有数据的通道里读 |
它每次让出后马上又能运行,别的任务排不上 | tokio 内置了预算机制,见下 |
预算机制:每个任务每次被调度时有一个操作数预算,tokio 自己的 IO、通道、定时器每成功一次就扣一点。预算用完后,这些操作即使数据已经就绪也返回 Pending,迫使任务让出。它只对 tokio 自己的资源生效,对纯 CPU 循环无效。
和 Go 的区别:Go 的运行时能抢占长时间运行的 goroutine,开发者不用操心这个问题。tokio 不能,所以「异步代码里不能有长时间不让出的段」是一条要自己遵守的规则。
6.24 tokio 的 IO 接口有什么要点?
| 主题 | 要点 |
|---|---|
| 核心 trait | AsyncRead 和 AsyncWrite,对应标准库的 Read 和 Write。日常用的 read、write_all、read_exact 等方法来自 AsyncReadExt、AsyncWriteExt |
| 缓冲 | BufReader、BufWriter 减少系统调用次数。BufWriter 要记得 flush().await |
| 读写分离 | TcpStream::split() 得到借用的两半,只能在同一个任务里用;into_split() 得到拥有所有权的两半,可以分别交给读任务和写任务 |
| 分帧 | 字节流没有消息边界。用 tokio_util::codec 的 Framed 加一个编解码器,把连接变成消息的 Stream 和 Sink |
| 文件 IO | tokio::fs 内部是把阻塞的文件操作放到阻塞线程池,不是真正的异步。大量小文件操作时开销明显 |
| io_uring | tokio 主体基于就绪通知模型。基于 io_uring 的完成通知模型在 tokio-uring 等独立的库里 |
一条连接常见的任务划分:
┌──────────── 读任务:从 socket 读 → 解码 → 发到业务通道
socket ───┤
└──────────── 写任务:从发送通道取 → 编码 → 写 socket
读写分给两个任务,写任务通过一个有界 mpsc 接收要发送的消息。这样任何任务想给这条连接发数据,都只是往通道里放一条消息,不需要锁住连接。
6.25 tokio 服务延迟高或卡住,怎么排查?
先判断是哪一类问题:
| 现象 | 可能的原因 | 怎么确认 |
|---|---|---|
| 所有请求都变慢,CPU 不高 | 工作线程被阻塞调用占住 | 看线程栈:工作线程停在同步的系统调用或锁上 |
| 所有请求都变慢,CPU 很高 | 任务里有长时间的计算不让出 | 火焰图里热点在业务计算上,而不在 tokio 内部 |
| 个别请求永远不返回 | 某个 .await 没有超时;或死锁 |
给外部调用加超时;检查是否跨 .await 持锁 |
| 内存持续上涨 | 无界通道堆积;任务泄漏 | 监控通道长度和存活任务数 |
| 延迟周期性抖动 | 定时任务或批处理占住了线程 | 对照抖动周期和定时任务的周期 |
工具:
| 工具 | 用途 |
|---|---|
tokio-console |
类似 top 的任务视图:每个任务被轮询了多少次、累计运行多久、上次让出是什么时候。能直接看出哪个任务长时间不让出 |
| 运行时指标 | 工作线程的轮询次数、偷取次数、全局队列深度、阻塞线程数。队列深度持续增长说明处理不过来 |
tracing |
给每个请求一个跨度,能跨 .await 保持上下文,输出每一段的耗时 |
perf 加火焰图 |
CPU 热点 |
一个简单有效的探测办法:起一个任务,每隔 10 ms sleep 一次并记录实际间隔。实际间隔远大于 10 ms,说明工作线程被占住了,调度出现了延迟。
7. 内存布局与 unsafe
7.1 常见类型有多大?
64 位平台:
| 类型 | 大小 | 说明 |
|---|---|---|
() |
0 | 零大小类型 |
bool |
1 | 只能是 0 或 1 |
char |
4 | 一个 Unicode 标量值,不是一个字节 |
&T、Box<T>,T 大小已知 |
8 | 一个指针 |
&[T]、&str |
16 | 指针加长度 |
&dyn Trait、Box<dyn Trait> |
16 | 指针加虚表指针 |
Option<&T>、Option<Box<T>> |
8 | 用空指针表示 None,见 7.2 节 |
Option<u32> |
8 | 4 字节的值加判别字段,再按 4 字节对齐 |
Option<NonZeroU32> |
4 | 用 0 表示 None |
String、Vec<T> |
24 | 指针、容量、长度。字段顺序实现相关 |
这些都可以在编译期断言:
use size_of;
use NonZeroU32;
const _: = assert!;
const _: = assert!;
const _: = assert!;
const _: = assert!;
const _: () = assert!(...) 相当于 C++ 的 static_assert,条件不成立时编译失败。
7.2 什么是 niche 优化?
如果一个类型有某些位模式是非法值,编译器就用这些非法值来表示枚举的其他变体,省掉单独的判别字段。
| 类型 | 非法值 | 用来表示 |
|---|---|---|
&T、Box<T>、NonNull<T> |
空指针 | Option 的 None |
NonZeroU32 |
0 | None |
bool |
2 到 255 | 外层枚举的其他变体 |
char |
大于 0x10FFFF 的值 |
同上 |
所以 Option<&T> 和 &T 一样大,没有额外开销。这也是 Rust 能去掉空指针却不损失性能的原因:可空的引用就是 Option<&T>,编译出来就是一个可能为空的指针,而类型系统强制你在使用前检查。
标准库对 Option<&T>、Option<Box<T>>、Option<NonNull<T>>、Option<NonZero*> 和函数指针保证了这个优化,并且保证 None 的表示就是全零。所以 FFI 里可以用 Option<&T> 对应 C 的可空指针。
7.3 repr(C)、repr(transparent) 是做什么的?
默认布局下,编译器可以重排结构体的字段以减少填充,具体排列不做保证。需要确定的布局时用 repr 属性:
| 属性 | 作用 | 用途 |
|---|---|---|
| 默认 | 编译器自由重排 | 一般代码 |
#[repr(C)] |
按声明顺序排列,对齐规则和 C 相同 | FFI、需要和 C 共享内存的结构 |
#[repr(transparent)] |
只有一个非零大小字段的结构体,布局和这个字段完全相同 | 新类型包装后仍能按内部类型传给 FFI |
#[repr(u8)] 等 |
指定枚举判别字段的整数类型 | 枚举和整数互转、和 C 枚举对应 |
#[repr(align(64))] |
提高对齐要求 | 让结构体独占一个缓存行,避免伪共享 |
#[repr(packed)] |
去掉填充 | 解析二进制协议;对其字段取引用是未对齐的,编译器会拒绝 |
// 默认:通常重排成 b、a、c,大小 8
// 按声明顺序:大小 12
A 的大小是实现相关的,B 的大小是确定的。
7.4 零大小类型和 PhantomData 有什么用?
零大小类型不占内存,Vec<()> 无论放多少个元素都不分配堆内存,HashMap<K, ()> 就是一个集合。常用于类型层面的标记。
PhantomData<T> 是一个零大小的标记,告诉编译器「这个类型在逻辑上持有或使用一个 T」,虽然它没有 T 类型的字段。用在两个场合:
| 场合 | 例子 |
|---|---|
| 持有裸指针的类型 | 自己实现的容器用 *mut T 存数据,加 PhantomData<T> 让编译器知道它拥有 T,正确推导 Send、Sync 和析构检查 |
| 类型状态 | struct Conn<State> { _s: PhantomData<State> },用类型参数区分已连接和未连接,在编译期阻止在未连接状态下发送 |
7.5 unsafe 能多做哪些事?
只多五件事:
- 解引用裸指针。
- 调用
unsafe函数,包括 FFI 函数。 - 读写可变的静态变量。
- 实现
unsafetrait,比如手动实现Send和Sync。 - 访问
union的字段。
unsafe 不关闭借用检查,也不关闭类型检查。它的含义是:编译器无法验证这五件事是否正确,由写代码的人来保证。
let x = 5;
let p = &x as *const i32; // 创建裸指针是安全的
let y = unsafe ; // 解引用需要 unsafe
2024 edition 的两个变化:unsafe fn 的函数体里直接做不安全操作会得到警告,要求再包一层 unsafe 块;extern 块必须写成 unsafe extern。目的都是让每一处不安全操作在代码里都看得见。
7.6 Rust 里有哪些未定义行为?
安全代码不会触发未定义行为。在 unsafe 代码里,下面这些都是:
| 类别 | 例子 |
|---|---|
| 数据竞争 | 两个线程通过裸指针同时写同一位置 |
| 悬垂或未对齐的指针被解引用 | 读已经释放的内存 |
| 违反别名规则 | 同时存在两个指向同一位置的 &mut;通过 &T 修改不在 UnsafeCell 里的数据 |
| 非法值 | bool 的值是 2;空的引用或 Box;越界的枚举判别值;char 落在代理区 |
| 读未初始化的内存 | 当整数读出来 |
| 错误的调用约定 | 以错误的签名调用 FFI 函数 |
别名规则这一条比 C++ 严格。C++ 里两个指向同一位置的非 const 指针很平常,Rust 里两个同时存在的 &mut 就是未定义行为,因为编译器会依据「&mut 是独占的」做优化。
需要未初始化的内存时用 MaybeUninit<T>,初始化完成后再转成 T,不要用 mem::zeroed 或 mem::uninitialized 去造一个假的值。
7.7 怎么写安全的 unsafe 封装?
原则是把 unsafe 关在一个小范围里,对外提供安全的接口,并且让任何安全代码都无法通过这个接口触发未定义行为。
| 做法 | 说明 |
|---|---|
缩小 unsafe 块 |
只包住真正需要的那一两行 |
| 写明前提 | 每个 unsafe 块上方用 // SAFETY: 注释说明为什么这里是安全的 |
| 在边界上检查 | 公开函数先检查参数,满足前提后再进 unsafe |
| 维护不变量 | 字段设为私有,所有修改都经过会维护不变量的方法 |
| 用 Miri 测试 | cargo +nightly miri test 解释执行测试,能发现越界、释放后使用、别名违规、未初始化读 |
标准库的 split_at_mut 是一个例子:内部用裸指针造出两个 &mut,借用检查器无法证明它们不重叠,但函数在进入 unsafe 之前检查了 mid <= len,所以对外是安全的。
7.8 Rust 和 C/C++ 怎么互相调用?
// 2024 edition 写法
pub extern "C"
unsafe extern "C"
| 要点 | 做法 |
|---|---|
| 调用约定 | 函数写 extern "C" |
| 数据布局 | 跨边界的结构体加 #[repr(C)] |
| 符号名 | 导出的函数加 no_mangle |
| 字符串 | Rust 的字符串不以零结尾。传给 C 用 CString,从 C 接收用 CStr |
| 所有权 | 谁分配谁释放。Rust 分配的内存交给 C 时,要同时导出一个释放函数,不能让 C 直接 free |
| panic | 不能展开到 C 里,见 4.3 节 |
| 生成绑定 | bindgen 从 C 头文件生成 Rust 声明;cbindgen 从 Rust 代码生成 C 头文件;和 C++ 交互可以用 cxx |
8. 宏、集合、工程与常用库
8.1 声明宏和过程宏有什么区别?
| 声明宏 | 过程宏 | |
|---|---|---|
| 写法 | macro_rules!,按语法模式匹配,替换成模板 |
一个编译期运行的 Rust 函数,输入输出都是词法单元流 |
| 位置 | 可以在任何 crate 里定义 | 必须放在单独的 proc-macro 类型的 crate 里 |
| 种类 | 一种 | 派生宏 #[derive(X)]、属性宏 #[x]、函数式宏 x!() |
| 常用库 | 无 | syn 解析语法树,quote 生成代码 |
| 例子 | vec!、println! |
serde 的 #[derive(Serialize)]、tokio 的 #[tokio::main] |
| 代价 | 小 | 编译时间明显增加 |
声明宏有卫生性:宏内部引入的局部变量不会和调用处的同名变量冲突。
let a = square!; // 25。$x 作为一个整体表达式代入,不是文本替换
同样的写法在 C 的宏里会得到 2 + 3 * 2 + 3 = 11。
8.2 Rust 的宏和泛型,分别对应 C++ 的什么?
| Rust 泛型 | Rust 宏 | C++ 模板 | C 预处理宏 | |
|---|---|---|---|---|
| 何时展开 | 类型检查之后单态化 | 类型检查之前,按语法树展开 | 实例化时 | 编译之前,按文本替换 |
| 类型检查 | 在定义处按约束检查一次 | 展开后检查 | 实例化时才检查,C++20 concept 能提前 | 无 |
| 错误信息 | 指向约束不满足的地方 | 指向展开后的代码 | 可能很长 | 难以定位 |
| 能做什么 | 类型参数化 | 生成任意代码,包括新的类型和 impl |
类型参数化加编译期计算 | 文本替换 |
Rust 泛型和 C++ 模板最大的区别是检查时机。Rust 在泛型函数定义处就按 trait 约束检查函数体,不满足约束的用法直接报错;C++ 模板要等实例化时才发现错误。
C++ 模板元编程能做的编译期计算,Rust 分别用 const fn、常量泛型和过程宏来做。
8.3 默认的 HashMap 有什么特点?
| 方面 | 默认行为 |
|---|---|
| 哈希函数 | SipHash-1-3,带进程级的随机种子 |
| 目的 | 抵抗哈希洪水攻击:攻击者无法构造大量冲突的键拖慢服务 |
| 代价 | 对整数这类短键,比非加密哈希慢 |
| 迭代顺序 | 不确定,每次运行都可能不同 |
| 底层实现 | 开放寻址的 SwissTable,实现相关 |
性能敏感又不面对不可信输入时,换成更快的哈希:
use FxHashMap;
let mut m: = default;
| 需求 | 选择 |
|---|---|
| 防攻击,默认 | std::collections::HashMap |
| 快,键是整数或短串 | rustc-hash 的 FxHashMap、ahash |
| 有序遍历、范围查询 | BTreeMap |
| 保持插入顺序 | indexmap |
| 多个节点要得到相同的遍历结果 | BTreeMap,或固定种子的哈希;不能依赖默认 HashMap 的迭代顺序 |
8.4 Vec 是怎么增长的?
容量不够时重新分配一块更大的内存,把元素移过去。当前实现每次至少翻倍,所以 push 的均摊时间复杂度是 O(1)。具体的增长因子和最小容量是实现相关的。
| 方法 | 作用 |
|---|---|
Vec::with_capacity(n) |
预先分配,已知大小时避免多次重新分配 |
reserve(n) |
保证至少还能放 n 个 |
clear() |
清空元素,保留容量。热路径上复用缓冲区就靠它 |
shrink_to_fit() |
释放多余的容量 |
和 C++ std::vector 的一个区别:Rust 的移动是按位拷贝,扩容时直接 memcpy 整块内存。C++ 要逐个调用移动构造函数,而且只有移动构造是 noexcept 时才会移动,否则退化成拷贝。
8.5 迭代器为什么能做到零开销?
迭代器适配器都是泛型结构体,每一层的 next 都能内联。编译器把整条链展开后,通常和手写的循环生成同样的机器码。
let sum: u64 = v.iter.filter.map.sum;
三种迭代方式:
| 方法 | 产出 | 之后原集合 |
|---|---|---|
iter() |
&T |
仍可用 |
iter_mut() |
&mut T |
仍可用 |
into_iter() |
T |
被消耗 |
for x in &v 等价于 for x in v.iter(),for x in v 等价于 for x in v.into_iter()。
迭代器还有一个性能上的好处:用迭代器遍历切片时,编译器知道下标不会越界,可以去掉边界检查。手写 for i in 0..v.len() { v[i] } 时,每次下标访问都有一次检查,除非编译器能证明它多余。
8.6 edition 是什么?
语言的版本次,目前有 2015、2018、2021、2024 四个。2024 edition 随 Rust 1.85 发布。
| 特点 | 说明 |
|---|---|
| 作用 | 引入不向后兼容的语法和默认行为变化,比如新关键字 |
| 按 crate 选择 | 在 Cargo.toml 里写 edition = "2024" |
| 互相兼容 | 不同 edition 的 crate 可以互相依赖,编译器把它们编译成同一种中间表示 |
| 迁移 | cargo fix --edition 自动改写大部分代码 |
这和 C++ 的语言标准不同:C++ 换标准是整个项目一起换,Rust 的每个依赖可以停在自己的 edition 上。
8.7 Cargo 工程有哪些常考的点?
| 主题 | 要点 |
|---|---|
| workspace | 多个 crate 共享一个 Cargo.lock 和输出目录,统一依赖版本 |
| features | 条件编译开关。设计上必须是可叠加的:整个依赖图里同一个 crate 的 feature 会取并集 |
| profiles | release 默认 opt-level = 3。追求性能时再加 lto = "fat"、codegen-units = 1、panic = "abort" |
build.rs |
构建脚本,编译 C 代码、生成代码、链接本地库 |
| 测试 | 单元测试和代码同文件,放在 #[cfg(test)] 模块里;集成测试放 tests/ 目录;文档里的代码示例也会被当成测试运行 |
| 基准测试 | 用第三方的 criterion,标准库的基准测试只在 nightly 可用 |
| 工具 | rustfmt 格式化,clippy 静态检查,cargo doc 生成文档 |
no_std |
不链接标准库,只用 core 和可选的 alloc,用于嵌入式和内核 |
8.8 Rust 程序怎么做性能分析?
| 工具 | 用途 |
|---|---|
perf 加火焰图 |
CPU 热点。cargo flamegraph 一条命令生成 |
perf stat |
缓存缺失、分支预测失败 |
criterion |
微基准,带统计分析,能判断改动是否显著 |
heaptrack、dhat |
内存分配的次数和来源 |
cargo-asm、Compiler Explorer |
看生成的汇编,确认是否内联、是否向量化 |
两个配置要点:
- release 构建默认没有调试信息,火焰图里看不到函数名。在
[profile.release]里加debug = true,不影响优化。 -C target-cpu=native能用上本机的全部指令集,但生成的二进制在旧 CPU 上可能无法运行。部署前要确认目标机器。
8.9 serde 是怎么工作的?
serde 把「数据结构」和「数据格式」拆成两边,中间用一套固定的数据模型连接。任何实现了序列化 trait 的类型,可以用于任何实现了序列化器的格式。
数据结构 serde 数据模型 数据格式
struct、enum、Vec … ──► bool、整数、字符串、序列、 ──► JSON、bincode、
实现 Serialize 映射、结构体、枚举等 29 种类型 MessagePack、TOML …
实现 Deserialize ◄── ◄── 实现 Serializer / Deserializer
四个核心 trait:
| trait | 谁来实现 | 作用 |
|---|---|---|
Serialize |
数据结构,通常用派生宏 | 把自己描述成数据模型里的类型,逐项交给序列化器 |
Serializer |
格式库,如 serde_json |
把数据模型里的每种类型写成具体格式 |
Deserialize<'de> |
数据结构,通常用派生宏 | 提供一个访问者,告诉反序列化器自己期望什么 |
Deserializer<'de> |
格式库 | 解析输入,按遇到的内容回调访问者 |
use ;
let text = to_string?;
let back: Order = from_str?;
三个要点:
- 没有反射,没有运行时开销。
#[derive(Serialize)]是过程宏,编译期为这个类型生成一段逐字段调用序列化器的代码。序列化器是泛型参数,Order配serde_json会单态化出一份专用代码,字段名、字段顺序都是编译期常量。 - 格式之间可以互换。 同一个
Order不改代码就能输出 JSON、bincode 或 MessagePack,换的只是调用哪个格式库。 - 反序列化用访问者模式。 由反序列化器驱动:它解析到一个映射,就调用访问者的
visit_map;访问者按字段名把值填进结构体。这样自描述格式和非自描述格式都能用同一套接口。
对比其他语言:
| Rust serde | Go encoding/json |
Java Jackson | C++ | |
|---|---|---|---|---|
| 机制 | 编译期生成代码 | 运行时反射 | 运行时反射加注解 | 手写,或宏列出字段;C++26 起可用静态反射 |
| 字段映射错误 | 编译期或反序列化时报错 | 运行时 | 运行时 | 取决于库 |
| 性能 | 高,可内联 | 反射有开销 | 反射有开销,可生成字节码缓解 | 高 |
8.10 serde 有哪些常用属性?有哪些常见问题?
属性写在类型或字段上,改变生成的代码:
| 属性 | 位置 | 作用 |
|---|---|---|
#[serde(rename = "x")] |
字段、变体 | 改名 |
#[serde(rename_all = "camelCase")] |
类型 | 所有字段按规则改名 |
#[serde(default)] |
字段、类型 | 输入里缺这个字段时用默认值,而不是报错 |
#[serde(skip)] |
字段 | 不参与序列化和反序列化 |
#[serde(skip_serializing_if = "Option::is_none")] |
字段 | 满足条件时不输出这个字段 |
#[serde(flatten)] |
字段 | 把内嵌结构体的字段摊平到外层 |
#[serde(with = "模块")] |
字段 | 用自定义的函数来序列化这个字段,比如时间戳、十六进制串 |
#[serde(deny_unknown_fields)] |
类型 | 输入里有未知字段时报错。默认是忽略 |
#[serde(borrow)] |
字段 | 允许这个字段借用输入缓冲区 |
枚举的四种表示。 这是对接外部接口时最常用到的:
// 内部标签
| 表示 | 属性 | Event::Trade { price: 1.5 } 的 JSON |
|---|---|---|
| 外部标签,默认 | 无 | {"Trade": {"price": 1.5}} |
| 内部标签 | tag = "type" |
{"type": "Trade", "price": 1.5} |
| 相邻标签 | tag = "t", content = "c" |
{"t": "Trade", "c": {"price": 1.5}} |
| 无标签 | untagged |
{"price": 1.5},反序列化时按声明顺序逐个尝试 |
零拷贝反序列化。 Deserialize<'de> 的生命周期参数 'de 表示输入数据的生命周期,结果可以借用输入:
let m: Msg = from_str?; // m 不能比 input 活得长
| 概念 | 含义 |
|---|---|
Deserialize<'de> |
结果可能借用输入,受输入的生命周期约束 |
DeserializeOwned |
结果完全拥有数据,等价于「对任意 'de 都实现了 Deserialize<'de>」。从流里读取时要求它,因为没有可借用的缓冲区 |
JSON 字符串里有转义字符时,内容和输入的字节不一致,&str 借用不了,会报错。这种字段用 Cow<'a, str> 加 #[serde(borrow)]:没有转义时借用,有转义时分配。
常见问题。
| 问题 | 说明 |
|---|---|
untagged 和 flatten 慢、报错差 |
两者都要先把输入缓存成中间表示再尝试匹配。untagged 失败时只说「没有变体匹配」,看不出错在哪个字段 |
| 大整数精度 | JSON 的数字在很多语言里是双精度浮点。64 位的订单号、金额建议用字符串传 |
| 浮点数表示金额 | 序列化往返可能改变末位。金额用整数最小单位或十进制类型 |
| 协议演进 | 新增字段加 default,不要用 deny_unknown_fields,否则旧版本读不了新数据 |
| 二进制格式不自描述 | bincode 这类格式不带字段名,增删字段或调整顺序都会破坏兼容 |
| 派生宏拖慢编译 | 类型多时过程宏的展开占编译时间的可观比例 |
| 追求极致性能 | simd-json 用 SIMD 加速解析;rkyv 不走 serde,直接把字节当结构体访问,没有反序列化这一步 |
8.11 Rust 访问数据库有哪些选择?diesel、SeaORM、sqlx 有什么区别?
| diesel | SeaORM | sqlx | |
|---|---|---|---|
| 定位 | ORM 加查询构造器 | ORM | 不是 ORM,直接写 SQL |
| 同步还是异步 | 同步;异步要用独立的 diesel-async |
异步 | 异步 |
| 查询怎么写 | 类型化的 DSL | 实体加方法链,可在运行时动态拼装 | SQL 字符串 |
| 正确性检查在什么时候 | 编译期:靠类型系统,根据 schema.rs 检查表、列和类型 |
运行时为主 | 编译期:query! 宏在编译时连数据库或读离线元数据,校验 SQL |
| 底层 | 自己的驱动封装 | 建在 sqlx 和 SeaQuery 之上 | 自己实现的异步驱动 |
| 学习成本 | 高,类型错误信息很长 | 低,接近其他语言的 ORM | 低,会 SQL 就行 |
| 动态查询 | 较难,要用装箱的查询类型 | 容易 | 用 QueryBuilder 拼 |
| 复杂 SQL | DSL 表达不了的要退回原生 SQL | 同左 | 天然支持 |
选择的依据:
| 需求 | 选择 |
|---|---|
| 想让编译器保证查询和表结构一致,团队接受较陡的学习曲线 | diesel |
| 异步 Web 服务,增删改查为主,希望上手快、查询条件动态组合 | SeaORM |
| SQL 复杂、想完全控制 SQL,又要编译期校验 | sqlx |
| 对延迟极敏感的路径 | 直接用驱动加预编译语句,不经过 ORM |
三者都用参数绑定传值,不拼接字符串,默认不存在 SQL 注入。风险只出现在自己把用户输入拼进原生 SQL 的地方。
8.12 diesel 怎么做到编译期检查查询?
靠一份描述表结构的代码加上类型系统。
第一步:schema.rs。 diesel 的命令行工具根据数据库里的实际表结构生成:
table!
table! 宏为每张表生成一个模块,每一列是一个零大小的类型,带着它的 SQL 类型。
第二步:模型结构体。
use *;
第三步:查询。 DSL 的每个方法都返回一个新的类型,整条查询的类型就是它的结构:
let rows: = table
.filter
.order
.limit
.select
.load?;
| 写错了什么 | 结果 |
|---|---|
| 列名拼错 | 编译错误:模块里没有这个名字 |
| 拿字符串和整数列比较 | 编译错误:类型不满足比较的 trait 约束 |
结构体字段类型和列类型对不上,比如可空列对应了 i32 |
编译错误 |
| 过滤条件引用了查询里没有的表 | 编译错误 |
代价是两点:类型错误的报错信息很长,新手难读;schema.rs 必须和数据库保持同步,表结构变更后要重新生成。
在异步服务里用 diesel。 diesel 的连接是同步的,直接在 tokio 的任务里调用会阻塞工作线程,见 6.7 节。两种做法:
| 做法 | 说明 |
|---|---|
spawn_blocking 加同步连接池 r2d2 |
每次数据库操作放到阻塞线程池 |
diesel-async |
提供异步的连接和同样的 DSL,配 deadpool 或 bb8 连接池 |
8.13 SeaORM 怎么用?
每张表对应一组生成的类型,查询通过实体上的方法链完成。
use *;
派生宏从 Model 生成四样东西:
| 类型 | 作用 |
|---|---|
Entity |
代表这张表,查询的入口 |
Model |
一行数据,只读 |
Column |
列的枚举,用来写条件和排序 |
ActiveModel |
可修改的一行,每个字段是「已设置」或「未设置」,用于插入和更新 |
use ;
// 查询
let rows: = find
.filter
.order_by_desc
.limit
.all
.await?;
// 插入:只设置需要的字段,其余交给数据库默认值
let user = ActiveModel
.insert
.await?;
// 更新:只有被 Set 的字段出现在 UPDATE 语句里
let mut am: ActiveModel = user.into;
am.age = Set;
am.update.await?;
| 主题 | 做法 |
|---|---|
| 动态条件 | 用 Condition::all() 按需 add,运行时拼出 WHERE |
| 关联 | 在 Relation 里声明,find_related、find_with_related 查询 |
| 事务 | let txn = db.begin().await?; 之后把 &txn 传给各操作,最后 txn.commit().await? |
| 实体代码从哪来 | sea-orm-cli 根据现有数据库生成;也可以手写 |
| 迁移 | sea-orm-migration,用 Rust 代码描述表结构变更 |
和 diesel 的取舍:SeaORM 的列是枚举值而不是类型,条件可以在运行时自由组合,写法简单;相应地,列和值的类型不匹配这类错误要到运行时才暴露。
8.14 在异步服务里访问数据库,有哪些常见问题?
| 问题 | 原因 | 处理 |
|---|---|---|
| N+1 查询 | 先查出 N 行,再对每一行各发一条查询取关联数据 | 一次联表查询,或用 IN 批量取。SeaORM 有按批加载关联的接口 |
| 连接池耗尽 | 拿着连接去做别的耗时操作,比如调外部接口 | 连接只在执行 SQL 时持有;池的大小、获取超时都要配置 |
| 池设得越大越好 | 数据库能并行处理的查询数有限,连接多了只是在数据库侧排队 | 池大小按数据库的承载能力定,通常是几十,不是几百 |
| 同步驱动阻塞运行时 | 在任务里直接调同步的数据库接口 | spawn_blocking,或换异步驱动 |
| 事务被取消 | 事务进行到一半,所在的 Future 被超时或 select! 丢弃 |
事务对象被丢弃时会回滚,数据不会坏;但要知道这次操作没有生效,调用方按失败处理 |
| 长事务 | 事务里夹了网络调用或大量计算 | 事务只包住必要的 SQL,其他事情放到事务之外 |
| 没有超时 | 慢查询把连接占住 | 语句级超时加 tokio::time::timeout |
| 每次请求都准备语句 | 重复解析 SQL | 用驱动的预编译语句缓存 |
连接池为什么重要:建立一条数据库连接要经过 TCP 握手、TLS 握手和认证,耗时在毫秒到几十毫秒。池把连接复用起来,请求到来时直接取用。
9. 工程场景题
9.1 多个节点要得到完全相同的计算结果,Rust 里要注意什么?
区块链节点、回放系统、对拍测试都要求同样的输入在任何机器上得到逐位相同的输出。
| 非确定性来源 | 规则 |
|---|---|
HashMap 的迭代顺序 |
每次运行都不同。不依赖它的顺序,需要遍历时用 BTreeMap 或先收集再排序;或者用固定种子的哈希 |
| 浮点数 | 不同指令集、编译选项下结果可能不同,比如是否使用融合乘加。金额和价格一律用定点整数 |
usize |
32 位和 64 位平台大小不同。不让它参与持久化或哈希 |
| 整数溢出 | release 默认回绕,debug 会 panic,两种构建行为不同。用 checked_*,溢出即拒绝这次操作 |
| 时间 | 不读本机时钟,只用输入里给定的时间戳 |
| 随机数 | 不用,或者用输入里给定的种子 |
| 线程调度 | 多线程的输出先写到线程本地,汇总时按确定的键排序 |
| 内存地址 | 不用指针值做键或排序依据 |
验证方法:同一份输入用不同的线程数各跑一遍,比较输出的哈希。
9.2 低延迟的热路径上,Rust 有哪些优化手段?
| 手段 | 做法 |
|---|---|
| 不分配内存 | 对象池或 slab 复用固定大小的对象;Vec 用 clear() 跨轮复用;请求级的临时对象用竞技场分配器,比如 bumpalo,请求结束整块释放 |
| 少用原子操作 | 热路径上不克隆 Arc,传引用;计数器按线程分开,汇总时再加 |
| 少用动态分派 | 热循环里用泛型或枚举分派而不是 dyn Trait,让编译器能内联;必须运行时分派时把粒度提到逐批,见 3.14 节 |
| 消除边界检查 | 用迭代器、chunks_exact;或在循环前做一次断言让编译器知道下标不会越界 |
| 避免伪共享 | 不同线程频繁写的数据各自独占缓存行:#[repr(align(64))],或 crossbeam 的 CachePadded |
| 线程绑核 | 计算线程固定在指定核上,比如用 core_affinity |
| 不用异步 | 计算密集的路径用固定线程池跑到完成,避免调度器带来的延迟抖动 |
| 数据布局 | 按访问模式组织,常一起访问的字段放在一起;大数组按结构的数组改成数组的结构 |
先用火焰图确认热点,再动手。
9.3 用 tokio 写网络服务,要注意什么?
| 问题 | 做法 |
|---|---|
| 连接处理 | 每个连接 tokio::spawn 一个任务 |
| 背压 | 任务之间用有界通道;连接数和在途请求数设上限 |
| 超时 | 每个外部调用都包 tokio::time::timeout,否则一个慢的下游会拖住任务 |
| 阻塞调用 | 放进 spawn_blocking,见 6.7 节 |
| 优雅退出 | 用 CancellationToken 或广播通道通知所有任务;停止接受新连接,等在途请求完成,设一个最长等待时间 |
| 共享状态 | 优先消息传递;必须共享时用 Arc,加锁时不跨 .await,见 6.8 节 |
| 可观测性 | tracing 库按请求记录跨度,能跨 .await 保持上下文 |
9.4 Rust 和其他语言一起用时,要注意什么?
| 场景 | 方案 |
|---|---|
| 被 C 或 C++ 调用 | 导出 C ABI,见 7.8 节 |
| 被 Flutter 调用 | flutter_rust_bridge 生成两侧的绑定,底层是 C ABI 加 Dart 的 FFI |
| 被 Python 调用 | PyO3 写扩展模块,maturin 打包 |
| 和 C++ 双向调用 | cxx 在两侧生成类型安全的绑定,能直接传递 String、Vec、unique_ptr |
共同的注意点:
- 跨边界的内存由分配它的一方释放。
- panic 不能展开出 Rust 的边界。
- 跨边界的回调可能来自对方的线程,回调里访问的 Rust 数据要满足
Send或Sync。
9.5 新项目为什么选 Rust 而不是 C++?
| 维度 | Rust 的优势 | Rust 的代价 |
|---|---|---|
| 内存安全 | 安全代码里没有释放后使用、越界、数据竞争 | 借用检查有学习成本,有些结构写起来别扭 |
| 并发 | 线程安全写进类型,重构时不会悄悄引入数据竞争 | 无 |
| 工具链 | Cargo 统一了构建、依赖、测试、格式化 | 无 |
| 性能 | 和 C++ 同一量级,没有运行时 | 编译慢 |
| 生态 | 网络、序列化、异步成熟 | 部分领域的库不如 C++ 多,比如图形、音视频、部分科学计算 |
| 团队 | 新人写出的代码更不容易出内存错误 | 招聘和培训成本 |
适合选 Rust 的:新写的、对安全和并发正确性要求高的系统,比如网络代理、区块链节点、存储引擎。继续用 C++ 的:要和大量已有 C++ 代码深度集成的项目。
10. 高频题索引
按前面各节的顺序列出五十个最常被问到的问题,每题指向展开讲解的小节。
| # | 类别 | 问题 | 见 |
|---|---|---|---|
| 1 | 所有权 | 所有权的三条规则是什么 | 1.1 |
| 2 | 所有权 | 移动、拷贝、克隆有什么区别 | 1.2 |
| 3 | 所有权 | 借用和引用是什么关系,借用规则是什么,为什么这样设计 | 1.3 |
| 4 | 所有权 | 生命周期标注解决什么问题,省略规则是什么 | 1.4、1.5 |
| 5 | 所有权 | 'static 是什么意思 |
1.6 |
| 6 | 所有权 | String、&str、&String 有什么区别 |
1.7 |
| 7 | 所有权 | 借用检查器拒绝了正确的代码怎么办 | 1.8 |
| 8 | 智能指针 | Box、Rc、Arc 各自什么时候用,为什么 Rc 不能跨线程 |
2.1、2.2 |
| 9 | 智能指针 | 循环引用怎么处理 | 2.3 |
| 10 | 智能指针 | 什么是内部可变性,Cell 和 RefCell 怎么用 |
2.4 |
| 11 | 智能指针 | RefCell 和 Mutex 有什么区别 |
2.5 |
| 12 | 智能指针 | Rust 的 Mutex 和 C++ 的 std::mutex 有什么不同 |
2.6 |
| 13 | 智能指针 | 析构的调用顺序是怎样的 | 2.8 |
| 14 | trait | 静态分派和动态分派有什么区别 | 3.2 |
| 15 | trait | dyn Trait 在内存里长什么样 |
3.3 |
| 16 | trait | 哪些 trait 可以做成 dyn Trait |
3.4 |
| 17 | trait | 关联类型和泛型参数怎么选 | 3.5 |
| 18 | trait | 孤儿规则是什么 | 3.6 |
| 19 | trait | 闭包的 Fn、FnMut、FnOnce 有什么区别 |
3.9 |
| 20 | trait | Rust 为什么没有继承 | 3.11 |
| 21 | trait | trait 对象和 Go 的 interface、C++ 的虚函数有什么异同 | 3.12、3.13 |
| 22 | trait | dyn Trait 的性能开销在哪,什么时候要避开 |
3.14 |
| 23 | 错误处理 | Rust 为什么没有异常,? 做了什么 |
4.1、4.2 |
| 24 | 错误处理 | 什么时候用 panic,什么时候用 Result |
4.3 |
| 25 | 错误处理 | 整数溢出会怎样 | 4.5 |
| 26 | 并发 | Send 和 Sync 是什么,举一个只满足其中一个的类型 |
5.1 |
| 27 | 并发 | Rust 怎么保证没有数据竞争,能不能保证没有死锁 | 5.2、5.3 |
| 28 | 并发 | 共享状态加锁还是消息传递 | 5.4 |
| 29 | 并发 | 作用域线程解决什么问题 | 5.5 |
| 30 | 并发 | 原子操作的内存序怎么选 | 5.6 |
| 31 | 异步 | Future 是什么,async fn 编译成什么 |
6.1、6.2 |
| 32 | 异步 | 为什么需要运行时,Waker 的作用是什么 |
6.3、6.4 |
| 33 | 异步 | 为什么需要 Pin,Pin 和 Unpin 的原理是什么 |
6.5 |
| 34 | 异步 | 异步任务怎么取消,什么是取消安全 | 6.6 |
| 35 | 异步 | 在异步代码里调用阻塞函数、跨 .await 持有锁会怎样 |
6.7、6.8 |
| 36 | 异步 | Rust 的异步和 C++20 协程有什么区别,是有栈还是无栈 | 6.9 |
| 37 | 异步 | Stream 是什么,怎么做背压 |
6.12、6.13 |
| 38 | tokio | tokio 的多线程调度器是怎么工作的 | 6.14 |
| 39 | tokio | spawn、spawn_blocking、block_on、block_in_place 有什么区别 |
6.15 |
| 40 | tokio | 任务和线程、和 Future 是什么关系 |
6.16 |
| 41 | tokio | tokio 的通道有哪几种,怎么选 | 6.17 |
| 42 | tokio | 什么时候用 tokio::sync::Mutex,怎么限制并发 |
6.18 |
| 43 | tokio | select! 的语义和注意事项,和 join!、spawn 怎么选 |
6.19、6.20 |
| 44 | tokio | 超时怎么做,服务怎么优雅退出 | 6.21、6.22 |
| 45 | tokio | 什么是协作式调度,延迟高或卡住怎么排查 | 6.23、6.25 |
| 46 | 底层 | 常见类型有多大,什么是 niche 优化 | 7.1、7.2 |
| 47 | 底层 | unsafe 能多做哪些事,有哪些未定义行为,怎么封装 |
7.5、7.6、7.7 |
| 48 | 底层 | Rust 和 C/C++ 怎么互相调用 | 7.8 |
| 49 | 工程 | 声明宏和过程宏有什么区别;默认的 HashMap 有什么特点 |
8.1、8.3 |
| 50 | 工程 | 新项目为什么选 Rust 而不是 C++,两者有哪些异同 | 9.5、11.1 |
11. Rust 与 C++ 对照总表
两种语言的定位相同:没有垃圾回收、编译成本地代码、抽象不带运行时开销的系统编程语言。差别集中在一点:C++ 把正确性交给程序员和规范,Rust 把其中内存安全和线程安全的部分交给编译器检查。
「异同」一列的含义:同表示机制和语义基本一致;近表示思想相同、细节有别;异表示做法不同。最后一列指向本文展开讲的小节。
11.1 总览
| 相同的地方 | 最大的不同 |
|---|---|
| 没有垃圾回收,没有重量级运行时 | 内存安全:C++ 靠规范和工具,Rust 靠编译期的所有权与借用检查 |
| RAII:资源绑定在对象的生命周期上 | 赋值的默认语义:C++ 拷贝,Rust 移动 |
| 泛型通过单态化实现,零开销 | 泛型的检查时机:C++ 在实例化时,Rust 在定义处按 trait 约束检查 |
| 值语义,对象可以放在栈上,能精确控制内存布局 | 错误处理:C++ 用异常,Rust 用返回值 |
| 能直接调用 C,能写内联汇编,能做裸机开发 | 未定义行为的范围:C++ 遍布语言各处,Rust 限制在 unsafe 块里 |
| 运行时多态都基于虚表 | 多态的组织方式:C++ 用继承,Rust 用 trait 加组合 |
| 性能在同一量级,后端都可以是 LLVM | 工程体系:C++ 多编译器、多构建系统、ISO 标准;Rust 一个参考编译器加 Cargo |
11.2 内存与资源管理
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 资源释放 | 析构函数,RAII | Drop,RAII |
同 | 2.8 |
| 所有权 | 惯用法,可以绕开 | 语言规则,编译器强制 | 异 | 1.1 |
| 赋值和传参的默认行为 | 拷贝 | 移动 | 异 | 1.2 |
| 移动的实现 | 移动构造函数,可自定义 | 按位拷贝,不可自定义,不会失败 | 异 | 1.2 |
| 移动之后的源对象 | 仍然存在,状态有效但未指定,照常析构 | 不能再使用,不会被析构 | 异 | 1.2 |
| 显式拷贝 | 拷贝构造函数,隐式调用 | .clone(),显式调用 |
近 | 1.2 |
| 平凡拷贝的类型 | 可平凡拷贝类型 | Copy |
近 | 1.2 |
| 局部变量的析构顺序 | 声明的逆序 | 声明的逆序 | 同 | 2.8 |
| 成员的析构顺序 | 声明的逆序 | 声明的顺序 | 异 | 2.8 |
| 独占的堆指针 | std::unique_ptr<T>,可为空 |
Box<T>,不可为空 |
近 | 2.1 |
| 共享的堆指针 | std::shared_ptr<T>,计数总是原子的 |
Rc<T> 非原子,Arc<T> 原子 |
近 | 2.2 |
| 弱引用 | std::weak_ptr<T> |
Weak<T> |
同 | 2.3 |
| 空指针 | nullptr,任何指针都可能为空 |
引用不可为空,可空用 Option<&T>,大小不变 |
异 | 7.2 |
| 悬垂引用 | 可能,编译器不检查 | 编译不过 | 异 | 1.3、1.4 |
| 读未初始化的变量 | 可以写出来,是未定义行为 | 编译不过 | 异 | 7.6 |
new 和 delete |
有 | 没有,分配由 Box、Vec 等类型完成 |
异 | 2.1 |
| 内存泄漏 | 可能 | 可能,并且算安全代码 | 同 | 2.3、2.8 |
| 自定义分配器 | 容器的分配器模板参数 | 全局分配器可替换;容器级的分配器参数未稳定 | 近 | 9.2 |
11.3 类型系统与语法
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 变量默认是否可变 | 可变,const 要显式写 |
不可变,mut 要显式写 |
异 | — |
| 类型推断 | auto,只看初始化表达式 |
let,可以根据后面的用法反推 |
近 | — |
| 隐式类型转换 | 多:数值提升、转换构造函数、转换运算符 | 几乎没有:数值转换要写 as 或 into(),只有解引用和非定长两类强制转换 |
异 | 3.7 |
| 引用 | 别名,不可重新绑定,不能放进容器 | 一个指针值,可以重新赋值,能放进容器 | 异 | 1.3 |
| 自定义数据类型 | class、struct |
struct |
近 | — |
| 带数据的枚举 | std::variant,C++17 |
enum,语言内置 |
近 | 7.2 |
| 可空的值 | std::optional<T>,C++17 |
Option<T> |
近 | 7.2 |
| 值或错误 | std::expected<T, E>,C++23 |
Result<T, E> |
近 | 4.1 |
| 模式匹配 | 只有结构化绑定;完整的模式匹配还是提案 | match、if let、let else,带穷尽性检查 |
异 | — |
| 字符串 | std::string,任意字节,有小字符串优化 |
String,保证 UTF-8,没有小字符串优化 |
异 | 1.7 |
| 字符串视图 | std::string_view |
&str |
近 | 1.7 |
| 连续内存的视图 | std::span<T>,C++20 |
&[T] |
近 | 1.7 |
| 闭包 | lambda,手动指定捕获方式 | 闭包,按用法推断捕获方式 | 近 | 3.9 |
| 类型擦除的可调用对象 | std::function |
Box<dyn Fn()> |
近 | 3.9 |
| 函数重载 | 有 | 没有 | 异 | 3.11 |
| 默认参数 | 有 | 没有 | 异 | 3.11 |
| 运算符重载 | 有,写成成员或自由函数 | 有,通过实现 Add、Index 等 trait |
近 | 3.10 |
| 构造函数 | 有,可重载,失败靠异常 | 没有,用返回值的关联函数,失败返回 Result |
异 | 3.11 |
| 成员的默认可见性 | struct 公开,class 私有,以类为边界 |
私有,以模块为边界 | 异 | — |
| 代码组织 | 头文件加源文件;C++20 起有模块 | 模块加 crate,没有头文件 | 异 | 8.7 |
| 整数溢出 | 有符号是未定义行为,无符号回绕 | debug 下 panic,release 下回绕,都不是未定义行为 | 异 | 4.5 |
| 默认的字段布局 | 按声明顺序 | 编译器可以重排,repr(C) 才按声明顺序 |
异 | 7.3 |
11.4 泛型与元编程
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 实现方式 | 模板,单态化 | 泛型,单态化 | 同 | 3.2 |
| 约束 | concept,C++20;之前靠 SFINAE | trait 约束 | 近 | 3.1 |
| 检查时机 | 实例化时才检查函数体 | 定义处就按约束检查函数体 | 异 | 8.2 |
| 不写约束 | 可以,鸭子类型 | 不可以,用到的每个操作都要有约束支持 | 异 | 8.2 |
| 特化 | 支持全特化和偏特化 | 未稳定 | 异 | — |
| 可变参数 | 可变参数模板 | 没有,用宏或元组代替 | 异 | — |
| 值作为参数 | 非类型模板参数,范围较广 | 常量泛型,稳定版只支持整数、bool、char |
近 | — |
| 编译期计算 | constexpr、consteval |
const fn |
近 | 8.2 |
| 宏 | 预处理器,文本替换,不卫生 | 声明宏按语法匹配且卫生;过程宏操作词法单元流 | 异 | 8.1 |
| 反射 | C++26 加入编译期静态反射和注解 | 没有反射,用派生宏在编译期生成代码 | 异 | 8.1 |
| 模板元编程 | 图灵完备,常用来做类型计算 | 用 trait 的关联类型、常量泛型和过程宏完成 | 近 | 3.5 |
11.5 多态
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 动态多态 | 虚函数 | dyn Trait |
近 | 3.13 |
| 虚表指针的位置 | 对象内部 | 胖指针里 | 异 | 3.3、3.13 |
| 何时决定用动态分派 | 定义类时 | 使用处 | 异 | 3.13 |
| 静态多态 | 模板、CRTP | 泛型加 trait 约束 | 近 | 3.13 |
| 接口 | 抽象基类 | trait | 近 | 3.1 |
| 代码复用 | 继承 | 组合、trait 的默认方法 | 异 | 3.11 |
| 多重继承 | 支持,有菱形继承问题 | 没有继承;一个类型可以实现多个 trait | 异 | 3.11 |
| 给已有类型增加行为 | 不能增加成员函数,只能写自由函数 | 可以为它实现自己定义的 trait | 异 | 3.6 |
| 向下转型 | dynamic_cast,靠运行时类型信息 |
借助 Any |
异 | 3.12 |
| 虚析构 | 要手动声明,忘了是未定义行为 | 析构函数总在虚表里 | 异 | 3.13 |
| 动态分派的开销 | 间接调用,挡住内联 | 相同 | 同 | 3.14 |
11.6 错误处理
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 可恢复的错误 | 异常;也有用错误码和 std::expected 的 |
Result<T, E> |
异 | 4.1 |
| 签名里能否看出会失败 | 看不出,noexcept 只是承诺 |
返回类型就说明了 | 异 | 4.1 |
| 错误向上传播 | 自动,隐式 | ?,显式 |
异 | 4.2 |
| 成功路径的开销 | 零开销模型下没有 | 一次分支 | 异 | 4.1 |
| 失败路径的开销 | 栈回溯,很贵 | 和成功路径相同 | 异 | 4.1 |
| 不可恢复的错误 | assert、std::terminate、abort |
panic! |
近 | 4.3 |
| 栈展开 | 异常传播时展开 | panic 时默认展开,可配置成直接终止 | 近 | 4.3 |
| 关闭异常 | -fno-exceptions,标准库的行为随之改变 |
不适用;panic = "abort" 去掉展开 |
近 | 4.3 |
11.7 并发与异步
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 线程 | std::thread |
std::thread |
同 | 5.5 |
| 线程对象析构时还没汇合 | std::thread 直接终止进程;std::jthread 自动汇合 |
句柄被丢弃,线程分离后继续运行 | 异 | — |
| 借用栈上数据的线程 | 可以,生命周期自己保证 | thread::scope,编译器保证 |
异 | 5.5 |
| 互斥锁 | std::mutex 和数据分开声明 |
Mutex<T> 把数据包在锁里 |
异 | 2.6 |
| 忘记加锁 | 编译通过,运行时数据竞争 | 编译不过 | 异 | 2.6 |
| 持锁时出错 | 没有对应机制 | 锁中毒 | 异 | 2.6 |
| 原子类型与内存序 | std::atomic,六种内存序 |
相同的模型,没有 consume |
同 | 5.6 |
| 数据竞争 | 未定义行为,编译器不检查 | 安全代码里编译不过 | 异 | 5.2 |
| 线程安全的标注 | 语言里没有;Clang 有线程安全分析的扩展 | Send 和 Sync,写在类型里 |
异 | 5.1 |
| 死锁 | 可能 | 可能 | 同 | 5.3 |
| 通道 | 标准库没有 | std::sync::mpsc |
异 | 5.4 |
| 条件变量 | std::condition_variable |
Condvar |
同 | — |
| 协程 | C++20,无栈 | async/await,无栈 |
近 | 6.9 |
| 协程状态存在哪 | 协程帧,通常在堆上 | 状态机,大小编译期确定,默认不需要堆分配 | 异 | 6.2 |
| 自引用的处理 | 帧在堆上不移动,不需要处理 | Pin |
异 | 6.5 |
| 运行时 | 标准库不提供,各库自建 | 标准库不提供,tokio 是事实标准 | 近 | 6.3 |
| 基于线程的异步调用 | std::async 加 std::future |
thread::spawn 加 JoinHandle |
近 | — |
| 并行算法 | 标准库的执行策略,C++17 | 第三方库 rayon |
近 | 5.5 |
11.8 安全与未定义行为
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 未定义行为的范围 | 遍布语言:越界、空指针、有符号溢出、释放后使用等 | 只可能出现在 unsafe 代码里 |
异 | 7.6 |
| 下标越界 | operator[] 不检查,at() 检查 |
[] 检查并 panic,get_unchecked 是 unsafe |
异 | 8.5 |
| 迭代器失效 | 未定义行为 | 编译不过 | 异 | 1.3 |
| 使用已移走的对象 | 允许 | 编译不过 | 异 | 1.2 |
| 别名规则 | 基于类型的严格别名 | &mut 独占;没有基于类型的别名规则 |
异 | 7.6 |
| 指针运算 | 随处可用 | 只能在 unsafe 里对裸指针做 |
异 | 7.5 |
| 重新解释内存 | reinterpret_cast、memcpy |
transmute,是 unsafe |
近 | 7.5 |
| 联合体 | union,读错成员是未定义行为 |
union,读字段是 unsafe |
近 | 7.5 |
| 检测工具 | ASan、UBSan、TSan、MSan | 同样可用,另有 Miri | 近 | 7.7 |
| 和 C 互操作 | 直接包含头文件 | extern "C" 加 bindgen 生成声明 |
近 | 7.8 |
11.9 性能
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 整体水平 | 同一量级 | 同一量级 | 同 | — |
| 抽象的开销 | 零开销抽象 | 零开销抽象 | 同 | 8.5 |
| 别名信息 | 要手写 restrict 扩展才有 |
每个 &mut 都自带「不重叠」的保证,编译器可以据此优化 |
异 | 7.6 |
| 边界检查 | 默认没有 | 默认有,用迭代器时通常能消除 | 异 | 8.5 |
| 容器扩容时搬移元素 | 逐个调用移动构造,且要求它是 noexcept |
整块 memcpy |
异 | 8.4 |
| 默认哈希 | 实现相关,libstdc++ 对整数是恒等映射 | SipHash,防攻击但较慢 | 异 | 8.3 |
| 小字符串优化 | 有 | 没有 | 异 | 1.7 |
| 字段重排减少填充 | 不做 | 默认做 | 异 | 7.3 |
| 错误处理的成本分布 | 成功路径无开销,失败路径贵 | 两条路径都是一次分支 | 异 | 4.1 |
| 编译速度 | 慢,头文件重复解析 | 慢,单态化和借用检查 | 近 | — |
11.10 工具链与工程
| 方面 | C++ | Rust | 异同 | 见 |
|---|---|---|---|---|
| 标准 | ISO 标准,约三年一版 | 没有 ISO 标准,六周一个版本 | 异 | 8.6 |
| 不兼容的语言变化 | 整个项目一起切换标准版本 | edition,按 crate 选择,不同 edition 可以互相依赖 | 异 | 8.6 |
| 编译器 | GCC、Clang、MSVC 等多个 | 一个参考实现 rustc |
异 | — |
| 构建系统 | CMake、Bazel、Make 等,没有统一的 | Cargo | 异 | 8.7 |
| 包管理 | vcpkg、Conan 等,没有统一的 | Cargo 加 crates.io | 异 | 8.7 |
| 格式化与静态检查 | clang-format、clang-tidy | rustfmt、clippy,随工具链分发 |
近 | 8.7 |
| 单元测试 | 第三方:GoogleTest、Catch2 | 语言内置 #[test] |
异 | 8.7 |
| 文档 | Doxygen | rustdoc,文档里的示例会被当作测试运行 |
近 | 8.7 |
| ABI | 各平台有事实上稳定的 C++ ABI | 没有稳定的 Rust ABI,跨语言走 C ABI | 异 | 7.3、7.8 |
| 链接方式 | 动态库很常见 | Rust 依赖默认静态链接进二进制 | 异 | — |
| 标准库的规模 | 大 | 小而精,网络、序列化、异步运行时都在第三方库里 | 异 | — |
| 存量代码与生态 | 几十年积累,图形、音视频、科学计算最全 | 网络、命令行、区块链、WebAssembly 较强 | 异 | 9.5 |
参考资料
| 资料 | 用途 |
|---|---|
| 《Rust 程序设计语言》中文版:https://kaisery.github.io/trpl-zh-cn/ | 官方入门书的翻译,跟着原版更新 |
| 《Programming Rust》第 2 版,中文版《Rust 程序设计(第 2 版)》 | 面向 C++ 程序员,有大量内存布局图 |
| 《Rust for Rustaceans》 | 进阶:类型布局、型变、Pin、异步运行时内部 |
| The Rustonomicon:https://doc.rust-lang.org/nomicon/ | unsafe 与未定义行为 |
| Asynchronous Programming in Rust:https://rust-lang.github.io/async-book/ | 异步的原理 |
| The Rust Reference:https://doc.rust-lang.org/reference/ | 析构顺序、类型布局、未定义行为的权威定义 |
| dex_qa.md 第 10 节 | 结合撮合引擎场景的并发问题 |
暂无评论,欢迎留下第一条评论。