【问题标题】:Handling out of order execution处理乱序执行
【发布时间】:2010-01-28 23:31:48
【问题描述】:

我最近偶然发现了这个Wikipedia article。根据我在多线程方面的经验,我知道由于程序能够随时在线程之间切换线程而导致的众多问题。但是,我从来不知道编译器和硬件优化可以以保证对单线程工作但不一定对多线程工作的方式重新排序操作。谁能解释如何正确处理多线程环境中重新排序操作的可能性?

更新:我最初不小心链接到了Out-of-Order Execution 文章而不是Memory barrier 文章,它对问题有更好的解释。

【问题讨论】:

  • 这是另一个并发代码问题的例子:drdobbs.com/go-parallel/article/…,这是一篇关于减少多线程程序争用和锁定的有趣文章(带有 24 核 cpu 的示例):drdobbs.com/hpc-high-performance-computing/212201163跨度>
  • 您明确谈论多线程,但这对于任何类型的并发代码来说都是一个问题,例如,带有中断处理程序的代码和与内存映射硬件外围设备对话的代码。
  • 简短的回答是您可能不需要担心。重新排序的唯一问题是当两个线程正在访问共享资源时,在这些情况下,您已经在使用包含内存屏障的锁定机制。也许有些编译器不能正确支持多处理,因此会进行愚蠢的优化,但唯一的答案是避免它们,或者至少禁用不好的调整。就是这样。

标签: multithreading


【解决方案1】:

我将把您的问题作为一个关于高级语言中的多线程的问题来解决,而不是讨论 CPU 管道优化。

谁能解释在多线程环境中如何正确处理重新排序操作的可能性?

大多数(如果不是全部)现代高级多线程语言提供了用于管理这种可能性的构造,以便编译器重新排序指令的逻辑执行。在 C# 中,这些包括字段级构造(volatile 修饰符)、块级构造(lock 关键字)和命令式构造(Thead.MemoryBarrier)。

volatile 应用于字段会导致对 CPU/内存中该字段的所有访问都按照指令序列(源代码)中出现的相同相对顺序执行。

在代码块周围使用 lock 会导致封闭的指令序列按照它在父代码块中出现的相对顺序执行。

Thread.MemoryBarrier 方法向编译器指示 CPU 不得在指令序列中的这一点附近重新排序内存访问。这为满足特殊要求提供了更先进的技术。

上述技术是按照复杂性和性能递增的顺序描述的。与所有并发编程一样,确定何时何地应用这些技术是一项挑战。在同步对单个字段的访问时,volatile 关键字将起作用,但它可能被证明是矫枉过正。有时您只需要同步写入(在这种情况下,ReaderWriterLockSlim 会以更好的性能完成同样的事情)。有时您需要快速连续多次操作该字段,或者您必须检查一个字段并有条件地操作它。在这些情况下,lock 关键字是一个更好的主意。有时,您有多个线程在非常松散同步的模型中操纵共享状态以提高性能(通常不推荐)。在这种情况下,精心布置的内存屏障可以防止在线程中使用陈旧和不一致的数据。

【讨论】:

    【解决方案2】:

    让我问一个问题:给定一个程序代码(假设它是一个单线程应用程序),正确的执行是什么?直观地说,按代码指定的顺序由 CPU 执行将是正确。这种顺序执行的错觉是程序员所拥有的。

    但是,现代 CPU 不遵守这样的限制。除非违反依赖性(数据依赖性、控制依赖性和内存依赖性),否则 CPU 会以乱序方式执行指令。但是,它对程序员是完全隐藏的。程序员永远无法看到 CPU 内部发生了什么。

    编译器也利用了这个事实。如果可以保留程序的语义(即代码中的固有依赖项),编译器将重新排序任何可能的指令以获得更好的性能。一项值得注意的优化是代码提升:编译器可以提升加载指令以最小化内存延迟。但是,别担心,编译器保证它的正确性;在任何情况下,编译器都不会因为这种指令重新排序而使您的程序崩溃,因为编译器必须至少保留依赖关系。 (但是,编译器可能有错误:-)

    如果您只考虑单线程应用程序,对于单线程情况,您无需担心编译器和 CPU 的这种乱序执行。

    (要了解更多信息,我建议您看一下ILP(instruction-level parallelism) 的概念。单线程性能主要取决于您可以从单个线程中提取多少 ILP。因此,CPU 和编译器都会做任何事情可以提高性能。)

    但是,当您考虑多线程执行时,就会有一个潜在的问题,称为内存一致性问题。直觉上,程序员有一个顺序一致性的概念。然而,现代多核架构正在进行肮脏和激进的优化(例如,缓存和缓冲区)。在现代计算机体系结构中,很难以低开销实现顺序一致性。因此,由于内存加载和存储的无序执行,可能会出现非常混乱的情况。您可能会观察到一些加载和存储已无序执行。阅读一些与宽松内存模型相关的文章,例如Intel x86's memory model(阅读第 8 章,内存排序,英特尔 64 和 IA-32 架构软件开发人员手册第 3A 卷)。在这种情况下需要内存屏障,您必须强制执行内存指令的顺序以确保正确性。

    问题的答案:简而言之,要回答这个问题并不容易。由于内存一致性模型(尽管有研究论文),没有好的工具可以检测到这种无序和有问题的行为。因此,简而言之,您甚至很难在代码中发现此类错误。但是,我强烈建议您阅读有关double-checked locking 及其detailed paper 的文章。在双重检查锁定中,由于宽松的内存一致性和编译器的重新排序(请注意,编译器不知道多线程行为,除非您明确指定内存屏障),它可能会导致错误行为。

    总之:

    • 如果您只处理单线程程序,则无需担心乱序行为。
    • 在多核上,您可能需要考虑内存一致性问题。但是,当您真正需要担心内存一致性问题时,它实际上很少见。大多数情况下,数据竞争死锁违反原子性会扼杀您的多线程程序。

    【讨论】:

    • 你从来没有真正回答过这个问题。您只是在详细说明 OP 在他的问题中已经说过的内容。
    • @RBarryYoung,我详细阐述了这方面的一些原则,并回答说在单线程中,您无需担心,但在多线程中,您可能担心这是由于宽松的内存一致性。
    • 我同意它并没有真正回答问题,但它包含有用的信息,所以 +1
    • @Casebash,谢谢。所以,我添加了一些答案 :D 老实说,我不能简单地回答这么大的问题。我发表了这篇长文(不回答!)以提供一些对理解问题至关重要的背景。我认为您的问题没有简单的答案。这真的取决于情况。
    • 你错了,实际上很容易知道代码是如何乱序执行的。看硬件规格。并且更容易判断代码何时生成重新排序的指令。查看 asm 列表。说它太难了,比实际上要知道你在说什么要容易得多。
    【解决方案3】:

    让我们明确一点 - 乱序执行是指处理器执行管道,而不是编译器本身,正如您的链接清楚地表明的那样。
    乱序执行是大多数现代 CPU 流水线采用的一种策略,它允许它们动态重新排序指令,以通常最大限度地减少读/写停顿,这是现代硬件上最常见的瓶颈,因为 CPU 执行速度和内存之间存在差异延迟(即与将结果更新回 RAM 的速度相比,我的处理器获取和处理的速度有多快)。
    所以这主要是硬件功能而不是编译器功能。
    如果您知道自己通常在做什么,则可以使用 memory barriers 覆盖此功能。 Power PC 有一个名为 eieio 的奇妙指令(强制执行 i/o),它强制 CPU 将所有挂起的读取和写入刷新到内存 - 这对于并发编程(无论是多线程还是多线程)尤其重要。处理器),因为它确保所有 CPU 或线程已同步所有内存位置的值。
    如果您想深入了解此内容,那么 this PDF 是一个极好的(虽然详细)介绍。
    高温

    【讨论】:

    • 谢谢,PDF 在硬件层面上看起来确实很有趣,但它似乎没有回答关于如何处理问题的问题。
    【解决方案4】:

    不是编译器,而是 CPU。 (实际上两者都是,但 CPU 更难控制。)不管你的代码是如何编译的,CPU 都会在指令流中向前看并乱序执行。例如,通常会提前开始读取,因为内存比 CPU 慢。 (即尽早开始,希望在您真正需要之前完成阅读)

    CPU 和编译器都基于相同的规则进行优化:只要不影响程序的结果,就重新排序任何东西* 假设是单线程单处理器环境*

    所以问题就来了——它针对单线程进行了优化,但实际上并非如此。为什么?因为否则一切都会慢 100 倍。真的。而且您的大部分代码单线程的(即单线程交互)——只有小部分需要以多线程方式交互。

    最好/最简单/最安全的控制方法是使用锁 - 互斥锁、信号量、事件等。

    只有当你真的,真的,需要优化(基于仔细测量),然后你可以查看内存屏障和原子操作 - 这些是用于构建互斥锁等的底层指令,在使用时正确限制乱序执行。

    但在进行这种优化之前,请检查算法和代码流是否正确,以及是否可以进一步最小化多线程交互。

    【讨论】:

    • 我不太担心性能 ATM,只是担心如何编写正确处理这些优化的代码。我只是偶然发现了内存屏障文章。
    • 那就用锁吧。线程之间共享的任何变量都必须在锁内写入和读取。
    • 我想锁会阻止编译器重新安排操作
    • 是的锁也可以防止编译器重新排序。通常不会有太多魔法,而只是因为对不透明函数的任何调用(例如锁定/解锁)都会强制编译器重新加载全局变量并避免其他优化,因为它不知道该不透明函数可能会改变什么。
    • 除非你运行 -O3,否则 gcc 会破坏所有未标记为 volatile 的内容;)
    【解决方案5】:

    编译器不会产生执行错误,它会优化和重新排序,只要它产生的结果符合您的源代码所说的结果。

    但是在处理多线程时,这确实会爆炸,尽管这通常与编译器如何重新排序您的代码无关(尽管在其他乐观的情况下它可能会使情况变得更糟。

    处理在相同数据上运行的线程,您需要非常非常小心,并确保您的数据受到适当的保护(信号量/互斥体/原子操作等)

    【讨论】:

    • 您能否详细说明一下您需要警卫来防止此问题的情况?
    • 基本上任何时候你有超过 1 个线程访问相同的数据。
    • 没有单线程应用程序这样的东西。如果您使用硬件,那么您使用的硬件中断。不管你喜欢与否,你都在使用多线程。
    • @Dan 不,应用程序可能不会,并且通常不使用中断。单线程应用程序在多线程操作系统上运行的事实与此正交。
    【解决方案6】:

    问题的关键在于,如果您才刚刚开始处理多线程代码(以至于您明确地谈论线程调度,就好像它有点可怕[不是说不是,而是由于不同的原因]),这比您需要担心的水平要低得多。正如其他人所说,如果不能保证正确性,编译器不会这样做,虽然很高兴知道存在这样的技术,但除非您正在编写自己的编译器或做真正的裸机工作,否则它不应该出现问题.

    【讨论】:

      【解决方案7】:

      但是,我从来不知道编译器和硬件优化可以以一种保证适用于单线程但不一定适用于多线程的方式对操作进行重新排序。

      由于 C 和 C++ 都没有严格定义的内存模型,编译器可以重新排序优化,这可能会导致多线程问题。但对于专为在多线程环境中使用而设计的编译器,它们

      多线程代码要么写入内存,并使用栅栏来确保线程之间写入的可见性,要么使用原子操作。

      由于原子操作案例中使用的值在单个线程中是可观察的,因此重新排序不会影响它 - 它们必须在原子操作之前正确计算。

      用于多线程应用程序的编译器不会跨内存栅栏重新排序。

      因此,重新排序要么不会影响行为,要么被抑制为特殊情况。

      如果您已经在编写正确的多线程代码,编译器重新排序就没有关系。如果编译器不知道内存栅栏,这只是一个问题,在这种情况下,您可能不应该首先使用它来编写多线程代码。

      【讨论】:

      • 在许多情况下,相同的编译器用于单线程和多线程程序,如果您不小心,它们可能会以在多线程程序中明显的方式重新排序操作。在 C 中,volatile 属性用于表示对其他线程可见的内存位​​置。 C 编译器不会对易失性内存的读写重新排序。
      • @Jamey Hicks 你似乎没有抓住重点。给定的编译器要么针对多线程环境,要么不是。您是否碰巧使用所述编译器编写多线程程序并不重要。请阅读 C 规范以了解 volatile 的语义——它没有提到线程。 “如果你不小心”意味着你没有正确使用内存屏障和原子操作。我要说的一点是,如果您小心避免在线程之间进行大量重新排序,那么它已经避免了编译器可以创建的较弱的重新排序。
      • 我认为这个问题的重点在于是否以及如何编写线程安全代码。如果第一句话被改写为说编译器不会以改变程序行为的方式重新排序线程安全代码中的内存操作,我会完全同意。正如所写的那样,在我看来,即使代码不是线程安全的,它也不会这样做。
      • OP 说他知道编写多线程代码的问题,但担心编译器/CPU 会重新排序每个线程中的指令。针对多线程环境的编译器不会在线程内引入任何操作重新排序,这将破坏已经使用栅栏处理线程之间可能的操作重新排序的代码,并且内存栅栏还在 CPU 级别强制执行顺序。因此,如果他知道如何克服多线程代码的问题,并且检查了编译器文档,那么就没有什么需要做的了。
      【解决方案8】:

      编译器和 cpu 都实现了确保为给定执行流保留顺序语义的算法。对他们来说,不实现上述算法就是一个错误。可以安全地假设指令重新排序不会影响您的程序语义。

      正如其他地方所指出的,内存是唯一可能出现非顺序语义的地方;可以通过各种众所周知的机制在此处获得与顺序性的同步(在汇编级别,有原子内存访问指令;更高级别的功能,如互斥锁、屏障、自旋锁等,是用原子汇编指令实现的)。

      回答你的标题:你不处理 OOO 执行。

      【讨论】:

        【解决方案9】:

        所以本质上您是在询问内存一致性模型。一些语言/环境,如 Java 和 .NET,定义了内存模型,程序员有责任不做不允许的事情,或者导致未定义的行为。如果您不确定“正常”操作的原子性行为,最好是安全而不是抱歉,只使用互斥体原语。

        对于 C 和 C++,情况并不那么好,因为这些语言标准没有定义内存模型。不,与不幸的流行观点相反, volatile 不能保证任何原子性。在这种情况下,您必须依赖平台线程库(其中包括执行所需的内存屏障)或编译器/硬件特定的原子内在函数,并希望编译器不会进行任何破坏程序语义的优化。只要您避免在函数(或使用 IPA 的翻译单元)内进行条件锁定,您就应该相对安全。

        幸运的是,C++0x 和下一个 C 标准正在通过定义内存模型来纠正这个问题。我问了一个与此相关的问题,结果是条件锁定here;该问题包含一些详细讨论该问题的文档的链接。我建议你阅读这些文件。

        【讨论】:

          【解决方案10】:

          您如何防止函数执行失败的可能性发生并在您的脸上炸毁?

          你没有——编译器只能在不改变最终结果的情况下改变执行顺序。

          【讨论】:

          • 严格来说这不是真的;在 Windows 和至少两个其他主要游戏平台上,C/C++ 编译器提供内存排序内在函数,以防止编译器重新排序。 (以及 CPU 重新排序。)
          • 它们只有在指令的顺序和最终结果都很重要时才需要(例如,在锁定算法中)。
          • 编译器可能会进行任何适用于单线程的优化,但是当启用多线程时,这些优化会失败。 en.wikipedia.org/wiki/Memory_barrier
          • 考虑到编译器所做的假设,这是正确的。在大多数情况下,编译器假定为单线程操作,除非您明确标记为对并发操作可见,例如 C/C++ 中的 volatile。
          【解决方案11】:

          现在大多数编译器都有明确的排序内在函数。 C++0x 也有内存排序内在函数。

          【讨论】:

          • 显式排序内在函数?
          【解决方案12】:

          您不应该在不同的线程中运行需要按顺序发生的事情。线程是用来并行处理事情的,所以如果顺序很重要,就需要串行处理。

          【讨论】:

          • 进程完全并行是非常罕见的。通常需要一些沟通,如果只是为了收集计算结果。
          • 在多个线程中存在排序依赖是可以的,只要同步强制排序即可。生产者-消费者并行是一种常见模式,但需要生产者和消费者之间的同步。
          • 请不要尝试回答您一无所知的问题。这是一页很长的愚蠢和愚蠢的帖子。其中大部分都清楚地表明了海报的无知。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2018-09-11
          • 2020-10-08
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2019-01-09
          相关资源
          最近更新 更多