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

Double checked locking implementation - using atomic vs not using

我看到了这个 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 为锁定所做的任何事情,因为它会影响我的 运行ning 时间。 (互斥 - 很多,call_once 大约是互斥时间的 1/3,但仍然是“选项 1”的 7 倍
  2. 我首先实现了第一个选项,知道第一个操作不是线程安全的,假设可能发生的最坏情况是竞争条件导致我进入具有实际保护的 call_once 并且这只会浪费一些 运行ning 时间在函数的前几次调用上。
  3. 有人指出了链接的 cpp 核心指南,告诉我我可能应该使用 atomic bool 以使所有线程安全。
  4. 这显然有额外的 运行ning 时间成本,但看看 bad 的例子,在我看来他们正在比较苹果和橙子,因为使用了挥发性物质。
  5. 我不确定 volatile 是否是使代码变得“糟糕”的必要条件,或者使用常规 storate 变量是否也可能导致不必要的问题
  6. 来这里问这个,而不是讨论我是否应该使用局部静态变量,就好像这是实际代码 运行 而不是简化示例
  7. 顺便说一句,也许答案就是 'every race condition is UB even if you can "proove" that the execution flow is the same as in this example'

volatile 告诉您内存的行为。 volatile 内存的一个很好的例子是 memory-mapped IO。粗略地说,这意味着底层内存值可能会因不可预测的原因而发生变化,例如映射到硬件。这是与竞争条件不同的问题。

在您的示例中,为了仅调用一次 costly_init_function,第一个选项应该足够了,不需要 if (!a) 检查。

double-checking 围绕互斥锁的目的是尽可能少地获取互斥锁。让另一个线程等待一个锁然后获取它,只是为了满足一个条件而什么都不做,这是不必要的昂贵。该示例不会导致关键区域被调用两次,但性能会降低。

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

如果您摆脱 if(!a) 测试并仅依赖 std::call_once 的隐式互斥锁,应该没问题。

IMO 最好使 aa_init 都成为 foo 的静态局部变量而不是全局变量(避免污染全局命名空间)。您也不需要 std::optional(可以只使用 A a;)除非 A 是 non-assignable 类型 and/or 没有默认构造函数。

如果你想避免 call_once 互斥锁,你可以用 std::atomic<A*> 做一些事情。您可以尝试 std::atomic<std::optional<A>> 但这可能会引入另一个互斥量。

制作功能thread-safe需要付出代价。是的,示例 1 更快,那是因为它没有完成所需的工作。

您的想法存在一个根本性错误,您可以在没有原子或同步的情况下推断并发访问。假设是错误的。 non-atomic 变量的任何并发读写都是 UB。

intro.races

The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

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

特别是,一个可能的结果是另一个线程在看到 init 函数的结果之前将 a 视为 true。

问题不在于挥发性,而是缺乏排序。此 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)。