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

2024-09-05 07:58:05 浏览数 (2)

讲动人的故事,写懂人的代码

赵可菲是一名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个避坑场景

方面/角色

变量(不可变与可变)

栈上值

堆上值

不可变引用(共享引用)

可变引用

Box<T>

Rc<T>

Arc<T>

Cell<T>

RefCell<T>

Mutex<T>

RwLock<T>

所有权

场景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 出现数据竞争问题的多线程并发剧院订票系统

代码语言:javascript复制
 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<T>:提供内部可变性,能够处理没有实现Copy trait的类型

Mutex<T>:提供(读写)互斥锁,用于在并发场景下安全地共享和修改数据

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<T>与自定义MyBox<T>的Deref trait的代码实例,如代码清单1-2所示。

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

代码语言:javascript复制
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 在标准库中的定义是这样的:

pub trait Drop { 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>),即使结构体实例是不可变的,也可以修改其内容。普通可变变量如果是内部可变类型,行为类似。

所有权和移动语义。结构体字段的所有权属于结构体。移动或复制结构体时,字段也会随之移动或复制。普通可变变量的所有权更加独立,可以单独被移动或复制。

重新赋值。结构体的可变字段可以被重新赋值,但前提是结构体实例本身是可变的。普通的可变变量可以在其作用域内随时被重新赋值。

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

欢迎关注吾真本的“避坑入门Rust”的下一篇文章,共同探讨如何怕踩坑好入门Rust。

如果喜欢这篇文章,别忘了给文章点个“赞”,好鼓励我继续写哦~

0 人点赞