讲动人的故事,写懂人的代码
1.4. 可多方只读借用的不可变引用
在Rust中,相比多方为了读取一份数据,而费尽周章地复制整个数据或转移所有权,有时运用不可变借用会更高效,所以我们需要不可变引用。
不可变引用(immutable references,也称为共享引用)是Rust中一种借用数据的方式,它允许你在不获取所有权的情况下,读取数据但不能修改它。在Rust中,不可变引用用 &T
表示,其中 T
是被引用的类型。这种机制提高了程序的安全性和并发性。
不可变引用具有以下优势。首先是安全性,防止数据竞争,因为多个不可变引用可以同时存在,在方便使用的同时,不用担心数据会被篡改。其次是共享,允许多个部分的代码同时访问数据,而不需要复制。最后是性能,避免了不必要的复制,提高了效率。
不可变引用具有以下劣势。首先是灵活性,不能通过不可变引用修改数据。其次是学习曲线,对新手来说可能需要一些时间来适应这个概念。
不可变引用适用以下场景。首先是当需要读取数据但不需要修改它时。其次是在函数参数中,当函数只需要读取而不需要修改传入的数据时。如代码清单4所示。
代码清单4 Rust中不可变引用的多线程共享与函数传递示例
1 use std::thread;
2 use std::sync::Arc;
3
4 fn main() {
5 let data = Arc::new(vec![1, 2, 3, 4, 5]);
6
7 let data_clone1 = Arc::clone(&data);
8 let handle1 = thread::spawn(move || {
9 let ref1 = &*data_clone1;
10 println!("Thread 1: {:?}", ref1);
11 // *ref1[0] = 10;
12 });
13
14 let data_clone2 = Arc::clone(&data);
15 let handle2 = thread::spawn(move || {
16 let ref2 = &*data_clone2;
17 println!("Thread 2: {:?}", ref2);
18 // ref2.push(6);
19 });
20
21 let ref3 = &*data;
22 println!("Main thread: {:?}", ref3);
23 // ref3.clear();
24
25 handle1.join().unwrap();
26 handle2.join().unwrap();
27
28 print_sum(&data);
29
30 println!("Original data: {:?}", data);
31 }
32
33 fn print_sum(numbers: &Vec<i32>) {
34 let sum: i32 = numbers.iter().sum();
35 println!("Sum: {}", sum);
36 // numbers[0] = 100;
37 }
// 运行结果:
// Main thread: [1, 2, 3, 4, 5]
// Thread 1: [1, 2, 3, 4, 5]
// Thread 2: [1, 2, 3, 4, 5]
// Sum: 15
// Original data: [1, 2, 3, 4, 5]
代码清单4解释如下。
第1行:导入标准库中的 thread
模块,用于创建和管理线程。这体现了不可变引用的优势之一,即提高了程序的并发性。
第2行:导入标准库中的 Arc
模块,用于多线程环境中的共享不可变所有权。这体现了不可变引用的优势之一,即允许多个部分的代码同时访问数据,而不需要复制。
第5行:创建一个包含整数vector的Arc
实例,Arc
允许多个线程安全地共享这个数据。vec![1, 2, 3, 4, 5]
是要共享的数据。vec![1, 2, 3, 4, 5]
是 Rust 中用于创建一个包含元素 [1, 2, 3, 4, 5]
的动态数组vector的宏。这个宏简化了创建 Vec
的过程,使得代码更加简洁和易读。在创建完vector后,又用Arc
包装它,这样就创建了一个包含整数vector的 Arc
实例,并与变量data
绑定,以便在多个线程之间安全共享数据vec![1, 2, 3, 4, 5]
。
vec!
宏是创建 Vec
的便捷方法。宏会自动推导元素类型并初始化 Vec
。
[在C++中,与Rust的Vec
类型最相似的概念是 std::vector
。std::vector
是标准模板库(STL)中的一个动态数组类型,提供了动态调整大小、随机访问和类似数组的功能。]
[在Java中,与Rust的Vec
类型最相似的概念是 ArrayList
。ArrayList
是 java.util
包中的一个动态数组类,提供了动态调整大小、随机访问和类似数组的功能。]
第7行:克隆Arc
,增加引用计数,以便第一个线程可以持有一个指向相同数据的引用。这体现了不可变引用的优势之一,即允许多个部分的代码同时访问数据,避免了不必要的复制,提高了效率。
Arc::clone
接受一个不可变引用 &data
作为参数,克隆 Arc
,生成一个新的 Arc
实例 data_clone1
,指向&data
所不可变借用的相同的数据。Arc::clone
只需要读取 Arc
的引用计数和指向的数据地址,并不需要修改 Arc
实例本身,因此使用不可变引用即可。使用不可变引用可以保证在调用 clone
方法时,原 Arc
实例不会被修改,符合 Rust 的安全性和并发模型。
生成新的 Arc
实例 data_clone1
后,就可以在不同线程中共享该数据。这增加了引用计数,但不复制实际数据。
这背后的含义是什么?先解释一下Arc
的工作原理。
当我们创建一个Arc<T>
时,Rust在堆上分配了两块内存。一块用于存储T
类型的实际数据,另一块用于存储引用计数。
引用计数是一个整数,初始值为1。每次克隆Arc
时,这个计数就会原子地增加1。当Arc
被丢弃时,计数减1。 当我们调用Arc::clone(&data)
时,Rust只复制指向上述两块内存的指针,原子地增加了引用计数,但没有复制T
类型的实际数据。
克隆Arc
的操作非常快,因为它只涉及指针复制和原子操作,而不会发生大量数据的复制,这在处理大型数据结构时特别有益。
当最后一个Arc
被丢弃(引用计数降为0)时,T
类型的数据才会被释放。这确保了只要还有Arc
存在,数据就不会被释放。
Arc
使用原子操作来修改引用计数,这使得它在多线程环境中是安全的。多个线程可以同时持有同一数据的Arc
,而不会导致数据竞争。
Arc<T>
只提供对T
的共享(不可变)访问。如果需要可变访问,通常会使用Arc<Mutex<T>>
或Arc<RwLock<T>>
。
这种机制允许多个线程高效地共享同一份数据,而不需要进行昂贵的数据复制操作。它是Rust实现高效且安全的并发编程的关键工具之一。在我们的代码中,这意味着所有线程都在操作同一份数据,而不是各自的副本,这既节省了内存,又保证了数据的一致性。
第8行:使用 thread::spawn
创建并启动了一个新的线程,并将 data_clone1
的所有权移动到该线程的闭包中。
thread::spawn
是 Rust 标准库中的一个函数,用于创建一个新线程,并在该线程中执行一个闭包(closure)。线程是并发编程中的一个基本单位,允许同时执行多个任务。
move
关键字用于将闭包中的所有变量捕获为所有权。这意味着闭包会获得这些变量的所有权,而不是借用它们。在这里,move
将 data_clone1
的所有权移动到新线程中,以确保数据在新线程中是有效的。
||
表示一个闭包的参数列表。在这个例子中,参数列表是空的,因为闭包不需要任何输入参数。{
表示闭包的主体部分开始。闭包是一个可以捕获其环境中变量的匿名函数。
此处为何需要move
?
Rust 的所有权机制确保每个值都有一个唯一的所有者。在当前作用域结束时,所有者会自动清理资源。当我们在线程中使用数据时,数据的所有权必须被移动到线程内,以确保线程能合法地访问和使用该数据。
Arc
允许多个线程共享同一个数据,但每个线程必须持有一个有效的 Arc
实例。如果不使用 move
,新线程将无法获得 Arc
实例的所有权,这可能导致线程在运行时无法访问数据或者访问已被释放的数据。
如果没有move
会怎样?
Rust 编译器会检查闭包捕获的变量的生存期。如果没有 move
,闭包将尝试借用(引用)外部变量 data_clone1
。在 thread::spawn
中,闭包必须是 'static
,这意味着闭包中引用的数据必须在整个程序生存期内有效。而在没有 move
的情况下,data_clone1
只在 main
函数的生存期内有效。因此,编译器会报错,指出闭包中引用的变量的生存期不足以满足要求。
另外,新线程可能在主线程结束后继续执行。如果数据不被移动到新线程,新线程可能会引用已被释放的数据,导致悬垂指针问题。
什么是'static
?
在 Rust 中,'static
生存期是一个特殊的生存期,它表示数据可以在程序的整个生存期内有效。理解这个概念对于多线程编程尤其重要,因为线程可能在主线程结束后继续运行,因此在线程中使用的数据必须确保在整个线程生存期内有效。以下是对 'static
生存期的详细解释。
'static
是 Rust 中最长的生存期,表示数据在程序的整个生存期内都是有效的。任何拥有 'static
生存期的数据都可以在程序的任何部分安全地使用。
'static
生存期适用于常量和静态变量,因为这些数据在程序的整个运行期间都存在。例如,字符串字面量(如 "hello")具有 'static
生存期,因为它们存储在程序的只读数据段中,直到程序退出才会被释放。
当我们在 thread::spawn
中创建一个新线程时,传递给它的闭包必须是 'static
。这意味着闭包捕获的数据和变量必须在整个线程生存期内有效。这是为了防止线程在运行时访问已经无效或被释放的数据,从而导致未定义行为或程序崩溃。
为什么需要 'static?
首先是因为线程生存期的不确定性。新线程的执行时间和主线程的执行时间可能不一致。新线程可能在主线程结束后仍然运行。如果闭包中捕获的数据不是 'static
,那么在主线程结束并释放这些数据后,新线程将无法安全地访问这些数据。
其次是因为数据安全性。Rust 的所有权和生存期机制确保内存安全。要求闭包是 'static
保证了新线程中的数据在其整个生存期内是有效的,防止悬垂指针和数据竞争。
如何实现 'static?
为了满足 thread::spawn
的要求,我们通常使用 move
关键字将闭包中的所有变量捕获为所有权。这样可以确保这些变量的生存期和线程一致。
第9行:创建一个不可变引用ref1
,指向data_clone1
。这里的&*data_clone1
解引用了Arc
,然后借用数据。
在&*data_clone1
中,&
表示取不可变引用。*
是解引用操作符,用于获取 Arc
内部的数据。data_clone1
是一个 Arc
类型,它内部持有一个 Vec<i32>
。使用 *data_clone1
可以得到这个 Vec,然后再使用 &
取得这个vector的不可变引用。
组合起来,&*data_clone1
表示通过 data_clone1
获得 Vec<i32>
的不可变引用。由于 Arc
提供了共享所有权,因此多个线程可以同时读取数据,而不会发生数据竞争。
第10行:打印第一个线程中的数据。{:?}
是一个格式说明符,用于调试打印。它会调用数据类型的 Debug
trait,实现该 trait 的数据类型可以用 {:?}
打印出来。
第11行:如果取消这行的注释,将导致编译错误,因为这里尝试修改不可变引用。
第14行:与第7行类似,克隆Arc
,以便第二个线程可以持有一个指向相同数据的引用。
第15行:与第8行类似,创建并启动第二个线程。move
关键字表示该线程获取其环境中的所有权。
第16行:与第9行类似,创建一个不可变引用ref2
,指向data_clone2
。这里的&*data_clone2
解引用了Arc
,然后借用数据。
第17行:与第10行类似,打印第二个线程中的数据。
第18行:如果取消这行的注释,将导致编译错误,因为这里尝试向不可变引用的Vec
添加元素。
第21行:创建一个不可变引用ref3
,指向主线程中的数据。这里的&*data
解引用了Arc
,然后借用数据。
第22行:打印主线程中的数据。
第23行:如果取消这行的注释,将导致编译错误,因为这里尝试通过不可变引用清空Vec
。
第25行:等待第一个线程完成。join
方法会阻塞当前线程直到目标线程终止。unwrap
确保如果线程发生错误,程序会崩溃并显示错误信息。
handle1
是在第8行创建的线程句柄(thread handle)。当我们调用 thread::spawn
创建新线程时,返回一个 JoinHandle
类型的值,存储在 handle1
中。这个句柄可以用来控制和操作该线程,例如等待线程完成。
join
是 JoinHandle
类型的方法,用于阻塞当前线程,直到被调用的线程(即 handle1
所代表的线程)完成其执行。换句话说,调用 join()
会让主线程等待 handle1
所代表的线程完成,然后继续执行后续代码。
join
方法返回一个 Result
类型,表示线程的运行结果。Result
是 Rust 中处理可能失败操作的标准类型。 Result
有两个变体。一个是Ok(T)
表示操作成功,包含成功值。另一个是Err(E)
表示操作失败,包含错误信息。unwrap
是 Result
类型的方法,用于获取 Result
中的成功值。如果 Result
是 Ok
,则返回内部的值;如果是 Err
,则程序会在此处崩溃,并打印错误信息。
为什么需要 join()?
首先是确保线程完成。join()
确保 handle1
所代表的线程完成其执行。只有当该线程执行完毕后,主线程才会继续执行后续的代码。这是为了避免主线程提前结束,从而导致新线程中的任务没有完成。
其次是数据一致性。join()
可以确保数据在并发操作中的一致性。在调用 join()
之后,我们可以确定该线程已经完成了所有对共享数据的读取操作。
最后是防止程序崩溃。如果 join()
不被调用,当主线程结束时,所有子线程也会被强制终止,可能导致未完成的任务和数据损坏。
在这段代码中,handle1.join().unwrap();
用于等待 handle1
所代表的线程完成。这确保了线程1中的 println!("Thread 1: {:?}", ref1);
执行完毕,然后主线程才会继续执行第26行等待线程2结束。最终,主线程继续执行第24行以后的代码,打印主线程的结果和调用 print_sum
函数。
第26行:等待第二个线程完成。
第28行:调用函数print_sum
,传递对数据的不可变引用。
第30行:打印原始数据,确认数据未被修改。
第33行:定义函数print_sum
,参数是一个指向整数vector的不可变引用。
第34行:计算vector中所有整数的和。
第35行:打印计算的和。
第36行:如果取消这行的注释,将导致编译错误,因为这里尝试在此函数中修改传入的不可变引用。
C++中最接近Rust不可变引用的概念是常量引用(const reference)。它们都允许读取数据但不允许修改,并且不涉及所有权转移。然而,C++的常量引用与Rust的不可变引用还有以下区别。
首先,Rust的所有权系统和借用检查器在编译时严格检查引用的有效性,防止悬垂引用和数据竞争,而C++则缺乏这种机制,安全性不如Rust。
其次,C++的常量引用可能存在空引用,需程序员小心处理,而Rust的不可变引用总是有效的,空引用在编译时会报错。
最后,Rust通过生存期参数在函数签名中明确引用的有效期,C++没有这种语法,引用的生存期容易混淆。
尽管有这些区别,C++的常量引用在避免复制开销和保证数据不被修改方面,与Rust的不可变引用有类似的优点。
Java中最接近Rust不可变引用的概念是final
变量。然而,它们在以下方面存在明显区别。
首先,Java的final
只能修饰变量不能重新赋值,但对象内部状态仍可变,而Rust的不可变引用意味着引用的数据完全不可变。
其次,Java缺乏Rust那样的所有权系统和借用规则,final
变量虽不可重新赋值,但存在对象内部状态被多处代码同时修改的风险,不能严格防止数据竞争。
第三,Java的垃圾回收减轻了程序员管理内存的负担,但牺牲了一些性能,而Rust通过所有权和借用实现了内存安全和高效。
最后,在多线程访问方面,Java需借助synchronized
等机制保证final
变量的线程安全,而Rust的不可变引用默认就是线程安全的。
(未完待续。划到文章下方能看目录和上下篇哦~😃)
如果喜欢这篇文章,别忘了给文章点个“赞” ,好鼓励我继续写哦~😃
如果哪里没讲明白,就在评论区给我留言哦~😃