跳转到内容

Rust 面试题

这里收集的是 Rust 面试里反复出现的问题,从初级岗位到高级岗位都有。每个答案都短到可以直接说出口,并且链接到深入讲解这个主题的课程。

不要逐字背诵。面试官一听就能听出来,而且他们通常会追问一句,看你是不是真的理解了。用这一页检查自己掌握了多少,觉得没把握的地方就去看链接的课程。如果你能用自己的话讲清楚一个答案,再写出一个小例子,那就准备好了。

Cargo 是 Rust 官方的构建工具和包管理器,安装 Rust 时就自带了。用这一个工具,你就可以:

  • 创建项目(cargo new),
  • 构建并运行项目(cargo build、cargo run),
  • 运行测试(cargo test),
  • 从 crates.io 添加库,这些库叫做 crate(cargo add serde)。

项目的设置和依赖写在 Cargo.toml 里。确切的版本锁定在 Cargo.lock 里,所以每次构建的结果都可以复现。一个不错的加分点:其他语言往往需要好几个工具来做这些事,而在 Rust 里大家都用 Cargo,所以每个项目看起来都很熟悉。

进一步学习:Cargo 与 crate、快速上手。

mut 让你修改同一个变量的值,类型必须保持不变。

遮蔽(shadowing)是指用同一个名字再写一次 let。它会创建一个新变量,把旧的那个挡住。新变量甚至可以是不同的类型。

fn main() {
let spaces = " ";
let spaces = spaces.len(); // 遮蔽:一个新变量,现在是数字
println!("{spaces}");
let mut count = 1;
count += 1; // mut:同一个变量得到一个新值
println!("{count}");
}

输出:

3
2
▶ 动手试一试

遮蔽很适合一步步转换一个值(比如把文本解析成数字),不用发明 input_str、input_num 这样的名字。而且最后那个变量仍然是不可变的。

进一步学习:变量。

函数在运行时被调用,参数的个数固定,而且都有类型。宏在编译时运行:它接收代码,在编译器检查之前生成更多代码。宏很好认,名字后面带 !,比如 println!、vec! 和 format!。

宏能做到函数做不到的事,比如接收可变数量的参数(println!("{} {}", a, b)),或者在编译时检查格式字符串。Rust 的宏处理的是代码的结构,而不是原始文本,所以能避开 C 语言宏的那些经典 bug:

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// 在 C 语言里,基于文本替换的宏会得到 2 + 1 * 2 + 1 = 5。
println!("{}", square!(2 + 1));
}

输出:

9
▶ 动手试一试

实话实说,宏也有代价:读起来、写起来、调试起来都更难。函数能搞定的事,就用函数。

进一步学习:宏。

问:“零成本抽象”是什么意思?

Section titled “问:“零成本抽象”是什么意思?”

意思是高级特性不应该让程序比你手写的底层代码更慢。“你不用的东西,不需要付出代价;你用到的东西,你自己手写也写不得更好。”

比如,.iter().filter(...).map(...).sum() 这样的迭代器链,编译后的机器码通常和手写的循环一样。泛型是另一个例子:编译器会为你用到的每种类型生成一份专门的副本(这叫单态化,monomorphization),所以运行时不需要任何查找。

对“成本”要说清楚:零成本指的是运行时零成本。你仍然要在编译时间上付出代价,而且泛型可能让程序文件变大。

进一步学习:迭代器、泛型。

是的。.map() 和 .filter() 这样的方法自己不做任何工作,它们只是构建出一份“要做什么”的描述。直到有东西消费这个迭代器,比如 .collect()、.sum() 或者 for 循环,才会真正执行。

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("正在把 {n} 翻倍");
n * 2
});
println!("到这里还什么都没执行。");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

输出:

到这里还什么都没执行。
正在把 1 翻倍
正在把 2 翻倍
正在把 3 翻倍
[2, 4, 6]
▶ 动手试一试

这也是为什么当你写了 numbers.iter().map(...); 却从不使用结果时,编译器会给出警告:这行代码什么都不会做。

进一步学习:迭代器。

一共有三条:

  1. Rust 中的每个值都有一个所有者(一个变量)。
  2. 值同一时间只能有一个所有者。
  3. 当所有者离开作用域时,值会被丢弃(它的内存被释放)。

靠这些规则,Rust 能在恰好的时机释放内存,既不需要垃圾回收器,也不需要你手动调用 free。编译器会检查这些规则,所以在安全代码里,“释放后使用”(use after free)和“重复释放”(double free)这类错误不可能发生。

进一步学习:所有权、作用域。

问:移动、复制和克隆有什么区别?

Section titled “问:移动、复制和克隆有什么区别?”
  • 移动(move):赋值或传参会转移所有权,旧变量之后就不能再用了。对于 String 和 Vec 这样的类型,这是默认行为。除了一个小小的指针、长度和容量之外,什么都不会被复制。
  • 复制(copy):对于 i32、f64、bool 和 char 这样小而简单的类型,赋值会做一次廉价的按位复制,两个变量都还能用。
  • 克隆(clone):.clone() 是一次显式的深拷贝。对 String 来说,它会把所有文本复制到新的内存里。这可能很昂贵,所以 Rust 要你亲手写出来。
fn main() {
let x = 5;
let y = x; // 复制:x 仍然可以用
println!("{x} {y}");
let a = String::from("hi");
let b = a.clone(); // 克隆:把文本完整复制一份
let c = a; // 移动:这一行之后 `a` 就不能用了
println!("{b} {c}");
}

输出:

5 5
hi hi
▶ 动手试一试

进一步学习:所有权。

问:Copy trait 是什么?哪些类型实现了它?

Section titled “问:Copy trait 是什么?哪些类型实现了它?”

Copy 是一个标记 trait,它表示“这个类型只要复制它的比特位就能得到一份副本”。实现了 Copy 的类型在赋值时会被复制,而不是被移动。

整数、浮点数、bool、char、共享引用(&T),以及只由 Copy 类型组成的元组或数组,都是 Copy 的。String、Vec 和 Box 不是,因为它们在别处拥有一块内存,只复制指针就会出现两个所有者。

你可以为自己的类型派生(derive)它,但前提是每个字段都是 Copy:

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

编译器说:不能为这个类型实现 Copy,因为 name 字段(String)没有实现 Copy。

Copy 总是同时要求 Clone。另外,实现了 Drop 的类型不能是 Copy。

进一步学习:常用 trait、所有权。

  • String 是拥有所有权、可以增长的一段文本,存放在堆上。你可以修改它,比如用 push_str。
  • &str(“字符串 slice”)是对某段文本的借用视图,它只是一个指针加一个长度。像 "hello" 这样的字符串字面量就是 &str。

一条好用的规则:只读取文本的函数应该接收 &str。这样它两种都能接受,因为 &String 会自动转换成 &str。

fn greet(name: &str) {
println!("你好,{name}!");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // 需要 &str 的地方可以传 &String
greet(literal);
let mut full = owned;
full.push_str(" Smith"); // 只有 String 才能增长
greet(&full);
}

输出:

你好,Ana!
你好,Ben!
你好,Ana Smith!
▶ 动手试一试

进一步学习:字符串、slice。

在任意时刻,对于一个给定的值,你要么拥有:

  • 任意数量的共享引用(&T),要么
  • 恰好一个可变引用(&mut T),

但不能两者同时存在。而且每个引用都必须始终指向有效的数据。

为什么?如果有人能在别人读取数据的同时修改它,就会出现 bug,比如你手里拿着指向 vector 内部的指针,vector 却把内存搬走了。放到多线程里,这正是数据竞争(data race)的定义。Rust 在编译时就阻止了它。从 2018 edition 开始,编译器能聪明地判断借用何时结束(这叫非词法作用域生命周期,non-lexical lifetimes):借用只持续到最后一次使用,而不是到代码块结束。

进一步学习:借用。

生命周期是编译器对“一个引用在多长时间内有效”的称呼。每个引用都有生命周期。大多数时候编译器能自己推断出来(这叫生命周期省略,lifetime elision),所以你根本看不到它们。

只有当编译器分不清输出的引用和输入之间是什么关系时,你才需要写出来,比如一个返回两个借用字符串之一的函数:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

输出:

borrow checker
▶ 动手试一试

这里的两个字符串保留了英文,因为 longest 比较的是 len(),也就是字节数,而一个汉字占 3 个字节。

面试时值得强调的一点:生命周期注解不会改变任何值活多久。它们只是描述引用之间的关系,好让编译器检查没有引用比它指向的数据活得更久。

进一步学习:生命周期。

问:Rust 没有 null,那它用什么代替?

Section titled “问:Rust 没有 null,那它用什么代替?”

用 Option<T>,一个有两个变体的枚举:Some(value) 或 None。因为它和 T 是不同的类型,编译器会强制你先处理 None 的情况,然后才能使用里面的值。你不可能忘记检查 null,因为忘了代码就编译不过。

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("找到了 {name}"),
None => println!("没有 id 为 {id} 的用户"),
}
}
}

输出:

找到了 Ana
没有 id 为 3 的用户
▶ 动手试一试

一个不错的补充细节:和普通指针相比,Option<&T> 和 Option<Box<T>> 不占用额外的内存。Rust 在内部用“null”的比特模式来表示 None,所以这里的安全是零成本的。

进一步学习:Option、if let。

问:trait 是什么?它和接口一样吗?

Section titled “问:trait 是什么?它和接口一样吗?”

trait 定义了一组类型可以实现的方法。它和 Java、C# 或 Go 里的接口很像,用这句话开头回答完全没问题。然后再说说区别:

  • trait 可以有默认方法实现。
  • 你可以在类型写好之后再为它实现 trait,甚至可以为不是你写的类型实现,比如为 i32 实现你自己的 trait。(“孤儿规则”,即 orphan rule,要求 trait 和类型至少有一个是你自己定义的。)
  • trait 既可以用作泛型约束(T: Display),在编译时确定;也可以用作 trait 对象(dyn Display),在运行时确定。
  • Rust 没有类继承,用 trait 加组合来代替它。

进一步学习:trait、常用 trait。

问:泛型和 trait 对象有什么区别?

Section titled “问:泛型和 trait 对象有什么区别?”

两者都能让一个函数处理多种类型。区别在于类型是什么时候确定的:

  • 泛型(fn f<T: Speak>(x: &T))使用静态分发。编译器为每种类型生成一份单独的函数副本。调用和普通函数调用一样快,但程序可能会变大。
  • trait 对象(&dyn Speak、Box<dyn Speak>)使用动态分发。函数只有一份,运行时通过一张表(“vtable”,虚表)找到正确的方法。会稍微慢那么一点点,但能让你在一个集合里混放不同的类型。
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("汪汪")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("喵喵")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// 只有 trait 对象才能在一个 Vec 里混放不同类型:
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

输出:

汪汪
喵喵
汪汪
喵喵
▶ 动手试一试

经验法则:先用泛型。当你需要一个装着不同类型的集合,或者想控制编译时间和二进制文件大小时,再用 trait 对象。

进一步学习:泛型、trait 对象。

问:Box<dyn Trait> 和 impl Trait 有什么区别?

Section titled “问:Box<dyn Trait> 和 impl Trait 有什么区别?”

两者都能让你表达“某个实现了这个 trait 的类型”,而不用写出它的名字。区别在于允许多少种具体类型:

  • impl Trait 表示一个具体类型,编译器知道它是什么,只是不要求你写出来。它是静态分发:没有堆分配,没有 vtable,和你亲手写出类型名一样快。在参数位置(fn f(x: impl Display)),它只是泛型的一种简写。在返回值位置,它是返回那些类型名很长或者根本写不出名字的东西(比如闭包和迭代器链)的方式。
  • Box<dyn Trait> 是放在堆上的 trait 对象。实际类型每次都可以不同,在运行时决定,方法通过 vtable 找到。这要付出一次内存分配和一次间接调用的代价,但它是返回多种类型之一、或者把它们存进同一个 Vec 的唯一办法。
use std::fmt::Display;
// impl Trait:一个隐藏的类型,在编译时就固定了。
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait>:真实类型可以在运行时变化。
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("四十二")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

输出:

[0, 2, 4, 6, 8]
42
四十二
▶ 动手试一试

一个值得一提的细节:如果把 label 改成返回 impl Display 并去掉 Box,代码就编译不过了。if 返回的是 i32,else 返回的是 &str,而 impl Trait 必须是单一的类型。经验法则:默认用 impl Trait,只有真的需要在运行时从几种类型中选一种时,才换成 Box<dyn Trait>。

进一步学习:trait 对象、泛型。

问:为什么 match 必须是穷尽的?

Section titled “问:为什么 match 必须是穷尽的?”

match 必须处理每一种可能的值,否则代码编译不过。这意味着当你给枚举加一个新变体时,编译器会把每一个需要更新的地方都指给你看。在其他语言里,switch 中漏掉的一个 case 会悄无声息地什么都不做。

你可以用 _ 兜底,但要小心:兜底分支也会把你以后新加的变体藏起来,这层安全网就没了。

进一步学习:match、模式匹配、枚举。

问:没有异常,Rust 怎么处理错误?

Section titled “问:没有异常,Rust 怎么处理错误?”

可恢复的错误就是 Result<T, E> 类型的普通值:要么是 Ok(value),要么是 Err(error)。可能失败的函数会在返回类型里写明这一点,调用者必须处理它。不会有任何东西像异常那样,悄悄地沿着调用栈往上跳。

好处是:看函数签名就知道它可能失败,而且如果你忽略了一个 Result,编译器会发出警告。代价是要多打一些字,而 ? 运算符基本上把这部分也省掉了。

进一步学习:Result。

问:什么时候该用 panic!,而不是返回 Result?

Section titled “问:什么时候该用 panic!,而不是返回 Result?”
  • 当失败是意料之中的、调用者也能合理处理时,返回 Result:文件不存在、用户输入有误、网络超时。
  • 当出现的是 bug 时就 panic,也就是程序到达了一个“本不该出现”的状态,继续运行下去就是错的。越过 vector 末尾去取索引会 panic,就是这个原因。

在测试、示例和快速原型里,panic 也完全没问题。在库里,不要因为调用者能控制的输入而 panic,应该返回一个错误,让调用者来决定。

进一步学习:panic、Result。

问:unwrap、expect 和 ? 有什么区别?

Section titled “问:unwrap、expect 和 ? 有什么区别?”
  • unwrap() 给你 Ok/Some 里面的值,否则就带着一条通用的信息 panic。
  • expect("msg") 做的事一样,但 panic 时用的是你自己的信息。用它来说明你为什么认为这里不会失败。
  • ? 不会 panic。遇到 Err/None 时,它会从当前函数返回这个错误,让调用者去处理。当前函数本身必须返回 Result 或 Option。
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // 出错时,把错误返回给调用者
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("写死在代码里的数字一定能解析");
println!("{n}");
}

输出:

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ 动手试一试

在生产代码里,优先用 ?。当失败意味着 bug 时用 expect,unwrap 留给测试和快速实验。

进一步学习:Result、Option。

问:怎样创建自己的错误类型?

Section titled “问:怎样创建自己的错误类型?”

通常是用一个枚举列出代码可能失败的各种方式,再实现 std::fmt::Display 和 std::error::Error。为其他错误类型实现 From,就能让 ? 自动完成转换。

实际项目里,很多人会用辅助 crate:在库里用 thiserror(英文)定义错误类型,在只需要带着上下文把错误往上传的应用程序里用 anyhow(英文)。写快速的小程序时,Box<dyn Error> 可以装下任何错误。

进一步学习:自定义错误类型。

问:什么时候用 anyhow,什么时候用 thiserror?

Section titled “问:什么时候用 anyhow,什么时候用 thiserror?”

它们解决的是同一个问题的两半,所以老实的回答是“经常两个都用”:

  • thiserror 用于库,或者任何调用者需要针对具体错误做出反应的代码。你自己写错误枚举,#[derive(Error)] 帮你补上 Display 和 Error 的样板代码。调用者拿到的是一个真正的类型,可以对它 match,比如 ConfigError::MissingField。
  • anyhow 用于应用程序,大多数错误只需要报告出来,而不需要逐个处理。每个函数都返回 anyhow::Result<T>,? 几乎能接受任何错误类型,.context("while reading config") 还能在上面加一句方便人看懂的说明。代价是:调用者没法再轻松地对错误 match 了。

常见的搭配是在库 crate 里用 thiserror,在 main 和二进制程序的其余部分用 anyhow。两个 crate 都没有什么魔法,它们只是在标准的 std::error::Error trait 之上省掉了样板代码,所以你随时都可以手写出同样的东西。

进一步学习:anyhow 与 thiserror、自定义错误类型。

没有。内存会在它的所有者离开作用域时释放,释放的时间点编译器事先就知道(这叫 RAII,负责清理的方法是 Drop)。这样你就能得到可预测的性能,没有 GC 停顿。

当你确实需要共享所有权时,可以主动选用引用计数(Rc 或 Arc),它会在最后一个所有者消失时释放这个值。

进一步学习:所有权、智能指针。

问:什么时候用 Box、Rc、Arc 和 RefCell?

Section titled “问:什么时候用 Box、Rc、Arc 和 RefCell?”
  • Box<T>:一个所有者,值放在堆上。用于递归类型(比如树或链表)、很大的值,或者 trait 对象(Box<dyn Trait>)。
  • Rc<T>:单个线程内的多个所有者。它统计引用的数量,计数归零时释放这个值。数据是只读的。
  • Arc<T>:和 Rc 一样,但计数是原子操作,所以可以安全地跨线程共享。比 Rc 稍慢一点。
  • RefCell<T>:让你通过共享引用修改数据(“内部可变性”)。借用规则在运行时而不是编译时检查,违反规则就会 panic。

它们可以组合使用。Rc<RefCell<T>> 表示“在单个线程内共享、并且可以修改”,Arc<Mutex<T>> 是它的线程安全版本。

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("所有者数量:{}", Rc::strong_count(&shared));
}

输出:

[1, 2, 3]
所有者数量:2
▶ 动手试一试

进一步学习:智能指针。

问:安全的 Rust 会内存泄漏吗?

Section titled “问:安全的 Rust 会内存泄漏吗?”

会,坦白说出来才是诚实的回答。Rust 保证的是内存安全(没有释放后使用、没有悬垂指针、没有数据竞争),而内存泄漏并不被认为是不安全的。常见的两种情况:

  • Rc 循环:两个值用 Rc 互相指向,计数永远到不了零。解决办法是把其中一个方向改成 Weak 引用。
  • Box::leak 和 std::mem::forget 这样的函数,它们是故意泄漏的。

在日常代码里,泄漏很少见,因为所有权几乎会自动释放所有东西。

进一步学习:智能指针。

问:unsafe 是什么?什么时候会用到?

Section titled “问:unsafe 是什么?什么时候会用到?”

unsafe 标记一个代码块(或函数),在里面你可以做五件编译器无法检查的额外操作,比如解引用裸指针、调用 unsafe 函数(包括通过 FFI 调用 C 代码),或者访问可变的静态变量。在这里面,由你来保证规则得到遵守。

重要的一点:unsafe 不会关掉借用检查器或其他检查。它只是解锁了那几种额外的操作。

它用于和 C 库打交道、编写底层数据结构,以及在编译器无法证明安全的地方榨取性能。标准库本身就在 Vec 这样的安全 API 内部用到了 unsafe。好的做法是:让 unsafe 块尽量小,用安全的 API 把它包起来,并写一条 // SAFETY: 注释说明它为什么是正确的。大多数应用代码根本用不到它。

进一步学习:unsafe Rust。

它们是编译器用来保证线程安全的标记 trait:

  • Send:这个类型的值可以被移动到另一个线程。
  • Sync:这个类型的值可以在线程之间通过引用共享(即 &T 是 Send)。

大多数类型两个都实现了,而且是编译器自动实现的。最有名的例外是 Rc,它的计数不是线程安全的。试着把一个 Rc 发送到另一个线程,代码就编译不过:

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

编译器说:Rc<i32> 不能安全地在线程之间发送,因为它没有实现 Send。

把 Rc 换成 Arc 就能运行了。人们说 Rust 的“无畏并发”(fearless concurrency),很大一部分指的就是这个:线程安全上的错误会变成编译错误。

进一步学习:并发。

问:怎样在线程之间共享可修改的数据?

Section titled “问:怎样在线程之间共享可修改的数据?”

经典答案是 Arc<Mutex<T>>。Arc 让多个线程共同拥有这个值。Mutex 保证同一时间只有一个线程能修改它:你调用 .lock(),会得到一个守卫(guard),它在离开作用域时自动解锁。

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("总数:{}", *counter.lock().unwrap());
}

输出:

总数:4000
▶ 动手试一试

还可以提一下替代方案:不共享内存,而是用信道(std::sync::mpsc)在线程之间发送消息。对于简单的计数器,AtomicUsize 这样的原子类型可以完全不用锁。

进一步学习:并发。

问:信道和用 Mutex 共享状态相比如何?

Section titled “问:信道和用 Mutex 共享状态相比如何?”

它们是在线程之间分担工作的两种风格:

  • 信道(std::sync::mpsc)传递的是消息。一个值被移动进信道,从另一头出来,所以同一时间只有一个线程拥有它。它适合流水线和工作线程池:“工作线程生产结果,一个线程负责收集。”信道通常更容易推理,因为没有两个线程会碰同一份数据。
  • 共享状态(Arc<Mutex<T>>)指多个线程使用同一份数据,通过锁轮流访问。当很多线程需要读取和更新同一个结构时,它很合适,比如一个缓存或一张计数表。风险是死锁,以及线程不停等锁导致的变慢。

下面是上一个答案里的计数器,用信道重写。每个工作线程各自计数,不用锁,然后把结果发送出去:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // 丢弃最初的发送端,否则 rx.iter() 永远不会结束
let total: i32 = rx.iter().sum();
println!("总数:{total}");
}

输出:

总数:4000
▶ 动手试一试

drop(tx) 这一行是很值得一提的细节:rx.iter() 会一直等到所有发送端都消失,所以忘了这一行,程序就会卡住不动。两种风格下,Rust 都会在编译时检查线程安全。选哪种取决于哪种设计能把你的问题表达得更清楚,很多程序两种都用。

进一步学习:信道(channel)、并发。

问:Rust 能防止所有并发 bug 吗?

Section titled “问:Rust 能防止所有并发 bug 吗?”

不能,能说出这一点才显得你真的懂。安全的 Rust 防止的是数据竞争(两个线程同时访问同一块内存,并且至少有一个在写)。它不能防止:

  • 死锁,比如两个线程各自在等对方持有的锁。
  • 逻辑上的竞态条件,比如先检查一个值,再根据它采取行动,而这期间另一个线程已经把它改了。

所以 Rust 消灭了一整类难缠的 bug,但你仍然需要仔细设计加锁的方式。

进一步学习:并发。

  • 线程由操作系统运行。每个线程都有自己的栈,由操作系统在它们之间切换。线程非常适合 CPU 密集型的工作,但成千上万个线程会占用大量内存。
  • 异步(async)任务要轻量得多。async fn 返回一个 Future,由一个运行时(通常是 Tokio(英文))在少数几个线程上运行许多 future。当一个任务在等待时(等网络、等定时器、等文件),.await 会让其他任务先运行。它非常适合需要大量等待的程序,比如同时处理成千上万个连接的 Web 服务器。
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // 假装在等网络
format!("{name}完成了")
}
#[tokio::main]
async fn main() {
// 两个任务同时运行,所以总共大约 200ms,而不是 300ms。
let (a, b) = tokio::join!(fetch("第一个任务", 200), fetch("第二个任务", 100));
println!("{a}");
println!("{b}");
}

输出:

第一个任务完成了
第二个任务完成了
▶ 动手试一试

面试官喜欢听到的两个实在细节:Rust 的 future 是惰性的,不 await 就什么都不做;标准库不包含运行时,所以你得自己选一个。另外,在异步任务里写阻塞代码(比如一段很长的计算,或者 std::thread::sleep),会把同一个线程上的其他任务也一起卡住。

进一步学习:async/await、并发。

  • 先简单,再深入。 先给一句话的答案(“Rc 是一种引用计数指针,用于在单个线程内共享所有权”),如果对方看起来感兴趣,再补充细节。
  • 讲清楚为什么。 Rust 的规则是为了防止特定的 bug 而存在的。说“借用规则防止了数据竞争和悬垂指针”,比单纯背出规则更有说服力。
  • 用一个小例子。 写三行代码,或者描述一个例子,往往比长篇大论解释得更清楚。
  • 坦诚面对取舍。 Rust 是有代价的:编译更慢、学习曲线更陡、错误处理要多写代码。承认这些体现的是经验,而不是弱点。
  • 不知道就说“我不知道”,然后把你的推理过程说出来。“我没有直接用过 Pin,但我知道它和那些不能在内存中移动的 future 有关”就是一个很好的回答。
  • 准备好一个故事。 面试官经常会问:“讲讲借用检查器拦住你的一次经历。”想一个你自己代码里的真实例子,以及你从中学到了什么。
  • 通过写代码来练习。 练习题页面,以及猜数字游戏和待办事项命令行工具这两个项目,都是很好的热身。

小测验

1. 要在线程之间共享只读数据,你会用哪种智能指针?

2. ? 运算符遇到 Err 时会做什么?

3. 下面哪一项是安全的 Rust 能在编译时防止的?

4. 生命周期注解会改变一个值活多久吗?

得分: 0 / 4