18

以前我写过一些非常简单的多线程代码,而且我一直都知道,在我正在做的事情中,任何时候都可能会发生上下文切换,所以我一直保护通过共享变量的访问一个 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”关键字(隐式使用内存屏障并提示编译器不要优化此代码)是否使程序员无需在读取 / 之间同步 x86/x64 变量上的 4 字节/8 字节写操作?

问题 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:这是错的,或者如果不是,为什么是对的?

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

4

6 回答 6

13

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

编辑:2)Windows 提供了一些原子功能。查找“联锁”功能

这些评论让我做了更多的阅读。如果您通读英特尔系统编程指南,您可以看到对齐的读写是原子的。

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

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

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

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

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

于 2010-03-31T10:58:36.197 回答
8

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

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

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

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

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

至于其他的,性能特征几乎相同:只需使用最适合您需要的语义即可。通常,临界区对于保护共享变量最方便,互斥锁可以很容易地用来设置一个“标志”,允许其他线程继续进行。

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

于 2010-03-31T12:25:55.337 回答
7

Q1:使用“volatile”关键字

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

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

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

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

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

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

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

关键部分甚至可以为您做旋转等待

Q5:单核不应该使用自旋锁

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

于 2010-03-31T12:49:13.157 回答
5

不要使用易失性。它几乎与线程安全无关。请参阅此处了解低调。

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

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

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

于 2010-03-31T10:53:09.607 回答
3

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

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

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

void test() 
{
    volatile int a;
    volatile int b;
    int c;

    c = 1;
    a = 5;
    b = 3;
}

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

于 2010-03-31T11:07:07.587 回答
2

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

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

于 2010-03-31T12:28:47.093 回答