【问题标题】:Race condition when incrementing and decrementing global variable in C++在 C++ 中递增和递减全局变量时的竞争条件
【发布时间】:2019-09-04 18:07:40
【问题描述】:

我发现了一个我能够在 linux 中的g++ 下重现的竞争条件示例。我不明白这个例子中操作顺序的重要性。

int va = 0;

void fa() {
    for (int i = 0; i < 10000; ++i)
        ++va;
}

void fb() {
    for (int i = 0; i < 10000; ++i)
        --va;
}

int main() {
    std::thread a(fa);
    std::thread b(fb);
    a.join();
    b.join();
    std::cout << va;
}

如果我使用了va = va + 1;,我可以理解订单很重要,因为在返回分配给 LHS va 之前,RHS va 可能已经改变。有人可以澄清一下吗?

【问题讨论】:

  • 不确定需要澄清什么。你自己说这是一场比赛。种族引入了未定义的行为。任何事情都有可能发生。
  • 两个线程都试图同时修改va,即使在for循环中也是如此。谁赢?未知,所以va 的最终值可以是任何值。
  • 我怀疑你认为递增和递减操作是原子的。他们不是。
  • 即使操作是原子操作,如果没有同步,也无法保证对va 的更改是否或何时实际推送到其他线程可以看到的位置。例如,va 可以很好地存储在寄存器中。
  • 顺便说一句,优化器可以将这些方法更改为va += 10000;va += 10000;,从而减少更多“机会”看到比赛效果。

标签: c++ concurrency race-condition


【解决方案1】:

这里的重要思想是,当 c++ 被编译时,它被“翻译”成汇编语言。 ++va--va 的转换将导致汇编代码将 va 的值移动到一个寄存器,然后在单独的指令中将该寄存器加 1 的结果存储回 va。这样一来,就和va = va + 1;一模一样了。这也意味着操作va++不一定是原子

请参阅here,了解这些指令的汇编代码的外观。

为了进行原子操作,变量可以使用锁定机制。您可以通过声明一个原子变量(它将为您处理线程同步)来做到这一点:

std::atomic<int> va;

参考:https://en.cppreference.com/w/cpp/atomic/atomic

【讨论】:

  • 恕我直言,您的第一部分有点误导。 UB 可能会导致组装在某一天看起来不错,但在另一天却不行
  • @foreknownas_463035818 我试图通过强调 c++ 操作是非原子的,因为底层的汇编指令分别执行 get 和 set 来更清楚地说明这一点。
  • 其实我现在才明白你在说什么;)+1
【解决方案2】:

标准说(引用最新草案):

[intro.races]

如果其中一个修改了内存位置 ([intro.memory]) 并且另一个读取或修改了相同的内存位置,则两个表达式计算会发生冲突。

如果程序的执行包含两个潜在的并发冲突操作,则程序的执行包含数据竞争,其中至少一个不是原子的,并且两者都不会在另一个之前发生,除了下面描述的信号处理程序的特殊情况。 任何此类数据竞争都会导致未定义的行为

您的示例程序存在数据竞争,程序的行为未定义。


我不明白这个例子中操作顺序的重要性。

操作的顺序很重要,因为操作不是原子的,它们读取和修改相同的内存位置。

如果我使用 va = va + 1; 可以理解顺序很重要;因为在回到分配的 LHS va 之前,RHS va 可能已经改变了

这同样适用于增量运算符。抽象机将:

  • 从内存中读取一个值
  • 增加值
  • 将值写回内存

那里有多个步骤可以与另一个线程中的操作交错。

即使每个线程只有一个操作,也无法保证定义良好的行为,除非这些操作是原子的。


C++ 范围之外的注意事项:CPU 可能有一条指令用于递增内存中的整数。例如,x86 就有这样的指令。它可以被原子地和非原子地调用。除非您在 C++ 中显式使用原子操作,否则编译器使用原子指令会很浪费。

【讨论】:

    【解决方案3】:

    首先,这是未定义的行为,因为两个线程对同一个非原子变量va 的读取和写入可能是并发的,并且两者都不会先于另一个发生。

    话虽如此,如果您想了解您的计算机在运行此程序时实际在做什么,假设++vava = va + 1 相同可能会有所帮助。事实上,标准说它们是相同的,编译器可能会以相同的方式编译它们。由于您的程序包含 UB,因此编译器不需要执行任何明智的操作,例如使用原子增量指令。如果你想要一个原子增量指令,你应该把va 原子化。同样,--vava = va - 1 相同。所以在实践中,各种结果都是可能的。

    【讨论】:

      猜你喜欢
      • 2019-03-03
      • 1970-01-01
      • 2022-08-18
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多