互斥锁和临界区的区别是什么?

148
请从Linux和Windows的角度解释一下。
我正在使用C#进行编程,这两个术语会有什么区别。请尽可能详细地说明,包括例子等等......
谢谢
11个回答

247

对于Windows系统来说,临界区比互斥量更轻量级。

互斥量可以在进程之间共享,但始终会导致一些开销的内核调用。

临界区只能在一个进程中使用,但具有这样一个优点:仅在发生争用时才切换到内核模式-无争用获取(应该是常见情况)非常快速。在发生争用的情况下,它们进入内核以等待某些同步原语(例如事件或信号量)。

我编写了一个简单的示例应用程序来比较两者之间的时间。在我的系统上,对于1,000,000个无争用获取和释放,互斥量需要超过一秒钟的时间。1,000,000个获取操作需要约50毫秒的临界区。

以下是测试代码,我运行了此代码,并得到了类似的结果,无论互斥量是第一个还是第二个,因此我们没有看到任何其他效果。

HANDLE mutex = CreateMutex(NULL, FALSE, NULL);
CRITICAL_SECTION critSec;
InitializeCriticalSection(&critSec);

LARGE_INTEGER freq;
QueryPerformanceFrequency(&freq);
LARGE_INTEGER start, end;

// Force code into memory, so we don't see any effects of paging.
EnterCriticalSection(&critSec);
LeaveCriticalSection(&critSec);
QueryPerformanceCounter(&start);
for (int i = 0; i < 1000000; i++)
{
    EnterCriticalSection(&critSec);
    LeaveCriticalSection(&critSec);
}

QueryPerformanceCounter(&end);

int totalTimeCS = (int)((end.QuadPart - start.QuadPart) * 1000 / freq.QuadPart);

// Force code into memory, so we don't see any effects of paging.
WaitForSingleObject(mutex, INFINITE);
ReleaseMutex(mutex);

QueryPerformanceCounter(&start);
for (int i = 0; i < 1000000; i++)
{
    WaitForSingleObject(mutex, INFINITE);
    ReleaseMutex(mutex);
}

QueryPerformanceCounter(&end);

int totalTime = (int)((end.QuadPart - start.QuadPart) * 1000 / freq.QuadPart);

printf("Mutex: %d CritSec: %d\n", totalTime, totalTimeCS);

1
不确定是否相关(因为我还没有编译和尝试过您的代码),但我发现使用INFINITE调用WaitForSingleObject会导致性能不佳。将其传递一个1的超时值,然后在检查其返回值时循环执行,这样做对我的某些代码性能有巨大的改善。这主要是在等待外部进程句柄的情况下。而不是互斥量。你的情况可能有所不同。我很想看看带有这种修改的互斥量的性能如何。从这个测试中得出的时间差异似乎比预期的要大。 - Troy Howard
5
你的意思是你在那个时候基本上只是在自旋锁吗? - dss539
这种区别的原因可能主要是历史上的。在未争用的情况下(少量原子指令,无系统调用),实现与CriticalSection一样快速的锁定并跨进程工作(使用一块共享内存)并不难。例如,请参阅Linux futexes - regnarg
3
@TroyHoward 尝试强制让你的CPU始终运行在100%的状态下,看看INFINITE是否能够更好地工作。电源策略可能需要长达40毫秒的时间(在我的Dell XPS-8700上)才能重新加速到全速运行状态,如果你只睡眠或等待1毫秒,它可能不会减速。 - Stevens Miller
我不确定我理解这里展示的内容。通常,进入关键部分需要获取某种信号量。你是说,在幕后,操作系统有一种有效的方式来实现这个关键部分的行为,而不需要互斥量吗? - Motorhead

93

从理论上讲,关键区域是一段代码,由于该代码访问共享资源,不应被多个线程同时运行。

互斥锁是一种算法(有时也是一种数据结构的名称),用于保护关键区域。

信号量监视器是互斥锁的常见实现方式。

实际上,在Windows中有许多互斥锁的实现可用。它们的差异主要取决于它们的锁定级别、范围、成本以及在不同竞争级别下的性能。请参见CLR Inside Out - 使用并发实现可扩展性,了解不同互斥锁实现的成本图表。

可用的同步原语:

lock(object)语句是使用Monitor实现的 - 参见MSDN了解详情。

近年来,关于非阻塞同步进行了大量研究。其目标是以无锁或无等待方式实现算法。在这种算法中,一个进程可以帮助其他进程完成它们的工作,以便该进程最终可以完成它的工作。因此,即使其他进程试图执行某些工作而挂起,该进程也可以完成其工作。使用锁会导致它们不释放其锁定并阻止其他进程继续执行。


看到被接受的答案,我在想也许我记错了关键部分的概念,直到我看到你写的那个理论视角。 :) - Anirudh Ramanathan
3
实际的无锁编程就像香格里拉一样,只不过它是真实存在的。Keir Fraser的论文(PDF)很有趣地探讨了这个问题(回溯到2004年),但我们仍在2012年苦苦挣扎。我们很糟糕。 - Tim Post

23
除了其他回答之外,以下细节是特定于Windows关键部分的:
  • 在没有争用的情况下,获取关键部分就像一个InterlockedCompareExchange操作一样简单。
  • 关键部分结构保留了一个互斥量的空间。它最初未分配。
  • 如果线程之间存在关键部分的争用,互斥量将被分配并使用。关键部分的性能会降到互斥量的水平。
  • 如果你预计存在高度争用,可以分配指定自旋计数的关键部分。
  • 如果自旋计数的关键部分存在争用,试图获取关键部分的线程将旋转(忙等待)那么多个处理器周期。这可能比休眠产生更好的性能,因为在执行上下文切换到另一个线程所需的周期数量可能比拥有线程释放互斥量所需的周期数要高得多。
  • 如果自旋计数过期,互斥量将被分配。
  • 当拥有线程释放关键部分时,需要检查是否已分配了互斥量,如果是,则将其设置为释放等待的线程。

在Linux中,我认为他们有一个“自旋锁”,其作用类似于具有自旋计数的关键部分。


不幸的是,Window关键段涉及在内核模式下执行CAS操作,这比实际的交错操作要昂贵得多。此外,Windows关键段可以与自旋计数关联。 - Promit
4
那绝对不是真的。 在用户模式下,可以使用cmpxchg执行CAS操作。 - Michael
我认为如果你调用了InitializeCriticalSection,那么默认的自旋次数是零 - 如果你想应用自旋次数,你必须调用InitializeCriticalSectionAndSpinCount。你有相关的参考资料吗? - 1800 INFORMATION

17

关键区域(Critical Section)和互斥锁(Mutex)并不是操作系统特定的,它们属于多线程/多进程的概念。

关键区域 是一段代码,要求在任何给定的时间内只能由其本身运行(例如,有5个线程同时运行,有一个名为“critical_section_function”的函数更新了一个数组...你不想让这5个线程同时更新该数组。所以当程序运行critical_section_function()时,其他线程都不能运行其critical_section_function。

互斥锁* 是实现关键区域代码的一种方式(把它看作是一个令牌...线程必须拥有该令牌才能运行关键区域代码)


3
此外,互斥锁可以在进程之间共享。 - configurator

15

一个互斥锁是一个对象,一个线程可以获取它,防止其他线程获取它。它是咨询性的,而不是强制性的;一个线程可以在不获取互斥锁的情况下使用表示互斥锁的资源。

一个临界区是一段代码,在操作系统保证不会被中断。在伪代码中,它可能像这样:

StartCriticalSection();
    DoSomethingImportant();
    DoSomeOtherImportantThing();
EndCriticalSection();

3
我认为海报在谈论用户模式同步原语,比如win32临界区对象,它仅提供互斥。我不清楚Linux,但Windows内核有关键区域,其行为与您描述的类似 - 不可中断的。 - Michael
2
我不知道为什么你被点踩了。有一个"critical section"的概念,你已经正确描述了它,它与Windows内核对象称为CriticalSection是不同的,后者是一种互斥锁。我相信OP是在询问后者的定义。 - Adam Rosenfield
1
至少我被“不关注编程语言”的标签所困惑。但无论如何,这就是微软将其实现命名为与基类相同的结果。糟糕的编码实践! - Mikko Rantanen
好的,他要求尽可能详细,并特别提到了Windows和Linux,因此听起来概念是正确的。+1 -- 我也不理解-1 :/ - Jason Coco

15
在Windows中,“critical selection”的快速等效物是futex,它代表快速用户空间互斥。 futex与互斥的区别在于,使用futex时,只有在需要仲裁时内核才会介入,因此您可以节省每次修改原子计数器时与内核通信的开销。这可以在某些应用程序中节省大量时间来协商锁定。
使用共享互斥的方式,futex也可以在进程之间共享。
不幸的是,实现futex可能非常棘手(PDF)。 (2018年更新,它们现在远没有2009年那么可怕)。
除此之外,在两个平台上基本相同。您正在以一种不会导致饥饿的方式对共享结构进行原子令牌驱动更新。剩下的就是完成这一任务的方法。

7

补充一下,关键段被定义为一种结构,在用户模式上执行它们的操作。

ntdll!_RTL_CRITICAL_SECTION
   +0x000 DebugInfo:Ptr32 _RTL_CRITICAL_SECTION_DEBUG
   +0x004 LockCount: Int4B
   +0x008 RecursionCount: Int4B
   +0x00c OwningThread:Ptr32 Void
   +0x010 LockSemaphore:Ptr32 Void
   +0x014 SpinCount: Uint4B

而互斥对象是内核对象(ExMutantObjectType),创建在Windows对象目录中。互斥操作大多在内核模式下实现。例如,创建一个互斥对象时,最终会在内核中调用nt!NtCreateMutant。


当一个初始化并使用Mutex对象的程序崩溃时会发生什么?Mutex对象会自动释放吗?我认为不会,对吧? - Ankur
6
内核对象具有引用计数。关闭对象的句柄会减少引用计数,当引用计数达到0时,对象将被释放。当一个进程崩溃时,它的所有句柄都会自动关闭,因此只有该进程具有句柄的互斥对象将被自动释放。 - Michael
这就是为什么关键区域对象是进程绑定的原因,而互斥量则可以在进程之间共享。 - Sisir

6
在Windows中,关键区段是本地进程的。互斥量可以在进程之间共享/访问。基本上,关键区段要便宜得多。无法具体评论Linux,但在某些系统上,它们只是相同事物的别名。

3

Michael的回答很好。我为C ++ 11中介绍的mutex类添加了第三个测试。结果有些有趣,并且仍然支持他对单进程使用CRITICAL_SECTION对象的原始认可。

mutex m;
HANDLE mutex = CreateMutex(NULL, FALSE, NULL);
CRITICAL_SECTION critSec;
InitializeCriticalSection(&critSec);

LARGE_INTEGER freq;
QueryPerformanceFrequency(&freq);
LARGE_INTEGER start, end;

// Force code into memory, so we don't see any effects of paging.
EnterCriticalSection(&critSec);
LeaveCriticalSection(&critSec);
QueryPerformanceCounter(&start);
for (int i = 0; i < 1000000; i++)
{
    EnterCriticalSection(&critSec);
    LeaveCriticalSection(&critSec);
}

QueryPerformanceCounter(&end);

int totalTimeCS = (int)((end.QuadPart - start.QuadPart) * 1000 / freq.QuadPart);

// Force code into memory, so we don't see any effects of paging.
WaitForSingleObject(mutex, INFINITE);
ReleaseMutex(mutex);

QueryPerformanceCounter(&start);
for (int i = 0; i < 1000000; i++)
{
    WaitForSingleObject(mutex, INFINITE);
    ReleaseMutex(mutex);
}

QueryPerformanceCounter(&end);

int totalTime = (int)((end.QuadPart - start.QuadPart) * 1000 / freq.QuadPart);

// Force code into memory, so we don't see any effects of paging.
m.lock();
m.unlock();

QueryPerformanceCounter(&start);
for (int i = 0; i < 1000000; i++)
{
    m.lock();
    m.unlock();
}

QueryPerformanceCounter(&end);

int totalTimeM = (int)((end.QuadPart - start.QuadPart) * 1000 / freq.QuadPart);


printf("C++ Mutex: %d Mutex: %d CritSec: %d\n", totalTimeM, totalTime, totalTimeCS);

我的测试结果是217、473和19(请注意,我最后两个测试的时间比例与Michael的大致相当,但我的计算机比他年轻至少四年,因此可以看到2009年至2013年之间速度提高的证据,当时XPS-8700推出)。新的互斥类比Windows互斥对象快两倍,但仍然不到Windows CRITICAL_SECTION对象的十分之一速度。请注意,我只测试了非递归互斥体。CRITICAL_SECTION对象是递归的(一个线程可以重复进入它们,前提是它离开的次数相同)。

0
我发现解释说关键段保护代码段不被多个线程进入是非常误导人的。保护代码是没有意义的,因为代码只读且不能被多个线程修改。通常想要保护的是数据,以防止多个线程修改导致不一致状态。通常一个互斥量(或关键段,具有相同目的)应该与某些数据相关联。每个访问此数据的代码段都应该获取互斥量/关键段,并在完成访问数据时释放。这可能比仅锁定线程不进入函数更细粒度。此外,从我的经验来看,通过某些同步锁定函数更容易出错,特别是死锁。可以在这里找到涵盖该主题的好文章: https://www.bogotobogo.com/cplusplus/multithreaded4_cplusplus11B.php 因此,总结一下,(递归)互斥量和关键段基本上具有相同的目的,即保护数据而不是保护代码。

关键区域的实现可能比普通内核互斥锁更有效。第一个答案中给出的示例有点误导人,因为它没有描述同步原语的设计目的:同步多个线程对某个东西的访问。该示例仅测量了临界区/互斥锁从未被另一个线程拥有的简单情况。 虽然如果两个线程在短时间内交替访问数据,则关键区域可能更有效,但如果我们有大量线程访问相同的数据,则可能会证明效率较低。每个线程都会自旋锁定,直到放弃并等待信号量,这是关键区域实现的一部分。 在测量执行时间时,也应考虑这种情况。


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