线程同步101

时间:2010-03-31 10:46:11

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

以前我写过一些非常简单的多线程代码,而且我一直都知道在任何时候都可以在我正在做的事情中间进行上下文切换,所以我总是保护访问通过CCriticalSection类共享变量,该类在构造时进入临界区并使其处于销毁状态。我知道这是相当激进的,我进入和离开关键部分非常频繁,有时非常惊人(例如,当我可以将CCriticalSection置于更严格的代码块中时,在函数的开头)但我的代码没有崩溃并且运行得足够快

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

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

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

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

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

  1. CPU实现乱序执行或第二个线程在不同的核心上运行,更新后的值不写入RAM供另一个核心查看;和
  2. int不是4字节对齐的。
  3. 我相信使用“volatile”关键字可以解决数字1。在VS2005及更高版本中,C ++编译器使用内存屏障围绕对此变量的访问,确保在使用变量之前始终完全写入/读取主变量。

    Number 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:这是错误的,或者如果没有,为什么会这样?

    提前感谢任何回复:)

6 个答案:

答案 0 :(得分:13)

1)没有volatile只是表示每次重新加载内存中的值时,它可能会被更新一半。

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

评论让我做了一些阅读。如果您通读Intel System Programming Guide,您可以看到对齐读取和写入是原子的。

8.1.1保证原子操作 Intel486处理器(以及更新的处理器)保证了以下内容 基本的记忆操作总是以原子方式进行:
•读或写字节
•读取或写入在16位边界上对齐的单词
•读取或写入在32位边界上对齐的双字
词 奔腾处理器(以及更新的处理器)保证以下内容 额外的记忆操作将始终以原子方式进行:
•读取或写入在64位边界上对齐的四字字
•对32位数据总线内未缓存的存储单元的16位访问
P6系列处理器(以及更新的处理器)保证以下内容 额外的记忆操作将始终以原子方式进行:
•未对齐的16位,32位和64位访问缓存中适合缓存的内存 线
访问可缓存的内存,这些内存分布在总线宽度,缓存行和 英特尔酷睿2双核英特尔不保证页面边界是原子的 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帮助有很多关于这些的好信息。我sugegst阅读他们。

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

答案 1 :(得分:8)

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

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

2:是的,必须将对象对齐以使读/写为原子。

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

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

至于其他人,性能特征几乎相同:只使用最符合您需求的语义。通常,关键部分最方便用于保护共享变量,并且可以很容易地使用互斥锁来设置“标志”以允许其他线程继续进行。

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

答案 2 :(得分:7)

Q1:使用“volatile”关键字

  

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

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

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

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

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

关键部分是轻量级互斥锁。除非您需要在进程之间进行同步,否则请使用关键部分。

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

Critical sections甚至可以为您do spin waits

Q5:不应在单核

中使用自旋锁

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

答案 3 :(得分:5)

不要使用挥发性物质。它几乎与线程安全无关。请参阅here了解低调。

BOOL的赋值不需要任何同步原语。没有任何特别的努力,它会正常工作。

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

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

答案 4 :(得分:3)

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

这只意味着它将成为内存模型感知状态的一部分。这意味着编译器无法优化变量,也不能仅在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规则的可感知状态。

答案 5 :(得分:2)

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

您还评论说“对于您想要保护的每个变量,不需要关键部分,如果您处于关键部分,那么没有其他任何内容可以打断您。”这是事实,但权衡是对任何变量的访问都需要您持有该锁。如果变量可以独立地进行有意义的更新,那么您将失去并行化这些操作的机会。 (因为这些是同一个对象的成员,但在结束之前我会认真思考它们可以真正地彼此独立地访问。)