>我在crossbeam
中遇到了内存回收问题。假设您正在实现一个简单的线程安全无锁容器,该容器包含单个值。任何线程都可以获取存储值的克隆,并且该值可以随时更新,之后读者开始观察新值的克隆。
尽管典型的用例是将类似Arc<X>
的东西指定为 T,但实现不能依赖于 T 是指针大小的 - 例如,X
可能是一个特征,导致胖指针Arc<X>
。但是对任意 T 的无锁访问似乎非常适合基于时代的无锁代码。根据这些示例,我想出了这个:
extern crate crossbeam;
use std::thread;
use std::sync::atomic::Ordering;
use crossbeam::epoch::{self, Atomic, Owned};
struct Container<T: Clone> {
current: Atomic<T>,
}
impl<T: Clone> Container<T> {
fn new(initial: T) -> Container<T> {
Container { current: Atomic::new(initial) }
}
fn set_current(&self, new: T) {
let guard = epoch::pin();
let prev = self.current.swap(Some(Owned::new(new)),
Ordering::AcqRel, &guard);
if let Some(prev) = prev {
unsafe {
// once swap has propagated, *PREV will no longer
// be observable
//drop(::std::ptr::read(*prev));
guard.unlinked(prev);
}
}
}
fn get_current(&self) -> T {
let guard = epoch::pin();
// clone the latest visible value
(*self.current.load(Ordering::Acquire, &guard).unwrap()).clone()
}
}
当与不分配的类型一起使用时,例如与T=u64
一起使用,它工作得很好 -set_current
和get_current
可以调用数百万次而不会泄漏。(进程监视器显示由于伪 gcepoch
引起的轻微内存振荡,正如预期的那样,但没有长期增长。但是,当 T 是分配的类型时,例如Box<u64>
,人们可以很容易地观察到泄漏。例如:
fn main() {
use std::sync::Arc;
let c = Arc::new(Container::new(Box::new(0)));
const ITERS: u64 = 100_000_000;
let producer = thread::spawn({
let c = Arc::clone(&c);
move || {
for i in 0..ITERS {
c.set_current(Box::new(i));
}
}
});
let consumers: Vec<_> = (0..16).map(|_| {
let c = Arc::clone(&c);
thread::spawn(move || {
let mut last = 0;
loop {
let current = c.get_current();
if *current == ITERS - 1 {
break;
}
assert!(*current >= last);
last = *current;
}
})}).collect();
producer.join().unwrap();
for x in consumers {
x.join().unwrap();
}
}
运行此程序显示内存使用量的稳定且显着增加,最终消耗的内存量与迭代次数成正比。
根据介绍它的博客文章,Crossbeam的纪元回收"不运行析构函数,而只是释放内存"。Treiber 堆栈示例中的try_pop
使用ptr::read(&(*head).data)
将head.data
中包含的值移出要解除分配的head
对象。数据对象的所有权被转移到调用方,调用方会将其移动到其他地方或在超出范围时解除分配。
这将如何转换为上面的代码?setter 是否是guard.unlinked
的合适位置,或者如何确保drop
在底层对象上运行?取消注释显式drop(ptr::read(*prev))
会导致检查单调性的断言失败,这可能表明过早释放。
问题的症结在于(正如你自己已经发现的那样)guard.unlinked(prev)
延迟执行以下代码段:
drop(Vec::from_raw_parts(prev.as_raw(), 0, 1));
但是您希望它推迟此操作:
drop(Vec::from_raw_parts(prev.as_raw(), 1, 1));
或者,等效地:
drop(Box::from_raw(prev.as_raw());
换句话说,unlinked
只是释放存储对象的内存,但不会删除对象本身。
这是目前Crossbeam中已知的痛点,但幸运的是它将很快得到解决。Crossbeam基于时代的垃圾收集器目前正在进行重新设计和重写,以便:
- 允许延迟丢弃和任意延迟函数
- 增量收集垃圾以最大程度地减少暂停
- 避免过度拥挤的螺纹本地垃圾袋
- 更急切地收集大块垃圾
- 修复了 API 中的健全性问题
如果您想了解有关新横梁设计的更多信息,请查看 RFC 存储库。我建议从新原子上的RFC和新GC上的RFC开始。
我创建了一个实验性板条箱Coco,它与Crossbeam的新设计有很多共同点。如果您现在需要解决方案,我建议您切换到它。但请记住,一旦我们发布新版本(可能是本月或下个月),Coco 将被弃用以支持 Crossbeam。
正如 Stjepan 详细回答的那样,当前 Crossbeam 的一个已知限制是,它只支持释放,而不是完全丢弃已变得无法访问的对象,但可能仍然对其他线程可见。这不会影响 Crossbeam 支持的无锁集合,它会自动删除集合用户"观察到"的项目 - 不允许偷看。这符合队列或堆栈的需求,但不适合例如无锁映射的需求。
这由coco crate解决,它定义了几个并发集合,并作为下一代Crossbeam设计的预览。它支持延迟丢弃值。以下是使用 coco 的Container
演绎:
use std::thread;
use std::sync::atomic::Ordering;
use coco::epoch::{self, Atomic, Owned};
struct Container<T: Clone> {
current: Atomic<T>,
}
impl<T: Clone> Container<T> {
fn new(initial: T) -> Container<T> {
Container { current: Atomic::new(initial) }
}
fn set_current(&self, new: T) {
epoch::pin(|scope| {
let prev = self.current.swap(Owned::new(new).into_ptr(&scope),
Ordering::AcqRel, &scope);
unsafe {
scope.defer_drop(prev);
}
})
}
fn get_current(&self) -> T {
epoch::pin(|scope| {
let obj_ref = unsafe {
self.current.load(Ordering::Acquire, &scope).as_ref().unwrap()
};
obj_ref.clone()
})
}
}
当使用与问题中相同的main()
运行时,它不会泄漏内存。
要考虑的一件事是,根据文档,epoch::pin()
附带一个SeqCst
围栏和一些原子操作的成本。(请注意,epoch::pin()
在Crossbeam下也不是免费的,实际上要贵得多。现代硬件上 10-15 ns 的延迟可能与大多数用途无关,但用户在编写试图从其无锁操作中挤出每一纳秒的代码时应该意识到这一点。