引用计数的发布-消费排序

Release-Consume ordering for reference counting

考虑以下简单的引用计数函数(与 boost::intrusive_ptr 一起使用):

class Foo {
    // ...

    std::atomic<std::size_t> refCount_{0};

    friend void intrusive_ptr_add_ref(Foo* ptr)
    {
        ++ptr->refCount_;  // ❶
    }

    friend void intrusive_ptr_release(Foo* ptr)
    {
        if (--ptr->refCount_ == 0) {  // ❷
            delete ptr;
        }
    }
};

我仍在学习内存排序,我想知道在这种情况下 fetch_add/sub (memory_order_seq_cst) 的默认内存排序是否过于严格。由于我要确保的唯一顺序是在❶和❷之间,我认为我们可以将❶替换为

ptr->refCount_.fetch_add(1, std::memory_order_release);

和❷与

if (ptr->refCount_.fetch_sub(1, std::memory_order_consume) == 1) {

但是内存排序对我来说仍然是新的和微妙的,所以我不确定这是否能正常工作。我错过了什么吗?

增加引用计数在正确的程序中不需要任何同步,只需要原子性。

我们假装引用由线程拥有。如果引用计数器至少为 1,则线程只能使用引用的对象,并且保证在使用对象时不会降为零,这要么意味着线程在使用对象期间增加了引用计数器,或者有另一种机制可以确保满足此条件。

因此,我们假设递增引用计数的线程拥有确保它可以访问对象的引用计数器的引用,因此没有其他线程在尝试递增计数器时将引用计数器递减为零。唯一允许删除初始引用的线程是当前线程(在递增引用计数之后),或者是当前线程已发出其共享对象(即原始引用的 "ownership" )的信号后的另一个线程) 已经停止——这两个都是可见的效果。

另一方面,减少引用计数器需要获取和释放语义,因为对象之后可能会被销毁。

std::memory_order 上的 CPP 参考页面说

Typical use for relaxed memory ordering is incrementing counters, such as the reference counters of std::shared_ptr, since this only requires atomicity, but not ordering or synchronization (note that decrementing the shared_ptr counters requires acquire-release synchronization with the destructor).

咨询 std::shared_ptr 的 libc++ 实现,您可能希望 memory_order_relaxed 用于递增,memory_order_acq_rel 用于递减。使这种用法合理化:

如果数量增加,那么重要的是它的一致性。当前线程已经确定它大于零。其他线程是不同步的,因此它们将在下一次原子修改之前的不确定时间看到更新,并且可能与其他变量的更新不一致。

如果数量减少,那么你需要确定当前线程已经修改完了。来自其他线程的所有更新必须是可见的。当前的减量必须对下一个可见。否则,如果计数器跑在它所保护的对象之前,该对象可能会过早销毁。

Cppreference 有一个关于内存排序的好页面。它包括这条注释:

The specification of release-consume ordering is being revised, and the use of memory_order_consume is temporarily discouraged.

这也暗示当前没有编译器或 CPU 实现 consume;它实际上与 acquire.

相同
if (ptr->refCount_.fetch_sub(1, std::memory_order_consume) == 1) {

那一个显然是错误的:std::memory_order_consume 的使用才有意义,但后来完成了一个可以在副作用中消耗的值,就像那样:

r = a.fetch_sub(1, std::memory_order_consume);
v[r] = 1; // depends on r
x = r+2; // depends on r
z = r-r; // this is a legal dependency but many compilers mis-compile it
y = r/r; // still a legal dependency
f (r); // where f is declared with carry dependency 
r2 = r; // transfers the dependency
v[r2] = 2; // dependency ordered
r2 = r ? 1 : 0; // breaks the dependency
v[r2] = 2; // not dependency ordered

Consume 在大多数 CPU 上是零成本排序,因为 CPU 仅根据值的结果来保证正确的排序。它假定汇编代码完全 反映了 C++ 代码,编译器通常无法保证;考虑这些:

int a1[1];
a[r] = 2; // r is 0 or out of bound

此处编译器可以假定 r 为 0:只有一个元素,因此代码的作用与以下相同:

a[0] = 2;

但是那个不依赖于 r,因此在消费排序的上下文中,汇编代码必须使用 r 进行寻址。

这意味着编译器必须能够有选择地禁用许多常见的、简单的优化,这些优化在编译器的另一部分完成,该部分与语言无关。

这些是很多明显的情况,不能优化,必须有一些复杂的情况,比如指向单例的指针。