双重检查锁定实现-使用原子还是不使用

问题描述

我看到了此cpp核心指南,并且正在尝试了解一些内容

http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#cp111-use-a-conventional-pattern-if-you-really-need-double-checked-locking

为什么第一个示例标记为不良?仅仅是因为变量是可变的吗?如果第一个检查是由互斥锁保护的,那么它不是线程安全的,会产生什么危害?在最坏的情况下,我们会陷入锁定的互斥锁中,但是一旦解锁,我们就不会运行“仅一次”的代码

考虑以下两个选项:

class A{};
A() costly_init_function();
std::optional<A> a;

//option 1   
std::once_flag a_init;
const A& foo() {
    if (!a) {
      std::call_once(a_init,[](){ a.emplace(costly_init_function()); });
    }
    return *a;
}

//option 2
 std::atomic_bool a_init_flag = false;
 std::mutex mutex;
 const A& moo() {
  if (!a_init_flag) {
    std::lock_guard lock{mutex};
    if (!a_init_flag) {
      a.emplace(costly_init_function());
      a_init_flag= true;
     }
  }
  return *a;
 }

选项1中可能会发生任何实际问题吗?在我看来,可能发生的最坏情况是,我们以非线程安全的方式访问a,结果我们等待到call_once完成,然后我们仅跳至返回*a。 我应该选择价格更高但更安全的选项2吗?

编辑:

似乎人们正在思考改写我的问题,而更详细地解释它实际上是一个答案。我只是删除了问题,但显然我不能

如此:

  1. 是的,我知道第一个if (!a)不是线程安全的
  2. 是的,我知道仅调用std::call_once
  3. 就能获得相同的结果
  4. 是的,我知道volatile的意思

尝试更具体地回答我的问题:

  1. 我不希望此函数使用互斥量或任何std :: call_once进行锁定操作,因为这会影响我的运行时间。 (互斥量-很多,call_once大约是互斥量花费时间的1/3,但仍比“选项1”多7倍。
  2. 我首先实现了第一个选项,因为知道第一个操作不是线程安全的,假设可能发生的最坏情况是竞争条件导致我进入具有实际保护功能的call_once,这仅会花费一些运行时间被浪费在对函数的前几次调用上。
  3. 有人指出了链接的cpp核心准则,告诉我我可能应该使用原子布尔来使所有线程安全。
  4. 这显然会增加运行时间,但在bad示例中,由于使用的挥发物,在我看来他们正在将苹果与橙子进行比较。
  5. 我不确定volatile对于使代码“变坏”是否必不可少,还是会使用常规的storate变量也可能导致不必要的问题
  6. 在这里问这个问题,而是讨论了我是否应该使用局部静态变量,就好像这是实际代码正在运行而不是简化示例一样
  7. 顺便说一句,也许答案就是“即使您可以“证明”执行流程与此示例相同,每个竞争条件都是UB”。

解决方法

使函数具有线程安全性需要付出一定的代价。是的,示例1的速度更快,这是因为它没有完成所需的工作。

您的想法中存在一个基本错误,即您可以推理并发访问而无需原子或同步。该假设是错误的。来自非原子变量的任何并发读取和写入都是UB。

intro.races

如果一个程序的执行包含两个,则它包含一个数据争用 潜在的并发冲突动作,其中至少一项是 不是原子的,除了 下文所述的信号处理程序的特殊情况。任何此类数据竞赛 导致不确定的行为。

请注意,“发生在……之前”有一个precise definition。仅在功能B之后5分钟运行功能A并不能验证为“ B发生在A之前”。

特别是可能出现的结果是,另一个线程在看到init函数的结果之前认为a为真。

问题不是不稳定,而是缺乏排序。该cppcoreguideline仅强调了volatile不提供排序保证的事实,因此表现出与volatile关键字不存在相同的不确定行为。

简而言之:

  • 没有同步,任何东西都保证为0。

例如:

int x = 0;
int y = 0;

void f() {
    y = 1;
    x = 1;
}

假设至少有一个线程运行此功能,并且您没有其他同步机制,那么读取这些线程的线程可以观察y == 0x == 1

在技术层面上:

  • 调用f的线程没有理由将写入的内容刷新到内存中,如果这样做,也没有理由以任何特定顺序进行刷新。
  • 读取xy的线程没有理由使其缓存无效并从内存中获取修改后的值,并且如果这样做,则没有任何理由以任何特定顺序进行操作(例如, :它可能在缓存中有y,但没有x,因此它将从内存中加载x并查看更新后的值,但使用缓存的y)。
,

1的问题是std::optional<A>不是原子的,因此当第一个线程正在调用!a时,第二个线程可能会尝试测试a.emplace,从而导致竞争状态和未定义的行为。

如果您摆脱了if(!a)测试而仅依靠std::call_once的隐式互斥量,那就没问题了。

IMO,最好使a的{​​{1}}和a_init成为静态局部而不是全局变量(避免污染全局名称空间)。除非foo是不可分配的类型和/或没有默认的ctor,否则您也不需要std::optional(只能使用A a;)。

如果要避免使用call_once互斥锁,可以使用A进行操作。您可以尝试std::atomic<A*>,但这可能会引入另一个互斥体。

,

{"$project": { "ip": 1,"timestamp": 1,"condition": 1 }} 告诉您有关内存行为的信息。 volatile内存的一个很好的例子是内存映射的IO。大致而言,这意味着基础内存值可能会由于不可预测的原因而发生更改,例如映射到硬件。这是与比赛条件分开的问题。

在您的示例中,为了只调用一次volatile,第一个选项就足够了,而costly_init_function检查是不必要的。

仔细检查互斥锁的目的是获取互斥锁的次数尽可能少。让另一个线程在锁上等待然后获取它,仅使条件失败却不执行任何操作,这会不必要地增加成本。该示例不会导致关键区域被调用两次,但是性能较低。