Rust 面试考点:从所有权到异步

本文按考察频率从高到低,把 Rust 面试的常见考点分成九类,每个考点给出结论、原理、代码和常见追问。写过 C++ 的读者可以重点看每题里和 C++ 的对比,第 11 节把两种语言的异同汇总成了对照表。

项 说明
版本基准 稳定版 Rust,2024 edition。依赖版本的特性标注了稳定的版本号
代码 全部未实测,本机没有 Rust 工具链。可以粘到 https://play.rust-lang.org/ 运行
类型大小 按 64 位平台。标注「实现相关」的是当前编译器的行为,语言没有保证

目录

  1. 所有权、借用与生命周期
  2. 智能指针与内部可变性
  3. trait 与泛型
  4. 错误处理
  5. 并发
  6. 异步与 tokio
  7. 内存布局与 unsafe
  8. 宏、集合、工程与常用库
  9. 工程场景题
  10. 高频题索引
  11. Rust 与 C++ 对照总表

1. 所有权、借用与生命周期

1.1 所有权的三条规则是什么?

  1. 每个值都有一个所有者,即持有它的变量。
  2. 同一时刻只有一个所有者。
  3. 所有者离开作用域时,值被释放。
fn main() {
    let a = String::from("hi");
    let b = a;              // 所有权从 a 移动到 b
    println!("{a}");        // 编译错误 E0382:a 已被移走
}                           // 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 = String::from("x");
if cond { consume(s); }     // 只在这个分支被移走
// 作用域结束:按析构标志决定是否释放 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 = String::from("a");
let r = &mut s;             // 可变借用开始
println!("{s}");            // 编译错误 E0502:s 已被可变借用,所有者自己也不能读
r.push('b');                // 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![1, 2, 3];
for x in &v {               // 不可变借用 v
    v.push(*x);             // 编译错误 E0502:v 已被不可变借用,不能再可变借用
}

借用的结束点是引用最后一次被使用的地方,而不是作用域的末尾。这叫非词法生命周期,2018 edition 起生效。

let mut s = String::from("a");
let r = &s;
println!("{r}");            // r 最后一次使用,借用到此结束
s.push('b');                // 可以,不冲突

1.4 生命周期标注解决什么问题?

它告诉编译器多个引用的存活时间之间的约束关系。标注本身不会延长或缩短任何值的存活时间。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

'a 的含义是:返回的引用,活得不会比 x 和 y 中较短的那个更长。编译器在调用处检查这个约束:

let a = String::from("long");
let r;
{
    let b = String::from("x");
    r = longest(&a, &b);
}                           // b 被释放
println!("{r}");            // 编译错误 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:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

闭包要求 '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 = [1, 2, 3, 4];
let (left, right) = v.split_at_mut(2);   // 两段不重叠的 &mut
left[0] += right[0];

下一代借用检查器 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 std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

循环引用造成的是内存泄漏,在 Rust 里这不算不安全。Rust 的安全保证覆盖释放后使用、重复释放、数据竞争,不覆盖泄漏。

2.4 什么是内部可变性?Cell 和 RefCell 怎么用?

内部可变性指通过一个不可变引用 &T 修改值的内部状态。它是借用规则的一个受控的例外,所有实现的底层都是 UnsafeCell<T>,这是编译器唯一允许「通过共享引用修改」的原语。

类型 机制 适用 违反规则时
Cell<T> 只能整体取出或替换,不给出内部引用 Copy 类型,比如计数器、标志位 不会违反,因为从不给出引用
RefCell<T> 运行时记录借用状态,borrow() 和 borrow_mut() 返回守卫 需要拿到内部引用的场景 panic;用 try_borrow_mut 可以得到错误而不是 panic
use std::cell::RefCell;

let c = RefCell::new(vec![1]);
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 std::sync::Mutex;

let m = Mutex::new(0);
{
    let mut guard = m.lock().unwrap();  // 只有拿到守卫才能访问数据
    *guard += 1;
}                                       // 守卫析构,自动解锁
方面 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 std::sync::LazyLock;
use std::collections::HashMap;

static CONFIG: LazyLock<HashMap<&str, u32>> = LazyLock::new(|| {
    HashMap::from([("timeout_ms", 200)])
});

对应 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 静态分派和动态分派有什么区别?

trait Shape { fn area(&self) -> f64; }

fn total_static<T: Shape>(xs: &[T]) -> f64 {          // 静态分派
    xs.iter().map(|s| s.area()).sum()
}

fn total_dyn(xs: &[Box<dyn Shape>]) -> f64 {          // 动态分派
    xs.iter().map(|s| s.area()).sum()
}
静态分派 动态分派
写法 泛型 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 std::mem::size_of;
const _: () = assert!(size_of::<&dyn Shape>() == 2 * size_of::<usize>());
const _: () = assert!(size_of::<&u8>() == size_of::<usize>());

和 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 关联类型和泛型参数怎么选?

trait Iterator {
    type Item;                                  // 关联类型
    fn next(&mut self) -> Option<Self::Item>;
}

trait From<T> {                                 // 泛型参数
    fn from(value: T) -> Self;
}
关联类型 泛型参数
一个类型能实现几次 一次 每个不同的 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。

struct Wrapper(Vec<String>);

impl std::fmt::Display for Wrapper {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

新类型没有运行时开销,它和内部的值大小相同。

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
fn takes_str(s: &str) {}
let s = Box::new(String::from("x"));
takes_str(&s);              // &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 才能接受动态大小类型:

fn print_len<T: ?Sized + AsRef<[u8]>>(x: &T) {
    println!("{}", x.as_ref().len());
}
print_len("abc");           // T = str,只有加了 ?Sized 才能这样调用

指向动态大小类型的指针都是胖指针,额外存长度或虚表。

3.9 闭包的 Fn、FnMut、FnOnce 有什么区别?

由闭包体怎么使用捕获的变量决定:

trait 闭包体对捕获变量做了什么 能调用几次
FnOnce 把捕获的值移出去,比如返回它、传给接收所有权的函数 一次
FnMut 修改捕获的值 多次,调用时需要 &mut
Fn 只读 多次,可以并发调用

三者是包含关系:实现 Fn 的也实现 FnMut,实现 FnMut 的也实现 FnOnce。所以参数类型写 FnOnce 的函数最宽松,什么闭包都能接受。

move 关键字只决定捕获方式,把变量按值移进闭包,不决定实现哪个 trait。一个 move 闭包如果只读捕获的值,仍然是 Fn。

let s = String::from("x");
let f = move || println!("{s}");   // 按值捕获,但只读:实现 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,因为它的类型那一半不是空的。

var p *MyErr = nil
var err error = p
fmt.Println(err == nil)   // 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 的目的是避开虚表:让基类在编译期知道派生类是谁,直接调用。

template <typename Derived>
struct Shape {
    void describe() const {
        std::cout << static_cast<const Derived*>(this)->area();   // 编译期绑定
    }
};
struct Circle : Shape<Circle> {
    double area() const { return 3.14 * r * r; }
    double r;
};

Rust 里是 trait 的默认方法加泛型:

trait Shape {
    fn area(&self) -> f64;
    fn describe(&self) {                    // 默认方法,调用实现者的 area
        println!("{}", self.area());
    }
}
struct Circle { r: f64 }
impl Shape for Circle {
    fn area(&self) -> f64 { 3.14 * self.r * self.r }
}

fn static_call<T: Shape>(s: &T) { s.describe(); }   // 相当于 CRTP:单态化,可内联
fn dynamic_call(s: &dyn Shape)  { s.describe(); }   // 相当于虚函数

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。

fn sum_dyn(xs: &[Box<dyn Shape>]) -> f64 {
    xs.iter().map(|s| s.area()).sum()       // 每次迭代一次间接调用,无法向量化
}

fn sum_static<T: Shape>(xs: &[T]) -> f64 {
    xs.iter().map(|s| s.area()).sum()       // area 被内联,循环可以向量化
}

什么时候可以不在意。

情况 原因
被调用的函数本身做的事很多 函数体耗时在几百纳秒以上,比如一次 IO、一次哈希表查找,间接调用的几纳秒可以忽略
调用频率低 初始化、配置加载、每个请求只调几次的路径
需要异构集合或插件 不同类型放进同一个容器、运行时才决定用哪个实现,这是 dyn 的本职

dyn 相对泛型还有两个好处:代码只生成一份,二进制更小;编译更快。泛型每多一个具体类型就多生成一份代码。

什么时候要避开。 热循环里、对每个元素调用一次、函数体又很小。四种替代办法:

办法 做法 适用
泛型 fn f<T: Trait> 类型在编译期已知
枚举分派 把几种实现放进一个 enum,用 match 分派 实现的种类固定且不多。match 是直接跳转,各分支可以内联
提高分派的粒度 一次 dyn 调用处理一整批数据,而不是一个元素 类型要运行时决定,但数据是成批的
按类型分组 把同类型的对象放在一起分别处理 异构集合的遍历

枚举分派的写法:

enum AnyShape { Circle(Circle), Rect(Rect) }

impl AnyShape {
    fn area(&self) -> f64 {
        match self {                        // 直接跳转,两个分支都能内联
            AnyShape::Circle(c) => c.area(),
            AnyShape::Rect(r) => r.area(),
        }
    }
}

元素连续存放在 Vec<AnyShape> 里,没有逐个的堆分配。代价是每个元素按最大的变体占空间,新增一种实现要改这个枚举。

提高分派粒度的例子:特征算子的接口从「对一个候选算一个值」改成「对一整列算一批值」。

trait Op {
    fn run(&self, input: &[f32], out: &mut [i64]);   // 一次调用处理整个分片
}

算子仍然通过 dyn Op 调用,但一次分派摊到几百个元素上,分派的开销可以忽略;每个算子内部是一个处理连续数组的紧凑循环,循环体照样能内联和向量化。

判断顺序:先用火焰图确认间接调用确实在热点里,再决定换哪一种。


4. 错误处理

4.1 Rust 为什么没有异常?

错误是返回值的一部分,写在函数签名里:

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.parse::<u16>()
}
方面 C++ 异常 Rust 的 Result
函数会不会失败 签名里看不出来,noexcept 只是承诺 返回类型就说明了
调用方忘记处理 编译通过,异常一路向上传播 Result 带 #[must_use],不处理有警告;要拿到值必须先处理错误
控制流 隐式跳转,任何一行都可能抛出 显式,只有写了 ? 或 match 的地方才会提前返回
成功路径的开销 零开销模型下没有开销 返回值多一个判别字段,一次分支
失败路径的开销 栈回溯,很贵 和成功路径一样,一次返回

不可恢复的错误用 panic 处理,见 4.3 节。

4.2 ? 运算符做了什么?

遇到 Err 时提前返回,并且把错误类型通过 From 转换成函数声明的错误类型。

let n = s.parse::<u16>()?;

大致等价于:

let n = match s.parse::<u16>() {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
};

第二行的 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,分别处理 通常只是记录日志或展示给用户
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("读取配置文件失败")]
    Io(#[from] std::io::Error),
    #[error("端口不合法:{0}")]
    BadPort(String),
}

#[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 用三层机制排除它:

  1. 借用规则:同一时刻要么一个 &mut,要么多个 &。所以「同时写」只可能通过共享引用 &T 发生。
  2. Sync:只有 Sync 的类型才能通过 &T 被多个线程共享。能通过 &T 修改的类型,比如 Cell,都不是 Sync;是 Sync 的,比如 Mutex 和原子类型,内部都有同步。
  3. Send 和 'static:thread::spawn 要求闭包是 Send + 'static,不能把 Rc 带进新线程,也不能借用当前栈上会先消失的数据。
use std::rc::Rc;
let rc = Rc::new(1);
std::thread::spawn(move || println!("{rc}"));   // 编译错误: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![0u64; 1_000_000];
std::thread::scope(|s| {
    for chunk in data.chunks_mut(250_000) {      // 切成四段不重叠的 &mut
        s.spawn(move || {
            for x in chunk.iter_mut() { *x += 1; }
        });
    }
});                                              // 这里等待所有线程结束
println!("{}", data[0]);

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 std::sync::atomic::{AtomicBool, Ordering};

static READY: AtomicBool = AtomicBool::new(false);
static mut DATA: u64 = 0;

// 线程 A
unsafe { DATA = 42; }
READY.store(true, Ordering::Release);

// 线程 B
while !READY.load(Ordering::Acquire) {}
assert_eq!(unsafe { DATA }, 42);        // 一定成立

这个例子为了直观用了 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 是什么?

一个可以被反复轮询的计算,每次轮询要么完成并返回结果,要么告诉调用方「还没好,好了会通知你」。

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

pub enum Poll<T> {
    Ready(T),
    Pending,
}

三个特点:

  • 惰性。创建一个 Future 什么都不执行,只有被轮询才推进。忘记 .await 的 Future 不会运行,编译器会警告。
  • 拉模型。由执行器主动调 poll,而不是任务完成后回调。
  • 零成本。Future 是一个普通的值,可以放在栈上,不需要堆分配。

6.2 async fn 编译成什么?

编译成一个返回匿名 Future 的函数。这个 Future 是一个状态机枚举:每个 .await 是一个状态,跨越 .await 还活着的局部变量是状态里的字段。

async fn fetch(id: u32) -> String {
    let conn = connect().await;          // 暂停点一
    let body = conn.get(id).await;       // 暂停点二
    body
}

编译器大致生成:

enum FetchFuture {
    Start { id: u32 },
    WaitConnect { id: u32, fut: ConnectFuture },
    WaitGet { conn: Conn, fut: GetFuture },
    Done,
}

每次 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 fn f() {
    let buf = [0u8; 64];
    let r = &buf;           // r 指向状态机自己的 buf 字段
    something().await;      // 跨越 .await,buf 和 r 都要保存在状态机里
    println!("{:?}", r);
}
移动之前                          按位拷贝到新地址之后
地址 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! 里没有胜出的分支,都是通过丢弃实现的。

tokio::select! {
    r = read_frame(&mut sock) => handle(r),
    _ = tokio::time::sleep(Duration::from_secs(1)) => {}   // 超时:read_frame 被丢弃
}

要注意的是取消安全: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 fn bad(m: &std::sync::Mutex<u32>) {
    let g = m.lock().unwrap();
    other().await;          // g 跨越了 .await
    drop(g);
}
// 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 起稳定。

trait Store {
    async fn get(&self, key: &str) -> Option<Vec<u8>>;
}

两个限制:

  • 不能用 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 { body }

// 展开后大致是
let mut it = rx.into_iter();
loop {
    match it.next() {       // fn next(&mut self) -> Option<Self::Item>
        Some(x) => { body }
        None => break,
    }
}

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 多出「还没好」这个状态:

fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;

现在的写法是用 while let 把 .await 显式写出来:

// 异步信道
while let Some(x) = rx.recv().await {
    handle(x);
}

// 实现了 Stream 的类型
use tokio_stream::StreamExt;
while let Some(x) = stream.next().await {
    handle(x);
}

效果和 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:

pub trait Stream {
    type Item;
    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}

和另外两个 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 tokio_stream::StreamExt;

while let Some(tick) = ticks.next().await {
    handle(tick);
}

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 futures::stream::{self, StreamExt};

let bodies: Vec<_> = stream::iter(urls)
    .map(|u| async move { fetch(u).await })   // 每个元素变成一个 Future,此时还没执行
    .buffer_unordered(16)                     // 同时最多 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::select! {
        Some(tick) = ticks.next() => on_tick(tick),
        Some(cmd) = commands.recv() => on_command(cmd),
        _ = shutdown.cancelled() => break,
    }
}

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 = tokio::runtime::Handle::current();   // 在运行时内取得,可以克隆后带到别的线程
std::thread::spawn(move || {
    handle.spawn(async { /* 在运行时里执行 */ });
});

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 tokio::sync::{mpsc, oneshot};

enum Cmd {
    Get { key: String, reply: oneshot::Sender<Option<String>> },
}

// 请求方
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(Cmd::Get { key: "a".into(), reply: reply_tx }).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 std::sync::Arc;
use tokio::sync::Semaphore;

let sem = Arc::new(Semaphore::new(16));
for job in jobs {
    let permit = sem.clone().acquire_owned().await.unwrap();   // 满了就在这里等
    tokio::spawn(async move {
        run(job).await;
        drop(permit);                                          // 归还许可
    });
}

6.19 select! 的语义是什么?有哪些要注意的地方?

select! 在同一个任务里同时轮询多个分支,第一个完成的分支被执行,其余分支的 Future 被丢弃。

tokio::select! {
    msg = rx.recv() => handle(msg),
    _ = tokio::time::sleep(Duration::from_secs(5)) => on_timeout(),
    _ = shutdown.cancelled() => return,
}
规则 说明
并发不并行 所有分支在当前任务上轮询,同一时刻只有一个在执行
公平性 默认每次随机选一个分支开始检查,避免排在前面的分支总是赢
biased; 写在第一行,改成按书写顺序检查。用于有优先级的场景,比如先看关闭信号
模式不匹配 分支写成 Some(x) = rx.recv() 时,结果是 None 则这个分支被禁用,继续等其他分支
else 分支 所有分支都被禁用时执行
未选中的分支 Future 被丢弃,即被取消

要注意的三点:

  • 取消安全。在循环里反复 select! 时,每一轮没被选中的分支都会被取消重建。如果那个操作在两个 .await 之间持有中间状态,状态就丢了。tokio 文档为每个方法标注了是否取消安全,recv() 是安全的,按长度读取固定字节数的 read_exact 不是。
  • 想跨轮次保留同一个 Future,要在循环外创建并固定它,分支里用可变引用:
let sleep = tokio::time::sleep(Duration::from_secs(30));
tokio::pin!(sleep);
loop {
    tokio::select! {
        _ = &mut sleep => break,                 // 整个循环共用一个 30 秒的计时
        Some(msg) = rx.recv() => handle(msg),
    }
}
  • 分支体里不要做长时间的事。分支体执行期间其他分支得不到轮询。

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 tokio::time::timeout(Duration::from_millis(200), call_downstream()).await {
    Ok(resp) => resp,
    Err(_) => default_response(),       // 超时,call_downstream 的 Future 已被取消
}

要点:

  • 每个外部调用都要有超时。没有超时的 .await 在对端不响应时会永远挂住,任务泄漏。
  • interval 错过了触发点怎么办。由 MissedTickBehavior 决定:默认是连续补发,追上进度;Delay 是从现在起重新计时;Skip 是跳过错过的。周期性上报这类任务通常用 Skip。
  • 超时只是不再等,不保证对端停下。超时后请求可能仍在下游执行,写操作要靠幂等来保证重试安全。
  • 测试里不用真的等。启用测试工具后可以暂停时间,sleep 会立即推进虚拟时钟,带定时逻辑的测试毫秒级跑完。

6.22 tokio 服务怎么做优雅退出?

三步:通知、停止接收、等待完成。

use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;

let token = CancellationToken::new();
let tracker = TaskTracker::new();

// 接入循环
loop {
    tokio::select! {
        _ = token.cancelled() => break,                     // 2. 停止接受新连接
        Ok((sock, _)) = listener.accept() => {
            let t = token.child_token();
            tracker.spawn(async move { serve(sock, t).await });
        }
    }
}

// 3. 等在途任务结束,设上限
tracker.close();
let _ = tokio::time::timeout(Duration::from_secs(30), tracker.wait()).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 std::mem::size_of;
use std::num::NonZeroU32;

const _: () = assert!(size_of::<&[u8]>() == 16);
const _: () = assert!(size_of::<Option<Box<u64>>>() == 8);
const _: () = assert!(size_of::<Option<u32>>() == 8);
const _: () = assert!(size_of::<Option<NonZeroU32>>() == 4);

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)] 去掉填充 解析二进制协议;对其字段取引用是未对齐的,编译器会拒绝
struct A { a: u8, b: u32, c: u8 }           // 默认:通常重排成 b、a、c,大小 8
#[repr(C)] struct B { a: u8, b: u32, c: u8 }  // 按声明顺序:大小 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 能多做哪些事?

只多五件事:

  1. 解引用裸指针。
  2. 调用 unsafe 函数,包括 FFI 函数。
  3. 读写可变的静态变量。
  4. 实现 unsafe trait,比如手动实现 Send 和 Sync。
  5. 访问 union 的字段。

unsafe 不关闭借用检查,也不关闭类型检查。它的含义是:编译器无法验证这五件事是否正确,由写代码的人来保证。

let x = 5;
let p = &x as *const i32;   // 创建裸指针是安全的
let y = unsafe { *p };      // 解引用需要 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,所以对外是安全的。

pub fn split_at_mut<T>(s: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = s.len();
    let p = s.as_mut_ptr();
    assert!(mid <= len);
    // SAFETY: mid <= len,两段 [0, mid) 与 [mid, len) 不重叠且都在原切片范围内
    unsafe {
        (std::slice::from_raw_parts_mut(p, mid),
         std::slice::from_raw_parts_mut(p.add(mid), len - mid))
    }
}

7.8 Rust 和 C/C++ 怎么互相调用?

#[repr(C)]
pub struct Point { pub x: f64, pub y: f64 }

#[unsafe(no_mangle)]                         // 2024 edition 写法
pub extern "C" fn point_len(p: *const Point) -> f64 {
    let p = unsafe { &*p };
    (p.x * p.x + p.y * p.y).sqrt()
}

unsafe extern "C" {                          // 调用 C 函数
    fn strlen(s: *const std::ffi::c_char) -> usize;
}
要点 做法
调用约定 函数写 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]
代价 小 编译时间明显增加

声明宏有卫生性:宏内部引入的局部变量不会和调用处的同名变量冲突。

macro_rules! square {
    ($x:expr) => { $x * $x };
}
let a = square!(2 + 3);     // 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 rustc_hash::FxHashMap;
let mut m: FxHashMap<u64, u32> = FxHashMap::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(|x| **x % 2 == 0).map(|x| x * x).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 serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct Order {
    id: u64,
    symbol: String,
    price: f64,
}

let text = serde_json::to_string(&Order { id: 1, symbol: "BTC".into(), price: 1.5 })?;
let back: Order = serde_json::from_str(&text)?;

三个要点:

  • 没有反射,没有运行时开销。 #[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)] 字段 允许这个字段借用输入缓冲区

枚举的四种表示。 这是对接外部接口时最常用到的:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]                  // 内部标签
enum Event {
    Trade { price: f64 },
    Cancel { id: u64 },
}
表示 属性 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 表示输入数据的生命周期,结果可以借用输入:

#[derive(Deserialize)]
struct Msg<'a> {
    symbol: &'a str,                    // 直接指向输入缓冲区,不分配
}
let m: Msg = serde_json::from_str(&input)?;   // 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 的命令行工具根据数据库里的实际表结构生成:

diesel::table! {
    users (id) {
        id -> Int4,
        name -> Varchar,
        age -> Nullable<Int4>,
    }
}

table! 宏为每张表生成一个模块,每一列是一个零大小的类型,带着它的 SQL 类型。

第二步:模型结构体。

use diesel::prelude::*;

#[derive(Queryable, Selectable)]
#[diesel(table_name = users)]
struct User {
    id: i32,
    name: String,
    age: Option<i32>,
}

#[derive(Insertable)]
#[diesel(table_name = users)]
struct NewUser<'a> {
    name: &'a str,
}

第三步:查询。 DSL 的每个方法都返回一个新的类型,整条查询的类型就是它的结构:

let rows: Vec<User> = users::table
    .filter(users::age.gt(18))
    .order(users::id.desc())
    .limit(10)
    .select(User::as_select())
    .load(&mut conn)?;
写错了什么 结果
列名拼错 编译错误:模块里没有这个名字
拿字符串和整数列比较 编译错误:类型不满足比较的 trait 约束
结构体字段类型和列类型对不上,比如可空列对应了 i32 编译错误
过滤条件引用了查询里没有的表 编译错误

代价是两点:类型错误的报错信息很长,新手难读;schema.rs 必须和数据库保持同步,表结构变更后要重新生成。

在异步服务里用 diesel。 diesel 的连接是同步的,直接在 tokio 的任务里调用会阻塞工作线程,见 6.7 节。两种做法:

做法 说明
spawn_blocking 加同步连接池 r2d2 每次数据库操作放到阻塞线程池
diesel-async 提供异步的连接和同样的 DSL,配 deadpool 或 bb8 连接池

8.13 SeaORM 怎么用?

每张表对应一组生成的类型,查询通过实体上的方法链完成。

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    pub age: Option<i32>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

派生宏从 Model 生成四样东西:

类型 作用
Entity 代表这张表,查询的入口
Model 一行数据,只读
Column 列的枚举,用来写条件和排序
ActiveModel 可修改的一行,每个字段是「已设置」或「未设置」,用于插入和更新
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};

// 查询
let rows: Vec<Model> = Entity::find()
    .filter(Column::Age.gt(18))
    .order_by_desc(Column::Id)
    .limit(10)
    .all(&db)
    .await?;

// 插入:只设置需要的字段,其余交给数据库默认值
let user = ActiveModel {
    name: Set("alice".to_owned()),
    ..Default::default()
}
.insert(&db)
.await?;

// 更新:只有被 Set 的字段出现在 UPDATE 语句里
let mut am: ActiveModel = user.into();
am.age = Set(Some(30));
am.update(&db).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 节 结合撮合引擎场景的并发问题