#arc #atomic #value #thread #instance #smart-pointers #access

arcshift

std::sync::Arc 的替代品,支持更新值,但有一些限制

6 个版本

新版本 0.1.9 2024 年 8 月 19 日
0.1.8 2024 年 8 月 16 日

内存管理 中排名第 36

Download history 46/week @ 2024-07-26 516/week @ 2024-08-02 86/week @ 2024-08-09 291/week @ 2024-08-16

每月下载量 939

MIT/Apache 许可证

215KB
4K SLoC

build build build build build build

Arcshift

Arcshift 是类似于 Arc 的智能指针类型,区别在于它允许更新所指向的值,但有一些限制。基本上,ArcShift 只有在更新非常频繁或可以定期获得所有 ArcShift 实例的可变访问来重新加载它们(释放内存)时才适用。

您可以将 ArcShift 视为在版本链上的 Arc<>,具有添加新版本和在读取时自动重新加载的能力(请参阅 ArcShift::get)。

use std::thread;
use arcshift::ArcShift;

fn main () {
   let mut arc = ArcShift::new("Hello".to_string());
   let mut arc2 = arc.clone();


   let j1 = thread::spawn(move || {
      println!("Value in thread 1: '{}'", *arc); //Prints 'Hello'
      arc.update("New value".to_string());
      println!("Updated value in thread 1: '{}'", *arc); //Prints 'New value'
   });

   let j2 = thread::spawn(move || {
      // Prints either 'Hello' or 'New value', depending on scheduling:
      println!("Value in thread 2: '{}'", *arc2);
   });

   j1.join().unwrap();
   j2.join().unwrap();
}

文档

有关文档,请参阅 https://docs.rs/arcshift/ .

本文件的其余部分是对 Arcshift 的一些描述。有关更多信息,请参阅上面的文档!

背景

我创建了 ArcShift,因为我想在计算机游戏项目中以低开销的方式存储资源。想法是将如 3d 模型、纹理等资源放在 std::sync::Arc 中似乎很合理。然而,一旦某物被放入 Arc 中,并且该 Arc 在系统中传播,就无法修改该值。一种解决方案是使用 Arc<Mutex<T>>。但是,Mutex 访问的开销很大,即使互斥锁没有竞争也是如此。如果值仅偶尔更新,则在每个访问上支付互斥锁开销是不希望的。

ArcSwap

经过一番搜索,我发现了一个 crate https://docs.rs/arc-swap/。然而,它并不是我想要的。ArcSwap 是一个用于 Arc<T> 对象的容器,它允许在不要求 &mut-reference 的情况下替换包含的 Arc 对象。

我想要的只是一个 Arc<T>,其中值 T 可以更新。

我知道可以使用ArcSwap来实现类似的功能,通过构造一个Arc<ArcSwap<Arc<T>>>,但这并不像我希望的那样方便。

ArcSwap看起来优化得很好,比ArcShift成熟得多。

然而,例如如果满足以下条件,ArcShift可能会提供类似或甚至更好的性能,同时使用起来稍微简单一些

  1. 更新很少
  2. 每个线程都可以有自己的(可变的)ArcShift实例副本

对于熟悉arc-swap的人来说,ArcShift实例的行为类似于arc_swap::cache::Cache,而ArcShift相对于ArcSwap的对应物是ArcShiftLight。

我确实找到了一些具有类似功能的crate,但没有找到任何让人信服的。我相信可能存在这样的crate,可能只是我错过了!

使命宣言

ArcShift的要求是

  • 只要没有写入操作,常规的共享读取访问应该与Arc<T>一样快。
  • 写入可能很昂贵(但当然不能比必要的慢)
  • 写入发生后,读取可能会稍微变慢是可以接受的。
  • 实现应该是无锁的(因此我们永远不需要挂起线程)
  • API必须是100%安全和可靠的。
  • 在值更新时,应尽快删除旧值。
  • 不应该使用线程局部变量。
  • 应该能够拥有对数据的“轻量级”句柄,这些句柄不提供快速访问,但另一方面也不保留旧值。
  • 与Arc相比,不应该有任何内存开销。

关于最后两点,任何提供无开销访问T的类型都必须始终保留T的存活状态,否则在授予权限之前需要某种同步(这是昂贵的)。

解决方案想法

我很快确定了一个解决方案,其中ArcShift是Arc的直接替代品,具有相同的通用内存布局。也就是说,ArcShift的每个实例都是一个指向包含实际值的堆块的指针。与Arc一样,ArcShift在堆块中有一个引用计数。与Arc不同,ArcShift只有一个引用计数,没有“弱引用”的概念。ArcShift还有一个“下一个”指针。这个“下一个”指针指向任何更新的值,在没有更新发生之前是null。

一般想法的示意图

注意,下面的图是简化的,请参见下面的文本。


 Heap block 1                                  Heap block 2
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓          ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃  refcount: 1                 ┃    ┌─── ➤┃  refcount: 1                ┃
┃  payload: "First version"    ┃    │     ┃  payload: "Second version"  ┃
┃  next_and_state: ptr ────────┃────┘     ┃  next_and_state: nullptr    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛          ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
                           ⮝
  ArcShift-instance        │
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃  item: ptr ──────────────┘   ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

由于每个ArcShift实例都包含一个指向包含payload类型T的实例的堆块的指针,因此访问底层值没有开销,这是项目设计中的一个设计约束。每当值更新时,就会在堆块链表中添加另一个节点。

复杂性

我们还希望支持ArcShiftLight实例。这些实例不保证立即指向的堆块包含一个非删除项T。为了实际访问payload T,ArcShiftLight实例需要升级为ArcShift实例。

因此,我们区分了“强链接”和“弱链接”。强链接保证了指向的块以及所有后续块都包含有效的'T'值。弱链接只保证节点链中至少包含一个有效的T对象。强链接将引用计数增加2^19。这意味着最多可以有2^19个弱链接(这足够用于许多目的)。使用64位引用计数,强链接的最大数量约为2^64/2^19,大约是35000亿。

使用的另一个技巧是,'next_and_state'指针的最低两位是一个2位整数,具有以下解释

枚举值 含义
0 无意义
暂定 最新更新的值尚未被访问,可以被丢弃
已被替代 有一个新项目可以使用,但此节点的有效负载不会被丢弃
丢弃 此节点的有效负载被丢弃

当没有强链接指向一个节点时,该节点的有效负载可以被丢弃。

正确性

上面的描述可能看起来很简单。然而,这个项目教会我的是,无锁算法绝非儿戏。各种操作之间可以有数千种不同的交错方式。在任何两行代码之间,可能还有数百行其他代码正在执行。

Arcshift使用了大量的不安全Rust代码和无锁算法。这些是众所周知非常难以正确实现的技巧。因此,Arcshift需要一个广泛的测试套件。

单元测试

Arcshift通过大量的单元测试进行验证。

有一些简单的测试路径测试(例如 simple_get,以及其他),以及一些不同的模糊测试案例。模糊测试案例运行不同操作在不同线程上的随机组合。

还有一个详尽的测试套件,它测试了三个并发线程上所有ArcShift操作的组合。

自定义验证功能

启用非默认功能'validate'会导致Arcshift在内存中插入金丝雀,以尽可能检测野指针或线程问题。功能'debug'使详细的调试输出发送到stdout。

经验教训

在开发Arcshift的过程中,我学到了一些教训。

无锁算法真的很困难

众所周知,无锁算法很难做对。然而,在亲身经历后,这个真相对我而言变得更加真实。

无锁错误示例#1

我遇到的一个错误,事后看来非常明显,是(损坏的)看起来有点像这样的代码

    
let count = get_refcount(item)
    .fetch_sub(STRONG_COUNT, Ordering::SeqCst);

if count == STRONG_COUNT {
    drop_item(item);
} else if count < 2*STRONG_COUNT { //WRONG!
    drop_payload(item);
}

想法是我们有一个指向节点'item'的强引用计数。我们减去这个引用计数。如果先前的引用计数值(由AtomicUsize::fetch_sub返回)是STRONG_COUNT,这意味着我们的引用是唯一的剩余引用,我们现在可以丢弃'item'。这有效。

下一个条件尝试检查先前的引用计数是否小于2 * STRONG_COUNT。如果是这样,这意味着没有其他强计数指向该节点,因此我们可以丢弃'item'的有效负载(但不是整个节点)。想法是非当前节点永远不会获得引用计数,因此项目不能回到强链接状态。

然而,这根本不起作用!

fetch_sub(STRONG_COUNT, Ordering::SeqCst);这一行之后,关于'item'的任何信息都不再存在!其他线程可以很容易地减少计数并在我们执行drop_payload(item)之前丢弃'item'。

这实际上是一个值得记忆的规律。在减少引用计数指针的引用计数后,指针完全无效,绝不能再接触它!

无锁错误示例 #2

如文档中详细说明,Arcshift使用一个单一的64位原子计数器来包含19位弱引用计数(用于ArcShiftLight实例)和45位强引用计数(用于ArcShift实例)。

这在我们增加计数时提出一个有趣的挑战。比如说,我们像这样增加弱计数

let count = item.fetch_add(1, Ordering::SeqCst);
if count == MAX_WEAK_COUNT { //WRONG!
    item.fetch_sub(1, Ordering::SeqCst);
    panic!("Max weak count exceeded");
}

上述代码不起作用,因为两个线程可能会同时执行'fetch_add',然后第二个线程可能会看到等于MAX_WEAK_COUNT + 1的计数。

解决此问题的一种方法是通过Rust标准库为Arc引用计数所做的方法:预留一定范围的计数作为某种“溢出区域”

let count = item.fetch_add(1, Ordering::SeqCst);
if count >= MAX_WEAK_COUNT/2 { // Only use half the 'available' count range
    item.fetch_sub(1, Ordering::SeqCst);
    panic!("Max weak count exceeded");
}

只要最多MAX_WEAK_COUNT/2个线程同时执行代码,上述方法就能正常工作。对于std库的'Arc',在64位机器上触发错误的线程数量是2^63,这在实际中显然永远不会发生。

对于ArcShiftLight,MAX_WEAK_COUNT只有2^19,其一半(2^18 = 262144)是一个庞大但实际可能存在的线程数量。我写这段代码的64位Linux机器有

> cat /proc/sys/kernel/threads-max
506892  #<- maximum 506892 threads 

因此,ArcShiftLight不使用原子'add'来增加弱计数,而是使用AtomicUsize::compare_exchange

对于强计数,对于ArcShift,使用的是“溢出区域”方法。溢出区域大约有180亿个强计数。

不安全的Rust很难

在Arcshift中持有有效载荷值的节点看起来像这样

struct ItemHolder<T: 'static> {
   next_and_state: AtomicPtr<ItemHolder<T>>,
   refcount: AtomicUsize,
   payload: T,
}

当某个项目不再有强链接时,'payload'字段会被丢弃。

我们小心不要在这一点之后对'payload'进行任何访问。然而,结果证明,这并不足够——至少根据miri来看。

问题是像这样的代码

    let ptr: *const ItemHolder<T> = ...;
    let count = unsafe{&*ptr}.refcount.load(Ordering::SeqCst);

... 在'payload'被丢弃时是不合法的。这是因为当其字段之一正在写入时,不允许对*item进行解引用。

相反,可以这样做

    unsafe { &*addr_of!((*ptr).refcount) }.load(Ordering::SeqCst)

这是合法的,因为'addr_of'没有解引用ptr。Rust的批评者可能会指出,这种代码比等效的C++代码更复杂。然而,美丽之处在于这种复杂性完全包含在ArcShift中,一旦ArcShift被验证,就没人再需要考虑它了。

更新:有人指出,更好的解决方案是让'payload'为ManuallyDrop而不是T。

Miri是一个出色的工具

我过去广泛使用了miri,但并没有意识到它不仅是一个确保单线程不安全代码正确性的强大工具,而且对于在多线程不安全代码中查找竞态条件也非常有用。通过使用miri的--many-seeds选项,miri将多次运行相同的测试用例,并使用程序线程的不同调度。默认情况下(撰写本文时),--many-seeds默认为64种不同的调度。可以通过指定范围来控制种子集,例如--many-seeds=0..1000,以尝试更多变体。

我遇到的一个问题是调试内存泄漏。Miri会提供很多有用的信息,比如泄露内存的分配位置。然而,在arcshift测试平台上,这通常并不是一个很大的线索。测试平台有很多调试输出,给出每个分配节点的内存地址。然而,miri不会打印泄露内存的地址,这使得将检测到的泄漏与测试运行的调试跟踪相关联变得困难。

最后,我通过引用计数测试有效载荷解决了这个问题,因此大多数内存泄漏可以在不使用miri的情况下检测到。

Loom是一个出色的工具

loom是一个用于检测Rust代码中线程错误的工具。请参阅https://crates.io/crates/loom

默认情况下,loom是穷尽的。这意味着它将检查您不同线程之间所有可能的交织。这带来了极大的安心。然而,随着Arcshift功能的积累,它变得过于复杂,无法使用loom进行测试。涉及3个不同线程的测试用例是可行的,但涉及4个线程的测试用例运行时间过长。

loom具有一个方便的特性,可以重放失败的运行。loom的一个有趣之处在于,它似乎通过切换栈来“模拟”实际的并行多线程,而不是实际运行多个线程。

loom(与miri共享)的另一个非常强大的功能是它可以模拟非SeqCst排序。这一点曾经让我很困惑。loom发现了一些错误,线程读取的值显然之前被赋予过其他值。然而,这由Rust(=C++)内存模型允许,只要不使用SeqCst排序。在内部,loom似乎在每个点上维护一个可以从原子变量中读取的可能值的列表。

loom不支持在原子变量上执行SeqCst操作。然而,可以通过插入完整的内存栅栏(loom::sync::atomic::fence)来实现相同的结果。

Shuttle是一个非常棒的工具

Shuttle是一个用于检测Rust代码中线程错误的工具。请参阅https://crates.io/crates/shuttle

Shuttle有点像loom,但使用随机调度而不是穷尽尝试所有可能的交织。与loom不同,shuttle 支持SeqCst排序。

这意味着shuttle比loom功能较弱。然而,由于它更快且不那么雄心勃勃,它可以处理更大的模型。就像loom一样,它支持重放发现的失败执行跟踪。

Cargo mutants非常酷

Cargo mutants是一个确保测试平台覆盖率足够的工具。

它是通过修改待测试的代码,并确保任何这样的修改都会导致测试平台中的至少一个测试失败来实现的。

让代码通过'cargo mutants'需要大量工作。首先,测试覆盖率必须接近100%。但这还不够 - 测试还必须在cargo mutants所做的修改下失败。

Cargo mutants很难使用,但它确实带来了一些独特的东西。对于像arcshift这样具有非常高正确性理想的较小代码库,它提供了很多价值。

Cargo mutants,挑战 #1

我在cargo mutants上遇到的一个挑战是以下代码

match get_next_and_state(candidate).compare_exchange(...)
{
   Ok(_) => {/* handle success */ } 
   Err(other) => {
       if !is_superseded_by_tentative(get_state(other)) {
           // Race condition, but still can advance
           candidate = other;
           continue;
       } else {
           // Race condition, need to retry
           continue;
       }
   }
}

在Err分支中,我们需要运行算法的另一个迭代。但由于事物的逻辑,我们可以做出更多或更少的进展。执行if语句的第一部分会给我们带来略微更好的性能。也就是说,代码是一种优化。然而,'cargo mutants'注意到,即使将if条件改为'false',测试平台仍然通过。

解决这个问题的一个方案可能是引入一个性能测试用例,使测试用例在移除这里的优化时失败。然而,测量性能将是非常困难的。

有人可能会说,如果没有测量的性能收益,应该移除优化。然而,这个论点并不完全正确。最后,我将此行的异常添加到了cargo mutants-config中。

Cargo mutants,挑战 #2

另一个挑战是处理ArcShift实例的溢出。问题是cargo mutants识别出移除处理ArcShift实例计数溢出的代码不会导致测试套件失败。然而,触发这种溢出需要创建2*45个ArcShift实例。不幸的是,这需要至少280 TB的RAM。

最后,我只为这个逻辑添加了一个异常。对于那些可能无法触发的正常检查,通常可以将它们添加到cargo mutants忽略列表中。

过度关注失败测试用例的思考

拥有一个非常详尽的测试平台非常有价值。然而,我在工作于Arcshift的过程中至少遇到了两次问题,那就是我失去了对整体情况的把握,陷入了以下循环:

  1. 运行测试
  2. 测试失败
  3. 通过添加更多代码来修复特定测试用例的失败
  4. 从#1重新开始

经过几次上述循环的迭代后,代码变得非常复杂,我也不再确定应该保持哪些不变性。

如果你的测试套件再全面,如果不能让它通过,那就没有意义了。

我本应该将以下设计部分确定下来而不是追逐破坏性测试用例,那就是弱/强引用计数的语义。

在arcshift中,规则如下:

  1. 每个ArcShiftLight实例对其主节点持有弱(值1)引用计数。
  2. 每个ArcShift实例对其主节点持有强(值为2^19)引用计数。
  3. 每个拥有活动(非丢弃)有效负载的节点对其下一个节点(如果有的话)持有强引用计数。
  4. 每个拥有丢弃有效负载的节点必须有一个下一个节点。
  5. 每个拥有丢弃有效负载的节点对其下一个节点持有弱引用计数。
  6. 在获取节点的强引用时,必须在增加引用计数后在增加引用计数的'next'指针上进行检查。如果'next'有值,则必须使用该值(适当地减少和增加引用计数)。
  7. 在决定丢弃有效负载时,节点必须被标记为已丢弃(影响下一个指针),然后必须检查引用计数。如果有强计数,则丢弃操作必须取消。
  8. 由于具有非丢弃有效负载的节点总是与下一个节点具有强链接,因此下一个节点不能被丢弃。通过归纳,所有位于非丢弃节点“右侧”的节点也都是非丢弃的。

在确定这些规则之前,我很难修复ArcShift和ArcShiftLight实例同时被重新加载和丢弃的bug。

依赖项

~0–25MB
~342K SLoC