【问题标题】:Thread Synchronisation 101线程同步 101
【发布时间】:2010-03-31 10:46:10
【问题描述】:

之前我写过一些非常简单的多线程代码,而且我一直都知道,在我正在做的事情中,任何时候都可能发生上下文切换,所以我一直保护着访问通过 CCriticalSection 类共享变量,该类在构造时进入临界区并在销毁时将其保留。我知道这是相当激进的,我经常进入和离开关键部分,有时甚至非常严重(例如,在函数开始时,我可以将 CCriticalSection 放在更紧凑的代码块中)但我的代码不会崩溃并且运行速度足够快.

在工作中,我的多线程代码需要更紧密,只需在所需的最低级别锁定/同步。

在工作中我试图调试一些多线程代码,我遇到了这个:

EnterCriticalSection(&m_Crit4);
m_bSomeVariable = true;
LeaveCriticalSection(&m_Crit4);

现在,m_bSomeVariable 是一个 Win32 BOOL(非易失性),据我所知,它被定义为一个 int,并且在 x86 上读取和写入这些值是一条指令,并且由于上下文切换发生在指令边界,则无需将此操作与临界区同步。

我在网上做了一些研究,看看这个操作是否不需要同步,我想出了两个场景:

  1. CPU 实现乱序执行或第二个线程在不同的内核上运行并且更新的值没有写入 RAM 以供其他内核查看;和
  2. int 不是 4 字节对齐的。

我相信数字 1 可以使用“volatile”关键字来解决。在 VS2005 及更高版本中,C++ 编译器使用内存屏障围绕对这个变量的访问,确保在使用之前始终将变量完全写入/读取到主系统内存。

数字 2 我无法验证,我不知道为什么字节对齐会产生影响。我不知道 x86 指令集,但 mov 是否需要被赋予 4 字节对齐的地址?如果不需要,您是否需要使用指令组合?这会带来问题。

所以...

问题 1: 是否使用“volatile”关键字(隐含地使用内存屏障并提示编译器不要优化此代码)使程序员无需同步 4 字节/8 -byte 在读/写操作之间的 x86/x64 变量上?

问题 2: 是否明确要求变量是 4 字节/8 字节对齐的?

我对我们的代码和类中定义的变量做了更多的研究:

class CExample
{

private:

    CRITICAL_SECTION m_Crit1; // Protects variable a
    CRITICAL_SECTION m_Crit2; // Protects variable b
    CRITICAL_SECTION m_Crit3; // Protects variable c
    CRITICAL_SECTION m_Crit4; // Protects variable d

    // ...

};

现在,对我来说,这似乎太过分了。我认为关键部分在进程之间同步线程,所以如果你有一个你可以输入它并且该进程中没有其他线程可以执行。对于要保护的每个变量,都不需要一个临界区,如果你在临界区,那么没有其他东西可以打断你。

我认为唯一可以从临界区外部更改变量的是进程是否与另一个进程共享内存页面(你可以这样做吗?)并且另一个进程开始更改值。互斥体在这里也有帮助,命名互斥体是跨进程共享的,还是只有同名的进程?

问题 3: 我对关键部分的分析是否正确,是否应该重写此代码以使用互斥锁?我看过其他同步对象(信号量和自旋锁),它们更适合这里吗?

问题 4: 关键部分/互斥体/信号量/自旋锁在哪里最适合?也就是说,它们应该应用于哪个同步问题。选择一个而不是另一个会带来巨大的性能损失吗?

在我们讨论的过程中,我读到自旋锁不应该在单核多线程环境中使用,只能在多核多线程环境中使用。那么,问题 5:这是错的吗?如果不是,为什么是对的?

提前感谢您的任何回复:)

【问题讨论】:

  • 你能提供自旋锁论文的来源吗!?

标签: c++ multithreading mutex volatile memory-barriers


【解决方案1】:

1) 没有 volatile 只是说每次仍然有可能更新一半时从内存中重新加载值。

编辑: 2) Windows 提供了一些原子功能。查找"Interlocked" functions

cmets 让我做更多的阅读。如果你通读Intel System Programming Guide你可以看到对齐的读写是原子的。

8.1.1 保证原子操作 Intel486 处理器(以及之后的更新处理器)保证以下 基本的内存操作将始终以原子方式执行:
• 读取或写入字节
• 读取或写入在 16 位边界上对齐的字
• 读取或写入在 32 位边界上对齐的双字
Pentium 处理器(以及之后的更新处理器)保证以下 额外的内存操作将始终以原子方式执行:
• 读取或写入在 64 位边界上对齐的四字
• 对适合 32 位数据总线的未缓存内存位置进行 16 位访问
P6 系列处理器(以及之后的更新处理器)保证以下 额外的内存操作将始终以原子方式执行:
• 对适合高速缓存的高速缓存内存进行未对齐的 16 位、32 位和 64 位访问 线路
对跨总线宽度、高速缓存行和 英特尔酷睿 2 双核不保证页面边界是原子的,英特尔 Atom、Intel Core Duo、Pentium M、Pentium 4、Intel Xeon、P6 系列、Pentium 和 Intel486 处理器。英特尔酷睿 2 双核、英特尔凌动、英特尔酷睿双核、奔腾 M、 Pentium 4、Intel Xeon 和 P6 系列处理器提供总线控制信号, 允许外部内存子系统使拆分访问原子化;然而, 不对齐的数据访问将严重影响处理器的性能和 应该避免。 访问大于四字的数据的 x87 指令或 SSE 指令 可以使用多个内存访问来实现。如果这样的指令存储 到内存,一些访问可能完成(写入内存),而另一些 由于架构原因导致操作出错(例如,由于页表条目 标记为“不存在”)。在这种情况下,已完成访问的效果 即使整个指令导致错误,软件也可能会看到。如果 TLB 失效已被延迟(参见第 4.10.3.4 节),可能会发生此类页面错误 即使所有访问都指向同一个页面。

所以基本上是的,如果您从任何地址进行 8 位读/写,从 16 位对齐地址等进行 16 位读/写等,您将获得原子操作。值得注意的是,您可以在现代机器上的高速缓存行中执行未对齐的内存读/写。这些规则看起来很复杂,所以如果我是你,我不会依赖它们。向评论者干杯,这对我来说是一次很好的学习经历:)

3) 一个临界区会尝试为它的锁旋转锁几次,然后锁定一个互斥体。自旋锁定可以无所事事地消耗 CPU 功率,而互斥锁可能需要一段时间才能完成它的工作。如果您不能使用联锁功能,CriticalSections 是一个不错的选择。

4) 选择一个而不是另一个会有性能损失。在这里体验一切的好处是一个很大的要求。 MSDN 帮助有很多关于这些的好信息。我建议阅读它们。

5) 您可以在单线程环境中使用自旋锁,尽管线程管理意味着您不能让 2 个处理器同时访问相同的数据,但这通常不是必需的。这是不可能的。

【讨论】:

  • 感谢您的回复。关于#2,您能否解释一下为什么微软有一个在 VS2005 及以后使用“volatile”关键字时不需要 Interlocked* 函数的示例:msdn.microsoft.com/en-us/library/ms686355(VS.85).aspx。它们表明您需要 VS2003 及更早版本的 Interlocked* 系列函数。值怎么可能更新一半?我以为从内存读/写是一条指令,CPU可以将4字节的读/写同步到系统RAM?
  • 32 位整数 在 x86 上是原子的(而 64 位整数在 x64 上是原子的)。当您想要做的不仅仅是读取或写入时,可以使用联锁功能。
  • 只是要添加到 Marcelos 评论中的内容:在 x86 上对 32 位 int 的单次读取或单次写入是原子的。读取 int 的值,更改它并将其写回不是。 volatile(或内存屏障)是否足以保护您的共享变量完全取决于您如何使用它们。 Volatile 仅防止访问和缓存的重新排序(在 VS2005 上)。它不会使操作成为原子操作。
  • 读/写在 x86 上肯定原子的。联锁指令有不同的用途
  • @david:是的,你是对的。它只是VS2005,你必须确保你不使用任何其他编译器。
【解决方案2】:

1:易失的本身对于多线程实际上是无用的。它保证读/写将被执行,而不是将值存储在寄存器中,并且它保证读/写不会被重新排序相对于其他volatile 读/写 .但是对于非易失性代码,它仍然可能会重新排序,这基本上是您代码的 99.9%。 Microsoft 已重新定义volatile 以将所有访问都包装在内存屏障中,但一般情况下不能保证如此。它只会在任何像标准一样定义volatile 的编译器上静默中断。 (代码会编译运行,只是不再是线程安全的)

除此之外,只要对象对齐良好,对整数大小对象的读取/写入在 x86 上都是原子操作。 (您无法保证何时写入会发生。编译器和 CPU 可能会对其重新排序,因此它是原子的,但不是线程安全的)

2:是的,对象必须对齐才能读/写是原子的。

3:不是。一次只有一个线程可以在给定的临界区执行代码。其他线程仍然可以执行其他代码。因此,您可以有四个变量,每个变量都受不同的关键部分保护。如果它们都共享相同的关键部分,那么在您操作对象 2 时,我将无法操作对象 1,这是低效的,并且对并行性的限制超过了必要的程度。如果它们被不同的临界区保护,我们就不能同时操作 same 对象。

4:自旋锁很少是个好主意。如果您希望线程在获得锁之前只需要等待很短的时间,它们就很有用,并且您绝对需要最小的延迟。它避免了操作系统上下文切换,这是一个相对较慢的操作。相反,线程只是坐在一个循环中,不断地轮询一个变量。所以更高的 CPU 使用率(内核在等待自旋锁时不会被释放以运行另一个线程),但线程将能够在锁被释放时继续

至于其他的,性能特征几乎相同:只需使用语义最适合您需求的那个即可。通常,临界区最方便保护共享变量,而互斥锁可轻松用于设置“标志”以允许其他线程继续执行。

至于在单核环境中不使用自旋锁,请记住自旋锁实际上不会产生。等待自旋锁的线程 A 实际上并没有被搁置,允许操作系统安排线程 B 运行。但是由于 A 正在等待这个自旋锁,所以其他一些线程将不得不释放那个锁。如果你只有一个核心,那么其他线程只有在 A 被关闭时才能运行。对于一个健全的操作系统,作为常规上下文切换的一部分,这迟早会发生。但是因为我们知道 A 在 B 有时间执行并释放锁之前无法获得锁,所以如果 A 立即让步,被放入,我们会更好操作系统的等待队列,并在 B 释放锁时重新启动。这就是所有其他锁类型的作用。 自旋锁在单核环境中仍然工作(假设操作系统具有抢占式多任务),它的效率会非常低。

【讨论】:

    【解决方案3】:

    Q1:使用“volatile”关键字

    在 VS2005 及更高版本中,C++ 编译器使用内存屏障围绕对该变量的访问,确保在使用之前始终将变量完全写入/读取到主系统内存。

    没错。如果您不创建可移植代码,Visual Studio 正是以这种方式实现它。如果你想便携,你的选择目前是“有限的”。在 C++0x 之前,没有可移植的方式来指定具有保证读/写顺序的原子操作,您需要实现每个平台的解决方案。也就是说,boost 已经为您完成了这项肮脏的工作,您可以使用 its atomic primitives

    Q2:变量需要4字节/8字节对齐?

    如果你确实让它们对齐,你是安全的。如果你不这样做,规则很复杂(缓存行,...),因此最安全的方法是保持它们对齐,因为这很容易实现。

    Q3:是否应该重写此代码以使用互斥锁?

    Critical 部分是一个轻量级的互斥体。除非您需要在进程之间进行同步,否则请使用临界区。

    Q4:关键部分/互斥体/信号量/自旋锁在哪里最适合?

    Critical sections 甚至可以为你do spin waits

    Q5:自旋锁不应该用在单核中

    自旋锁使用这样一个事实,即当等待的 CPU 正在自旋时,另一个 CPU 可能会释放该锁。这不可能仅在一个 CPU 上发生,因此这只是浪费时间。在多 CPU 上自旋锁可能是个好主意,但这取决于自旋等待成功的频率。这个想法是等待一小会比在那里进行上下文切换要快得多,因此如果等待可能很短,最好等待。

    【讨论】:

      【解决方案4】:

      不要使用易失性。它几乎与线程安全无关。请参阅here 了解详情。

      对 BOOL 的赋值不需要任何同步原语。无需您付出任何特别的努力,它就可以正常工作。

      如果要设置变量,然后确保另一个线程看到新值,则需要在两个线程之间建立某种通信。仅在分配之前立即锁定没有任何效果,因为其他线程可能在您获得锁定之前来去匆匆。

      最后一句警告:线程很难正确处理。最有经验的程序员往往最不喜欢使用线程,这应该为没有使用线程经验的人敲响警钟。我强烈建议您使用一些更高级的原语在您的应用程序中实现并发。通过同步队列传递不可变数据结构是一种大大降低危险的方法。

      【讨论】:

      • 我不确定你是对的。请参阅msdn.microsoft.com/en-us/library/ms686355(VS.85).aspx - 在给出的第一个示例中,在线程安全方面,volatile 产生了很大的影响。
      • 不一定如此。可能仍然存在可见性问题(一个核心看不到另一个核心所做的更新。)锁定原语通常意味着一个合适的内存屏障,这是使所有核心都能看到更改后的值所必需的。
      • 但是在 VS2005 以后的 volatile 关键字在使用时会在变量周围添加内存屏障?
      • RE:“在分配之前立即锁定没有任何效果,因为其他线程可能在您获得锁定之前来去匆匆。” - 我知道双重锁定检查模式。
      • 微软对 volatile 做了一些奇怪的事情。文中的case与两个不同的变量有关,中间没有加锁,这通常是自找麻烦,而且正如文中所指出的,它甚至不适用于VS2003。相信这种恶作剧,后果自负。
      【解决方案5】:

      易失性并不意味着内存障碍。

      这只意味着它将成为记忆模型感知状态的一部分。这意味着编译器无法将变量优化掉,也无法仅在 CPU 寄存器中对变量执行操作(它实际上会加载并存储到内存中)。

      由于没有隐含的内存屏障,编译器可以随意重新排序指令。唯一的保证是不同volatile变量的读/写顺序和代码中的一样:

      void test() 
      {
          volatile int a;
          volatile int b;
          int c;
      
          c = 1;
          a = 5;
          b = 3;
      }
      

      使用上面的代码(假设 c 没有优化掉)c 的更新可以在 ab 的更新之前或之后发生,提供 3 种可能的结果。 ab 更新保证按顺序执行。 c 可以被任何编译器轻松优化掉。有了足够的信息,编译器甚至可以优化掉ab(如果可以证明没有其他线程读取变量并且它们没有绑定到硬件数组(所以在这种情况下,它们实际上可以被删除)。请注意,该标准不需要特定的行为,而是使用as-if 规则的可感知状态。

      【讨论】:

      • 感谢您的回复。正如我上面提到的,如果您使用的是 VS2005 或更高版本,“易失性”会使用内存屏障保护变量。见msdn.microsoft.com/en-us/library/ms686355(VS.85).aspx。但是,我很想知道标准要说什么。你有标准的链接吗?我只能找到引用该标准的文章。另外我知道 volatile 暗示内存屏障仅适用于 VS2005+,所以我可以将它视为穷人的内存屏障,只要我只针对这些编译器,或者在 VS2005+ 上是否有“正确”的方法来做到这一点(忽略便携性)
      • @taspeotis:该标准没有线程的概念,因此不能保证内存屏障。执行内存屏障的正确方法始终是指定内存屏障。 (在 GCC 中,我相信它是 __synchronize(),而在 Windows 下,它是 __ReadWriteBarrier())。不要依赖volatile 加倍努力。它在 VS2005 和 2008 中会这样做,但在其他编译器下不会这样做,并且不能保证在未来的 VS 版本中也会这样做。
      • @taspeotis,这是 MS 特有的功能。事实上,我最近被 GCC 在 volatile var 写入之后重新排序常规 var 读取/写入所困扰,导致了一个非常讨厌的 heisenbug。
      【解决方案6】:

      问题 3:CRITICAL_SECTION 和 Mutex 的工作方式几乎相同。 Win32 互斥锁是一个内核对象,因此它可以在进程之间共享,并使用 WaitForMultipleObjects 等待,而使用 CRITICAL_SECTION 则无法做到这一点。另一方面,CRITICAL_SECTION 重量更轻,因此速度更快。但是代码的逻辑应该不受你使用的影响。

      您还评论说“对于要保护的每个变量都不需要一个临界区,如果您处于临界区,那么没有其他东西可以打扰您。”这是真的,但权衡是访问任何变量都需要您持有该锁。如果变量可以有意义地独立更新,那么您将失去并行化这些操作的机会。 (不过,由于它们是同一个对象的成员,所以在得出结论它们确实可以相互独立地访问之前,我会仔细考虑。)

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2014-04-30
        • 1970-01-01
        • 1970-01-01
        • 2018-12-23
        • 2016-01-20
        • 1970-01-01
        相关资源
        最近更新 更多