【问题标题】:Can rust guarantee I free an object with the right object pool?rust 可以保证我使用正确的对象池释放对象吗?
【发布时间】:2020-08-12 04:34:49
【问题描述】:

假设我已经定义了自己的对象池结构。在内部,它保留了所有对象的Vec 和一些数据结构,让它知道向量中的哪些项目当前已分发,哪些是免费的。它有一个 allocate 方法可以返回向量中未使用项的索引,还有一个 free 方法可以告诉池在向量中的索引处可以再次使用。

我是否可以定义对象池的 API,以使类型系统和借用检查器保证我将对象释放回正确的池中?这是假设我可能有多个相同类型的池实例的情况。在我看来,对于常规的全局分配器,rust 不必担心这个问题,因为只有一个全局分配器。

用法示例:

fn foo() {
    let new_obj1 = global_pool1.allocate();
    let new_obj2 = global_pool2.allocate();

    // do stuff with objects

    global_pool1.free(new_obj2); // oops!, giving back to the wrong pool
    global_pool2.free(new_obj1); // oops!, giving back to the wrong pool
}

【问题讨论】:

  • 您能否举例说明如何将 API 用于对象池,并从中分配/释放对象?
  • 只要你没有明确地使用unsafe 代码,编译器将保证所有的东西在其各自的生命周期结束时都会被删除一次,没有任何异常。我想其他任何事情都可能被视为未定义的行为。如果它不能保证,那么它将拒绝编译你的代码。这是 rust 的一大卖点,并且对于 rust 语言非常重要,以至于您可以始终假设它在未来的更新中是正确的。
  • @Coder-256 添加
  • @Locke 在这种情况下,一切都被丢弃了,问题是每个池中的不变量会被搞砸,他们会将向量索引标记为免费的,而不是
  • @JosephGarvin 在这种情况下,您可能会发现查看crates.io/crates/generational-arena 会很有趣。它不是您所要求的完美匹配,但它处理类似的问题。

标签: memory rust borrow-checker double-free


【解决方案1】:

您可以使用零大小类型(简称 ZST)来获取您想要的 API,而无需其他指针的开销。

这是 2 个池的实现,可以扩展以支持任意数量的池,使用宏生成“标记”结构(P1P2 等)。 一个主要的缺点是忘记free 使用池会“泄漏”内存。

这个Ferrous Systems blog post 有许多您可能会感兴趣的改进,尤其是在您静态分配池时,并且它们还有许多技巧可以利用P1 的可见性,这样用户就无法滥用 API。


use std::marker::PhantomData;
use std::{cell::RefCell, mem::size_of};

struct Index<D>(usize, PhantomData<D>);
struct Pool<D> {
    data: Vec<[u8; 4]>,
    free_list: RefCell<Vec<bool>>,
    marker: PhantomData<D>,
}

impl<D> Pool<D> {
    fn new() -> Pool<D> {
        Pool {
            data: vec![[0,0,0,0]],
            free_list: vec![true].into(),
            marker: PhantomData::default(),
        }
    }
    
    fn allocate(&self) -> Index<D> {
        self.free_list.borrow_mut()[0] = false;
        
        Index(0, self.marker)
    }
    
    fn free<'a>(&self, item: Index<D>) {
        self.free_list.borrow_mut()[item.0] = true;
    }
}

struct P1;
fn create_pool1() -> Pool<P1> {
    assert_eq!(size_of::<Index<P1>>(), size_of::<usize>());
    Pool::new()
}

struct P2;
fn create_pool2() -> Pool<P2> {
    Pool::new()
}


fn main() {
    
    let global_pool1 = create_pool1();
    let global_pool2 = create_pool2();
    
    let new_obj1 = global_pool1.allocate();
    let new_obj2 = global_pool2.allocate();

    // do stuff with objects

    global_pool1.free(new_obj1);
    global_pool2.free(new_obj2);

    global_pool1.free(new_obj2); // oops!, giving back to the wrong pool
    global_pool2.free(new_obj1); // oops!, giving back to the wrong pool
}

尝试使用错误的池释放会导致:

error[E0308]: mismatched types
  --> zpool\src\main.rs:57:23
   |
57 |     global_pool1.free(new_obj2); // oops!, giving back to the wrong pool
   |                       ^^^^^^^^ expected struct `P1`, found struct `P2`
   |
   = note: expected struct `Index<P1>`
              found struct `Index<P2>`

Link to playground

这可以稍微改进一下,以便借用检查器将强制Index 不会超过Pool,使用:

fn allocate(&self) -> Index<&'_ D> {
    self.free_list.borrow_mut()[0] = false;
        
    Index(0, Default::default())
}

因此,如果在 Index 处于活动状态时删除池,则会出现此错误:

error[E0505]: cannot move out of `global_pool1` because it is borrowed
  --> zpool\src\main.rs:54:10
   |
49 |     let new_obj1 = global_pool1.allocate();
   |                    ------------ borrow of `global_pool1` occurs here
...
54 |     drop(global_pool1);
   |          ^^^^^^^^^^^^ move out of `global_pool1` occurs here
...
58 |     println!("{}", new_obj1.0);
   |                    ---------- borrow later used here

Link to playground

另外,a link to playground with Item API(返回 Item,与仅和 Index 相比)

【讨论】:

  • 值得注意的是,此 API 阻止用户调用 create_pool1 两次并意外地从错误的池中释放某些内容。 heapless 板条箱通过使用宏实现了这种安全性,正如您链接的博客文章也提到的那样。
【解决方案2】:

首先,需要考虑的是,将项目插入Vec 有时会导致它重新分配和更改地址,这意味着对Vec 中项目的所有现有引用都将变为无效。我想您原本打算让用户可以保留对 Vec 中项目的引用并同时插入新项目,但遗憾的是这是不可能的。

解决此问题的一种方法是generational_arena 使用的方法。插入一个对象会返回一个索引。你可以调用arena.remove(index)释放对象,调用arena.get[_mut](index)获取对象的引用,借用整个arena。

但是,为了争论,我们假设您有办法在插入新项目并执行您可能需要的任何其他操作时保持对竞技场的引用。考虑到引用本质上是一个指针,答案是否定的,没有办法自动记住它来自哪里。但是,您可以创建一个类似于BoxRc 等的“智能指针”,保留对竞技场的引用,以便在对象被丢弃时释放它。

例如(非常粗略的伪代码):

struct Arena<T>(Vec<UnsafeCell<T>>);

struct ArenaMutPointer<'a, T> {
    arena: &'a Arena,
    index: usize,
}

impl<T> DerefMut for ArenaPointer<'_, T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        unsafe { self.arena[self.index].get() as &mut T }
    }
}

impl<T> Drop for ArenaPointer<'_, T> {
    fn drop(&mut self) {
        self.arena.free(self.index);
    }
}

【讨论】:

  • 这可以工作,但具有增加指针类型大小的显着缺点。有时使用较小宽度的整数而不是全宽指针是使用池的全部目的。关于失效,如果池是全局的,则创建“指针”,您只返回一个索引,并让 Deref 使用全局。
  • @JosephGarvin 在这种情况下,指向竞技场的指针应该存储在哪里?换句话说,如果不是通过在每个对象旁边存储指向 arena 的指针,您能否解释一下您希望编译器如何跟踪 arena?
  • 它是全局的,就像主系统分配器一样,这就是我在示例中使用名称 global_pool 的原因。
【解决方案3】:

品牌推广

将生命周期用作品牌的想法已经进行了多次尝试,以将特定变量与一个其他变量联系起来,而不是其他变量。

为了获得保证在范围内的索引,已经进行了特别探索:在创建时检查一次,之后始终可用。

不幸的是,这需要创建不变的生命周期以防止编译器将多个生命周期“合并”在一起,尽管可能我还没有看到任何引人注目的 API。

仿射,而非线性。

还需要注意的是,Rust 没有线性类型系统,而是仿射类型系统。

线性类型系统是每个值只使用一次一次的系统,而仿射类型系统是每个值最多使用一次的系统。

这里的结果是很容易意外忘记将对象返回到池中。虽然在 Rust 中泄漏对象总是安全的——mem::forget 是一种简单的方法——但这些情况通常很突出,因此相对容易审核。另一方面,只是忘记将值返回到池中会导致意外泄漏,这可能会花费相当长的时间。

放下它!

因此,解决方案是让值本身返回到它在其Drop 实现中来自的池:

  • 让不小心忘记回来就不可能了。
  • 它要求对象持有对它来自的池的引用,因此不可能意外地将其返回到错误的池中。

当然,这是有代价的,即与对象一起存储额外的 8 个字节。

这里有两种可能的解决方案:

  • 细指针:struct Thin&lt;'a, T&gt;(&amp;'a Pooled&lt;'a, T&gt;); 其中struct Pooled&lt;'a, T&gt;(&amp;'a Pool&lt;T&gt;, T);
  • 胖指针:struct Fat&lt;'a, T&gt;(&amp;'a Pool&lt;T&gt;, &amp;'a T);.

为简单起见,我建议从 Fat 替代方案开始:它更简单。

然后Drop 实现ThinFat 只需返回指向池的指针。

【讨论】:

  • 感谢您关注品牌一词。不幸的是,这些额外的 8 个字节在许多情况下都无法做到这一点——使用 32 位指针而不是 64 位指针来减少 CPU 缓存的使用。
  • 你知道我在哪里可以读到关于使用品牌的尝试吗?我在 Google 时没有找到任何东西。
  • @JosephGarvin:不幸的是,搜索非常令人沮丧,而且我已经看到它曾经意味着不同的东西。例如this paper 使用品牌来模拟名义类型,这似乎略有不同。我认为最近的一篇博文是Abusing Rust Type System for Sound Bounds Check Elision,它使用闭包而不是生命周期来实现效果。
  • 否则,你可以看到这个 reddit 帖子试图使用 lifetimes 来做同样的事情。不幸的是,我不确定两者都行得通。
猜你喜欢
  • 1970-01-01
  • 2011-02-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多