当一个Arc被克隆时会发生什么?

53

我正在学习并发编程,想要澄清我对Rust 书中的代码示例的理解。如果我错了,请纠正我。

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));

    for i in 0..3 {
        let data = data.clone();
        thread::spawn(move || {
            let mut data = data.lock().unwrap();
            data[0] += i;
        });
    }

    thread::sleep(Duration::from_millis(50));
}
在代码行 let data = data.clone() 上发生了什么?
《Rust编程之道》中提到:

我们使用 clone() 创建一个新的所有权句柄,然后将该句柄移动到新线程中。

新的 "owned handle" 是什么意思?听起来像是对数据的引用?
由于 clone 接受一个 &self 并返回一个 Self,因此每个线程是否修改原始数据而不是副本?我想这就是为什么这里没有使用 data.copy() 而是使用 data.clone() 的原因。
右侧的 data 是一个引用,左侧的 data 是一个拥有的值。这里存在变量遮蔽。
3个回答

143

对于let data = data.clone(),发生了什么?

Arc代表原子引用计数。一个Arc管理一个对象(类型为T),并充当代理,以允许共享所有权,这意味着:一个对象由多个名称拥有。哇,听起来很抽象,让我们分解一下吧!

共享所有权

假设你有一个Turtle类型的对象,你为你的家人买了它。现在问题出现了,你无法指定乌龟的明确所有者:每个家庭成员都拥有这个宠物!这意味着(对于我这么说可能有点残酷),如果一个家庭成员死了,乌龟不会随那个家庭成员而去。只有在所有家庭成员都离开后,乌龟才会死去。每个人都拥有,最后一个清理

所以在Rust中如何表达这种共享所有权呢?您很快就会发现,仅使用标准方法是不可能的:您总是必须选择一个所有者,而其他人只能对龟进行引用。不好!
于是出现了Rc和Arc(就本故事而言,这两个东西具有完全相同的用途)。它们通过微调不安全的Rust来实现共享所有权。让我们看一下执行以下代码后的内存(注意:内存布局供学习之用,可能与真实世界中的内存布局不完全相同):
let annas = Rc::new(Turtle { legs: 4 });

内存:

  Stack                    Heap
  -----                    ----


  annas:
+--------+               +------------+
| ptr: o-|-------------->| count: 1   |
+--------+               | data:    |
                         +------------+

我们可以看到,乌龟住在堆上...旁边有一个计数器,该计数器设置为1。这个计数器知道对象当前有多少所有者。而且1是正确的: annas现在是唯一拥有乌龟的人。让我们通过clone()来获取更多的所有者:
let peters = annas.clone();
let bobs = annas.clone();

现在内存看起来像这样:

  Stack                    Heap
  -----                    ----


  annas:
+--------+               +------------+
| ptr: o-|-------------->| count: 3   |
+--------+    ^          | data:    |
              |          +------------+
 peters:      |
+--------+    |
| ptr: o-|----+
+--------+    ^
              |
  bobs:       |
+--------+    |
| ptr: o-|----+
+--------+

如您所见,乌龟仍然只存在一次。但引用计数已增加,现在为3,这是有道理的,因为现在乌龟有三个所有者。这三个所有者都引用了堆上的这个内存块。这就是Rust书中所谓的owned handle:这种句柄的每个所有者也有点拥有底层对象。
(还可以参见"为什么std::rc::Rc<>不是Copy?"

原子性和可变性

您问Arc<T>Rc<T>之间有什么区别?Arc以原子方式增加和减少其计数器。这意味着多个线程可以同时增加和减少计数器而不会出现问题。这就是为什么您可以跨线程边界发送Arc,但不能发送Rc的原因。
现在您注意到,无法通过 Arc<T> 修改数据!如果您的狗狗失去一条腿怎么办?Arc 没有设计以允许多个所有者同时进行可变访问。这就是为什么您经常会看到像 Arc<Mutex<T>> 这样的类型。 Mutex<T> 是一种提供了内部可变性的类型,这意味着您可以从 &Mutex<T> 中获取 &mut T!这通常会与 Rust 的核心原则冲突,但是完全安全,因为互斥锁也管理访问:您必须请求访问该对象。如果另一个线程/源当前正在访问该对象,则必须等待。因此,在某个给定的时刻,只有一个线程能够访问 T

结论

[...] 每个线程都修改原始数据而不是副本吗?

希望你能从上面的解释中理解:是的,每个线程都在修改原始数据。对 Arc<T> 进行 clone() 不会克隆 T,而只是创建另一个拥有的句柄;这个句柄实际上只是一个指针,表现得好像它拥有底层对象一样。

20
非常有趣和酷的解释。我希望将来 Rust 书籍也能以同样的方式编写。 - enaJ
@Lukas,我期待着阅读更多你的回答,谢谢。 - user25064
5
Arc<Mutex<T>>是Rc<RefCell<T>>的线程安全版本,如果对海龟的访问不总是可变的,则Arc<RwLock<T>>可能会有用。这三种组合相当常见。 - Josh Lee
在大多数情况下,我更喜欢使用互斥锁;https://dev59.com/9FUL5IYBdhLWcg3wM1k6 - ozanmuyes

6
我不是标准库内部的专家,仍在学习Rust...但这是我所能看到的:(如果您想要,您也可以检查源代码)。
首先,在Rust中需要记住的一件重要事情是,如果你知道自己在做什么,实际上可以走出编译器提供的“安全边界”。因此,尝试使用所有权系统作为理解基础来推断一些标准库类型的内部工作方式可能没有太多意义。 Arc是一种标准库类型,它在内部绕过了所有权系统。它本质上管理一个指针,并调用clone()会返回一个新的Arc,它指向与原始指针完全相同的内存位置,但引用计数已增加。

从高层次上来说,是的,clone()返回一个新的Arc实例,并且该新实例的所有权移动到了赋值语句的左侧。然而,在内部,新的Arc实例仍然指向旧实例所在的位置,通过一个原始指针(或者在源代码中显示为通过一个Shared实例,它是一个原始指针的包装器)。我想文档所指的“拥有句柄”就是指原始指针周围的包装器。


5

std::sync::Arc 是一个智能指针,它增加了以下功能:

用于共享状态的原子引用计数包装器。

Arc(及其非线程安全的朋友std::rc::Rc)允许共享所有权。这意味着多个“句柄”指向同一个值。每当复制一个句柄时,引用计数将增加。每当删除一个句柄时,计数器就会减少。当计数器变为零时,句柄指向的值将被释放。

请注意,此智能指针不会调用数据的底层clone方法;实际上,可能不需要底层clone方法!Arc处理调用clone时发生的情况。

什么是新的“拥有句柄”?听起来像是对数据的引用?

它既不是一个引用。在更广泛的编程和英语意义上,“引用”这个词,它是一个引用。在 Rust 引用(&Foo)的特定意义上,它不是一个引用。令人困惑,对吧?


你的问题的第二部分是关于std::sync::Mutex,它被描述为:

用于保护共享数据的互斥原语

Mutexes 是多线程程序中常见的工具,在其他地方有很好的描述,因此我不会在这里重复。需要注意的重要一点是,Rust 的 Mutex 给您修改共享状态的能力。让多个所有者可以访问 Mutex 以尝试修改状态是由 Arc 实现的。

这比其他语言更细粒度,但允许这些部件以新颖的方式重复使用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接