为什么跨线程改变共享变量的代码显然不会受到竞争条件的影响?

Why does code mutating a shared variable across threads apparently NOT suffer from a race condition?

我正在使用 Cygwin GCC 并且 运行 此代码:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

使用以下行编译:g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o

它打印出 1000,这是正确的。但是,由于线程覆盖了先前增加的值,我预计会出现较少的数字。为什么这段代码没有互访问题?

我的测试机是4核的,我对我知道的程序没有限制。

用更复杂的内容替换共享 foo 的内容时问题仍然存在,例如

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

foo() 太短了,以至于每个线程都可能在下一个线程生成之前完成。如果您在 u++ 之前的 foo() 中随机添加一个睡眠时间,您可能会开始看到您期望的结果。

了解竞争条件并不能保证代码会 运行 错误很重要,只是它可以做任何事情,因为它是未定义的行为。包括预期的 运行ning。

特别是在 X86 和 AMD64 机器上,竞争条件在某些情况下很少会导致问题,因为许多指令都是原子指令并且一致性保证非常高。这些保证在多处理器系统上有所减少,其中许多指令需要锁定前缀才能成为原子。

如果在您的机器上增量是一个原子操作,这可能 运行 正确,即使根据语言标准它是未定义的行为。

具体来说,我希望在这种情况下,代码可能被编译为原子 Fetch and Add 指令(X86 汇编中的 ADD 或 XADD),这在单处理器系统中确实是原子的,但是在多处理器系统上这不是保证是原子的,并且需要锁才能做到这一点。如果您 运行 在多处理器系统上运行,将会有 window 线程可能会干扰并产生不正确结果的地方。

具体来说,我使用 https://godbolt.org/ 将您的代码编译为汇编,foo() 编译为:

foo():
        add     DWORD PTR u[rip], 1
        ret

这意味着它只执行一条加法指令,对于单个处理器来说该指令是原子的(尽管如上所述对于多处理器系统而言并非如此)。

它确实存在竞争条件。在 foo 中将 usleep(1000); 放在 u++; 之前,我每次都会看到不同的输出 (< 1000)。

我认为如果你在 u++ 之前或之后睡一觉就不是那么重要了。相反,操作 u++ 转换为代码 - 与调用 foo 的生成线程的开销相比 - 执行得非常快,因此不太可能被拦截。但是,如果您 "prolong" 操作 u++,那么竞争条件将变得更有可能:

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

结果:694


顺便说一句:我也试过了

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

它给了我大多数时间 1997,但有时 1995

  1. 尽管确实存在,但为什么没有为您显示竞争条件的可能答案是foo()太快了,与启动线程所需的时间相比,每个线程甚至在下一个线程启动之前就完成了。但是...

  2. 即使使用您的原始版本,结果也因系统而异:我在 (quad-core) Macbook 上按照您的方式进行了尝试,十次运行中,我获得了 1000 三次,999六次,和一次 998。所以比赛有点罕见,但很明显。

  3. 您使用 '-g' 编译,它有办法使错误消失。我重新编译了你的代码,仍然没有改变,但没有 '-g',竞争变得更加明显:我得到了 1000 一次,999 三次,998 两次,997 两次,996 一次,和 992 一次。

  4. 回复。添加睡眠的建议 - 这有帮助,但是(a)固定的睡眠时间使线程仍然因开始时间而倾斜(取决于计时器分辨率),并且(b)随机睡眠将它们分散开来,当我们想要的是把他们拉得更近。相反,我会对它们进行编码以等待开始信号,这样我就可以在让它们开始工作之前创建它们。使用此版本(有或没有 '-g'),我得到的结果到处都是,低至 974,不高于 998:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }