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

71

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

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的调试模式或者gcc中的-O0模式下,程序运行正常,并会在1秒后输出结果。但在Release模式或者-O1 -O2 -O3模式下,程序停滞不前,无法输出任何信息。


评论不适合进行长时间的讨论;此对话已被移至聊天室 - Samuel Liew
3个回答

105

两个线程访问一个非原子的、未受保护的变量会导致未定义行为(U.B.),这涉及到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设置为非保护、非原子可能会导致其他问题:

  • 编译器可能决定将变量优化为一个寄存器,甚至将多次访问合并成一个,并将负载移出循环。
  • 该变量可能会被缓存在CPU内核中。(在实际生活中,CPU具有协同缓存。这不是真正的问题,但C++标准足够松散,以涵盖在非协同共享内存上的假设C++实现,其中使用memory_order_relaxed存储/加载的atomic<bool>将工作,但使用volatile则不会。为此使用易失性将是未定义的行为,即使它在实际的C++实现中确实有效。)

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


对于与此问题潜在关联的volatile讨论的演变,我有点惊讶。因此,我想说几句:


4
我看了一眼func(),便想:“我可以优化掉它”。 优化器完全不关心线程,会检测到无限循环,并将其转换为“while(True)”。如果我们看一下https://godbolt.org/z/Tl44iN,就会发现这一点。如果 finished 是True,则返回。如果不是,则跳到标签.L5处(一个无限循环)。 - Baldrickk
3
当使用多线程时,何时使用volatile? - Scheff's Cat
2
@val:在C++11中,基本上没有滥用volatile的理由,因为您可以使用atomic<T>std::memory_order_relaxed获得相同的汇编代码。但是,在实际硬件上确实有效:缓存是一致的,因此一条加载指令不能在另一个核心上的存储提交到缓存后继续读取过时的值。(MESI) - Peter Cordes
5
@PeterCordes 使用 volatile 仍然是未定义行为。你真的不应该假设某些明显的未定义行为是安全的,仅仅因为你无法想出它会出错的方式,并且当你尝试过时它起作用。这已经让人们一次又一次地受到伤害了。 - David Schwartz
3
互斥锁具有释放/获取语义。如果在使用互斥锁时将finished变量保护起来,编译器就不允许优化读操作,这样就不需要使用volatile或者atomic了。实际上,你可以用一个“简单”的值+互斥锁的方案替换所有的原子操作;它仍然能够正常工作,但速度会慢一些。atomic<T>允许使用内部互斥锁;只有atomic_flag保证是无锁的。 - Erlkoenig
显示剩余21条评论

45

Scheff的答案描述了如何修复您的代码。我想补充一些关于这种情况实际发生的信息。

我在godbolt上使用优化级别1(-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。

如果它不是false(也就是true),我们应该在第一次运行时退出循环。这通过 jne .L4 实现,它会跳转到标签.L4,其中寄存器中存储有i的值(0)以便以后使用,并且函数返回。

如果它确实为false,我们继续执行

.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 的值比它应该的值多一个。

使用数据结构的更复杂系统更有可能导致数据损坏或执行不当。

6
C++11将线程和线程感知的内存模型确实纳入了语言本身。这意味着即使在不写这些变量的代码中,编译器也不能发明非“atomic”变量的写入。例如,if (cond) foo=1; 不能转换为类似于 foo = cond ? 1 : foo; 的汇编代码,因为该加载+存储(不是原子 RMW)可能会覆盖另一个线程的写入。编译器已经避免这样的情况,因为它们希望对编写多线程程序有用,但是C++11正式要求编译器不得破坏其中两个线程写入a[1]a[2]的代码。 - Peter Cordes
2
除了关于编译器完全不知道线程的夸大说法之外,你提供的答案是正确的。数据竞争 UB 允许提升非原子变量(包括全局变量)的负载,以及我们想要的其他针对单线程代码的积极优化。MCU 编程 - C++ O2 优化破坏 while 循环 在 electronics.SE 上是我对此解释的版本。 - Peter Cordes
1
@PeterCordes: Java使用GC的一个优势是,在旧的和新的使用之间有一个全局内存屏障来回收对象的内存,这意味着任何检查对象的核心始终会看到某个值,该值在引用首次发布后的某个时间保持过。虽然全局内存屏障如果经常使用可能非常昂贵,但即使稀少地使用,它们也可以极大地减少其他地方需要内存屏障的需求。 - supercat
1
是的,我知道你想说什么,但我认为你的措辞并不完全意味着那个。说优化器“完全忽略它们”并不完全正确:众所周知,在优化时真正忽略线程可能涉及诸如字负载/修改字节/字存储之类的事情,实际上已经导致了错误,其中一个线程对char或位域的访问会影响到对相邻结构成员的写入。请参见https://lwn.net/Articles/478657/以获取完整的故事,以及只有C11 / C++11内存模型使这种优化非法,而不仅仅是在实践中不受欢迎。 - Peter Cordes
1
不,这很好。谢谢@PeterCordes。我感谢你的改进。 - Baldrickk
显示剩余6条评论

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;
}

wandbox 上实时运行


1
在函数块内,也可以将 finished 声明为 static。它仍然只会被初始化一次,如果它被初始化为一个常量,这不需要锁定。 - Davislor
访问finished也可以使用更便宜的std::memory_order_relaxed加载和存储;在任何线程中,与其他变量相比,没有所需的排序。虽然我不确定@Davislor的“static”建议是否合理;如果您有多个旋转计数线程,则不一定希望使用相同的标志停止它们所有。您确实希望以仅初始化的方式编写finished的初始化,而不是原子存储。 (就像您正在使用的finished = false;默认初始化程序C++17语法。 https://godbolt.org/z/EjoKgq)。 - Peter Cordes
@PeterCordes 把标志放在一个对象中确实允许有多个,用于不同的线程池,正如你所说。然而,最初的设计是所有线程都使用单个标志。 - Davislor

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接