首页 > 解决方案 > 在原子多线程代码中删除容器

问题描述

考虑以下代码:

struct T { std::atomic<int> a = 2; };
T* t = new T();
// Thread 1
if(t->a.fetch_sub(1,std::memory_order_relaxed) == 1)
  delete t;
// Thread 2
if(t->a.fetch_sub(1,std::memory_order_relaxed) == 1)
  delete t;

我们确切地知道线程 1 和线程 2 之一将执行delete. 但是我们安全吗?我的意思是假设线程 1 将执行delete. 是否保证当线程 1启动delete,线程 2 甚至不会读取t

标签: c++multithreadingatomicstdatomic

解决方案


请注意,调用delete发生在Releasein之后Thread 2ReleaseinThread 2发生在Releasein之后Thread 1

所以调用delete发生在Thread 2之后 不再访问 t 之后ReleaseThread 1Release

但在现实生活中(不是在这个具体例子中)一般我们需要 usememory_order_acq_rel代替memory_order_relaxed

这是因为真实的对象通常有更多的数据字段,而不仅仅是原子引用计数。

并且线程可以写入/修改对象中的一些数据。从另一面——在析构函数内部,我们需要查看其他线程所做的所有修改。

因为这不是最后一个版本必须有memory_order_release语义。最后Release必须memory_order_acquire在这一切修改后查看。举个例子

#include <atomic>

struct T { 
  std::atomic<int> a; 
  char* p;

  void Release() {
    if(a.fetch_sub(1,std::memory_order_acq_rel) == 1) delete this;
  }

  T()
  {
    a = 2, p = nullptr;
  }

  ~T()
  {
      if (p) delete [] p;
  }
};

// thread 1 execute
void fn_1(T* t)
{
  t->p = new char[16];
  t->Release();
}

// thread 2 execute
void fn_2(T* t)
{
  t->Release();
}

在析构函数中,即使在线程 2 中调用析构函数~T(),我们也必须查看结果。如果使用正式,则不能保证。但使用 memory_order_acq_relt->p = new char[16];memory_order_relaxed

final 之后的线程Release,也将使用memory_order_acquire语义执行(因为memory_order_acq_rel包含它)将是t->p = new char[16];操作的视图结果,因为它发生在对具有语义的同一a变量的另一个原子操作之前memory_order_release(因为memory_order_acq_rel包含它)


因为仍然存在疑问,我尝试再做一点证明

给定:

struct T { 
    std::atomic<int> a;

    T(int N) : a(N) {}

    void Release() {
        if (a.fetch_sub(1,std::memory_order_relaxed) == 1) delete this;
    }
};
  • 让 a 初始化为 N (=1,2,...∞)
  • 让 Release() 准确地调用了 N 次

问题:代码是否正确并且T会被删除?

N = 1- 所以a == 1在开始并Release()调用一次。

这里存在问题吗?有人说这是“UB”?(在开始执行a后访问delete this或如何访问?!)

delete this无法开始执行,直到a.fetch_sub(1,std::memory_order_relaxed)将被计算,因为delete this 取决于. 编译器或 cpu 在完成之前a.fetch_sub无法重新排序。delete thisa.fetch_sub(1,std::memory_order_relaxed)

因为a == 1-a.fetch_sub(1,std::memory_order_relaxed)返回 1,1 == 1所以delete this会被调用。

delete this以及在开始执行之前对对象的所有访问。

所以代码正确并T删除以防万一N == 1

现在让我们以防万一N == n。所以找案例N = n + 1. (n = 1,2..∞)

  • a.fetch_sub是原子变量的修改。
  • 对任何特定原子变量的所有修改都以特定于该原子变量的总顺序发生。
  • 所以我们可以说一些a.fetch_sub将首先执行按修改顺序a
  • this first(按修改顺序aa.fetch_subreturn n + 1 != 1 (n = 1..∞)- soRelease()将首先执行 this a.fetch_sub退出而不调用delete this
  • delete this 没有被调用- 它只会 a.fetch_sub返回 1 之后被调用,但这a.fetch_sub在第一次调用之后被调用 a.fetch_sub
  • 并且将a == n第一次 a.fetch_sub完成之后(这将所有其他之前n a.fetch_sub
  • 所以一个Release第一次 a.fetch_sub执行的地方)没有退出,它在开始之前delete this完成访问对象 delete this
  • 我们现在有n休息Release()电话和a == n之前的任何 电话a.fetch_sub,但这种情况已经可以了

对于那些认为代码不安全 / UB 的人来说,还有一个注意事项。

只有在对对象的任何访问完成之前开始删除时,才可能是不安全的。

但删除只会在a.fetch_sub返回 1 之后。

这意味着另一个a.fetch_sub已经修改a

因为a.fetch_sub是原子的——如果我们查看它的副作用(修改aa.fetch_sub——不再访问a

真的,如果操作将值写入内存位置 ( a) 并在此之后再次访问该内存 - 这在意义上已经不是原子的了。

所以如果我们查看原子修改的结果 - 它已经完成并且没有更多的访问变量

结果删除将在所有访问完成之后已经a完成。

并且这里不需要任何特殊的内存顺序(relaxed,acq,rel)用于原子。即使是轻松的订单也可以。我们只需要操作的原子性。

memory_order_acq_rel如果对象 T 不仅包含a计数器,则需要。我们希望在析构函数中查看对 T 的另一个字段的所有内存修改


推荐阅读