前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >挖数据竞争大坑的可变变量:避坑入门Rust之一

挖数据竞争大坑的可变变量:避坑入门Rust之一

原创
作者头像
程序员吾真本
发布2024-08-29 12:14:18
1150
发布2024-08-29 12:14:18
举报
文章被收录于专栏:Rust奇幻之旅:从Java和C++开启

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

赵可菲是一名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所有权机制的避坑规则,会涉及12个角色和6个方面,一共有72个避坑场景。如表1-1所示。

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

角色/方面

所有权

所有权移动

作用域

生存期

丢弃

复制

变量(不可变与可变)

场景1

场景2

场景3

场景4

场景5

场景6

栈上值

场景7

场景8

场景9

场景10

场景11

场景12

堆上值

场景13

场景14

场景15

场景16

场景17

场景18

不可变引用(共享引用)

场景19

场景20

场景21

场景22

场景23

场景24

可变引用

场景25

场景26

场景27

场景28

场景29

场景30

Box<T>

场景31

场景32

场景33

场景34

场景35

场景36

Rc<T>

场景37

场景38

场景39

场景40

场景41

场景42

Arc<T>

场景43

场景44

场景45

场景46

场景47

场景48

Cell<T>

场景49

场景50

场景51

场景52

场景53

场景54

RefCell<T>

场景55

场景56

场景57

场景58

场景59

场景60

Mutex<T>

场景61

场景62

场景63

场景64

场景65

场景66

RwLock<T>

场景67

场景68

场景69

场景70

场景71

场景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模拟了一个简单的剧院售票系统,存在一些并发问题和安全隐患。

1.4 如何把代码运行起来

要把代码清单1-1运行起来,并看到类似代码后边注释掉的打印输出(因为发生了数据竞争,在你电脑上看到的输出或许在数字上略有不同),有两种办法。

第一种办法是在mycompiler.io网页上运行。

打开www.mycompiler.io/new/rust网页,把代码清单1-1所对应的没有行号的代码(可以在github.com/wubin28/wuzhenbens_playground下载,并切换到immutable_variable_theater_booking_rust_data_race分支,找到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运行程序。

代码运行起来后,如果能看到类似代码后边注释掉的打印输出,说明程序就能运行了。

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

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)。

这里使用 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<T> 实例,而不是 Theater 对象本身,并将其绑定给不可变变量theater_clone,以便安全地移动到新线程中。每个线程都需要自己的指向 TheaterArc。这样就允许多个线程同时访问同一 Theater 实例。

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

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

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

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

第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行结构体定义最后有一个逗号是可选的。可以选择加上它,也可以选择不加。

如果 Theater 结构体只有这一个字段,那么这个逗号可以省略而不影响代码的正确性。如果结构体有多个字段,最后一个字段后的逗号可以省略,但前面的字段必须有逗号分隔。

Rust 的官方风格指南建议在多行的结构体定义中,即使是最后一个字段也保留逗号。这被称为"尾随逗号"(trailing comma)。这样保留尾随逗号,可以使添加新字段更容易,因为不需要记得在前一行添加逗号。它还可以使版本控制系统的差异更清晰,因为添加新字段只会显示为一行的变化。

为了保持代码风格的一致性,通常建议在所有类似的结构(如结构体、枚举、数组等)定义中都使用尾随逗号。

在Rust中,这里使用原始指针是不寻常的,并且可能不安全。原始指针通常用于与C代码交互或实现低级数据结构。它们绕过了Rust通常的安全保证,这就是为什么涉及它们的操作总是被包裹在unsafe代码块中。

在第5行,原始指针被用来允许跨线程共享可变状态,这在Rust中通常不被推荐。更安全的方法通常涉及使用同步原语,如MutexAtomicI32

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

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

❓什么是trait?

Rust中的trait是一种定义共享行为的方式。trait定义了一组方法,这些方法描述了某种能力或行为。可以将trait视为一种接口,它指定了类型应该实现的方法。结构体或枚举可以实现(implement)一个或多个trait,从而获得这些trait定义的行为。trait可以为其方法提供默认实现,实现该trait的类型可以选择使用默认实现或覆盖它。trait可以继承其他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 的安全抽象,如 MutexAtomicI32,来处理多线程环境下的共享可变状态,除非有明确的理由需要使用不安全的代码。

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

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

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

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 在标准库中的定义是这样的:

代码语言:javascript
复制
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就是这样的共享可变状态。它是一个字段,存储了一个指向可变 i32 的可变原始(裸)指针。指针本身可以被修改(即可以指向不同的内存位置),指针指向的值也可以被修改。多个线程共享并直接修改它。这种共享可变状态没有任何同步机制,是数据竞争的根源。

之后,book_ticket 方法使用 unsafe 块直接读写 available_tickets。而且多个线程可以同时访问和修改这个值,没有任何互斥或原子操作保护。这些都是不安全的并发访问。

最后,在检查票数和减少票数之间有一个延迟(thread::sleep)。这增加了竞态条件的可能性,因为多个线程可能同时认为还有票可订。

虽然在代码清单1-1中的第5行available_tickets是一个裸指针,并不是Rust的可变变量,但它所指向的mut i32这个在堆上分配的可变的i32值,与Rust的可变变量的本质是一致的,即都是可变的。

1.5.6 什么是可变变量

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

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

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

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

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

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

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

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

欢迎关注吾真本的“避坑入门Rust”的下一篇文章,共同探讨不可变变量是如何解决这个难题的。

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.1 专业程序员常踩哪些坑
  • 1.2 Rust所有权机制的避坑规则是怎样的
  • 1.3 可变变量挖了什么坑
  • 1.4 如何把代码运行起来
  • 1.5 用共享可变状态进行多线程并发编程时会踩什么坑
    • 1.5.1 main函数
      • 1.5.2 Theater结构体的定义与trait实现
        • 1.5.3 Theater结构体关联函数与方法的实现
          • 1.5.4 Drop trait 实现
            • 1.5.5 哪些共享可变状态挖了多线程数据竞争的坑
              • 1.5.6 什么是可变变量
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档