当客下载站:专业免费游戏下载中心

Chapter08-用户模式下的线程同步教程

时间:2015-09-16 10:23:43

发布者:fangfang

来源:当客下载站

Cache Lines

       如果想编写一个能够在多核上高效率的程序,你就有必要理解Cache Lines.学过《操作系统》应该都知道,CPU从物理内存中读取内容的时候不是每次读取一个字节,而是读取多个字节的数据放入Cache Line之中。一个Cache Line可能是32、64或128个字节(总之是2的指数),并且它们一般都是按照32、64或128字节数对齐的。

       值得注意的是,在多核上的Cache Line进行内存更新时可能会出现问题,看下面这个实例:

1.       CPU1读取内存中一个字节,顺便会将附近的几个字节一起读进Cache Line。

2.       CPU2读取和CPU1中一样的那个字节,顺带也读取了这个字节附近的几个字节。

3.       CPU1改写Cache Line中这个字节的内容,但是没有及时更新到RAM中去。

4.       CPU2读取这个字节的内容。由于CPU2的Cache Line中已经有了这个字节,它就不会到内存中去读,从而导致数据不同步。

       上面提到的情形在某种情况下,可能导致灾难性的后果。不过芯片制造者已经意识到这个问题,目前的多核系统中如果某个Cache Line修改后将会通知其他CPU去校验其Cache Line对应的内容;会强制CPU1把修改后的内容写入RAM中,然后CPU2再去RAM读取内容,重新填充对应的Cache Line。正如你所了解的,一方面Cache Line可以提高性能;另一方面它又可能降低性能。

       总之,如果你想提高性能的话,你就需要将所有的数据结构聚齐在Cache Line之中。目标就是确保不同的CPU访问不同的内存地址时,它们不会分布在同一个Cache Line访问内。

       structCUSTINFO {

                            DWORDdwCustomerID; // Mostly read-only

                            intnBalanceDue; // Read-write

                            wchar_tszName[100]; // Mostly read-only

                            FILETIMEftLastOrderDate; // Read-write

       };

       获取CPU的Cache Line的方法是:调用GetLogicalProcessorInformation函数,该函数返回一个SYSTEM_LOGICAL_PROCESSOR_INFORMATION结构体,再通过结构体内的Cache域,其指向CACHE_DESCRIPTOR结构体,在该结构体内有一个域:LineSize。一旦你有了这个域值就可以用如下相似的代码进行程序优化:

      

[cpp] view plaincopy
  1. #define CACHE_ALIGN 64  
  2.   
  3.        //Force each structure to be in a different cache line.  
  4.        struct__declspec(align(CACHE_ALIGN)) CUSTINFO {  
  5.               DWORDdwCustomerID; // Mostly read-only  
  6.               wchar_tszName[100]; // Mostly read-only  
  7.   
  8.               //Force the following members to be in a different cache line.  
  9.               __declspec(align(CACHE_ALIGN))  
  10.               intnBalanceDue; // Read-write  
  11.   
  12.               FILETIMEftLastOrderDate; // Read-write  
  13.   
  14.        }  

 

 

高级线程同步—Critical Section

       我们需要一种机制:当线程等待一种可用资源时,不用浪费CPU资源。

       当一个线程想去访问一个共享资源或等待某个特殊事件通知时,线程需要调用一个系统函数,并传递一个标识线程是否正在等待的参数。

       如果系统侦测到资源可用或者特殊事件发生了,函数返回并且使得线程处于可调度状态;如果资源不可用或者特殊事件没有发生,系统将线程置于等待状态,使之不可调度(这样就不会浪费CPU资源了)。

       看下面的一段代码:

     

[cpp] view plaincopy
  1. const int COUNT = 1000;  
  2.          int g_nSum = 0;  
  3.          DWORD WINAPI FirstThread(PVOID pvParam){  
  4.                    g_nSum = 0;  
  5.                    for (int n = 1; n <=COUNT; n++) {  
  6.                             g_nSum += n;  
  7.                    }  
  8.                    return(g_nSum);  
  9.          }  
  10.   
  11.          DWORD WINAPI SecondThread(PVOIDpvParam) {  
  12.                    g_nSum = 0;  
  13.                    for (int n = 1; n <=COUNT; n++) {  
  14.                             g_nSum += n;  
  15.                    }  
  16.                    return(g_nSum);  
  17.               }  

 

       如果这两个线程单独运行,一切正常;设想在FirstThread执行过程中CPU时间片用完,SecondThread被调度到CPU上运行,这是g_nSum的值就会乱掉,最后g_nSum值是不可知的。这显然与程序编程初衷相悖。

       Windows提供了一种解决这种问题的方法--CRITICAL_SECTION结构体加上几个对应的API函数:

       1.VOID InitializeCriticalSection(PCRITICAL_SECTION pcs)

       该函数提供CRITICAL_SECTION结构体的初始化,设置一些成员变量的值,这个函数必须在EnterCriticalSection之前调用。

       2.VOID EnterCriticalSection(PCRITICAL_SECTIONpcs)

       该函数检查结构体的一些成员变量,这些变量标识了哪些线程在访问资源,该函数做了一下检查:

a)       如果没有线程在访问资源,EnterCriticalSection函数更新成员变量来标识调用线程已经授权访问资源了,并且立即返回,允许线程继续执行。

b)       如果结构体成员变量指示调用线程已经被授权在访问资源,EnterCriticalSection函数更新变量来标识调用线程被授权访问的次数,并且立即返回使得线程继续运行。(这种情况很少出现,除非线程连续调用EnterCriticalSection而没有调用LeaveCriticalSection函数)

c)       如果结构体变量指示已有其他线程被授权访问资源,EnterCriticalSection函数将调用线程放入一个等待列表中,这样线程就不会浪费CPU资源。在资源被释放后,该线程将处于可调度状态,等待CPU的调用执行。

       也许你会认为:被EnterCriticalSection函数挂起的线程,可能长期处于不可调度状态,这种状态称之为饿死。但是实际上这种情况是不会发生的,因为EnterCriticalSection最后会因为超时而引起异常。

       3. BOOLTryEnterCriticalSection(PCRITICAL_SECTIONpcs)

       与EnterCriticalSection函数不同的是:TryEnterCriticalSection不会让线程处于等待状态,它用返回值来标示调用线程是否有权访问资源。如果资源已经被其他线程占用时,则返回FALSE;否则返回TRUE。

       利用这个函数,线程可以很快检查出它是否能访问资源,如果不能则继续做其他事而不是简单的等待;如果TryEnterCriticalSection函数返回TRUE,则更新CRITICAL_SECTION结构体的成员变量来标识当前线程正在访问资源。因此每个TryEnterCriticalSection返回TRUE之后就得再调用LeaveCriticalSection。

       4. VOIDLeaveCriticalSection(PCRITICAL_SECTION pcs)

       该函数检查CRITICAL_SECTION结构体内的成员变量,并将标识当前线程被授权访问资源次数的计数器减一。如果计数器还是大于0,则函数只是简单返回即可;如果计数器为0,则更改结构体内的成员变量来标示没有线程在占用资源,再查看是否有线程在调用EnterCriticalSection,如果有再选中一个线程使之处于可调度状态。

 

 

Critical Sections and Spinlocks

 

       当一个线程尝试进入被其他线程占用的临界区域,当前线程会立即被置于等待状态,这意味着线程必须从用户模式(User mode)转化为内核模式(Kernelmode)(耗时大概1000CPU周期),这种转换的代价是非常昂贵的。在一个多核系统中,在当前线程完成从用户模式到内核模式转化完成之前,占用资源的线程可能就已经释放资源了,在这种情况下,大量的CPU时间片被浪费了。

       为了提高临界区域的性能,微软引进了自旋锁(spinlock),当EnterCriticalSection函数被调用时,它用循环使用自旋锁尝试在一定次数内获取到资源,只有所有的尝试都失败了,线程才进入内核模式处于等待状态。

       两个相关函数:

       1.BOOLInitializeCriticalSectionAndSpinCount(

                                          PCRITICAL_SECTION pcs,

                                          DWORD dwSpinCount);

       第一个参数和InitializeCriticalSection的参数一样,第二个参数表示的是线程自旋锁尝试的次数。

       2.DWORD SetCriticalSectionSpinCount(

                                          PCRITICAL_SECTION pcs,

                                          DWORD dwSpinCount);

       该函数改变自旋锁尝试的次数。

       PS:dwSpinCount的推荐值是4000。

 

Slim Reader-Writer Locks

 

       该系列函数是源于典型的读者-写者问题,一次可以有多个读者读,但一次只能一个写者写。它与Critical Section(临界区域)不同的是:它允许你去区分哪些线程是简单地读取数据,哪些线程是要修改数据;它允许多个线程同时读取数据。

       该系列有一个结构体和三个相应的函数:

[cpp] view plaincopy
  1. typedef struct_RTL_SRWLOCK {  
  2.   
  3. PVOID Ptr;  
  4.   
  5. } RTL_SRWLOCK, *PRTL_SRWLOCK;  
  6.   
  7. VOID InitializeSRWLock(PSRWLOCK SRWLock);  
  8.   
  9. VOID AcquireSRWLockShared(PSRWLOCK SRWLock);  
  10.   
  11. VOID ReleaseSRWLockShared(PSRWLOCK SRWLock);  
  12.   
  13. VOID WINAPI AcquireSRWLockExclusive(  __inout  PSRWLOCK SRWLock);  
  14.   
  15. VOID WINAPI ReleaseSRWLockExclusive(  __inout  PSRWLOCK SRWLock);  
       InitialSRWLock函数是初始化结构体,
       AcquireSRQLockShared和ReleaseSRWLockShared函数的功能就是获取和释放读者权限,
       AcquireSRWLockExclusive和ReleaseSRWLockExclusive函数就是获取和释放写者权限。

 

 

Volatile read &Volatile write:(这一点说的有问题,建议不要用volatile关键字来做线程同步)

 

       是最简单直接一种共享数据的办法,其不是靠编程去实现的,而是利用volatile关键字,告诉编译器不要保留该关键字修饰的数据的临时拷贝,更新后要立即回写到内存。
       简单地举个例子说明:CPU0的Cache和CPU1的Cache同时存取了内存中由volatile修饰的某个内容。当CPU0对数据进行更改时,会将更改后的数据更新到内存中去,然后再通知CPU1重新读取这块数据。如果没有volatile修饰,CPU0和CPU1中Cache数据不一致,这显然是我们不愿看到的。
       PS:Volatile除了可以修饰一般数据类型外,还可以修饰struct和class。

 

interlocked APIs

     Interlocked API系列如下,该系列函数保证所有第一个参数指定的内存地址内的数据执行的都是原子操作。具体的函数使用我就不再赘述,查MSDN可知。

[cpp] view plaincopy
  1. LONG InterlockedExchangeAdd(   
  2. PLONG volatile plAddend,   
  3. LONG lIncrement);   
  4. LONGLONG InterlockedExchangeAdd64(   
  5. PLONGLONG volatile pllAddend,   
  6. LONGLONG llIncrement);  
  7. LONG InterlockedExchange(  
  8. PLONG volatile plTarget,  
  9. LONG lValue);  
  10. LONGLONG InterlockedExchange64(  
  11. PLONGLONG volatile plTarget,  
  12. LONGLONG lValue);  
  13. PVOID InterlockedExchangePointer(  
  14. PVOID* volatile ppvTarget,  
  15. PVOID pvValue);  
  16. PVOID InterlockedCompareExchange(   
  17. PLONG plDestination,  
  18. LONG lExchange,   
  19. LONG lComparand);   
  20. PVOID InterlockedCompareExchangePointer(   
  21. PVOID* ppvDestination,   
  22. PVOID pvExchange,   
  23. PVOID pvComparand);  
  24. LONGLONG InterlockedCompareExchange64(   
  25. LONGLONG pllDestination,   
  26. LONGLONG llExchange,   
  27. LONGLONG llComparand);  
  28. LONG InterlockedIncrement(PLONG plAddend);   
  29. LONG InterlockedDecrement(PLONG plAddend);  

最新软件