多线程程序停留在优化模式下,但在-O0下正常运行


68

我编写了一个简单的多线程程序,如下所示:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

它通常表现在调试模式下在Visual Studio中-O0GC c和后打印出的结果1秒钟。但是它卡住了,在“ 释放”模式或中不打印任何内容-O1 -O2 -O3


评论不作进一步讨论;此对话已转移至聊天
塞缪尔·柳

Answers:


100

UB这是一个访问非原子,非保护变量的线程finished。您可以通过finished类型std::atomic<bool>来解决此问题。

我的解决方法:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

输出:

result =1023045342
main thread id=140147660588864

在coliru上进行现场演示


有人可能会认为“ bool大概是一小部分。这怎么可能是非原子的?(我是从多线程开始的。)

但是请注意,缺乏磨练并不是唯一std::atomic给您带来帮助的东西。它还使来自多个线程的并发读写访问权限得到明确定义,从而使编译器无法假设重新读取该变量将始终看到相同的值。

制作bool不受保护的,非原子的会导致其他问题:

  • 编译器可能会决定将变量优化到一个寄存器中,甚至将CSE多次访问优化到一个寄存器中,并减轻循环负担。
  • 该变量可能已为CPU内核缓存。(在现实生活中,CPU具有一致的缓存。这不是一个真正的问题,但是C ++标准足够宽松,无法在非一致共享内存上假设的C ++实现在哪里可以atomic<bool>使用memory_order_relaxed存储/加载,但在哪里volatile不起作用。尽管它实际上可以在实际的C ++实现中使用,但它的可变性将是UB。)

为防止这种情况发生,必须明确告知编译器不要这样做。


对于volatile与该问题潜在关系的不断发展的讨论,我感到有些惊讶。因此,我想花两分钱:


4
我看了一眼func(),并认为“我可以优化该走”优化器不关心的所有线程,并且将检测无限循环,并愉快地把它变成一个“而(真)”如果我们看一下godbolt .org / z / Tl44iN我们可以看到这一点。如果完成,True则返回。如果没有,它会在标签上无条件跳回自身(无限循环).L5
Baldrickk


2
@val:volatile在C ++ 11中基本上没有理由滥用,因为您可以使用atomic<T>和获得相同的asm std::memory_order_relaxed。它确实可以在真正的硬件上运行:缓存是连贯的,因此一旦另一个内核上的存储提交到那里缓存,装入指令就无法继续读取过时的值。(MESI)
彼得·科德斯,

5
@PeterCordes Using volatile仍然是UB。您真的不应该假设UB是绝对安全的,只是因为您无法想到它可能会出错并且在您尝试时有效的方法。那使人们一遍又一遍地被烧死。
戴维·史瓦兹

2
@Damon互斥体具有发布/获取语义。如果互斥锁之前已被锁定,则不允许编译器优化读取,因此finished可以通过std::mutex工作(无volatileatomic)进行保护。实际上,您可以将所有原子替换为“简单”值+互斥锁方案;它仍然可以工作,但速度较慢。atomic<T>允许使用内部互斥锁;仅atomic_flag保证无锁。
Erlkoenig

42

Scheff的答案描述了如何修复您的代码。我想我会添加一些有关这种情况下实际发生情况的信息。

我使用优化级别1()在Godbolt上编译了您的代码-O1。您的函数编译如下:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

那么,这里发生了什么?首先,我们有一个比较:cmp BYTE PTR finished[rip], 0-检查是否finished为假。

如果不是 false(也称为true),则应在第一次运行时退出循环。此完成由jne .L4Ĵ UMPS当Ñ OT ë QUAL到标签.L4,其中的值i0)被存储在供以后使用和该函数返回的寄存器。

如果假然而,我们转移到

.L5:
  jmp .L5

这是一个无条件的跳转,要标记.L5为恰好是跳转命令本身。

换句话说,线程被置于无限繁忙循环中。

那么为什么会这样呢?

就优化器而言,线程不在其权限范围之内。假定其他线程不会同时读取或写入变量(因为这将是数据争用UB)。您需要告诉它它不能优化访问。这就是Scheff的答案所在。我不会再重复他了。

由于未告知优化器该finished变量在函数执行期间可能会发生更改,因此它会发现变量finished未由函数本身修改,并假定其为常量。

优化的代码提供了通过恒定的bool值进入函数而产生的两条代码路径;它要么无限运行循环,要么永不运行循环。

-O0编译器处(如预期的那样)不会优化循环体并进行比较:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

因此,当未优化的功能起作用时,这里的原子性不足通常不是问题,因为代码和数据类型很简单。我们可能遇到的最坏的情况可能是该值与应有的值i相差一个。

具有数据结构的更复杂的系统更有可能导致数据损坏或执行不正确。


3
C ++ 11确实使线程和线程感知的内存模型成为语言本身的一部分。这意味着编译器甚至无法atomic在不写那些变量的代码中发明对非变量的写操作。例如if (cond) foo=1;,不能将其转换为asm,foo = cond ? 1 : foo;因为这种加载+存储(不是原子RMW)可能会踩到来自另一个线程的写入。编译器已经在避免这样的事情,因为它们希望对编写多线程程序很有用,但是C ++ 11正式宣布编译器必须不要破坏2个线程编写的代码,a[1]并且a[2]
Peter Cordes

2
但是,是的,比言过其实约编译器怎么不知道线程的其它所有,你的答案是正确的。Data-race UB可以提升非原子变量的负载,包括全局变量,以及我们希望对单线程代码进行的其他积极优化。 MCU编程-电子设备循环C ++ O2优化中断。SE是我的解释版本。
彼得·科德斯

1
@PeterCordes:使用GC的Java的一个优势是,在新旧用法之间没有中间的全局内存屏障的情况下,对象的内存将不会被回收,这意味着任何检查对象的内核都将始终看到其具有的某些值该参考文献首次发布后的某个时间举行。尽管全局内存屏障如果经常使用可能会非常昂贵,但即使很少使用,它们也可以大大减少对其他地方的内存屏障的需求。
超级猫

1
是的,我知道这就是您要说的,但我认为您的措辞100%并非如此。说优化器“完全忽略它们”。并不完全正确:众所周知,在优化时真正忽略线程可能会涉及到单词加载/修改单词/单词存储中的字节之类的事情,这实际上导致了一个错误,即一个线程访问char或bitfield的步骤写入相邻的struct成员。有关完整的故事,请参见lwn.net/Articles/478657,以及仅C11 / C ++ 11内存模型如何使这种优化不合法,而不仅仅是在实践中不希望这样做。
彼得·科德斯

1
不,很好。.谢谢@PeterCordes。我很高兴得到改善。
Baldrickk

5

为了学习曲线的完整性;您应该避免使用全局变量。尽管通过将其静态化,您还是做得很好,因此它对于翻译部门来说是本地的。

这是一个例子:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

生活在魔盒上


1
finished可以static在功能块中声明为。仍将仅初始化一次,并且如果将其初始化为常量,则不需要锁定。
戴维斯洛

的访问finished也可以使用便宜的std::memory_order_relaxed负载和存储;无需订购wrt。每个线程中的其他变量。不过,我不确定@Davislor的建议是否合理static。如果您有多个旋转计数线程,则无需使用相同的标志将它们全部停止。您确实想以finished一种可以编译为仅初始化而不是原子存储的方式编写的初始化。(就像您使用finished = false;默认的初始化程序C ++ 17语法一样。godbolt.org/z/EjoKgq)。
彼得·科德斯

如您所说,@ PeterCordes将标志放在一个对象中确实可以为不同的线程池提供多个对象。但是,原始设计的所有线程只有一个标志。
戴维斯洛
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.