《Rust避坑入门记》第1章:挖数据竞争大坑的滥用可变性

赵可菲是一名Java程序员,一直在维护一个有十多年历史的老旧系统。这个系统即将被淘汰,代码质量也很差,每次上线都会出现很多bug,她不得不加班修复。公司给了她3个月的内部转岗期,如果转不出去就会被裁员。她得知公司可能会用Rust重写很多系统,于是就报名参加了公司的Rust培训,希望能够转型。

半天的Rust培训其实只是开了一个头,赵可菲需要自学Rust。她主要通过阅读Rust官网推荐的书籍来学习,但感觉进步很慢。因为Rust作为一门以内存和并发安全著称的系统级编程语言,有很多新的概念和知识点,她经常学了就忘。赵可菲对于能否在3个月内掌握Rust,从而完成内部转岗感到焦虑。

一次,赵可菲向她的结对编程搭档C++程序员席双嘉提出了一个问题:“如何才能减缓入门Rust过程中所学知识点的遗忘速度?”

席双嘉回答说:“可以试试从避坑的角度来入门Rust。Rust有很多容易踩坑的地方,比如所有权、生存期、迭代器等。与其花大量时间系统地学习这些概念,不如先学习在使用Rust过程中如何避开这些常见的陷阱。这样做有两个好处:第一,顺应人的损失厌恶心理特点能提升行动力。人都不想踩坑,从避坑的角度学习,动力会更足;第二,可以在公司内部AI大模型小艾的帮助下,一上来直接学习专业Rust程序员经常踩坑和避坑的代码,不仅加快入门速度,而且起点就是专业水准,让眼界更开阔。”

赵可菲听了席双嘉的建议后茅塞顿开。她开始有针对性地学习Rust最容易踩坑的地方,果然学习动力和记忆深度都有了很大提高。

下面就是小艾记录的他俩用避坑法自学Rust的过程。其中带问号❓的问题,都是他俩问小艾的问题。针对不懂的编程概念,他俩一般会这样问小艾:“请展开解释这个概念的定义、用法、优势、劣势和适用场景”。除此之外的内容,都是经他俩验证后的小艾的答复。众所周知,小艾的答复因为AI大模型所固有的幻觉,总会有瑕疵。好在赵可菲和席双嘉在入门Rust的愿望的驱使下,会一丝不苟地验证小艾的答复。因为,验证的过程,也是避坑(避免被小艾坑)的过程。

在这里插入图片描述

1.1 专业程序员常踩哪些坑

专业程序员在编程时,经常会踩下面7类坑。

  • 代码正确性是最基本的要求。如果代码逻辑不符合预期需求,或者未处理的边缘情况和异常情况导致程序崩溃,再或者模块间接口不匹配造成系统失效,都会严重影响软件的正常运行。
  • 内存安全也是一个关键问题。内存泄漏会导致程序性能逐渐下降,缓冲区溢出可能引发安全漏洞,动态分配的内存如果管理不当,就会导致程序不稳定。
  • 对于并发程序,还需要特别注意并发安全。如果出现死锁,程序就会卡死;如果存在竞态条件,就可能引起数据不一致;有时候,并发优化如果做得不好,反而会降低系统性能。
  • 代码效率方面,不必要的计算和资源消耗会导致性能低下;选择了不合适的数据结构和算法,也会影响程序效率;如果I/O操作和网络通信未经优化,往往会成为整个系统的性能瓶颈。
  • 软件的安全性也不容忽视。如果存在常见的安全漏洞(如SQL注入、XSS),就可能被攻击者利用;敏感数据如果泄露,后果不堪设想;加密和认证机制如果实现不当,同样会导致安全风险。
  • 错误处理方面,如果错误处理机制设计不合理,就会难以定位问题;如果遗漏某些错误情况的处理,可能导致程序意外退出;如果错误信息不明确,就会增加调试的难度。
  • 依赖管理也可能引入问题。使用了不可靠的第三方库,就可能引入潜在风险;如果项目依赖管理混乱,就会导致构建和部署困难;如果依赖冲突解决不当,就可能造成功能异常。

在这里插入图片描述

1.2 Rust所有权机制避坑规则的框架是怎样的

Rust最有特色的优势,就是强调内存和并发安全。而内存和并发安全的基础,就是独特的所有权机制。

Rust所有权机制的避坑规则,会涉及6个方面和12个角色,一共有72个避坑场景。如表1-1所示。

表1-1 Rust所有权机制72个避坑场景

方面/角色变量(不可变与可变)栈上值堆上值不可变引用(共享引用)可变引用BoxRcArcCellRefCellMutexRwLock
所有权场景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个避坑场景,会在后面逐步介绍。

1.3 可变性挖了什么坑

若不采取任何并发安全措施,滥用可变性,会带来多线程并发编程时的数据竞争难题。

先看一个因共享可变状态,带来多线程并发时的数据竞争的剧院订票系统的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输出(因为数据竞争具有随机性,在你电脑上看到的输出或许略有不同),反映了在多线程并发环境下滥用可变性所导致的数据竞争问题。具体表现如下:

  • 不一致的票数减少。输出显示票数并非按预期从10递减到0。有些数字被跳过(如8、9),有些数字重复出现(如2、1)。这表明多个线程同时修改票数,导致一些更新被覆盖。
  • 负数票数。尽管初始票数为10张,但最终票数变为负数(-5)。这说明即使票已售罄,仍有线程在继续售票,表明检票和售票操作未能正确同步。
  • 超售问题。代码创建了15个线程来订票,而初始只有10张票。理想情况下,应该只有10次成功订票,剩余5次应显示无票。但输出显示15次都"成功"订票,导致了超售。
  • 最终票数不一致。最后一行显示最终票数为-5,与之前打印的剩余票数不一致。这进一步证实了数据的不一致性。

这些现象清楚地展示了由于缺乏适当的同步机制(如互斥锁),多个线程并发访问和修改共享资源(票数)时产生的数据竞争问题。这导致了不可预测的结果和数据不一致性,是并发编程中典型的问题场景。

在这里插入图片描述

1.4 如何把代码运行起来

要把代码清单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.5 用共享可变状态进行多线程并发编程时会踩什么坑

先看看代码清单1-1第47行的main函数都做了什么事情。

1.5.1 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<T> 本身是栈上一个智能指针,指向堆上包含控制块(包括引用计数)和数据的内存位置。Arc<T>用于在多个线程间共享所有权。它允许多个线程对同一数据进行只读访问。

上面提到,Arc<T>是一个智能指针,什么是智能指针?

❓什么是智能指针?

智能指针是一种数据结构,行为类似于指针,但具有额外的元数据和功能。在Rust中,智能指针通常实现了DerefDrop trait。

Rust中常用的智能指针有以下7种。

  • Box<T>:用于在堆上分配值
  • Rc<T>:引用计数智能指针,允许多个所有者共享同一数据的不可变所有权
  • Arc<T>:原子引用计数智能指针,用于在并发场景下以不可变访问来避免数据竞争
  • Cell<T>:提供内部可变性(详见第2章),只适用于实现了Copy trait的类型
  • RefCell:提供内部可变性,能够处理没有实现Copy trait的类型
  • Mutex:提供(读写)互斥锁,用于在并发场景下安全地共享和修改数据
  • RwLock<T>:提供读写锁,在并发场景下允许多个读操作同时进行,或者单个写操作独占访问

智能指针最大的优势,是实现了自动内存管理,避免内存泄漏。另外它还提供额外功能,如共享所有权、内部可变性等。它还使用方便,语法类似于普通引用。最后是编译时检查,提高安全性。

智能指针也有一些劣势。它可能引入轻微的运行时开销。在某些情况下可能导致性能下降。学习曲线相对陡峭,尤其是对新手来说。

智能指针适用以下场景。

  • 需要在堆上分配数据或存储递归数据结构时使用Box<T>
  • 需要在多个所有者之间共享只读所有权时使用Rc<T>(单线程)或Arc<T>(多线程)。
  • 需要在不可变上下文中修改小型数据结构时使用Cell<T>
  • 需要在不可变上下文中修改复杂数据结构时使用RefCell<T>
  • 多线程环境中需要共享和修改的数据(特别是读写操作频繁交替的并发场景)时使用Mutex<T>
  • 读多写少的并发场景(如配置信息、缓存数据等)时使用RwLock<T>

上面提到,智能指针通常实现了DerefDrop trait。那什么是trait?

❓什么是trait?

Rust中的trait是一种定义共享行为的方式。trait定义了一组方法,这些方法描述了某种能力或行为。可以将trait视为一种接口,它指定了类型应该实现的方法。智能指针、结构体或枚举可以实现(implement)一个或多个trait,从而获得这些trait定义的行为。trait可以为其方法提供默认实现,实现该trait的类型可以选择使用默认实现或覆盖它。trait可以继承其他trait,从而组合多个行为。

智能指针通常实现了DerefDrop trait,这意味着什么?

❓实现了DerefDrop trait的智能指针意味着什么?

Deref
trait允许智能指针像引用一样被解引用。这意味着可以使用*操作符来访问智能指针包含的值。允许智能指针的方法自动解引用,使其行为更像普通引用。启用了解引用强制转换(deref
coercions),允许在需要引用的地方使用智能指针。

Drop
trait允许自定义当值离开作用域时应该发生的行为。这意味着可以在对象被销毁前执行清理操作。管理不由Rust内存管理的资源(如文件句柄、网络连接等)。防止资源泄露,确保资源被正确释放。

演示Box与自定义MyBox的Deref trait的代码实例,如代码清单1-2所示。

代码清单1-2 Box<T>与自定义MyBox<T>Deref trait演示

use std::ops::Deref;

// 定义一个简单的结构体
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

// 为 MyBox<T> 实现 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<String>
    let boxed_string = Box::new(String::from("Hello, Deref!"));
    
    // 使用 * 操作符解引用
    println!("使用 * 解引用 Box<String>: {}", *boxed_string);
    
    // 直接调用方法,无需显式解引用
    println!("直接调用方法,自动解引用: {}", boxed_string.len());
    
    // 创建自定义的 MyBox<String>
    let my_boxed_string = MyBox::new(String::from("Hello, custom Box!"));
    
    // 使用 * 操作符解引用自定义 Box
    println!("使用 * 解引用 MyBox<String>: {}", *my_boxed_string);
    
    // 解引用强制转换:将 Box<String> 传递给接受 &str 的函数
    print_string(&boxed_string);
    
    // 解引用强制转换:将 MyBox<String> 传递给接受 &str 的函数
    print_string(&my_boxed_string);
}
// Output:
// 使用 * 解引用 Box<String>: Hello, Deref!
// 直接调用方法,自动解引用: 13
// 使用 * 解引用 MyBox<String>: Hello, custom Box!
// 通过解引用强制转换传递的字符串: Hello, Deref!
// 通过解引用强制转换传递的字符串: Hello, custom Box!

什么是Arc<T>智能指针?

❓什么是Arc<T>

Arc<T>的全称是Atomic Reference
Counted(原子引用计数),它是原子引用计数智能指针,允许多线程间安全地共享数据的不可变所有权。它是Rc<T>的多线程版本。

Arc<T>使用原子操作来更新引用计数,确保多线程安全。它本身是栈上一个智能指针,指向堆上包含控制块(包括引用计数)和数据的内存位置。当T实现了Send
trait时,Arc<T>也会自动实现SendArc<T>总是实现Sync
trait,允许在大多数情况下安全地在线程间传递和共享。

Arc<T>的最大优势,是允许在线程间安全地共享和传递所有权,而无需深度拷贝数据。Arc<T>的克隆操作是O(1)复杂度,非常高效。

Arc<T>也有一些劣势。相比Rc<T>,它有更高的性能开销,因为需要额外的空间来存储原子计数器。它不适合单线程环境,在单线程中使用Rc<T>更高效。如果用它创建了循环引用,可能导致内存泄漏,需要谨慎使用,或考虑使用Weak<T>来打破循环。尽管Arc<T>是线程安全的,但它不提供任何其他同步保证。如果需要进行复杂的线程间通信,可能需要配合使用其他并发原语(如Mutex<T>RwLock<T>)。Arc<T>提供的是不可变的共享访问。如需可变访问,通常需要使用互斥锁等同步原语(如Mutex<T>RwLock<T>)。

Arc<T>特别适用于需要在多个线程之间共享大型不可变数据结构的情况。另外,它还适合在多线程应用中共享只读数据。还适合实现线程安全的缓存或配置信息。

let theater = ...Arc<Theater> 绑定到不可变变量 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> 实例绑定到 theater。这个绑定涉及所有权的转移(Arc<Theater> 的所有权移动到 theater)。

这里使用 Arc<Theater> 是必要的,因为代码后面会创建多个需要访问同一 Theater 实例的线程。Arc<Theater> 确保只要还有任何线程在使用,Theater 实例就会保持存活,并提供线程安全的引用计数。

通过使用 Arc<Theater>,可以在第52行为每个线程克隆 Theater 的引用,使它们能够安全地共享相同的数据。然而,需要注意的是,虽然 Arc<Theater> 提供了引用的安全共享,但它并不能使 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 对象本身,并将其绑定给不可变变量theater_clone,以便安全地移动到新线程中。每个线程都需要自己的指向 TheaterArc<Theater>。这样就允许多个线程同时访问同一 Theater 实例。

Arc::clone() 方法会增加引用计数,但不会复制底层数据。即使增加了引用计数,Arc<T>clone() 仍然是轻量级操作,因为它们共享相同的底层数据。

每次循环,程序会将 Arc<Theater> 的引用计数增1,并创建一个指向同一 Theater 实例的新 Arc<Theater>Arc<T>使用原子操作来更新引用计数,确保多线程安全。

当创建一个新的 Arc<T> 实例时,引用计数设为 1。每当克隆这个 Arc<T>(通过 Arc::clone),引用计数就会增加 1。当一个 Arc<T> 实例离开作用域时,引用计数减少 1。当引用计数降到 0 时,说明Arc<T> 的所有实例都超出作用域或被手动丢弃(非必须)时,引用计数降为 0,Arc<T> 所指向的数据会被自动清理。

使用 Arc<Theater> 能确保只要还有任何线程在使用,Theater 对象就会保持存活,并且当所有指向它的 Arc<Theater> 都被丢弃时,它会自动被释放。

第53-55行模拟多个并发订票。每个启动的线程通过调用共享Theater对象上的book_ticket()方法来尝试订票。然而,由于缺乏适当的同步,这可能导致竞态条件和不正确的结果,正如在输出中所看到的,票数变成了负数。

第53行使用Rust标准库的thread::spawn函数创建一个新线程。spawn函数接受一个闭包(匿名函数)作为参数,并返回一个JoinHandleJoinHandle 代表了一个正在运行的线程。通过第60行调用 join() 方法,可以等待该线程执行完毕。

❓什么是闭包?

闭包是一种匿名函数,可以捕获其定义环境中的变量。在 Rust 中,闭包使用 || 语法定义,它使用 ||
包围参数列表(这里是空的),后跟代码块。||左侧的move
关键字,表示这个闭包将获取它从环境中捕获的任何变量的所有权。之后花括号包起来的闭包体,包含要执行的代码(这里是调用
book_ticket 方法)。

闭包有很多优势。比如简洁,可以内联定义小型函数,无需单独的函数定义。另外它很灵活,可以捕获环境中的变量。闭包还支持高阶函数和函数式编程范式。最后闭包是线程安全的,它通过
move 可以在线程间安全地转移所有权。

闭包也有一些劣势。比如语法可能不直观,对新手来说可能较难理解。生命周期较复杂,在某些情况下可能需要显式处理生命周期。它还有类型推断限制,有时需要显式指定类型。

闭包适用以下场景。闭包可以作为函数参数,如在 thread::spawn
中。可以作为回调函数,用于事件处理或异步编程。可以用于迭代器操作,如 mapfilter
等。可以用于自定义数据结构,实现延迟计算或自定义行为。

闭包分三种类型。Fn类型,不可变借用捕获的变量。FnMut类型,可变借用捕获的变量。FnOnce类型,获取捕获变量的所有权(如本例中使用
move,就是FnOnce类型)。

闭包与普通函数之间还是有区别的。首先闭包可以捕获环境,普通函数不行。另外闭包类型(是FnFnMut还是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结构体。

1.5.2 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<T>AtomicI32

这种设计选择引入了潜在的问题。首先是线程安全问题,没有适当的同步,并发访问可能导致竞态条件。其次是内存安全问题,不当使用裸指针可能导致未定义行为。最后是绕过Rust的所有权规则,裸指针规避了Rust的所有权和借用规则。更符合Rust惯用法的方法是使用安全的并发原语来管理线程间的共享状态。

第8-9行,为 Theater 结构体实现了 SendSync trait。

这里的SendSync是Rust标准库中的内置trait,用于并发安全性。通过为Theater实现这两个trait,代码表明Theater类型可以安全地在线程间传递和共享,尽管在这个特定情况下,实际实现并不是线程安全的。

Send trait 表示在线程间传递类型的所有权是安全的。通过实现 Send,代码告诉 Rust 编译器在线程间移动 Theater 实例是安全的。

Sync trait 表示在线程间共享类型的引用是安全的。通过实现 Sync,代码告诉 Rust 编译器在多个线程间共享 Theater 实例的引用是安全的。

这里使用 unsafe 关键字是因为编译器无法自动验证 Theater 结构体的线程安全性,这是由于它使用了裸指针(*mut i32)。使用 unsafe 意味着程序员需要承担确保实现实际上是线程安全的责任。

需要注意的是,在这种情况下,代码实现实际上并不是线程安全的。book_ticket 方法可能导致竞态条件,因为它在没有适当同步的情况下修改共享状态。这就是为什么程序会产生不正确的结果,允许预订的票数超过可用票数。

1.5.3 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<i32>智能指针实例。Box::into_raw(...)Box<i32> 转换为裸指针 *mut i32。这个操作将内存管理的责任从 Rust 的所有权系统转移到了程序员手中。available_tickets: 是在结构体初始化或定义中声明字段的语法。它指定了一个名为 available_tickets 的字段,该字段将被赋予冒号右侧表达式的值。这种语法是 Rust 中创建结构体实例或定义结构体字段的标准方式。

new关联函数之所以这样实现,有以下几个原因。首先是可变性,通过使用裸指针,可以在不改变 Theater 结构体本身的情况下修改票数。其次是线程安全,裸指针允许在多线程环境中共享和修改数据,尽管这需要小心处理以避免数据竞争。最后是性能,直接操作内存可能在某些情况下提供更好的性能。

然而,这种方法也带来了一些风险。首先是安全性,使用裸指针和 unsafe 代码块增加了出错的风险。第二是内存管理,程序员需要确保正确管理内存,避免内存泄漏或使用已释放的内存。

在实际应用中,通常推荐使用 Rust 的安全抽象,如 Mutex<T>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<T>)来保护共享资源。

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方法的返回值。

1.5.4 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<T>。然后左侧的drop(...)是显式调用 drop 函数来释放 Box<T> 所管理的内存。

为何这里要显式定义Drop trait的实现?如果不显式定义,rust会提供Drop的默认实现,以满足本项目的需求吗?

❓何时要显式定义Drop trait的实现?

Drop trait 用于定义当一个值离开作用域时应该执行的清理操作。它包含一个 drop 方法,该方法在对象被销毁时自动调用。

之所以要显式定义 Drop,是因为在这个例子中,Theater 结构体使用了裸指针 mut i32
来管理可用票数。这个指针是通过 Box::into_raw() 创建的,它将堆分配的内存的所有权转移到了裸指针上。如果不显式定义
Drop,Rust 的默认实现不会知道如何正确释放这个裸指针指向的内存,可能导致内存泄漏。

第41-43行这段unsafe代码,先将裸指针转换回 Box<T>,然后调用 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.5.5 哪个共享可变状态挖了多线程数据竞争的坑

从代码清单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 的借用规则,如一个值在同一时间只能有一个可变引用。

1.6 什么是可变变量

Rust的变量分为两种,一种是不可变变量,另一种是可变变量。

可变变量(Mutable variable),指在声明后其值可以被改变的变量。在Rust中,需要使用mut关键字明确声明。

可变变量的特点是允许修改绑定的值。可变性仅限于变量的所有者。

可变变量的优势是解决了Rust默认变量不可变所带来无法就地改变变量值的难题。另外比较灵活,可以根据需要修改变量值。某些情况下,修改现有值比创建新实例更高效。它还适合某些算法,这些算法或相关数据结构需要就地修改数据,这对于某些算法(如排序、图操作)来说更为高效。它还提供了更灵活的内存使用模式,特别是在处理大型数据结构时。

可变变量也存在劣势。比如会导致安全性降低,可能导致意外修改和相关bug。并发复杂性,在多线程环境中需要额外的同步机制。代码推理难度增加,可变性使得代码流程更难追踪。增加了代码复杂性,可能使推理和调试变得更困难。

可变变量适用于需要频繁更新的数据结构(如缓存、计数器)。在性能关键的代码段中,可避免不必要的克隆和内存分配。

虽然可变变量解决了Rust默认变量不可变所带来无法就地改变变量值的难题,但滥用可变性,会在多线程并发编程时,带来数据竞争的难题。

前面介绍了Rust的可变变量与结构体的可变字段的相似点,那两者之间有什么区别?

❓可变变量与结构体的可变字段的差异点是什么?

Rust的可变变量与结构体的可变字段存在以下差异点。

  • 可变性的来源。一般情况下,结构体字段的可变性取决于结构体实例的可变性。只有当结构体实例被声明为可变(使用 mut 关键字)时,其字段才能被修改。对于包含原始指针或其他提供内部可变性的类型(如 Cell<T>, RefCell<T>,
    Mutex<T>
    等)的结构体字段,即使结构体实例是不可变的,也可以修改这些字段指向或包含的值。普通可变变量的可变性在声明时就已确定,直接用 mut
    关键字声明。
    • 在图2-1左侧第5行的available_tickets 是一个指向可变i32的裸指针。裸指针在 Rust 中是特殊的,它们绕过了 Rust 的常规安全检查。字段 available_tickets 本身(即指针的值)仍然遵循前述规则,即如果
      Theater 是不可变的,那不能改变指针本身。然而,指针指向的内容可以被修改,即使 Theater
      实例是不可变的。修改指针指向的内容需要使用 unsafe 代码块。这意味着 Rust
      编译器不再保证这些操作的安全性,责任转移到了程序员身上。这种行为是原始指针的特性,而不是普通结构体字段的标准行为。
  • 生存期和作用域。结构体字段的生存期与结构体实例绑定。普通可变变量的生存期通常限于其声明的作用域。
  • 方法中的行为。在结构体的方法中,只有 &mut self 方法(结构体的可变引用)才能修改可变字段。普通的可变变量可以在任何拥有其所有权或可变引用的地方被修改。
  • 内部可变性的影响。结构体的可变字段如果是内部可变类型(如 RefCell<T>),即使结构体实例是不可变的,也可以修改其内容。普通可变变量如果是内部可变类型,行为类似。
  • 所有权和移动语义。结构体字段的所有权属于结构体。移动或复制结构体时,字段也会随之移动或复制。普通可变变量的所有权更加独立,可以单独被移动或复制。
  • 重新赋值。结构体的可变字段可以被重新赋值,但前提是结构体实例本身是可变的。普通的可变变量可以在其作用域内随时被重新赋值。

❓共享可变状态所带来的多线程并发时的数据竞争难题,该如何解决?

  • 18
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值