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


107

我正在使用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;
}

66
英特尔CPU具有一些惊人的内部“向下调试”逻辑,以保持与SMP系统(如双奔腾Pro机器)中使用的早期x86 CPU的兼容性。在x86机器上几乎不可能发生我们所教过的许多失败情况。因此,可以说有一个核心去写u回内存。实际上,CPU会做一些令人惊奇的事情,例如注意内存行u不在CPU的高速缓存中,它将重新开始增量操作。这就是为什么从x86迁移到其他体系结构可以令人大开眼界的原因!
David Schwartz

1
也许还是太快了。您需要添加代码以确保该线程在执行任何操作之前先生成,以确保其他线程在完成之前启动。
罗伯K

1
就像其他地方提到的那样,线程代码太短了,它很可能在下一个线程排队之前就已经执行了。将u ++置于100个计数循环中的10个线程怎么样?循环开始之前的短暂延迟(或全局“ go”标志可同时启动所有循环)
RufusVS

5
实际上,反复循环生成该程序最终会表明它已崩溃:类似while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;在我的系统上打印999或998的内容。
Daniel Kamil Kozar

Answers:


266

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


51
这确实以预期的方式更改了输出。
mafu

49
我要指出,总体而言,这是展示比赛状况的一种相当不错的策略。您应该能够在任何两个操作之间插入一个暂停;如果没有,那就是比赛条件。
Matthieu M.

最近,我们在C#中遇到了这个问题。代码通常几乎从来不会失败,但是最近在两次调用之间添加了API调用,引入了足够的延迟以使其不断变化。
黑曜石凤凰城

@MatthieuM。微软是否有一种自动化工具能够准确地做到这一点,既可作为检测比赛状况并使其可靠地再现的方法呢?
梅森惠勒

1
@MasonWheeler:我几乎只在Linux上工作,所以... dunno :(
Matthieu

59

重要的是要了解竞争条件并不能保证代码可以正确运行,而不能保证代码可以执行任何操作,因为这是未定义的行为。包括按预期运行。

特别是在X86和AMD64机器上,在某些情况下,竞态条件很少引起问题,因为许多指令是原子性的,并且一致性保证非常高。这些保证在多处理器系统上有所降低,在多处理器系统上,许多指令都是原子的,需要使用锁定前缀。

如果在您的计算机上增加一个原子操作,即使按照语言标准它是“未定义行为”,它也可能会正确运行。

我特别希望在这种情况下,代码可以被编译为原子的Fetch and Add指令(在X86汇编中为ADD或XADD),在单处理器系统中确实是原子的,但是在多处理器系统上,这不能保证是原子的并且是锁将需要这样做。如果您在多处理器系统上运行,则将出现一个窗口,线程可能会干扰并产生不正确的结果。

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

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

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


41
重要的是要记住,“按预期运行”是未定义行为的允许结果。
Mark

3
如您所指出的,该指令在SMP机器(所有现代系统都是)上不是 原子的。甚至inc [u]不是原子的。该LOCK前缀必须作出的指令真正原子。OP简直就是幸运。回想一下,即使您告诉CPU“在此地址的单词上加1”,CPU仍必须获取,递增,存储该值,并且另一个CPU可以同时执行相同的操作,从而导致结果不正确。
Jonathon Reinhart

2
我投下了反对票,但随后我重新阅读了您的问题,并意识到您的原子性语句假设使用单个CPU。如果您编辑问题以使其更清楚(当您说“原子”时,请注意,这仅在单个CPU上是这种情况),那么我将可以删除我的不赞成票。
乔纳森·莱因哈特

3
我对此表示不满,认为“特别是在X86和AMD64机器上的运行状况在某些情况下很少引起问题,因为许多指令是原子性的,并且一致性保证非常高。” 该段应该开始做出明确的假设,即您专注于单核。即便如此,在当今的消费类设备中,多核体系结构已成为事实上的标准,我认为这是最后而不是首先要解释的一个极端案例。
Patrick Trentin

3
哦,是的 x86具有大量的向后兼容性……可以确保正确编写的代码在可能的范围内起作用。当奔腾Pro引入乱序执行时,这确实是一件大事。英特尔希望确保已安装的代码库可以正常工作,而无需专门针对其新芯片进行重新编译。x86最初是作为CISC核心,但在内部已发展成为RISC核心,尽管从程序员的角度来看,它仍然以CISC的形式呈现和表现。有关更多信息,请参见此处的 Peter Cordes的答案。
科迪·格雷

20

我认为如果在睡前或睡后睡不着,那就不重要了u++。确切地说,操作u++转换为的代码-与产生调用线程的开销相比foo-非常快地执行,因此不太可能被拦截。但是,如果您“延长”操作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
我希望在任何模糊不清的编译器上,整个功能都可以针对同一件事进行优化。我很惊讶,事实并非如此。感谢您的有趣结果。

这是完全正确的。在下一个线程开始执行所讨论的微型函数之前,需要运行数千条指令。当您使函数中的执行时间更接近于线程创建开销时,您会看到竞争条件的影响。
乔纳森·莱因哈特

@Vality:我还期望它可以删除O3优化下的虚假for循环。不是吗
user21820

怎么会else u -= 1被执行?即使在并行环境中,该值也永远不会不合适%2,不是吗?
mafu

2
从输出else u -= 1中看,当u == 0时,第一次执行foo()时执行一次。剩余的999次u是奇数并u += 2执行,导致u = -1 + 999 * 2 = 1997; 即正确的输出。竞争状况有时会导致+ = 2之一被并行线程覆盖,并且您得到1995。–
路加福音

7

它确实患有比赛条件。放在usleep(1000);之前u++;foo我每次看到不同的输出(<1000)。


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

  2. 即使使用原始版本,结果也会因系统而异:我在四核Macbook上按您的方式进行了尝试,在10次运行中,我获得了1000次3次,999次6次和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;
    }

请注意。该-g标志绝不会“使错误消失”。-gGNU和Clang编译器上的标志仅将调试符号添加到已编译的二进制文件中。这样,您就可以在程序上运行带有一些人类可读输出的诊断工具,例如GDB和Memcheck。例如,当Memcheck在存在内存泄漏的程序上运行时,除非程序使用该-g标志构建,否则它不会告诉您行号。
MS-DDOS

诚然,隐藏在调试器中的错误通常更多是编译器优化问题。我都试过了,说:“用-O2 代替-g”。但这就是说,如果您从未有过寻找只有 进行编译的情况下才会表现出来的错误的乐趣,那就让您自己感到-g幸运。它可以发生,与一些最恶劣的极细微的混淆错误。我已经看过了,虽然不是最近,但是我可以相信这可能是一个旧的专有编译器的怪癖,所以我暂时相信您会了解GNU和Clang的现代版本。
dgould

-g不会阻止您使用优化。例如gcc -O3 -g,与进行相同的汇编gcc -O3,但带有调试元数据。如果您尝试打印一些变量,gdb会说“ optimized out”。 -g如果它添加的任何内容是本.text节的一部分,则可能会更改某些内容在内存中的相对位置。它肯定会占用目标文件中的空间,但是我认为,链接之后,所有内容最终都将出现在文本段的一端(而不是节),或者根本不属于段的一部分。可能会影响将事物映射到动态库的位置。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.