#refcell #cell #borrow #borrowing #rc #send-sync

no-std qcell

RefCell和RwLock的静态检查替代方案

6个版本

0.5.4 2023年7月13日
0.5.3 2023年1月15日
0.5.2 2022年6月11日
0.5.0 2022年1月23日
0.2.0 2019年3月21日

#40 in 内存管理

Download history 1921/week @ 2024-04-18 1956/week @ 2024-04-25 2567/week @ 2024-05-02 2108/week @ 2024-05-09 2373/week @ 2024-05-16 1964/week @ 2024-05-23 2031/week @ 2024-05-30 2127/week @ 2024-06-06 2171/week @ 2024-06-13 2293/week @ 2024-06-20 2241/week @ 2024-06-27 1746/week @ 2024-07-04 2715/week @ 2024-07-11 2586/week @ 2024-07-18 2736/week @ 2024-07-25 3597/week @ 2024-08-01

12,075 每月下载量
用于 13 个crates(6个直接)

MIT/Apache

175KB
1.5K SLoC

RefCell或RwLock的静态检查替代方案

Cell类型,与RefCell不同,在运行时不会引发panic,而是会在编译时给出编译错误,或者用粗粒度的锁定(RwLock)代替细粒度的锁定。

文档

请参阅crates文档

许可证

本项目采用Apache License 2.0或MIT许可证,由您选择。(见LICENSE-APACHELICENSE-MIT)。

贡献

除非您明确表示,否则任何有意提交以包含在本crate中的贡献,根据Apache-2.0许可证定义,均应以上述方式双重许可,不得附加任何额外条款或条件。


lib.rs:

RefCell和RwLock的静态检查替代方案。

此crate提供了四种RefCell的替代方案,每个方案都在编译时(静态)检查从cell的借用,而不是像RefCell那样在运行时检查。检查机制对所有四种方案都相同。它们只在表示所有权的方式上有所不同:QCell使用整数ID,TCell和TLCell使用标记类型,而LCell使用Rust生命周期。每种方法都有其优点和缺点。

QCell 为例:QCell 是一种单元格类型,其中单元格内容在借用目的上逻辑上由所有者类型的实例“拥有”,QCellOwner。因此,只能通过在该所有者上执行借用调用来访问单元格内容。这类似于从结构中借用字段或从 Vec 中借用元素。但实际上,对象之间的唯一联系是在创建单元格时提供了所有者实例的引用。实际上,借用所有者和释放所有者是分离的。

这使编译器能够在编译时静态检查对存储在 Rc 引用(或其他引用类型)背后的数据的可变访问。这种模式如下:所有者保持在栈上,并将对其的可变引用传递到栈上的调用中(例如作为上下文结构的一部分)。这通过借用检查器在编译时完全检查。然后,通过在所有者实例上使用借用调用以访问单元格内容,将这种静态借用检查扩展到单元格内容(在 Rc 后面)。这提供了一个编译时保证,即对单元格内容的访问是安全的。

另一种选择是使用 RefCell,如果尝试对同一数据执行两次可变引用,它将引发恐慌。使用 RefCell 时,没有警告或错误可以在编译时检测到问题。另一方面,使用 QCell 时,错误将在编译时检测到,但限制比实际需要严格得多。例如,如果它们由同一个所有者保护,则无法同时从超过几个不同的单元格中借用(RefCell 会正确地允许)。然而,如果您能够在这些限制内工作(例如,通过仅保留短时间的借用),那么优点是永远不会因为错误的借用使用而引发恐慌,因为一切都是由编译器检查的。

除了 QCellQCellOwner 之外,此软件包还提供了 TCellTCellOwner,它们的工作方式相同,但使用标记类型而不是所有者 ID,TLCellTLCellOwner 也使用标记类型,但它们是线程局部的,以及 LCellLCellOwner 使用生命周期。请参阅下面的 "单元格类型比较"

示例

使用 RefCell,这可以编译但会引发恐慌

let item = Rc::new(RefCell::new(Vec::<u8>::new()));
let mut iref = item.borrow_mut();
test(&item);
iref.push(1);

fn test(item: &Rc<RefCell<Vec<u8>>>) {
    item.borrow_mut().push(2);    // Panics here
}

使用 QCell,它拒绝编译

let mut owner = QCellOwner::new();

let item = Rc::new(QCell::new(&owner, Vec::<u8>::new()));
let iref = owner.rw(&item);
test(&mut owner, &item);    // Compile error
iref.push(1);

fn test(owner: &mut QCellOwner, item: &Rc<QCell<Vec<u8>>>) {
    owner.rw(&item).push(2);
}

在这两种情况下,解决方案都是确保在调用时 iref 不是活动的,但 QCell 使用标准编译时借用检查来强制修复错误。这是使用这些类型的主要优势。

以下是使用 TCell 的有效版本

再次使用 LCell

LCellOwner::scope(|mut owner| {
    let item = Rc::new(LCell::new(Vec::<u8>::new()));
    let iref = owner.rw(&item);
    iref.push(1);
    test(&mut owner, &item);
});

fn test<'id>(owner: &mut LCellOwner<'id>, item: &Rc<LCell<'id, Vec<u8>>>) {
    owner.rw(&item).push(2);
}

为什么这是安全的

这是声明此 crate 接口安全的理由

  • 在创建和销毁 cell 之间,唯一访问内容(读取或写入)的方式是通过借用所有者实例。因此,借用所有者是这些数据的独家守护者。

  • 借用调用需要一个 & 所有者引用来返回一个 & cell 引用,或者对所有者的 &mut 来返回一个 &mut cell 引用。所以这类似于两边的借用。我们允许对 cell 的唯一借用是 Rust 允许对借用所有者的借用,并且当这种借用活动时,借用所有者和 cell 的引用被阻止进行进一步的互斥借用。cell 的内容就像是由借用所有者拥有,就像 Vec 内的元素一样。所以 Rust 的保证得到了维护。

  • 借用所有者无法控制 cell 内容何时被丢弃,因此在此点不能作为数据的门卫。然而,这不会与数据的任何活动借用冲突,因为在借用活动期间,Rust 的借用检查会锁定 cell 的引用。如果这后面是 Rc,则直到该借用释放之前,不可能释放最后一个强引用。

如果您在这个推理或代码中看到任何缺陷,请提出问题,最好是带有演示问题的测试代码。Rust playground 中的 MIRI 可以报告一些不安全的情况。

cell 类型比较

RefCell 优点和缺点

  • 优点:简单
  • 优点:允许非常灵活的借用模式
  • 优点:支持 no_std
  • 缺点:没有编译时借用检查
  • 缺点:可能因远程代码更改而引发恐慌
  • 缺点:运行时借用检查和某些 cell 空间开销

QCell 优点和缺点

  • 优点:简单
  • 优点:编译时借用检查
  • 优点:动态所有者创建,不受任何限制
  • 优点:不需要寿命注释或类型参数
  • 优点:支持 no_std
  • 缺点:一次只能借用最多 3 个对象
  • 缺点:运行时所有者检查和某些 cell 空间开销

TCellTLCell 优点和缺点

  • 优点:编译时借用检查
  • 优点:借用或所有权检查的运行时开销为零
  • 优点:没有 cell 空间开销
  • 优点:支持 no_std(通过外部 crate)
  • 缺点:一次只能借用最多 3 个对象
  • 缺点:使用单例,要么是按进程(TCell)要么是按线程(TLCell),这意味着每个线程或进程的标记类型只允许一个所有者。旨在在调用栈上嵌套的代码必须用外部标记类型参数化。

LCell 优点和缺点

  • 优点:编译时借用检查
  • 优点:借用或所有权检查的运行时开销为零
  • 优点:没有 cell 空间开销
  • 优点:不需要单例,这意味着一次使用不会限制其他嵌套使用
  • 优点:支持 no_std
  • 缺点:一次只能借用最多 3 个对象
  • 缺点:需要在调用和结构中添加寿命注释
Cell 所有者 ID Cell 开销 借阅检查 所有者检查 创建所有者检查
RefCell 不适用 usize 运行时 不适用 不适用
QCell 整数 usize 编译时 运行时 运行时
TCellTLCell 标记类型 编译时 编译时 运行时
LCell 生命周期 编译时 编译时 编译时

所有者易用性

Cell 所有者类型 创建所有者
RefCell 不适用 不适用
QCell QCellOwner QCellOwner::()
TCell
TLCell
ACellOwner
(或 BCellOwnerCCellOwner 等)
结构体 MarkerA;
类型 ACell<T> = TCell<MarkerA, T>;
类型 ACellOwner = TCellOwner<MarkerA>;
ACellOwner::()
LCell LCellOwner<'id> LCellOwner::scope(|owner| { ... })

单元易用性

Cell 单元类型 单元创建
RefCell RefCell<T> RefCell::(v)
QCell QCell<T> owner.cell(v)QCell::new(&owner, v)
TCellTLCell ACell<T> owner.cell(v)ACell::new(v)
LCell LCell<'id, T> owner.cell(v)LCell::new(v)

借用易用性

Cell 单元不可变借用 单元可变借用
RefCell 单元.借用() 单元.借用_mut()
QCell cell.ro(&owner)
所有者.ro(&单元)
cell.rw(&mut owner)
所有者.rw(&单元)
TCellTLCell cell.ro(&owner)
所有者.ro(&单元)
cell.rw(&mut owner)
所有者.rw(&单元)
LCell cell.ro(&owner)
所有者.ro(&单元)
cell.rw(&mut owner)
所有者.rw(&单元)

多线程使用:Send 和 Sync

通常,单元格所有者只由一个线程持有,所有对单元格的访问都在该线程中进行。然而,在某些情况下,如果包含的类型允许,仍然可以在线程之间传递或共享这些对象。

Cell 所有者类型 单元类型
RefCell 不适用 Send
QCell Send + Sync Send + Sync
TCell Send + Sync Send + Sync
TLCell Send
LCell Send + Sync Send + Sync

感谢 Github 用户 Migipythonesque 对启用 Send 和/或 Sync 的理由进行论证。(pythonesqueGhostCell 是一个基于生命周期的单元,它早于 LCell,但直到 2021 年才正式发表。该论文的作者证明了 GhostCell 的逻辑推理是正确的,这间接加强了类似单元类型(如本软件包中的类型)的理论论证。)

以下是推理概述

  • RefCell 不同,这些单元格类型可能是 Sync,因为可变访问受到单元格所有者的保护。只有当您对单元格所有者具有可变访问权限时,才能获取单元格内容的可变访问权限。(请注意,Sync 仅在包含类型是 Send + Sync 时才可用。)

  • 单元格所有者可能是 Sync,因为 Sync 只允许线程间对单元格所有者的共享不可变访问。因此,在两个线程中可能存在 &QCell&QCellOwner 引用,但只能像那样进行单元格内容的不可变访问,因此不存在安全性问题。

  • 一般来说,Send 是安全的,因为这表示从一个线程到另一个线程的某些权利的完全转移(假设包含的类型也是 Send)。

  • TLCell 是一个例外,因为在每个线程中可以有不同的所有者,其标记类型相同,因此所有者不能被发送或共享。此外,如果两个线程对同一单元格有 &TLCell 引用,则可以在两个线程中创建包含数据的可变引用,这会破坏 Rust 的保证。因此,TLCell 不能是 Sync。然而,它可以是 Send,因为在那种情况下,访问数据的权利被完全从一个线程转移到另一个线程。

多线程使用:RwLock

QCell 和类似类型也可以用作 RwLock 的替代品。例如,如果您有一个 Arc<RwLock<T>> 的集合,您可以将它们替换为 Arc<QCell<T>>。本质上,您是将针对每个单个 T 的细粒度锁定(一个用于每个单个 T)交换为围绕 QCellOwner 的粗粒度锁定。根据访问模式,这可能会好或更差。例如,如果您经常需要在一次逻辑操作中访问多个 T 实例,并且大锁的竞争很小,那么这将更好。或者,如果您已经对包含 QCellOwnerstruct&mut,那么您几乎可以免费获得对 T 实例的访问。

no_std 支持

qcell crate 可以在四个级别上进行构建

  • 完整的 std 支持,这是默认值

  • no_stdexclusion-set 一起,当使用 --no-default-features--features exclusion-set 构建

  • no_stdalloc 一起,当使用 --no-default-features--features alloc 构建

  • no_stdalloc,在构建时使用 --no-default-features

两者 QCellLCell 都支持所有四个级别,而 TCell 也适用于前两个。

命名来源

"Q"最初指的是量子纠缠,这个想法是这是一种远程所有权的概念。"T"指的是它基于类型系统,"TL"是线程局部,"L"是基于生命周期。

阻止不安全代码模式

请参阅doctest_qcelldoctest_tcelldoctest_tlcelldoctest_lcelldoctest_qcell_noallocdoctest_lcell_generativity 模块,其文档和 doc-tests 展示了哪些不安全模式被阻止。

依赖关系

~0-26MB
~329K SLoC