共享指针析构函数中的内存顺序

11

我正在尝试找出与共享指针析构有关的最放松(且正确)的内存顺序。我目前考虑的是如下所示:

~shared_ptr() {
   if (p) {
     if (p->cnt.fetch_sub(1, std::memory_order_release) == 1) {
       p->cnt.load(std::memory_order_acquire);
       delete p;
     }
   }
 }

基本上,我认为所有以前的fetch_sub()都应该在delete p;之前发生,并通过p->cnt.load(std::memory_order_acquire);构建一个释放序列来确保这一点。

我对C++内存模型还不是很熟悉,也不太有信心。我的上述推理是否正确,并且我指定的内存顺序是否正确和最轻松的?


1
你应该使用 atomic_thread_fence 而不是调用 p->cnt.load(std::memory_order_acquire); 来强制排序。 - keith
这是一个完整的 std::shared_ptr 吗?你销毁了控制块吗? - curiousguy
@curiousguy delete p会销毁控制块。完整代码在这里 - Lingxi
好的,但我在这里看不到弱指针的空间(一个对控制块的强引用,而不是对数据的强引用),因此不符合标准。 - curiousguy
1
这个名称有误导性。代码更像是一个侵入式的引用计数指针,比如来自Boost的指针。它们不支持弱指针。 - Arne Vogel
显示剩余2条评论
1个回答

11
在理论上,你可以拥有最有效率的代码,因为没有比必要更多的同步。
但是,在实践中,几乎没有CPU提供完全对应获取/释放内存顺序的指令(也许未来ARMv8.3-A会提供)。所以你必须检查每个目标生成的代码。
例如,在x86_64上,fetch_sub(std::memory_order_acq_rel)fetch_sub(std::memory_order_release)将会产生完全相同的指令。
因此,虽然在理论上你的代码看起来最优,但在实践中,你得到的代码不如选择更简单的方法优化。
std::atomic<int> cnt;
int* p;
void optimal_in_therory() {
     if (cnt.fetch_sub(1, std::memory_order_release) == 1) {
       cnt.load(std::memory_order_acquire);
       delete p;
   }
}
void optimal_in_practice_on_x86_64() {
     if (cnt.fetch_sub(1, std::memory_order_acq_rel) == 1) {
       delete p;
   }
}

汇编语言:

optimal_in_therory():
  lock sub DWORD PTR cnt[rip], 1
  je .L4
  rep ret
.L4:
  mov eax, DWORD PTR cnt[rip]  ;Unnecessary extra load
  mov rdi, QWORD PTR p[rip]
  mov esi, 4
  jmp operator delete(void*, unsigned long)
optimal_in_practice_on_x86_64():
  lock sub DWORD PTR cnt[rip], 1
  je .L7
  rep ret
.L7:
  mov rdi, QWORD PTR p[rip]
  mov esi, 4
  jmp operator delete(void*, unsigned long)

有一天我会住在理论里,因为在理论中一切都很顺利 -皮埃尔·德斯普罗热


为什么编译器要保留这个额外的负载?

根据标准,优化器可以省略在非易失性原子上执行的冗余加载。例如,如果您的代码中添加了三个额外的负载:

cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);

使用GCC或Clang,这三个加载项将出现在汇编中:

mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]

这是一个非常糟糕的性能优化。我认为它保存原样是因为"波动性"和"原子性"之间的历史混淆。虽然几乎所有程序员都知道volatile没有原子变量的属性,但仍有许多代码是按照原子具有volatile属性的想法编写的:"原子访问是可观察行为"。 根据标准,它不是(标准中有关此事实的显式示例注释)。这是SO上经常出现的问题。
因此,从理论上讲,您的代码是最优代码,但编译器会将代码优化为如果原子也是volatile一样。解决方法是用Kieth在其评论中提出的atomic_thread_fence替换load。我不是硬件专家,但我想象这样的障碍可能会引起比必要更多的内存"synchronization"(或者至少在理论上是这样的;))。
为什么我认为您的代码在理论上是最优的?
一个单一对象的最后一个shared_ptr必须在不引起数据竞争的情况下调用该对象的析构函数。析构函数可能访问对象的值,因此析构函数调用必须发生在指向对象的指针“失效”之后。
因此,delete p; 必须在共享同一指向对象的所有其他 shared_ptr 的析构函数调用之后“发生”。
在标准中,“happens before”由以下段落定义:

[intro.races]/9:

如果一个评估A与另一个评估B存在跨线程发生关系,则A在B之前发生,如果:

  • A与B同步,或者[...]
  • 任何具有“序列化之前”的组合都是可传递的规则。

[intro.races]/10:

如果一个评估A在评估B之前发生(或者等价地,B在A之后发生),则需要以下条件之一:
  • A在B之前排序;或者

  • A的跨线程发生在B之前。

因此,在排在delete p之前的fetch_sub和其他fetch_sub之间必须存在“同步与”关系。
根据[atomics.order]/2
如果原子操作A对原子对象M执行释放操作,则执行从A开始的释放序列中的任何副作用的原子操作B与执行从M执行获取操作并获取其值的原子操作B同步。
因此,delete p必须在执行一个获取操作之后排序,该操作加载一个在所有其他fetch_sub的释放序列中的值。
根据[expr.races]/5,在cnt的修改顺序中,最后一个fetch_sub将属于所有其他release fetch_sub的release sequence,因为fetch_sub是一个read-modify-write操作,就像fetch_add一样(假设没有其他操作发生在cnt上)。
所以delete p将发生在所有其他fetch_sub之后,只有在调用delete p之前才会产生“同步”。恰好不超过必要的范围。

好的,我尝试编写可移植的代码,所以我会站在理论的一边 ;) - Lingxi
(void)p->cnt.load(std::memory_order_acquire); 这样做会更好吗?可能会去除不必要的加载吗? - Lingxi
@Lingxi (void)x;x; 都是被丢弃的值表达式,这不应该改变代码。 - Oliv
@xskxzr 我做到了。 - Oliv
@Lingxi,我做了一些补充。如果你现在觉得自己是“新手”,那么想象一下你不再认为自己是“新手”的时候会变成什么样子! - Oliv
1
关于屏障,使用std::atomic_thread_fence(std::memory_order_acquire);代替load似乎是完美的选择。在x86上它是一个no-op,在arm64上它是dmb ishld,听起来已经足够了(fetch_sub也更好地使用非获取内存顺序:使用ldxr而不是ldaxr)。https://godbolt.org/z/EnWM8a - user5667904

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接