C++ producer/consumer 模式中的数据竞争是大问题吗?

C++ Is data race big problem in producer/consumer pattern?

我还在学习涉及网络的多线程编程。

我的问题是,当我将线程设计为consumer/producer模式,生产者随机修改变量,消费者检查变量并根据它做一些事情时,它是否仍然很糟糕(大问题)?

喜欢下面的代码。

int flag = 0;

void producer()
{
    while(true) 
    {
        // waits until packet arrives.
        recv(socket, data);
        if(data == 1)
            flag = 1;
    }
}

void consumer()
{
    while(true)
    {
        if(flag == 1)
            doSomething();
    }
}

我想 doSomething() 函数最终会被调用。我不需要完美同步这两个线程。所以我认为即使我不使用 std::atomic_int 而不是 int 也可以。 (我会在这个例子中使用 std::atomic_int,但在我的实际实现中,变量太复杂而无法使用 std::atomic。)

那么忽略数据竞争问题是不好的做法吗?或者它是性能的不错选择?

数据竞争导致未定义的行为。

C++ draft N4860

6.9.2.1 Data races (21.2)

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.

3.30 undefined behavior

Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, [...]

所以,如果你能忍受未定义的行为,例如格式化硬盘还是鼻魔,任你选择

如果它是一个 Hello world 程序,您可能会侥幸逃脱。如果是超过 2000 欧元的银行交易丢失了,我认为这是一个问题。在我的业务中,如果我不小心错过了一个带有测量点的网络包,飞机可能会坠毁。我认为这是一个大问题。

在您写的评论中:

Actually what I'm doing is game development, which sometimes I need to choose performance over correctness.

诚然,在游戏开发中,您会在性能和正确性之间做出妥协。但通常这意味着正确性的数量是可测量或可估计的。例如。物理引擎每秒仅更新 50 次而不是每秒 100 次。但是,结果还是尽可能正确。

对于未定义的行为,很难说出“不可预测的结果”到底有多不正确。例如,您不希望结果随每个新编译器而改变。

我建议你以thread-safe的方式实现它,然后检查你是否真的有性能问题。我敢打赌:会有一个不需要未定义行为并且仍然足够快的解决方案。以前做过。

Premature optimization is the root of all evil.

正如 Donald Knuth 所说。

抛开所有理论,这个非常简单的例子在现实生活中已经完全失效

因为数据竞争会导致未定义的行为,编译器有权假定 non-atomic 变量不会异步更改。因此,优化编译器 可以并将 将您的 consumer 转换为:

void consumer()
{
    while (true) {
        if (flag == 0) {
            while (true); // infinite loop
        } else {
            doSomething();
        }
    }
}

也就是说,如果在任何迭代中 flag 等于 0,编译器可以看到使用者线程在再次检查之前不会写入 flag 也不会调用任何函数。因此,它假设 flag 在再次检查之前不会改变。因此,它得出结论,我们已经知道在下一次检查时它仍然是错误的。啊哈,一个优化!我们可以节省下一次实际测试 flag 的费用,并一直循环下去,因为没有什么可以让我们崩溃。

您可以看到 g++ -O3 正是这样做的:https://godbolt.org/z/414PG4nnE。请注意汇编输出第 13 行和第 21 行的无限循环。 (它将这两种情况分为 flag 最初是假的还是在以后的迭代中变成假的。)

如果 flag 最初为真,它将调用 doSomething(),在这种情况下,它将再次测试标志,以防 doSomething() 本身可能已更改它。但是,如果编译器能够告诉(通过内联、link-time 优化等)doSomething() 不会写入 flag,它也可以优化重新测试,并无条件地输入一个第一次调用后无限循环。

实际上,您 可能 可以通过将 flag 设置为 volatile 变量来实现它。然而,(1) 这在理论上仍然无法解决数据竞争问题,并且 (2) 它会施加与 std::atomic 几乎所有相同的优化惩罚。因此,您不妨只使用 std::atomic 并在使用时获得正确性。