#thread #rc #options #graph #send-sync

sendable

提供在线程间发送数据的 Rc 和 Option 等价类型

12 个版本 (5 个破坏性更新)

0.6.1 2022年4月5日
0.6.0 2022年3月31日
0.5.2 2022年3月31日
0.4.0 2022年3月29日
0.1.0 2022年3月26日

#922 in Rust 模式

Apache-2.0/MIT

49KB
683

sendable

sendable 包定义了类型,这些类型有助于在线程之间发送相互连接的数据

  • SendRc,一个线程安全的引用计数指针,可以在线程之间发送。你可以将其视为一个变体,即 Rc<T>,当 TSend 时,它是 Send。这与 Rc<T> 不同,它永远不会是 Send,也与 Arc<T> 不同,后者需要 T: Send + Sync 才能是 Send
  • SendOption,它包含一个 Option<T>,即使在 T 不是 Send 的情况下也是 Send。它对于存储引用到一个单线程区域很有用,该区域单独发送。

何时使用 SendRc?

如果你

  • 值形成一个无环图或带有交叉引用的层次结构;
  • 从单个线程构建和使用层次结构;
  • 偶尔需要将整个结构移动到另一个线程。

在单个线程的范围内,RcRefCell 提供了方便且安全的循环图表示。它们还非常高效,因为单线程操作不需要原子操作或锁,使得 deref(解引用)变得简单,并允许编译器内联 borrow(借用)和 borrow_mut(可变借用)操作,甚至在它们不可全局观察的情况下优化它们。

在处理许多此类图的程序中,能够在单个线程中创建它们并将其发送到另一个线程进行(可能还有第三个线程进行拆卸)非常有用。鉴于像 RefCellCell 这样的类型是 Send,这个想法并非不可行。问题是 Rc,它既不是 Send 也不是 Sync,这是有充分理由的。尽管将整个 Rc 层次从一個线程移动到另一个线程是完全安全的,但借用检查器不允许这样做,因为它无法静态证明你已移动 所有 的它们。如果一些指向共享数据的 Rc 仍然留在原始线程中,那么对非 Sync 单元的未同步访问和对引用计数的未同步操作将是未定义的行为,并造成混乱。

如果有一种方法可以向 Rust 证明您已将所有指向特定共享值的 Rc 指针发送到另一个线程,那么这样做就不会有问题,只要 T 本身是 Send。这正是 SendRc 所提供的功能。

SendRc 是如何工作的?

当创建一个 SendRc 时,它将当前线程的 ID 与值和引用计数一起存储。在提供对值的访问之前,以及在通过 clonedrop 操作修改引用计数之前,它将检查当前线程是否仍然是预期的线程,否则引发恐慌。

在将 SendRc 移动到不同的线程之前,每个指针必须明确地“停放”,即注册为发送。一旦停放,对它所指向的值的访问被禁止,即使在原始线程中也是如此。一旦所有指向共享值的 SendRc 都已停放,它们就可以跨越线程边界发送,并在新线程中一次性重新启用。在两个简单的 SendRc 的情况下,这个过程看起来像这样

// create two SendRcs pointing to a shared value
let mut r1 = SendRc::new(RefCell::new(1));
let mut r2 = SendRc::clone(&r1);

// prepare to send them to a different thread
let pre_send = SendRc::pre_send();
pre_send.park(&mut r1); // r1 and r2 cannot be dereferenced from this point
pre_send.park(&mut r2);
// ready() would panic if there were unparked SendRcs pointing to the value
let post_send = pre_send.ready();

// move everything to a different thread
std::thread::spawn(move || {
    // SendRcs are still unusable until unparked
    post_send.unpark();
    // they're again usable from this point, and only in this thread
    *r1.borrow_mut() += 1;
    assert_eq!(*r2.borrow(), 2);
});

为什么不用 Arc?

Arc 确实允许线程之间的移动,但它从根本上假设底层值将在不同的线程之间被 共享。为了让 Arc<T> 成为 SendArc 需要 T: Send + Sync,因为如果它只需要 T: Send,则可以创建一个 Arc<RefCell<u32>>,克隆它,将克隆发送到不同的线程,并在同一 RefCell 上从两个线程调用 borrow_mut() 而不进行同步。这是被禁止的,这就是为什么在 Rust 中不存在 Arc<RefCell<T>> 的原因。

SendRc 可以避免这种情况,因为它使用线程检查来保护对数据的每次访问。在跨线程移动数据时,它需要证明在移动之前,前一个线程中所有对分配值的引用都已释放。SendRc<RefCell<u32>> 是有效的,因为如果您克隆它并将克隆发送到不同的线程,您将无法访问数据,也无法克隆或甚至丢弃它——任何这些都会引发 panic。

使用标准库,可以通过切换到完全实现的 Arc<Mutex<T>>Arc<RwLock<T>> 来修复问题。然而,这会减慢对数据的访问速度,因为它需要强有序原语、中毒检查和对 pthread API 的调用。它还由于必须分配系统互斥锁而增加了内存开销。即使是效率最高的互斥锁实现,如 parking_lot,也不是免费的,并且需要同步的开销。但是,即使不考虑成本,如果既不需要 Arc 也不需要 Mutex,则使用 Arc<Mutex<T>> 在概念上也是错误的,因为代码 不会 从多个线程并行访问 T 的值。

总的来说,SendRc<T> 是在运行时强制执行某些保证的 Send,就像 Arc<Mutex<T>> 在运行时强制执行某些保证的 Send + Sync 一样。它们只是服务于不同的目的。

为什么不使用竞技场?或者不安全操作?

要实现一个Send竞技场,整个设计必须从底层开始就致力于这个理念。简单地将每个Rc替换为竞技场ID的方法实际上并不奏效,因为除了ID之外,对象还需要对竞技场的引用。它不能有类型为Option<&Arena>Option<Rc<Arena>>的字段,因为这样的字段会使得类型在竞技场包含RefCell时不再符合Send

确实有一些基于竞技场的方案可以工作,但需要做出更彻底的改变,比如将值的存储与访问和共享解耦。所有数据都位于竞技场中,访问器是即时创建的,其生命周期与竞技场的生命周期相关联。这要求在各个地方处理生命周期,对于非专家来说并不容易做对。

最后,可以通过仅使用用于将整个世界发送到新线程的包装类型的unsafe impl Send来避免竞技场。这种解决方案是黑客式的,并且放弃了Rust提供的保证。如果你出错,比如说在原始线程中留下一个Rc克隆,你将面临未定义的行为和核心转储,就像在C++中一样。在Rust中,我们希望做得更好,SendRc旨在提供一种可靠的解决方案,以应对这种场景。

SendOption呢?

SendOption是一个相关的提议:一个持有Option<T>的类型,并且总是Send,无论T是否Send。这显然不可能安全吧?

使其工作的原因是SendOption要求你在将其发送到另一个线程之前将其值设置为None。如果内部Option<T>None,那么即使T不是Send也没有关系,因为实际上没有任何T被发送到任何地方。如果你将非NoneSendOption<T>发送到另一个线程,SendOption将通过panic来阻止你以任何方式访问它(包括通过丢弃它)。不遵守规则将导致实际上从未“发送”到另一个线程的T,只是它的位被浅拷贝并遗忘,这是安全的。

SendOption是为由Send数据组成的类型设计的,除了一个非Send类型的可选字段。该字段仅应在特定线程内设置和使用,在跨线程传输时会变为None,但由于Rust无法证明这一点,因此Option<NonSendType>字段使整个外部类型非Send。例如,一个具有SendOption<Rc<Arena>>的字段可以用来创建一个指向单线程场地的Send类型。

这真的安全吗?

与任何包含不安全代码的crate一样,人们永远不能100%确信没有安全性问题。上述设计的实现代码相当直接。我在确定当前方法之前,对设计和实现进行了多次迭代,虽然我偶尔发现了问题,但基本思想在审查中得到了证明。MIRI在运行测试时没有发现未定义的行为。

欢迎您审查代码——它并不大——并报告您遇到的问题。

运行时检查昂贵吗?

尽管SendRcSendOption执行的运行时检查不是免费的,但它们相对便宜。

SendRc::deref()将获取并固定到线程的整数id与当前线程(从线程本地存储获取)进行松散的原子加载进行比较。它还通过非原子的整数比较零来检查迁移是否正在进行。松散的原子加载在Intel上编译为普通加载,这是最便宜的,如果您担心,可以保持对引用的持有以避免重复检查。(借用检查器将阻止您在存在未解决引用的情况下将SendRc发送到另一个线程。)SendRc::clone()SendRc::drop()执行类似的检查。

SendOption::deref()SendOption::deref_mut()仅检查当前线程是否是预期的线程,使用与SendRc类似的松散加载和比较。

关于内存使用,SendRc的堆开销是两个u64用于固定和停靠信息,一个机器字用于引用计数,即64位架构上比Rc多一个u64。单个SendRc有两个机器字宽,因为它必须跟踪每个指针的标识。 SendOption存储一个u64,与底层选项一起。

许可

sendable同时遵循MIT许可协议和Apache许可协议(版本2.0)。有关详情,请参阅LICENSE-APACHELICENSE-MIT。贡献更改被视为同意这些许可条款。

依赖项