从第1章所讨论的出现数据竞争问题的多线程并发剧院订票系统的代码能够看出,虽然可变性能够方便地随时修改值,但滥用可变性,会在多线程并发编程时,带来数据竞争的难题。
共享可变状态所带来的多线程并发时的数据竞争难题,该如何解决?
2.1 使用不可变性避坑
Rust的不可变性,如果与Mutex<T>
智能指针相配合,就能解决多线程并发数据竞争的难题。可以使用不可变性与Mutex<T>
智能指针,将代码清单1-1的有多线程并发数据竞争问题的代码,重构为没有数据竞争的并发安全的代码,如代码清单2-1所示。
代码清单2-1 将有多线程并发数据竞争的代码重构为并发安全的代码
rust
1 use std::sync::{Arc, Mutex};
2 use std::thread;
3
4 struct Theater {
5 available_tickets: Mutex<i32>,
6 }
7
8 impl Theater {
9 fn new(initial_tickets: i32) -> Self {
10 Theater {
11 available_tickets: Mutex::new(initial_tickets),
12 }
13 }
14
15 fn book_ticket(&self) {
16 let mut tickets = self.available_tickets.lock().unwrap();
17 if *tickets > 0 {
18 // 模拟一些处理时间
19 thread::sleep(std::time::Duration::from_millis(10));
20 *tickets -= 1;
21 println!("Ticket booked. Remaining tickets: {}", *tickets);
22 } else {
23 println!("Sorry, no more tickets available.");
24 }
25 }
26
27 fn get_available_tickets(&self) -> i32 {
28 *self.available_tickets.lock().unwrap()
29 }
30 }
31
32 fn main() {
33 let theater = Arc::new(Theater::new(10)); // 初始有10张票
34
35 let mut handles = vec![];
36 for _ in 0..15 {
37 let theater_clone = Arc::clone(&theater);
38 let handle = thread::spawn(move || {
39 theater_clone.book_ticket();
40 });
41 handles.push(handle);
42 }
43
44 for handle in handles {
45 handle.join().unwrap();
46 }
47
48 println!("Final ticket count: {}", theater.get_available_tickets());
49 }
// Output:
// Ticket booked. Remaining tickets: 9
// Ticket booked. Remaining tickets: 8
// 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: 1
// Ticket booked. Remaining tickets: 0
// Sorry, no more tickets available.
// Sorry, no more tickets available.
// Sorry, no more tickets available.
// Sorry, no more tickets available.
// Sorry, no more tickets available.
// Final ticket count: 0
代码清单2-1的输出展示了通过使用不可变性和适当的同步机制(Mutex<T>
),能解决多线程并发编程中的数据竞争问题。具体表现如下:
- 票数减少很一致。输出显示票数从9严格递减到0,没有跳过或重复的数字。这表明每次售票操作都是原子的,不会被其他线程干扰。
- 准确的售票限制。初始票数为10张,输出显示正好有10次成功的售票操作,剩余5次尝试都显示"无票可售"。这说明程序正确地控制了售票数量,避免了超售问题。
- 无负数票数。所有的票售完后,票数始终保持为0,不会出现负数票数的情况。这表明检票和售票操作被正确同步,防止了在无票情况下继续售票。
- 最终票数一致。最后一行显示最终票数为0,与之前打印的剩余票数一致。这证实了数据的一致性得到了保证。
- 线程安全。尽管启动了15个线程来订票,但程序表现出了完全的线程安全性。每个线程的操作都是互斥的,不会干扰其他线程。
这些现象清楚地展示了通过使用不可变性和Mutex<T>
来保护共享资源(票数),成功解决了之前代码中的数据竞争问题。Mutex<T>
确保了在任一时刻只有一个线程可以访问和修改票数,从而保证了操作的原子性和数据的一致性。这是一个典型的将非线程安全的代码重构为线程安全的并发程序的例子,展示了如何正确处理多线程环境下的共享状态。
要运行代码清单2-1,可以用下面的方法找到没有行号的代码。克隆github.com/wubin28/wuzhenbens_playground代码库,进入wuzhenbens_playground文件夹,在main
分支中,进入immutable_variable_theater_booking_rust文件夹,找到main.rs源文件。
2.2 不可变性是如何避坑的
要想了解不可变性是如何避坑的,最直观的方法是将重构后的代码清单2-1与重构前的代码清单1-1进行对比,如图2-1所示。
图2-1 有多线程并发数据竞争问题的代码重构前后的差异
在图2-1中,左侧为有多线程并发数据竞争的代码,右侧为重构后的并发安全的代码。可以看到,右侧只有main
函数与左侧一致,而Theater
结构体的定义、关联函数和方法实现都与左侧不同。
如何才能看到图2-1这样清晰的代码差异?
可以用你最喜爱的搜索引擎,找到并下载免费版的IntelliJ IDEA Community版。用IDEA打开克隆好的github.com/wubin28/wuzhenbens_playground代码库,在main分支里,找到2024年8月27日11:38的提交1cc6a7f5,就能看到main.rs的代码与上一次代码提交的差异,也就是图2-1。
2.2.1 Theater结构体定义的差异
在第1章1.5.5中提到,在图2-1左侧第5行Theater
结构体定义中,available_tickets
是Theater
结构体中唯一的字段。它是一个指向可变32位整数的裸指针。这个可变裸指针可能导致多线程同时访问和修改它所指向的数据。这是数据竞争的根源。
而在图2-1右侧第5行,available_tickets
被重构成Mutex<i32>
智能指针。为了让编译通过,在右侧第1行use
语句中增加了Mutex
同步原语。这个同步原语,为共享票数数据提供了互斥访问机制,解决了并发访问的问题。
❓什么是同步原语?
同步原语(synchronization
primitive),是操作系统或编程语言提供的低级同步机制,用于确保在多线程环境中对共享资源的安全访问。常见的同步原语包括:互斥锁(Mutex)、信号量(Semaphore)、条件变量(Condition
Variable)、读写锁(Read-Write Lock)和原子操作(Atomic Operations)。
互斥锁用于保护共享资源,确保同一时间只有一个线程可以访问。信号量用于控制对资源的并发访问数量。条件变量用于线程间的通信和协调。读写锁允许多个读操作同时进行,但写操作需要独占访问。原子操作是不可分割的操作,常用于简单的共享状态管理。
同步原语有不少优势。首先它提供了基本的并发安全保证。其次它的性能较高,通常是操作系统级别的实现。它的灵活性强,可以根据需求组合使用。最后是广泛支持,几乎所有的并发编程环境都提供这些原语。
同步原语也有一些劣势。使用不当容易导致死锁、活锁或其他并发问题。它是低级别的抽象,直接使用可能会使代码复杂化。可能带来性能开销,特别是在高并发情况下。难以组合使用,容易出错。
同步原语适用于下面的场景。需要细粒度控制并发行为的场合。性能关键的应用,需要最小化同步开销。实现更高级的同步抽象或并发数据结构。系统级编程,如操作系统内核或设备驱动程序。
在Rust中,这些原语通常通过标准库的std::sync
模块(module)提供。在图2-1中可以看到,右侧代码第1行,Mutex
和Arc
都来自std::sync
模块。
在Rust中,Mutex<T>
是一个泛型结构,其中T
可以是任何类型。它提供了对任何类型T
的互斥访问。
图2-1右侧第5行的Mutex<i32>
,则是Mutex<T>
的一个具体实例,其中T
被特化为i32
类型。它专门用于对i32
类型的值提供互斥访问。
❓什么是
Mutex<T>
?
Mutex<T>
的全称是MutualExclusion(互斥),它是一个智能指针类型,以互斥锁的形式,提供了实现互斥访问的同步原语。它提供了在多线程环境中安全共享和修改数据的机制,确保在任意时刻只有一个线程可以访问被保护的数据。
Mutex<T>
提供内部可变性,允许在拥有不可变引用的情况下修改其内部值(所以称其为内部可变性),能确保在任何给定时间只有一个线程可以访问被保护的数据。这是通过锁定机制来实现防止数据竞争的。它提供RAII 风格的锁管理,能自动处理锁的获取和释放。
Mutex<T>
有两大优势。首先是允许在多线程环境中安全地共享和修改数据。其次是保证对共享状态的独占访问。
Mutex<T>
有下面的劣势。可能导致性能开销,特别是在高并发情况下。使用不当可能导致死锁,不要在持有锁的情况下尝试再次获取同一个锁。相比读写锁,不能同时允许多个读操作。过度使用可能降低程序的并发性。
Mutex<T>
适用于以下场景。多线程环境中需要共享和修改的数据。读写操作频繁交替的场景。需要确保数据一致性的关键部分。实现线程安全的数据结构。在并发环境中实现单例模式。
上面提到,"Mutex<T>
提供内部可变性,允许在拥有不可变引用的情况下修改其内部值"。这话背后是什么意思?简单来说,代码清单2-1第11行available_tickets: Mutex<i32>,
,将available_tickets
声明为 Mutex<i32>
。Mutex<i32>
本身是不可变的(如果尝试在它左边加上mut
将其变为可变的,编译会报错),但它允许安全地访问和修改其内部的可变状态(即票数)。本章后面会结合图2-1右侧代码实例展开讨论这个问题。Mutex<i32>
本身的不可变性及其所提供的内部可变性,就是使用不可变性进行避坑的关键。
上面又提到,"Mutex<T>
提供 RAII 风格的锁管理,能自动处理锁的获取和释放",这里的RAII是什么意思?
❓什么是RAII?
RAII (Resource Acquisition Is
Initialization,资源获取即初始化),是一种编程思想,最初源于C++,但在其他现代编程语言中也广泛应用,包括Rust。RAII
是一种资源管理技术,它将资源的生命周期与对象的生命周期绑定。资源在对象构造时获取,在对象析构时释放。
RAII的优势有不少,比如自动化资源管理,能减少手动资源释放的错误。异常安全,即使在异常发生时也能确保资源被正确释放。代码简洁,能减少显式的资源管理代码。提升可预测性,资源的释放时间是确定的。避免资源泄漏,有效防止因忘记释放资源导致的泄漏。
RAII主要的劣势,是不适用于某些复杂的资源管理场景,如循环引用或需要手动控制资源生命周期的情况。
RAII适用于下面各种资源管理场景。内存管理,智能指针如 Rust 的
Box<T>
、Rc<T>
、Arc<T>
等。文件操作,自动关闭文件句柄。数据库连接,自动关闭数据库连接。线程同步,如
Mutex<T>
的自动锁定和解锁。网络编程,自动关闭套接字。图形用户界面,自动释放GUI资源。
2.2.2 Theater
结构体的trait的实现的差异
图2-1左侧第8-9行,为 Theater
结构体实现了 Send
和 Sync
trait。但在右侧,却把这两行删除了。这是为什么?
左侧代码由于使用了裸指针(*mut i32
),所以需要用unsafe
来让Theater
结构体实现Send
和 Sync
trait,以便可以安全地在线程间传递和共享Theater
结构体。
但在右侧重构后的代码中,就不再需要这些 unsafe
实现了。这是因为右侧代码使用了 Mutex<i32>
来封装 available_tickets
。Mutex<T>
本身就已经实现了 Send
和 Sync
trait,它提供了线程安全的访问机制。当一个结构体的所有字段都实现了 Send
和 Sync
时,该结构体会自动实现这两个trait。在这个情况下,Mutex<i32>
已经实现了这些trait,所以 Theater
结构体自动获得了这些trait的实现。
通过使用 Mutex<T>
,右侧代码提供了一种更安全、更符合 Rust 线程安全模型的实现方式。它不需要使用 unsafe
关键字,因为所有的并发访问都通过 Mutex<T>
进行了安全的管理。这种重构消除了手动实现 Send
和 Sync
的需要,减少了出错的可能性,并提高了代码的安全性和可维护性。
2.2.3 Theater
结构体关联函数与方法的实现的差异
从图2-1能够看出,图中右侧Theater
结构体的关联函数new
的实现,以及 ****book_ticket
****和get_available_tickets
这两个方法的实现,都与左侧存在差异。
new
关联函数的差异
图2-1右侧第11行,用Mutex
替换掉了左侧第14行的Box::into_raw(Box
。 右侧使用了 Mutex
来包装 available_tickets
,而左侧使用了裸指针。这个差异的目的是为了避免多线程并发访问时的数据竞争。
右侧使用 Mutex
有以下几个原因。
- 线程安全。
Mutex
提供了互斥访问机制,确保在任一时刻只有一个线程可以访问或修改available_tickets
。 - 原子操作。
Mutex
保证了对available_tickets
的读写是原子的,防止在多线程环境下出现数据不一致的情况。 - 避免数据竞争。当多个线程同时尝试修改
available_tickets
时,Mutex
会强制它们排队,避免了潜在的数据竞争问题。 - 安全性。使用
Mutex
比直接操作原始指针更安全,减少了内存安全相关的错误风险。 - 符合 Rust 的所有权模型。
Mutex
允许在保持所有权和借用规则的同时,实现多线程间的共享状态。
通过这种改变,右侧的实现使得 Theater
结构体可以安全地在多线程环境中使用,而不会因为并发访问 available_tickets
而导致未定义行为或数据不一致。这是一个典型的使用 Rust 并发原语来确保线程安全的例子。
book_ticket
方法的差异
图2-1右侧 ****book_ticket
****方法的第16-23行,与左侧第19-30行存在以下显著差异。
- 锁机制。右侧第16行使用了
Mutex
的lock()
方法来获取互斥锁。而左侧第20行直接访问了原始指针,没有任何同步机制。 - 安全性。右侧的实现在安全的 Rust 代码中完成。左侧的实现使用了
unsafe
块,直接操作原始指针。 - 并发控制。右侧通过
Mutex
确保了对tickets
的互斥访问。左侧没有任何并发控制机制。
为何这些差异能避免多线程并发的数据竞争?有下面这些原因。
- 互斥访问。右侧的
Mutex
确保在任何时刻只有一个线程可以修改tickets
。左侧的实现可能导致多个线程同时读写available_tickets
,造成数据竞争。 - 原子操作。右侧的实现保证了检查票数、延时和减少票数这一系列操作是原子的。左侧的实现可能导致在检查票数和实际减少票数之间被其他线程中断,造成超售。
- 数据一致性。右侧的
Mutex
保证了数据的一致性,防止多个线程同时修改导致的不一致状态。左侧的实现可能导致多个线程同时减少票数,最终票数不正确。 - 内存安全。右侧的实现完全在安全的 Rust 代码中进行,避免了潜在的内存安全问题。左侧使用
unsafe
和原始指针,增加了出错的风险。 - 可见性保证。右侧的
Mutex
提供了跨线程的内存可见性保证,确保一个线程的修改对其他线程立即可见。左侧的实现可能导致不同线程看到的available_tickets
值不一致。
右侧的实现通过使用 Mutex
有效地解决了多线程环境下的数据竞争问题,保证了操作的原子性、可见性和一致性,从而提供了线程安全的票务预订系统。这种实现方式是在多线程 Rust 程序中处理共享状态的标准做法。
相比左侧,右侧新写了下面的代码。
第16行self.available_tickets
是一个 Mutex<i32>
类型。.lock()
尝试获取互斥锁。如果锁已被其他线程持有,当前线程将阻塞直到获得锁。.unwrap()
用于处理可能的错误(如果锁被污染)。为了不增加代码的复杂度,这里使用了unwrap()
,但在实际应用中,应该更优雅地处理这个错误。let mut tickets
创建一个可变绑定,它实际上是一个 MutexGuard<i32>
类型,允许程序员安全地访问和修改被锁保护的值。
❓什么是锁被污染?什么是panic?
当一个线程在持有锁时 panic,Mutex 会被标记为"已污染"(poisoned)。这是 Rust
的一种安全机制,用于指示可能存在的数据不一致状态。
什么是panic?这是一种错误处理机制,用于处理程序遇到无法恢复的错误情况。它是程序遇到无法继续执行的情况时的一种反应。它会导致当前线程,通常是整个程序的突然终止。当
panic
发生时,程序会开始"展开"(unwind)调用栈。它会打印错误信息和调用栈跟踪。清理当前线程的资源(调用析构函数)。默认情况下,整个程序会在此终止。
上面提到,.unwrap()
用于处理可能的错误(如果锁被污染)。在实际应用中,应该更优雅地处理这个错误。这两句该如何理解?
❓什么是
.unwrap()
?
.unwrap()
是 Rust 中用于处理Result
类型的一个方法。在图2-1右侧第16行,lock()
方法返回一个
Result<MutexGuard<T>, PoisonError<MutexGuard<T>>>
。
.unwrap()
有什么作用?如果lock()
成功,.unwrap()
会返回
MutexGuard<T>
。如果锁被污染,.unwrap()
会导致程序 panic。为什么
.unwrap()
不是最佳实践?在生产环境中,突然的 panic可能导致整个程序崩溃。它没有提供优雅的错误处理机制,使得程序难以从错误中恢复。
下面是几种更优雅的错误处理方式。
使用
.expect()
。
rustlet mut tickets = self.available_tickets.lock() .expect("Mutex was poisoned");
这提供了更明确的错误信息,但仍会导致 panic。
使用模式匹配。
rustlet mut tickets = match self.available_tickets.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), };
这允许程序员在锁被污染的情况下仍然获取 guard,但可能需要额外的检查来确保数据一致性。
使用
?
操作符(在返回Result
的函数中)。
rustfn book_ticket(&self) -> Result<(), Box<dyn Error>> { let mut tickets = self.available_tickets.lock()?; // 剩余的逻辑... Ok(()) }
这将错误传播到调用者,允许在更高层次处理错误。
自定义错误处理。
rustlet mut tickets = self.available_tickets.lock().map_err(|e| { // 记录错误 log::error!("Mutex poisoned: {:?}", e); // 执行一些恢复操作 // 返回一个默认值或重新初始化的状态 })?;
这种方法允许程序员记录错误,尝试恢复,或者采取其他适当的行动。
为什么更优雅的错误处理很重要?因为有下面的一些原因。提高程序的健壮性和可靠性。允许程序从错误中恢复,而不是简单地崩溃。提供更好的调试信息和错误追踪能力。使代码更易于维护和理解。
在实际应用中,选择哪种错误处理方式取决于具体的需求、错误的严重性、以及程序员希望程序如何响应这些错误。通常,一个好的做法是记录错误,尝试恢复(如果可能),并以对用户友好的方式报告问题。
上面提到,let mut tickets
创建一个可变绑定,它实际上是一个 MutexGuard<i32>
类型,允许我们安全地访问和修改被锁保护的值。这句话该如何理解?
❓什么是
MutexGuard<T>
?
MutexGuard<T>
是Rust标准库中与互斥锁(Mutex<T>
)相关的一个重要智能指针类型。它是当锁定一个Mutex<T>
时返回的RAII守卫。这个守卫提供了对被保护数据的访问,并在离开作用域时自动解锁互斥锁。要使用
MutexGuard<T>
,通常会这样做:
rustlet mutex = Mutex::new(0); // 创建一个保护整数的互斥锁 { let mut guard = mutex.lock().unwrap(); // 锁定互斥锁并获得MutexGuard *guard += 1; // 通过解引用来修改受保护的数据 } // guard在这里离开作用域,自动解锁互斥锁 ``` `MutexGuard<T>`具有以下优势。安全性,`MutexGuard<T>`确保了在访问共享数据时,其他线程无法同时访问,从而防止数据竞争。RAII原则,遵循"资源获取即初始化"原则,确保资源(在这里是锁)在不再需要时自动释放。编译时检查,Rust的所有权系统确保程序员不能在持有锁的同时转移所有权或在多个地方使用锁。 `MutexGuard<T>`也有一些劣势。性能开销,使用互斥锁会引入一些性能开销,特别是在高并发场景下。可能的死锁,如果不小心,可能会导致死锁,尽管Rust的设计使这种情况比其他语言少见。粒度较大,互斥锁可能会锁定比实际需要更多的数据,可能影响并发性。 `MutexGuard<T>`适用于以下场景。多线程环境中共享可变状态,当多个线程需要读写共享数据时。实现线程安全的数据结构,如线程安全的计数器、缓存等。控制对资源的并发访问,例如限制对数据库连接池的并发访问。
let mut tickets
创建了一个可变的绑定。这允许后续修改 tickets
的值。MutexGuard<i32>
是 Mutex::lock()
方法返回的类型。MutexGuard
是一个智能指针,它代表对互斥锁保护的数据的独占访问权。<i32>
表示被保护的数据类型是一个32位整数。MutexGuard
遵循 RAII 原则。当 MutexGuard
被创建时,它获取锁。当 MutexGuard
离开作用域时,它自动释放锁。
MutexGuard
实现了 Deref
和 DerefMut
trait。这允许程序员通过 *tickets
语法(如图2-1右侧第20行)直接访问或修改被锁保护的 i32
值。它确保在访问或修改数据时,锁始终被持有。
MutexGuard
确保在同一时间只有一个线程可以访问被保护的数据。这防止了数据竞争和其他并发问题。
tickets
的生存期限制了锁的持有时间。一旦 tickets
离开作用域,锁就会被自动释放。
MutexGuard
可能包含一个被"污染"的值(如果之前的线程在持有锁时 panic)。这就是为什么 lock()
返回一个 Result
,可以使用 .unwrap()
来获取 MutexGuard
。
即使 self
是不可变引用,Mutex
也允许修改其内部值。这是 Rust 中内部可变性模式的一个例子。
通过这种机制,Rust 提供了一种安全且让编程体验良好的方式来处理多线程环境中的共享可变状态,同时强制执行锁的正确使用,防止常见的并发错误。
图2-1右侧第17行是在判断是否还有可用的票。这一行使用了解引用操作 *tickets
。tickets
是一个 MutexGuard<i32>
类型的智能指针。*
操作符用于解引用,获取 MutexGuard
所指向的实际 i32
值。这里利用了 MutexGuard
实现的 Deref
trait,允许它像引用一样被使用。
由于 tickets
是 MutexGuard<i32>
类型,这个if
判断是线程安全的。在判断期间,其他线程无法同时访问或修改票数。这个if
判断是整个订票操作原子性的一部分。从判断到可能的修改,整个过程都在同一个锁的保护下进行。这种方式防止了可能的竞态条件,例如两个线程同时看到最后一张票并尝试订购。在票务系统中,这是确保不会出现"超卖"情况的关键检查。尽管有锁的开销,这种检查方式确保了高并发情况下的数据一致性。
这行代码展示了 Rust 如何在保证线程安全的同时,提供直观和高效的方式来处理共享状态。它结合了低级的内存操作(解引用)和高级的并发安全保证,是 Rust 强大表现力的一个很好例子。
上面提到,MutexGuard
实现了 Deref
和 DerefMut
trait。这里的Deref
和 DerefMut
trait分别是什么?
❓什么是
Deref
和DerefMut
trait
Deref
trait允许一个类型的行为像对其目标类型的不可变引用(允许读取但不允许修改被引用的值)一样。实现该 trait的类型可以使用
*
操作符进行解引用。允许智能指针类型模仿普通引用的行为。
Deref
trait具有以下优势。提供了类型之间的透明转换。使自定义类型的使用更加直观和符合直觉。允许创建行为类似于内建引用的新类型。
Deref
trait也存在一些劣势。可能导致隐式转换,使代码的行为不那么明显。过度使用可能导致代码难以理解。
Deref
trait适用于以下场景。实现智能指针类型(如Box<T>
,Rc<T>
,
Arc<T>
)。包装类型,让它们的使用更加自然。当程序员希望一个类型能够像引用一样被使用时。
DerefMut
trait允许一个类型行为像对其目标类型的可变引用(允许独占借用和修改被引用的值)一样。实现该 trait的类型可以使用
*
操作符进行可变解引用。这扩展了Deref
,提供了可变访问的能力。
DerefMut
trait具有以下优势。允许透明地修改被包装的值。使得可变智能指针的行为更接近原生可变引用。增强了类型的灵活性和可用性。
DerefMut
trait也有以下劣势。如果使用不当,可能导致意外的可变性。增加了代码复杂性,可能使所有权和借用关系变得不那么明显。
DerefMut
trait适用于以下场景。实现可变智能指针类型(如Box<T>
,
RefCell<T>
)。当需要提供对内部值的可变访问时。在需要可变性的包装类型中。在使用
Deref
和DerefMut
trait时要注意以下几点。Deref
用于只读访问,DerefMut
用于可变访问。
DerefMut
要求同时实现Deref
。这两个 trait常常一起使用,以提供完整的引用语义。使用时需要注意不要破坏 Rust
的借用规则。过度使用可能导致"神奇"的隐式转换,影响代码可读性。在标准库中广泛使用,如
String
实现了
Deref<Target=str>
。
图2-1右侧第20行实现了当还有可订票时就减少一张票的业务功能。tickets
是 MutexGuard<i32>
类型。*
操作符解引用 MutexGuard<i32>
,访问其保护的 i32
值。这利用了 MutexGuard<i32>
实现的 DerefMut
trait,允许可变访问。之后代码对解引用后的值执行减 1 操作。这是复合赋值运算符,等同于 tickets = *tickets - 1
。
整个操作在 Mutex
锁的保护下进行,确保了原子性。从检查票数到减少票数的整个过程都是原子的,不会被其他线程中断。Mutex
保证在这个操作执行时,没有其他线程能访问或修改票数。这防止了多线程环境中可能出现的数据竞争。
这行代码实际上在票务系统中"售出"一张票。它直接修改了共享状态(可用票数)。
Rust 的类型系统和 Mutex
的使用确保了这个操作的安全性,无需额外的同步原语。Rust 的所有权系统确保了这种修改是安全的,不会导致未定义行为。
当 MutexGuard<i32>
离开作用域时,锁会自动释放,允许其他线程访问票数。
get_available_tickets
方法的差异
图2-1右侧用来获取可订票数的 ****get_available_tickets
****方法的第28行,与左侧第35行存在很大差异。
左侧代码unsafe { *self.available_tickets }
是非线程安全的实现。使用 unsafe
块绕过了 Rust 的安全检查。这行代码没有任何并发控制机制,将线程安全的责任转移给了程序员。在多线程环境中可能导致数据竞争和未定义行为。另外它也没有错误处理机制。虽然在理论上这行代码运行更快,因为没有锁的开销,但在多线程环境中会导致不正确的结果。
右侧代码*self.available_tickets.lock().unwrap()
是线程安全的实现。它使用 Mutex
确保在任意时刻只有一个线程可以访问 available_tickets
。.lock()
方法获取互斥锁,防止数据竞争。如果锁已被其他线程持有,当前线程会阻塞等待。.unwrap()
能够处理锁可能被污染的情况,虽然不够优雅。这行代码完全符合 Rust 的线程安全模型。编译器可以在编译时捕获潜在的并发问题。另外这行代码有一定的运行时开销,因为需要获取和释放锁。
Drop
trait 实现的差异
图2-1左侧第39-45行显式实现结构体Theater
的Drop trait,在右侧就没必要了。为什么?
左侧代码使用 *mut i32
作为 available_tickets
的类型。右侧代码使用 Mutex<i32>
作为 available_tickets
的类型。左侧代码中,available_tickets
是使用 Box::into_raw()
创建的裸指针,需要手动管理内存。右侧代码使用 Mutex
,它是一个标准库类型,会自动管理其内部资源。Mutex
类型已经实现了 Drop
trait。当 Theater
结构体被销毁时,Mutex
会自动清理其内部资源。左侧代码使用 unsafe
块来手动释放内存,这可能引入安全风险。右侧代码通过使用 Mutex
,避免了直接操作裸指针,提高了代码的安全性。右侧代码通过使用 Mutex
,消除了手动内存管理的需求。这种方式简化了代码结构,减少了出错的可能性。
右侧代码通过使用 Mutex
替代了原始的裸指针管理,不再需要显式实现 Drop
trait。这种改变提高了代码的安全性和可维护性,同时保持了原有的功能。
2.2.4 不可变性具体在哪里实现了避坑
在讨论不可变性具体在哪里实现了避坑前,先简单回顾一下图2-1左侧存在数据竞争的代码究竟在哪里出现了问题。
在图2-1左侧的代码中,导致数据竞争的可变变量是 available_tickets
。具体来说,available_tickets
在左侧第5行被声明为 *mut i32
,这是一个可变的原始指针。而在 book_ticket
方法中第23行,直接通过这个指针修改票数。多个线程同时访问和修改这个共享的可变状态,没有任何同步机制,导致了数据竞争。
在图2-1右侧线程安全的代码中,解决数据竞争的关键是使用了不可变性配合 Mutex
。具体来说,右侧第5行available_tickets
被声明为 Mutex<i32>
。Mutex<i32>
本身是不可变的,但它允许安全地访问和修改其内部的可变状态,即在右侧第15-25行 book_ticket
方法中,通过 lock()
方法来安全地访问和修改票数*tickets
。
图2-1左右两侧存在下面的关键区别。
- 可变性。在左侧代码中,
mut i32
是直接可变的,任何线程都可以不受限制地修改它。在右侧代码中,Mutex<i32>
本身是不可变的,但它提供了一种安全的方式来修改其内部状态。 - 访问控制。在左侧代码中,对
available_tickets
的访问没有任何限制或同步。在右侧代码中,必须通过lock()
方法才能访问Mutex
内部的值,这确保了在任一时刻只有一个线程可以修改票数。 - 安全性。左侧代码使用了
unsafe
代码块,绕过了 Rust 的安全检查。右侧代码完全在 Rust 的安全边界内操作,利用Mutex
提供的线程安全保证。
虽然在图2-1中右侧第5行available_tickets
是Theater
结构体的不可变字段,并不是Rust的不可变变量,但两者还是有以下相似点。默认不可修改,结构体的不可变字段和不可变变量默认都不能直接修改其值。编译时检查,Rust 编译器会在编译时检查并阻止对不可变字段和不可变变量的直接修改尝试。安全性保证,两者都提供了一定程度的安全性保证,有助于防止意外修改。
2.3 什么是不可变变量
不可变变量(Immutable variable),指在声明后其值不能被改变的变量。在Rust中,默认情况下所有变量都是不可变的。
不可变变量的特点是绑定后其值在作用域内不可直接更改。然而,Rust 提供了下面一些特殊的类型来实现内部可变性,允许在特定情况下安全地修改不可变引用(对可变变量或不可变变量的不可变借用)中的值。
Mutex<T>
。用于多线程环境,提供互斥访问,确保在并发情况下安全地修改内部值。Cell<T>
。用于单线程环境,适用于Copy
trait 类型,提供了一种不需要借用规则检查就能修改内部值的方法。RefCell<T>
。也用于单线程环境,但比Cell<T>
更灵活,可用于非Copy
trait类型。它在运行时执行借用规则检查。
不可变变量具有以下优势。有助于解决共享可变状态所带来的多线程并发时的数据竞争难题。在安全性方面,能防止意外修改,减少bug。无需同步机制,当多个线程同时只读访问一个不可变变量时,通常不需要额外的同步机制(如锁或原子操作),这可以提高性能并简化代码。防止数据竞争,不可变变量有效防止了数据竞争。数据竞争发生在多个线程同时访问同一内存位置,且至少有一个线程进行写操作时。由于不可变变量不允许写操作,它自然避免了这种情况。编译时保证,Rust 的编译器可以在编译时检测和防止对不可变变量的修改尝试,提供了强大的安全保证。共享状态简化,在复杂的并发系统中,不可变数据可以自由地在线程间共享,无需担心同步问题。有助于实现函数式编程范式,促进无副作用的函数设计。
不可变变量也存在一些劣势。灵活性较低,不能直接修改变量值。在某些情况下可能导致额外的内存分配(如需要创建新实例而不是修改现有实例)。在某些情况下可能导致性能开销,特别是在需要频繁"更新"大型数据结构时。
不可变变量适用于以下场景。用于表示常量或配置值。在函数参数中使用,确保函数不会修改传入的值。用于实现线程安全的共享状态。在并行计算中安全地共享数据。
前面介绍了Rust的不可变变量与结构体的不可变字段的相似点,那两者之间有什么区别?
❓不可变变量与结构体的不可变字段的差异点是什么?
Rust的不可变变量与结构体的不可变字段存在以下差异点。
- 可变性的来源。结构体字段的可变性取决于结构体实例的可变性。如果结构体实例是可变的(用
mut
声明),其字段也变为可变。普通变量的可变性在声明时就确定,用mut
关键字直接声明。- 内部可变性的处理。结构体的不可变字段可以包含提供内部可变性的类型(如
Mutex<T>
,RefCell<T>
),允许在不改变字段本身的情况下修改其内容。普通的不可变变量如果是这些类型,行为是一致的。但对于基本类型,不可变变量就真的不可变了。- 生存期和作用域。结构体字段的生存期与结构体实例绑定。普通变量的生存期通常限于其声明的作用域。
- 重新赋值与修改。结构体的不可变字段不能被重新赋值(除非整个结构体是可变的)。不可变变量既不能被重新赋值,也不能被修改。
- 方法中的行为。在结构体的方法中,即使是
&self
方法(结构体的不可变引用),也可以通过内部可变性类型修改字段的内容。普通的不可变变量在任何情况下都不能被直接修改。
❓还记得第1章表1-1 Rust所有权机制72个避坑场景吧。不可变变量在Rust所有权机制中的6个避坑规则是怎样的?