1.原子访问:Interlocked系列函数
所谓原子访问,指的是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。
我们需要有一种方法能够保证对一个值的递增操作时原子操作——也就是说,不会被打断。Interlocked系列函数提供了我们需要的解决方案。
LONG InterlockedExchangeAdd(
PLONG volatile plAddend,
LONG lIncrement);
LONGLONG InterlockedExchangeAdd64(
PLONGLONG volatile pllAddend,
LONGLONG llIncrement);
只要调用这个函数,传一个长整形变量的地址和另一个增量值,函数就会保证递增操作是以原子方式进行的。
Interlocked 函数又是如何工作的呢?取决于代码运行的CPU平台。如果是x86系列CPU,那么Interlocked函数会在总线上维持一个硬件信号,这个信号会阻 止其它CPU访问同一个内存地址。无论编译器如何生成代码,无论机器上装配了多少个CPU,这些函数都能够保证对值的修改时以原子方式进行的。
Interlocked函数执行得极快,调用一次Interlocked函数,通常只占用几个CPU周期(通常小于50),而且也不需要在用户模式和内核模式之间进行切换(这个切换通常需要占用1000个周期以上)。
当然,也可以用InterlockedExchangeAdd来做减法——只要在第二个参数中传入一个负值就行了。
下面是其它三个Interlocked函数:
LONG InterlockedExchange(
PLONG volatile plTarget,
LONG lValue);
LONGLONG InterlockedExchange64(
PLONGLONG volatile plTarget,
LONGLONG lValue);
PVOID InterlockedExchangePointer(
PVOID* volatile ppvTarget,
PVOID pvValue);
实现自旋锁的时候,InterlockedExchange及其有用:
// Global variable indicating whether a shared resource is in use or not
BOOL g_fResourceInUse = FALSE; ...
void Func1() {
// Wait to access the resource.
while (InterlockedExchange (&g_fResourceInUse, TRUE) == TRUE)
Sleep(0);
// Access the resource.
...
// We no longer need to access the resource. InterlockedExchange(&g_fResourceInUse, FALSE);
}
在单CPU的机器上应避免使用旋转锁 。
PVOID InterlockedCompareExchange(
PLONG plDestination,
LONG lExchange,
LONG lComparand);
PVOID InterlockedCompareExchangePointer(
PVOID* ppvDestination,
PVOID pvExchange,
PVOID pvComparand);
该函数对当前值( plDestination 参数指向的值)与 lComparand 参数中传递的值进行比较。如果两个值相同,那么* plDestination 改为 lExchange 参数的值。如果* plDestination 中的值与lExchange 的值不匹配, * plDestination 保持不变。该函数返回* plDestination 中的原始值。记住,所有这些操作都是作为一个原子执行单位来进行的。
2.高级线程同步
如果我们只需要以原子方式修改一个值,那么Interlocked系列函数非常好用,我们当然应该优先使用它们。为了能够以“原子”方式访问复杂的数据结构,我们必须超越Interlocked系列函数。
我们既不应该使用旋转锁,也不应该进行轮循,因为浪费CPU时间是件很糟糕的事情。而应该调用函数把线程切换到等待状态,直到线程想要访问的资源可供使用为止。
volatile关键字:
volatile限定符告诉编译器这个变量可能被应用程序之外的其它东西修改,比如操作系统、硬件或者一个并发执行的线程。确切地说,volatile限 定符告诉编译器不要对这个变量进行任何形式的优化,而是始终从变量在内存中的位置读取变量的值。给一个结构加volatile限定符等于给结构中所有的成 员都加volatile限定符,这样可以确保任何一个成员始终都是从内存中读取的。
3.关键段
关键段 (critical section)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”来对资源进行操控。即,代码知道除了 当前线程之外,没有任何线程会同时访问该资源。当然,系统仍然可以暂停当前线程去调度其它线程。但是,在当前线程离开关键段之前,系统是不会去调度任何想 要访问同一资源的其它线程的。
一般情况下,我们会将CRITICAL_SECTION结构作为全局变量来分配,这样进程中的所有线程就能够非常方便地通过变量名来访问这些结构。在使用 CRIICAL_SECTION的时候,只有两个必要条件:第一条件是所有想要访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结 构的地址(我们可以通过自己喜欢的任何方式来把这个地址传给各个线程)。第二个条件是在任何线程试图访问被保护的资源之前,必须对 CRITICAL_SECTION结构的内部成员进行初始化。
下面这个函数用来对结构进行初始化:
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);
当知道线程不再需要访问共享资源的时候,我们应该调用下面的函数来清理CRITICAL_SECTION结构:
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs);
然后我们在以下两个函数之间访问共享资源:
VOID EnterCriticalSection(PCRITICAL_SECTION pcs);
。。。共享资源的访问。。。
VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);
EnterCriticalSection会执行下面的测试:
我们可以用下面的函数的函数来代替EnterCriticalSection:
BOOL TryEnterCriticalSection(PCRITICAL_SECTION pcs);
TryEnterCriticalSection从来不会让调用线程进入等待状态。 它会通过返回值来表示调用线程是否获准访问资源。如果资源正在被其它线程访问,那么返回值为FALSE,其它为TRUE。如果返回TRUE,那么CRITICAL_SECTION的成员已经更新过了,以表示该线程正在访问资源。因此,每个返回TRUE的 TryEnterCriticalSection调用必须有一个对应的 LeaveCriticalSection 。
当不能用Interlocked函数解决同步问题的时候,我们应该试一试关键段。关键段的最大好处在于它们非常容易使用,而且它们在内部也使用了Interlocked函数,因此执行速度非常快。关键段的最大缺点在于它们无法用来在多个进程之间对线程进行同步。
当线程试图进入一个关键段,但这个关键段正被另一个线程占用的时候,函数会立即把调用线程切换到等待状态。这意味着线程必须从用户模式切换到内核模式(大 约1000个CPU周期),这个切换的开销非常大。为了提高关键段的性能,Microsoft把旋转锁合并到了关键段中。因此,当调用EnterCriticalSection的时候,它会用一个旋转锁不断地循环,尝试在一段时间内获得对资源的访问权。只有尝试失败的时候,线程才会切换到内核模式并进入等待状态。
为了在使用关键段的时候同时使用旋转锁,我们必须调用下面的函数来初始化关键段:
BOOL InitializeCriticalSectionAndSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);
dwSpinCount是我们希望旋转锁循环的次数。 这个值可以是0~0x00FFFFFF之间的任何一个值。在单处理器的机器上调用这个函数,那么函数会忽略 dwSpinCount参数,因此次数总是0。因为如果一个线程正在循环,那么占用资源的线程将没有机会放弃对资源的访问权。
我们可以调用一下函数来改变关键段的旋转次数:
DWORD SetCriticalSectionSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);
用来保护进程堆的关键段锁使用的旋转次数大约是4000,这可以作为我们的一个参考值。
4.Slim读/写锁
SRWLock的目的和关键段相同:对一个资源进行保护,不让其它线程访问它。但是,与关键段不同的是,SRWLock允许我们区分哪些想要读取资源的值 的线程(读取者线程)和想要更新资源的值的线程(写入者线程)。让所有的读取者线程在同一时刻访问共享资源应该是可行的,这是因为仅仅读取资源的值并不存 在破坏数据的风险。只有当写入者线程想要对资源进行更新的时候才需要进行同步。在这种情况下,写入者线程想要对资源进行更新的时候才需要进行同步。在这种 情况下,写入者线程应该独占对资源的访问权:任何其它线程,无论是读取者线程还是写入者线程,都不允许访问资源。这就是SRWLock提供的全部功能。
首先,我们需要分配一个SRWLOCK结构并用InitializeSRWLock函数对它进行初始化:
VOID InitializeSRWLock(PSRWLOCK SRWLock);
一旦SRWLock初始化完成之后,写入者线程就可以调用AcquireSRWLockExclusive,将SRWLOCK对象的地址作为参数传入,以尝试获得对被保护资源的独占访问权。
VOID AcquireSRWLockExclusive(PSRWLOCK SRWLock);
完成对资源的更新之后,应该调用ReleaseSRWLockExclusice,并将SRWLOCK对象的地址作为参数传入,这样就解除了对资源的锁定。
VOID ReleaseSRWLockExclusive(PSRWLOCK SRWLock);
对读取者线程来说,同样有两个步骤,单调用的是下面两个新的函数:
VOID AcquireSRWLockShared(PSRWLOCK SRWLock);
VOID ReleaseSRWLockShared(PSRWLOCK SRWLock);
不存在用来删除或销毁SRWLOCK的函数,系统会自动执行清理工作。
与关键段相比,SRWLock缺乏下面两个特性:
线程/微妙 |
Volatile 读取 |
Volatile 写入 |
Interlocked 递增 |
Critical Section 关键段 |
SRWLock 共享模式 |
SRWLock 独占模式 |
Mutex 互斥量 |
---|---|---|---|---|---|---|---|
1 |
8 |
8 |
35 |
66 |
66 |
67 |
1060 |
2 |
8 |
76 |
153 |
268 |
134 |
148 |
11082 |
4 |
9 |
145 |
361 |
768 |
244 |
307 |
23785 |
总结一下,如果希望在应用程序中得到最佳性能,那么首先应该尝试不要共享数据,然后依次使用volatile读取,volatile写 入,Interlocked API,SRWLock以及关键段。当且仅当所有这些都不能满足要求的时候,再使用内核对象。因为每次等待和释放内核对象都需要在用户模式和内核模式之间 切换,这种切换的CPU开销非常大。
5.一些有用的窍门和技巧
内核模式和用户模式(Kernel Mode vs. User Mode)
为了防止用户程序访问并篡改操作系统的关键部分,Windows使用了2种处理器存取模式(事实上Windows运行的处理器可以支持4种模式):用户模式和内核模式。用户程序运行在用户模式而操作系统代码(如系统服务和设备驱动程序)则运行在内核模式。在内核模式下程序可以访问所有的内存和硬件,并使用所有的处理器指令。操作系统程序比用户程序有更高的权限,使得系统设计者可以确保用户程序不会意外的破坏系统的稳定性。
虽然Windows进程有自己的运行空间,但是内核模式的操作系统代码和设备驱动程序代码则运行在同一个虚拟地址空间。虚拟内存中的每一页都标明了他可由处理器以哪种方式访问。系统空间中的也只能在内核模式下访问,而用户空间中的也在任何模式下都可以访问。而只读页(如可执行代码)在任何模式下都不能被写入。
Windows对运行在内核模式组件的空间并不提供读/写保护,这意味着在内核模式下,操作系统和驱动程序代码可以进入系统空间,并绕过系统的安全机制而访问对象。因为Windows的大部分运行在内核模式,因此在设计内核程序时要特别的谨慎,防止他们破坏系统的安全。
以上问题也说明在用第三方的驱动程序时也要很小心,因为一旦驱动程序运行在内核模式他就可访问系统的所有数据。这也是在Windows中进入驱动程序签证的一个原因,即当用户使用未注册的第三方软件时系统会给出警告。一个叫做驱动检测(Driver Verifier)的机制可以帮助驱动开发者发现bug(如缓存溢出和内存泄漏)。
用户程序在调用一个系统服务时会转入内核模式,比如Windows的ReadFile函数最终会调用内部的一个例程,后者实际执行了读文件,因为他要访问系统数据,因此它必须允许在内核模式。从用户模式向内核模式的转换是由一个特殊的指令完成的。操作系统捕获了这个指令,通知系统要请求一个服务,并将线程的参数传递给系统函数从而执行内部的函数功能。在将控制权将给线程之前,处理器会转回到用户模式,这样操作系统可以防止它的数据被误读或误写。
注意,从用户模式向内核模式的转换并不会影响线程的调度,事实上,模式的转换并不意味着运行环境的改变。
因此一个用户线程事实上一部分时间在用户模式下运行,另一部分时间在内核模式下运行。由于图画和窗口系统也在内核模式下,因此画图较多的程序在内核模式下的运行时间回比用户模式下更多。比如你可以允许一个Windows的图画本,或者弹球游戏,并观察他们分别在用户模式和内核模式下的时间。
可以用Windows自带的性能工具察看。通过开始-程序-管理工具-性能打开,或者在控制面板里面打开,管理工具-性能。