赵可菲是一名Java程序员,一直在维护一个有十多年历史的老旧系统。这个系统即将被淘汰,代码质量也很差,每次上线都会出现很多bug,她不得不加班修复。公司给了她3个月的内部转岗期,如果转不出去就会被裁员。她得知公司可能会用Rust重写很多系统,于是就报名参加了公司的Rust培训,希望能够转型。
半天的Rust培训其实只是开了一个头,赵可菲需要自学Rust。她主要通过阅读Rust官网推荐的书籍来学习,但感觉进步很慢。因为Rust作为一门以内存和并发安全著称的系统级编程语言,有很多新的概念和知识点,她经常学了就忘。赵可菲对于能否在3个月内掌握Rust,从而完成内部转岗感到焦虑。
一次,赵可菲向她的结对编程搭档C++程序员席双嘉提出了一个问题:“如何才能减缓入门Rust过程中所学知识点的遗忘速度?”
席双嘉回答说:“可以试试从避坑的角度来入门Rust。Rust有很多容易踩坑的地方,比如所有权、生存期、迭代器等。与其花大量时间系统地学习这些概念,不如先学习在使用Rust过程中如何避开这些常见的陷阱。这样做有两个好处:第一,顺应人的损失厌恶心理特点能提升行动力。人都不想踩坑,从避坑的角度学习,动力会更足;第二,可以在公司内部AI大模型小艾的帮助下,一上来直接学习专业Rust程序员经常踩坑和避坑的代码,不仅加快入门速度,而且起点就是专业水准,让眼界更开阔。”
赵可菲听了席双嘉的建议后茅塞顿开。她开始有针对性地学习Rust最容易踩坑的地方,果然学习动力和记忆深度都有了很大提高。
下面就是小艾记录的他俩用避坑法自学Rust的过程。其中带问号❓的问题,都是他俩问小艾的问题。针对不懂的编程概念,他俩一般会这样问小艾:“请展开解释这个概念的定义、用法、优势、劣势和适用场景”。除此之外的内容,都是经他俩验证后的小艾的答复。众所周知,小艾的答复因为AI大模型所固有的幻觉,总会有瑕疵。好在赵可菲和席双嘉在入门Rust的愿望的驱使下,会一丝不苟地验证小艾的答复。因为,验证的过程,也是避坑(避免被小艾坑)的过程。
专业程序员在编程时,经常会踩下面7类坑。
Rust最有特色的优势,就是强调内存和并发安全。而内存和并发安全的基础,就是独特的所有权机制。
Rust所有权机制的避坑规则,会涉及6个方面和12个角色,一共有72个避坑场景。如表1-1所示。
表1-1 Rust所有权机制72个避坑场景
方面/角色 | 变量(不可变与可变) | 栈上值 | 堆上值 | 不可变引用(共享引用) | 可变引用 | Box | Rc | Arc | Cell | RefCell | Mutex | RwLock |
---|---|---|---|---|---|---|---|---|---|---|---|---|
所有权 | 场景1 | 场景7 | 场景13 | 场景19 | 场景25 | 场景31 | 场景37 | 场景43 | 场景49 | 场景55 | 场景61 | 场景67 |
所有权移动 | 场景2 | 场景8 | 场景14 | 场景20 | 场景26 | 场景32 | 场景38 | 场景44 | 场景50 | 场景56 | 场景62 | 场景68 |
作用域 | 场景3 | 场景9 | 场景15 | 场景21 | 场景27 | 场景33 | 场景39 | 场景45 | 场景51 | 场景57 | 场景63 | 场景69 |
生存期 | 场景4 | 场景10 | 场景16 | 场景22 | 场景28 | 场景34 | 场景40 | 场景46 | 场景52 | 场景58 | 场景64 | 场景70 |
丢弃 | 场景5 | 场景11 | 场景17 | 场景23 | 场景29 | 场景35 | 场景41 | 场景47 | 场景53 | 场景59 | 场景65 | 场景71 |
复制 | 场景6 | 场景12 | 场景18 | 场景24 | 场景30 | 场景36 | 场景42 | 场景48 | 场景54 | 场景60 | 场景66 | 场景72 |
这72个避坑场景,会在后面逐步介绍。
若不采取任何并发安全措施,滥用可变性,会带来多线程并发编程时的数据竞争难题。
先看一个因共享可变状态,带来多线程并发时的数据竞争的剧院订票系统的Rust代码实例,如代码清单1-1所示。
代码清单1-1 出现数据竞争问题的多线程并发剧院订票系统
1 use std::sync::Arc;
2 use std::thread;
3
4 struct Theater {
5 available_tickets: *mut i32,
6 }
7
8 unsafe impl Send for Theater {}
9 unsafe impl Sync for Theater {}
10
11 impl Theater {
12 fn new(initial_tickets: i32) -> Self {
13 Theater {
14 available_tickets: Box::into_raw(Box::new(initial_tickets)),
15 }
16 }
17
18 fn book_ticket(&self) {
19 unsafe {
20 if *self.available_tickets > 0 {
21 // 模拟一些处理时间,增加竞争条件的可能性
22 thread::sleep(std::time::Duration::from_millis(10));
23 *self.available_tickets -= 1;
24 println!(
25 "Ticket booked. Remaining tickets: {}",
26 *self.available_tickets
27 );
28 } else {
29 println!("Sorry, no more tickets available.");
30 }
31 }
32 }
33
34 fn get_available_tickets(&self) -> i32 {
35 unsafe { *self.available_tickets }
36 }
37 }
38
39 impl Drop for Theater {
40 fn drop(&mut self) {
41 unsafe {
42 drop(Box::from_raw(self.available_tickets));
43 }
44 }
45 }
46
47 fn main() {
48 let theater = Arc::new(Theater::new(10)); // 初始有10张票
49
50 let mut handles = vec![];
51 for _ in 0..15 {
52 let theater_clone = Arc::clone(&theater);
53 let handle = thread::spawn(move || {
54 theater_clone.book_ticket();
55 });
56 handles.push(handle);
57 }
58
59 for handle in handles {
60 handle.join().unwrap();
61 }
62
63 println!("Final ticket count: {}", theater.get_available_tickets());
64 }
// Output:
// Ticket booked. Remaining tickets: 7
// Ticket booked. Remaining tickets: 6
// Ticket booked. Remaining tickets: 5
// Ticket booked. Remaining tickets: 4
// Ticket booked. Remaining tickets: 3
// Ticket booked. Remaining tickets: 2
// Ticket booked. Remaining tickets: 2
// Ticket booked. Remaining tickets: 1
// Ticket booked. Remaining tickets: 1
// Ticket booked. Remaining tickets: 0
// Ticket booked. Remaining tickets: -1
// Ticket booked. Remaining tickets: -2
// Ticket booked. Remaining tickets: -3
// Ticket booked. Remaining tickets: -4
// Ticket booked. Remaining tickets: -5
// Final ticket count: -5
代码清单1-1模拟了一个简单的剧院售票系统,存在一些并发问题和安全隐患。代码后面的Output输出(因为数据竞争具有随机性,在你电脑上看到的输出或许略有不同),反映了在多线程并发环境下滥用可变性所导致的数据竞争问题。具体表现如下:
这些现象清楚地展示了由于缺乏适当的同步机制(如互斥锁),多个线程并发访问和修改共享资源(票数)时产生的数据竞争问题。这导致了不可预测的结果和数据不一致性,是并发编程中典型的问题场景。
要把代码清单1-1运行起来,并看到类似代码后边注释掉的打印输出,有两种办法。
第一种办法是在mycompiler.io网页上运行。
打开www.mycompiler.io/new/rust网页,把代码清单1-1所对应的没有行号的代码(可以克隆github.com/wubin28/wuzhenbens_playground代码库,进入wuzhenbens_playground文件夹,切换到immutable_variable_theater_booking_rust_data_race
分支,再进入immutable_variable_theater_booking_rust文件夹,找到main.rs源文件),复制粘贴到网页左侧。然后点击网页右上角的Run按钮即可运行。
第二种办法是在本地电脑上运行。
先用你最喜欢的搜索引擎或AI大模型,找到用rustup安装Rust的方法,并在本地电脑上安装Rust。
❓如何验证安装是否成功?
等安装好后,在终端窗口运行命令
rustc --version
。如果看到类似这样的输出rustc 1.80.1 (3f5fd8dd4 2024-08-06)
,就说明你已经安装好Rust了。
之后你可以用git命令把代码github.com/wubin28/wuzhenbens_playground
给clone下来,再进入文件夹wuzhenbens_playground
,然后再进入文件夹immutable_variable_theater_booking_rust
。之后可以运行git checkout immutable_variable_theater_booking_rust_data_race
,切换到相应的分支,就能在src目录中,看到main.rs文件里的代码清单1-1的代码。
你可以用任何喜爱的IDE(比如Cursor、vscode或rustrover),打开这个main.rs文件。
要想运行这个文件,可以在终端的immutable_variable_theater_booking_rust
文件夹下,运行命令cargo run
即可。要是你改动了代码,可以先运行cargo fmt
格式化代码,然后运行cargo build
进行编译构建,最后再运行cargo run
运行程序。
如果你想从零开始,构建这个项目,可以在一个新项目文件夹中,运行命令cargo new immutable_variable_theater_booking_rust
,再进入文件夹immutable_variable_theater_booking_rust
,你就能看到src
文件夹下,有一个main.rs文件。里面有一个hello world程序。此时你可以运行cargo run
运行一下。之后,就可以把代码清单1-1所对应的没有行号的代码,复制粘贴进去,然后运行cargo fmt
格式化代码,再运行cargo build
进行编译构建,最后再运行cargo run
运行程序。
代码运行起来后,如果能看到类似代码后边注释掉的打印输出,说明程序就能运行了。
本书所有有main
函数的代码,都也可以用上述方法运行。之后不再赘述。
先看看代码清单1-1第47行的main函数都做了什么事情。
第47行fn
关键字在 Rust 中用来定义一个函数。
main
是 Rust 程序的入口点。每个可执行的 Rust 程序都必须有一个 main
函数。空括号 ()
表示这个函数不接受任何参数。main
函数通常不显式指定返回类型。默认返回 ()
,即 unit 类型。左花括号 { 标志着函数体的开始。main 函数是程序执行的起点。当程序启动时,Rust 运行时会自动调用 main 函数。
❓什么是Unit类型?
Unit 类型在 Rust 中写作
()
。它是一个零大小的类型,只有一个值,也写作()
。可以理解为一个空的元组。Unit类型可以作为不返回有意义值的函数的返回类型,可以在泛型编程中作为占位符类型,可以用于表示副作用操作(如打印到控制台)的结果。
Unit类型很简洁,明确表示函数不返回有意义的值。它是零开销的,不占用内存空间。它是类型安全的,比使用
void
更加类型安全。它保持了
Rust “一切皆表达式” 的理念。但Unit类型对于初学者可能不太直观。在某些情况下可能需要显式处理
()
值。Unit类型可以用于表达主要执行副作用的函数的返回值,如
println!
的返回值。可以用于实现 trait
方法时,方法不需要返回值。可以在Result<(), Error>
中表示成功但无需返回值的情况。可以在异步编程中作为 future
的占位结果类型。
main
函数默认返回 ()
,表示程序正常结束。可以显式指定 fn main() -> () {
但通常省略。
第48行Theater::new(10)
创建了一个新的 Theater
剧院实例,初始票数为10。
Arc::new(...)
将 Theater
实例包装在 Arc
(Atomic Reference Counted,原子引用计数)中。Arc
本身是栈上一个智能指针,指向堆上包含控制块(包括引用计数)和数据的内存位置。Arc
用于在多个线程间共享所有权。它允许多个线程对同一数据进行只读访问。
上面提到,Arc
是一个智能指针,什么是智能指针?
❓什么是智能指针?
智能指针是一种数据结构,行为类似于指针,但具有额外的元数据和功能。在Rust中,智能指针通常实现了
Deref
和Drop
trait。Rust中常用的智能指针有以下7种。
Box
:用于在堆上分配值Rc
:引用计数智能指针,允许多个所有者共享同一数据的不可变所有权Arc
:原子引用计数智能指针,用于在并发场景下以不可变访问来避免数据竞争Cell
:提供内部可变性(详见第2章),只适用于实现了Copy
trait的类型RefCell
:提供内部可变性,能够处理没有实现Copy
trait的类型Mutex
:提供(读写)互斥锁,用于在并发场景下安全地共享和修改数据RwLock
:提供读写锁,在并发场景下允许多个读操作同时进行,或者单个写操作独占访问智能指针最大的优势,是实现了自动内存管理,避免内存泄漏。另外它还提供额外功能,如共享所有权、内部可变性等。它还使用方便,语法类似于普通引用。最后是编译时检查,提高安全性。
智能指针也有一些劣势。它可能引入轻微的运行时开销。在某些情况下可能导致性能下降。学习曲线相对陡峭,尤其是对新手来说。
智能指针适用以下场景。
- 需要在堆上分配数据或存储递归数据结构时使用
Box
。- 需要在多个所有者之间共享只读所有权时使用
Rc
(单线程)或Arc
(多线程)。- 需要在不可变上下文中修改小型数据结构时使用
Cell
。- 需要在不可变上下文中修改复杂数据结构时使用
RefCell
。- 多线程环境中需要共享和修改的数据(特别是读写操作频繁交替的并发场景)时使用
Mutex
。- 读多写少的并发场景(如配置信息、缓存数据等)时使用
RwLock
。
上面提到,智能指针通常实现了Deref
和Drop
trait。那什么是trait?
❓什么是trait?
Rust中的trait是一种定义共享行为的方式。trait定义了一组方法,这些方法描述了某种能力或行为。可以将trait视为一种接口,它指定了类型应该实现的方法。智能指针、结构体或枚举可以实现(implement)一个或多个trait,从而获得这些trait定义的行为。trait可以为其方法提供默认实现,实现该trait的类型可以选择使用默认实现或覆盖它。trait可以继承其他trait,从而组合多个行为。
智能指针通常实现了Deref
和Drop
trait,这意味着什么?
❓实现了
Deref
和Drop
trait的智能指针意味着什么?
Deref
trait允许智能指针像引用一样被解引用。这意味着可以使用*
操作符来访问智能指针包含的值。允许智能指针的方法自动解引用,使其行为更像普通引用。启用了解引用强制转换(deref
coercions),允许在需要引用的地方使用智能指针。
Drop
trait允许自定义当值离开作用域时应该发生的行为。这意味着可以在对象被销毁前执行清理操作。管理不由Rust内存管理的资源(如文件句柄、网络连接等)。防止资源泄露,确保资源被正确释放。
演示Box与自定义MyBox的Deref trait的代码实例,如代码清单1-2所示。
代码清单1-2 Box
与自定义MyBox
的Deref
trait演示
use std::ops::Deref;
// 定义一个简单的结构体
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
// 为 MyBox 实现 Deref trait
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// 一个接受 &str 类型参数的函数
fn print_string(s: &str) {
println!("通过解引用强制转换传递的字符串: {}", s);
}
fn main() {
// 创建一个 Box
let boxed_string = Box::new(String::from("Hello, Deref!"));
// 使用 * 操作符解引用
println!("使用 * 解引用 Box: {}" , *boxed_string);
// 直接调用方法,无需显式解引用
println!("直接调用方法,自动解引用: {}", boxed_string.len());
// 创建自定义的 MyBox
let my_boxed_string = MyBox::new(String::from("Hello, custom Box!"));
// 使用 * 操作符解引用自定义 Box
println!("使用 * 解引用 MyBox: {}" , *my_boxed_string);
// 解引用强制转换:将 Box 传递给接受 &str 的函数
print_string(&boxed_string);
// 解引用强制转换:将 MyBox 传递给接受 &str 的函数
print_string(&my_boxed_string);
}
// Output:
// 使用 * 解引用 Box: Hello, Deref!
// 直接调用方法,自动解引用: 13
// 使用 * 解引用 MyBox: Hello, custom Box!
// 通过解引用强制转换传递的字符串: Hello, Deref!
// 通过解引用强制转换传递的字符串: Hello, custom Box!
什么是Arc
智能指针?
❓什么是
Arc
?
Arc
的全称是Atomic Reference
Counted(原子引用计数),它是原子引用计数智能指针,允许多线程间安全地共享数据的不可变所有权。它是Rc
的多线程版本。
Arc
使用原子操作来更新引用计数,确保多线程安全。它本身是栈上一个智能指针,指向堆上包含控制块(包括引用计数)和数据的内存位置。当T
实现了Send
trait时,Arc
也会自动实现Send
。Arc
总是实现Sync
trait,允许在大多数情况下安全地在线程间传递和共享。
Arc
的最大优势,是允许在线程间安全地共享和传递所有权,而无需深度拷贝数据。Arc
的克隆操作是O(1)复杂度,非常高效。
Arc
也有一些劣势。相比Rc
,它有更高的性能开销,因为需要额外的空间来存储原子计数器。它不适合单线程环境,在单线程中使用Rc
更高效。如果用它创建了循环引用,可能导致内存泄漏,需要谨慎使用,或考虑使用Weak
来打破循环。尽管Arc
是线程安全的,但它不提供任何其他同步保证。如果需要进行复杂的线程间通信,可能需要配合使用其他并发原语(如Mutex
或RwLock
)。Arc
提供的是不可变的共享访问。如需可变访问,通常需要使用互斥锁等同步原语(如Mutex
或RwLock
)。
Arc
特别适用于需要在多个线程之间共享大型不可变数据结构的情况。另外,它还适合在多线程应用中共享只读数据。还适合实现线程安全的缓存或配置信息。
let theater = ...
将 Arc
绑定到不可变变量 theater
。
❓绑定和赋值有什么不同?
在 Rust 中,使用
let
关键字创建一个新的变量并将值与之关联,这个过程称为绑定(Binding)。绑定创建了一个新的变量,并可能涉及所有权的转移。例如:let x = 5;
创建了一个新的变量x
并将值 5 绑定到它。赋值(Assignment)是将一个新值分配给一个已经存在的变量。在 Rust 中,赋值通常用于可变变量(使用
mut
关键字声明)。例如:x = 10;
(假设x
之前被声明为可变)绑定与赋值存在下面的区别,绑定创建新的变量,赋值修改现有变量的值。绑定可以是不可变的,而赋值总是涉及可变性。绑定可能涉及所有权转移,赋值通常不会。
在绑定过程中,如果值不是
Copy
类型,所有权会被移动。赋值通常不涉及所有权转移,除非使用了std::mem::replace
或类似的函数。绑定允许类型推断,而赋值通常不需要(因为变量类型已经确定)。
绑定可以用于模式匹配,如
let (x, y) = (1, 2);
。赋值不支持这种复杂的模式匹配。绑定创建的变量有其特定的作用域。赋值不会改变变量的作用域。
第48行是一个绑定操作。它创建了一个新的不可变变量 theater
。将一个新创建的 Arc
实例绑定到 theater
。这个绑定涉及所有权的转移(Arc
的所有权移动到 theater
)。
这里使用 Arc
是必要的,因为代码后面会创建多个需要访问同一 Theater
实例的线程。Arc
确保只要还有任何线程在使用,Theater
实例就会保持存活,并提供线程安全的引用计数。
通过使用 Arc
,可以在第52行为每个线程克隆 Theater
的引用,使它们能够安全地共享相同的数据。然而,需要注意的是,虽然 Arc
提供了引用的安全共享,但它并不能使 Theater
的内部操作变得线程安全。当前的实现由于对第5行的 available_tickets
的不安全可变访问,仍然存在竞态条件。
第50行创建了一个名为handles
的可变向量。这个向量是可变的(mut
),因为稍后会向其中添加线程handle
。
❓什么是向量?
Rust的向量(Vector)是一种动态数组类型,它提供了一个灵活、可增长的数据结构。
vec![]
是一个创建空向量的宏。
❓什么是宏?
在Rust中,尾部带叹号的语言构造,通常是宏。Rust中的宏是一种元编程工具,允许程序员编写可以生成其他代码的代码。宏在编译时展开,可以生成比函数更复杂的代码。
第51行for _ in 0..15 {
开始一个将迭代15次的循环。这里使用下划线 _
,是因为这里不需要使用循环计数器。
第52行Arc::clone(&theater)
创建一个新的 Arc
实例,而不是 Theater
对象本身,并将其绑定给不可变变量theater_clone
,以便安全地移动到新线程中。每个线程都需要自己的指向 Theater
的 Arc
。这样就允许多个线程同时访问同一 Theater
实例。
Arc::clone()
方法会增加引用计数,但不会复制底层数据。即使增加了引用计数,Arc
的 clone()
仍然是轻量级操作,因为它们共享相同的底层数据。
每次循环,程序会将 Arc
的引用计数增1,并创建一个指向同一 Theater
实例的新 Arc
。Arc
使用原子操作来更新引用计数,确保多线程安全。
当创建一个新的 Arc
实例时,引用计数设为 1。每当克隆这个 Arc
(通过 Arc::clone
),引用计数就会增加 1。当一个 Arc
实例离开作用域时,引用计数减少 1。当引用计数降到 0 时,说明Arc
的所有实例都超出作用域或被手动丢弃(非必须)时,引用计数降为 0,Arc
所指向的数据会被自动清理。
使用 Arc
能确保只要还有任何线程在使用,Theater
对象就会保持存活,并且当所有指向它的 Arc
都被丢弃时,它会自动被释放。
第53-55行模拟多个并发订票。每个启动的线程通过调用共享Theater
对象上的book_ticket()
方法来尝试订票。然而,由于缺乏适当的同步,这可能导致竞态条件和不正确的结果,正如在输出中所看到的,票数变成了负数。
第53行使用Rust标准库的thread::spawn
函数创建一个新线程。spawn
函数接受一个闭包(匿名函数)作为参数,并返回一个JoinHandle
。JoinHandle
代表了一个正在运行的线程。通过第60行调用 join()
方法,可以等待该线程执行完毕。
❓什么是闭包?
闭包是一种匿名函数,可以捕获其定义环境中的变量。在 Rust 中,闭包使用
||
语法定义,它使用||
包围参数列表(这里是空的),后跟代码块。||
左侧的move
关键字,表示这个闭包将获取它从环境中捕获的任何变量的所有权。之后花括号包起来的闭包体,包含要执行的代码(这里是调用
book_ticket
方法)。闭包有很多优势。比如简洁,可以内联定义小型函数,无需单独的函数定义。另外它很灵活,可以捕获环境中的变量。闭包还支持高阶函数和函数式编程范式。最后闭包是线程安全的,它通过
move
可以在线程间安全地转移所有权。闭包也有一些劣势。比如语法可能不直观,对新手来说可能较难理解。生命周期较复杂,在某些情况下可能需要显式处理生命周期。它还有类型推断限制,有时需要显式指定类型。
闭包适用以下场景。闭包可以作为函数参数,如在
thread::spawn
中。可以作为回调函数,用于事件处理或异步编程。可以用于迭代器操作,如map
、filter
等。可以用于自定义数据结构,实现延迟计算或自定义行为。闭包分三种类型。
Fn
类型,不可变借用捕获的变量。FnMut
类型,可变借用捕获的变量。FnOnce
类型,获取捕获变量的所有权(如本例中使用
move
,就是FnOnce
类型)。闭包与普通函数之间还是有区别的。首先闭包可以捕获环境,普通函数不行。另外闭包类型(是
Fn
、FnMut
还是FnOnce
)是自动推导的,普通函数需要显式类型声明。在多线程上下文中,
move
闭包确保了数据的安全转移,防止了潜在的数据竞争。
第53行的move ||
是传递给thread::spawn
的闭包的开始,用作线程的执行函数。move
关键字表示这个闭包将捕获 theater_clone
,并在新线程中使用,确保 theater_clone
的所有权转移到新线程,避免数据竞争。||
标志着一个闭包的开始。它类似于函数的参数列表。闭包的语法为:|参数1, 参数2, ...| { 闭包体 }
。如果没有参数,就直接使用空的 ||
。
第54行是闭包的主体。它在theater_clone
对象上调用book_ticket()
方法。
第56行将新创建的线程handle
添加到 handles
向量中。
第59-61行确保主线程在所有已创建的线程完成订票之前不会继续执行。这很重要,因为它要防止程序在所有订票处理完成之前过早终止,也要确保当打印最终票数时,所有订票操作都已完成。
第59行开始一个循环,遍历 handles
向量中的每个 handle
。每个 handle
代表一个已创建的线程。
第60行handle.join()
方法等待线程完成执行。它会阻塞当前线程(在这种情况下是主线程),直到已创建的线程完成。.unwrap()
是在 join()
返回的 Result 上调用的。如果连接线程时出现错误,它会引发 panic,但在这种情况下,它用于简化错误处理。
第63行打印最后剩余的票数。
再看看Theater
结构体。
Theater
结构体的定义与trait实现第4-6行在Rust中定义了一个名为Theater
的结构体。
第4行声明了一个名为Theater
的新结构体类型。
第5行available_tickets: *mut i32,
是Theater
结构体中唯一的字段。它是一个指向可变32位整数(i32
)的原始(裸)指针。*
表示这是一个指针。mut
表示这个指针指向的内容是可变的。i32
是指针所指向的数据类型(32位整数)。
第5行结构体定义最后的逗号可以不写吗?
❓结构体定义最后一行后面的逗号是不是可选的?
第5行结构体定义最后有一个逗号是可选的。可以选择加上它,也可以选择不加。
如果
Theater
结构体只有这一个字段,那么这个逗号可以省略而不影响代码的正确性。如果结构体有多个字段,最后一个字段后的逗号可以省略,但前面的字段必须有逗号分隔。Rust 的官方风格指南建议在多行的结构体定义中,即使是最后一个字段也保留逗号。这被称为"尾随逗号"(trailing
comma)。这样保留尾随逗号,可以使添加新字段更容易,因为不需要记得在前一行添加逗号。它还可以使版本控制系统的差异更清晰,因为添加新字段只会显示为一行的变化。为了保持代码风格的一致性,通常建议在所有类似的结构(如结构体、枚举、数组等)定义中都使用尾随逗号。
在Rust中,这里使用裸指针是不寻常的,并且可能不安全。裸指针通常用于与C代码交互或实现低级数据结构。它们绕过了Rust通常的安全保证,这就是为什么涉及它们的操作总是被包裹在unsafe
代码块中。
在第5行,裸指针被用来允许跨线程共享可变状态,这在Rust中通常不被推荐。更安全的方法通常涉及使用同步原语,如Mutex
或AtomicI32
。
这种设计选择引入了潜在的问题。首先是线程安全问题,没有适当的同步,并发访问可能导致竞态条件。其次是内存安全问题,不当使用裸指针可能导致未定义行为。最后是绕过Rust的所有权规则,裸指针规避了Rust的所有权和借用规则。更符合Rust惯用法的方法是使用安全的并发原语来管理线程间的共享状态。
第8-9行,为 Theater
结构体实现了 Send
和 Sync
trait。
这里的Send
和Sync
是Rust标准库中的内置trait,用于并发安全性。通过为Theater
实现这两个trait,代码表明Theater
类型可以安全地在线程间传递和共享,尽管在这个特定情况下,实际实现并不是线程安全的。
Send
trait 表示在线程间传递类型的所有权是安全的。通过实现 Send
,代码告诉 Rust 编译器在线程间移动 Theater
实例是安全的。
Sync
trait 表示在线程间共享类型的引用是安全的。通过实现 Sync
,代码告诉 Rust 编译器在多个线程间共享 Theater
实例的引用是安全的。
这里使用 unsafe
关键字是因为编译器无法自动验证 Theater
结构体的线程安全性,这是由于它使用了裸指针(*mut i32
)。使用 unsafe
意味着程序员需要承担确保实现实际上是线程安全的责任。
需要注意的是,在这种情况下,代码实现实际上并不是线程安全的。book_ticket
方法可能导致竞态条件,因为它在没有适当同步的情况下修改共享状态。这就是为什么程序会产生不正确的结果,允许预订的票数超过可用票数。
Theater
结构体关联函数与方法的实现第11-37行,定义了 Theater
结构体的一个关联函数(associated function)和两个方法(method)的实现。new
关联函数创建一个新的 Theater
实例。book_ticket
方法尝试预订一张票。get_available_tickets
方法返回当前可用票数。
new
关联函数
第12行定义了 Theater
结构体的 new
关联函数(类似于其他语言中的静态方法),用于创建一个新的 Theater
实例。它接受一个 i32
类型的参数 initial_tickets
,表示初始票数。返回类型 Self
表示返回 Theater
类型的实例。
❓什么是关联函数?什么是方法?
关联函数是定义在 impl 块内,但不接受
self
参数的函数。与结构体或枚举相关联,但不需要实例来调用,例如Rectangle::new(10, 20)
。关联函数通过结构体类型名调用:StructName::function_name()
。通常用于构造器或工具函数。当用于构造器时,常用于创建新实例,类似构造函数。可以定义多个关联函数,用于不同的初始化场景。方法(Methods)也定义在 impl 块中,但有
self
参数。方法可以用于操作结构体或枚举的实例,例如rect.area()
,rect.resize(15, 25)
,
rect.destroy()
。方法的self
参数可以有下面不同的变体。
&self
:不可变引用,最常见的形式。&mut self
:可变引用,允许修改实例。self
:获取所有权,较少使用。mut self
:获取可变所有权,更少见。
self
在方法里起两个作用。首先是提供对实例的访问。其次是决定方法如何与实例交互(只读、可变、获取所有权)。关联函数之所以类似于其他语言中的静态方法,是因为首先调用方式相似,关联函数和静态方法都通过类型名来调用,而不是实例。其次两者调用都不需要实例,两者都不需要类型的实例就能调用。最后是都能用于创建实例,两者都常用于创建类型的新实例,类似构造函数。
但两者也存在不同之处。首先在self参数方面,关联函数可以通过添加 self 参数变体(如
fn(&self)
),成为方法。其次在继承方面,许多面向对象语言的静态方法可以被继承,而 Rust
没有继承概念。最后在动态分发方面,一些语言的静态方法可以参与动态分发,Rust 的关联函数不行,无法通过 trait
对象调用。动态分发是指程序在运行时(而非编译时)决定调用哪个具体的方法实现。
第13-15行Theater { ... }
创建并返回一个新的 Theater
结构体实例。
第14行available_tickets: Box::into_raw(Box::new(initial_tickets)),
有点长,咱们从右往左一点点看。Box::new(initial_tickets)
创建一个包含 initial_tickets
值的堆分配的 Box
智能指针实例。Box::into_raw(...)
将 Box
转换为裸指针 *mut i32
。这个操作将内存管理的责任从 Rust 的所有权系统转移到了程序员手中。available_tickets:
是在结构体初始化或定义中声明字段的语法。它指定了一个名为 available_tickets
的字段,该字段将被赋予冒号右侧表达式的值。这种语法是 Rust 中创建结构体实例或定义结构体字段的标准方式。
new
关联函数之所以这样实现,有以下几个原因。首先是可变性,通过使用裸指针,可以在不改变 Theater
结构体本身的情况下修改票数。其次是线程安全,裸指针允许在多线程环境中共享和修改数据,尽管这需要小心处理以避免数据竞争。最后是性能,直接操作内存可能在某些情况下提供更好的性能。
然而,这种方法也带来了一些风险。首先是安全性,使用裸指针和 unsafe
代码块增加了出错的风险。第二是内存管理,程序员需要确保正确管理内存,避免内存泄漏或使用已释放的内存。
在实际应用中,通常推荐使用 Rust 的安全抽象,如 Mutex
或 AtomicI32
,来处理多线程环境下的共享可变状态,除非有明确的理由需要使用不安全的代码。
book_ticket
方法
Theater
结构体中的 book_ticket
方法,用于模拟售票过程。
❓
book_ticket
方法,与main
函数,两者都是用fn
定义,为何一个是函数,另一个是方法?两者有什么区别?在 Rust 中,方法和函数的区别主要在于两方面。首要的区别在于定义位置,方法是在 impl
块内定义的,与特定的类型(如结构体或枚举)相关联。函数既可以在 impl
块外独立定义,也可以在impl块内定义(成为关联函数)。另一个区别在于第一个参数,方法的self
参数在定义时是显式的,但在调用时是隐式传递的。函数没有这个特殊的第一个参数。
第18行定义了book_ticket
实例方法,接受一个不可变的引用 &self
,即实例本身的不可变引用。方法可以读取实例的数据,但不能修改它。
从第19行开始,整个方法体被包裹在 unsafe
块中,因为它涉及到对裸指针的操作。
第20行检查是否还有可用的票。*self.available_tickets
解引用指针来获取当前可用票数。
第22行模拟了一些处理时间,增加了线程间竞争的可能性。
第23行如果有票可用,就减少一张票。
第24-27行打印订票成功的消息和剩余票数。
第28-30行如果没有可用的票,打印无票消息。
这段代码存在线程安全问题,因为多个线程可能同时访问和修改 available_tickets
,导致数据竞争。这就是为什么在输出中出现了负数的票数,这在现实世界的售票系统中是不可能发生的。要解决这个问题,需要使用适当的同步机制,如互斥锁(Mutex
)来保护共享资源。
get_available_tickets
方法
第34-36行的get_available_tickets
方法允许外部代码安全地查询当前可用的票数,而不需要直接接触不安全的裸指针。使用 unsafe 块将不安全操作限制在最小范围内,同时通过公共 API 提供了一个安全的接口。
第34行定义了一个名为 get_available_tickets 的方法。&self
表示这是一个不可变的引用方法,不会修改 Theater 实例。-> i32
指定方法返回一个 i32
类型的值(票数)。
第35行unsafe { ... }
声明一个不安全代码块,因为这里要解引用裸指针。self.available_tickets
解引用 available_tickets
指针,获取存储的 i32 值,并返回这个值。
❓
get_available_tickets
方法既然返回值是i32
类型,但为何没有return语句?在 Rust
中,代码块中的最后一个表达式(如果不带分号)会被视为该代码块的返回值。对于函数或方法,如果最后一个表达式不带分号,它就会成为该函数或方法的返回值。在
Rust 中,这是一种常见的隐式返回方式。这里*self.available_tickets
作为最后一个不带分号的表达式,被隐式地用作代码块,进而作为get_available_tickets
方法的返回值。
Drop
trait 实现第39-45行定义了 Theater
结构体的 Drop
trait 实现。
第39行为 Theater
结构体实现 Drop
trait。
第40行定义 drop
方法,接受一个可变引用 &mut self
。
第41行unsafe {
开始一个不安全代码块,因为接下来第42行 Box::from_raw()
是一个不安全的操作。它假设指针是有效的并且是通过 Box::into_raw()
创建的,这些条件在安全 Rust 中无法保证。
第42行首先用Box::from_raw(...)
将裸指针转换回 Box
。然后左侧的drop(...)
是显式调用 drop
函数来释放 Box
所管理的内存。
为何这里要显式定义Drop trait的实现?如果不显式定义,rust会提供Drop的默认实现,以满足本项目的需求吗?
❓何时要显式定义
Drop
trait的实现?
Drop
trait 用于定义当一个值离开作用域时应该执行的清理操作。它包含一个drop
方法,该方法在对象被销毁时自动调用。之所以要显式定义
Drop
,是因为在这个例子中,Theater
结构体使用了裸指针mut i32
来管理可用票数。这个指针是通过Box::into_raw()
创建的,它将堆分配的内存的所有权转移到了裸指针上。如果不显式定义
Drop
,Rust 的默认实现不会知道如何正确释放这个裸指针指向的内存,可能导致内存泄漏。
第41-43行这段unsafe代码,先将裸指针转换回 Box
,然后调用 drop
函数来释放内存。这是必要的,因为 Box::into_raw()
的逆操作需要手动完成。
Rust 确实为大多数类型提供了默认的 Drop
实现,但这个默认实现只会递归地调用其成员的 drop
方法。对于包含裸指针的类型,默认实现不足以正确清理资源,因为裸指针不是由 Rust 的内存管理系统直接管理的。
在这个例子中,如果不显式定义 Drop
,Rust 的默认实现只会丢弃 mut i32
类型的指针本身,而不会释放指针指向的堆内存。这会导致内存泄漏,因为分配的票数内存永远不会被释放。
❓
self
之前为何要写成&mut
?写成&
不行吗?第40行
self
之前为何要写成&mut
,而不能是&
。这是因为Drop
trait 在标准库中的定义是这样的:fn drop(&mut self); } ``` 可以看到,`drop` 方法要求一个可变引用 `&mut self`。Rust 编译器会强制要求 `drop` 方法的签名与 `Drop` trait 的定义完全匹配。如果尝试使用 `&self`,编译器会报错。 当一个对象被 drop 时,通常需要修改它的内部状态来释放资源。这就需要可变访问权限。另外,在释放资源的过程中,对象可能需要修改自己的字段或调用其他需要可变访问的方法。 使用 `&mut self` 可以确保在 `drop` 过程中,没有其他引用可以访问这个对象,避免了潜在的数据竞争。这也防止了在 `drop` 过程中对对象进行意外的共享访问。
从代码清单1-1末尾注释中的Output输出能够看出,有些线程所查出的剩余票数,以及最后的剩余票数,都是负数。这说明在进行多线程并发编程时,如果使用共享可变状态,就会踩数据竞争的坑。
在代码清单1-1中,下面描述的这个共享可变状态,会在多线程并发编程时,挖了数据竞争的坑。
第5行available_tickets
就是这样的共享可变状态。它是结构体Theater
的一个字段,存储了一个指向可变 i32
的可变原始(裸)指针。指针本身可以被修改(即可以指向不同的内存位置),指针指向的值也可以被修改。多个线程共享并直接修改它。这种共享可变状态没有任何同步机制,是数据竞争的根源。
之后,book_ticket
方法使用 unsafe
块直接读写 available_tickets
。而且多个线程可以同时访问和修改这个值,没有任何互斥或原子操作保护。这些都是不安全的并发访问。
最后,在检查票数和减少票数之间有一个延迟(thread::sleep
)。这增加了竞态条件的可能性,因为多个线程可能同时认为还有票可订。
虽然在代码清单1-1中的第5行available_tickets
是一个可变裸指针类型的结构体字段,并不是Rust的可变变量,但两者还是有以下相似点。可直接修改,结构体的可变字段和可变变量都可以直接修改其值。编译时检查,Rust 编译器允许对可变字段和可变变量进行修改操作。借用规则,两者都遵循 Rust 的借用规则,如一个值在同一时间只能有一个可变引用。
Rust的变量分为两种,一种是不可变变量,另一种是可变变量。
可变变量(Mutable variable),指在声明后其值可以被改变的变量。在Rust中,需要使用mut
关键字明确声明。
可变变量的特点是允许修改绑定的值。可变性仅限于变量的所有者。
可变变量的优势是解决了Rust默认变量不可变所带来无法就地改变变量值的难题。另外比较灵活,可以根据需要修改变量值。某些情况下,修改现有值比创建新实例更高效。它还适合某些算法,这些算法或相关数据结构需要就地修改数据,这对于某些算法(如排序、图操作)来说更为高效。它还提供了更灵活的内存使用模式,特别是在处理大型数据结构时。
可变变量也存在劣势。比如会导致安全性降低,可能导致意外修改和相关bug。并发复杂性,在多线程环境中需要额外的同步机制。代码推理难度增加,可变性使得代码流程更难追踪。增加了代码复杂性,可能使推理和调试变得更困难。
可变变量适用于需要频繁更新的数据结构(如缓存、计数器)。在性能关键的代码段中,可避免不必要的克隆和内存分配。
虽然可变变量解决了Rust默认变量不可变所带来无法就地改变变量值的难题,但滥用可变性,会在多线程并发编程时,带来数据竞争的难题。
前面介绍了Rust的可变变量与结构体的可变字段的相似点,那两者之间有什么区别?
❓可变变量与结构体的可变字段的差异点是什么?
Rust的可变变量与结构体的可变字段存在以下差异点。
- 可变性的来源。一般情况下,结构体字段的可变性取决于结构体实例的可变性。只有当结构体实例被声明为可变(使用
mut
关键字)时,其字段才能被修改。对于包含原始指针或其他提供内部可变性的类型(如Cell
,RefCell
,
Mutex
等)的结构体字段,即使结构体实例是不可变的,也可以修改这些字段指向或包含的值。普通可变变量的可变性在声明时就已确定,直接用mut
关键字声明。
- 在图2-1左侧第5行的
available_tickets
是一个指向可变i32
的裸指针。裸指针在 Rust 中是特殊的,它们绕过了 Rust 的常规安全检查。字段available_tickets
本身(即指针的值)仍然遵循前述规则,即如果
Theater
是不可变的,那不能改变指针本身。然而,指针指向的内容可以被修改,即使Theater
实例是不可变的。修改指针指向的内容需要使用unsafe
代码块。这意味着 Rust
编译器不再保证这些操作的安全性,责任转移到了程序员身上。这种行为是原始指针的特性,而不是普通结构体字段的标准行为。- 生存期和作用域。结构体字段的生存期与结构体实例绑定。普通可变变量的生存期通常限于其声明的作用域。
- 方法中的行为。在结构体的方法中,只有
&mut self
方法(结构体的可变引用)才能修改可变字段。普通的可变变量可以在任何拥有其所有权或可变引用的地方被修改。- 内部可变性的影响。结构体的可变字段如果是内部可变类型(如
RefCell
),即使结构体实例是不可变的,也可以修改其内容。普通可变变量如果是内部可变类型,行为类似。- 所有权和移动语义。结构体字段的所有权属于结构体。移动或复制结构体时,字段也会随之移动或复制。普通可变变量的所有权更加独立,可以单独被移动或复制。
- 重新赋值。结构体的可变字段可以被重新赋值,但前提是结构体实例本身是可变的。普通的可变变量可以在其作用域内随时被重新赋值。
❓共享可变状态所带来的多线程并发时的数据竞争难题,该如何解决?