使用互锁函数和临界区(CriticalSection)实现线程同步
- 作者: luckzj
- 发表时间: 2010年8月12日
- 本文链接: http://www.soft-bin.com/html/2010/08/12/thread_sync_critical_section.html
- copy right (c) http://soft-bin.com all right reserved.
- 转载请注明出处
线程同步是多线程编程中比较重要的操作。在多个线程同时访问某个资源时,如果没有做好同步操作,则轻可以导致程序运行无法实现预定的目标,重则可能导致程序异常崩溃。
例如对于 i++ 这么一个简单的操作,实际上在执行时,也是分为多步的:
MOVE EAX, [i]
INC EAX
MOVE [i], EAX
假设现在有两个线程同时执行i++,且其执行顺序为:
Thread 1: MOVE EAX, [i]
Thread 1: INC EAX
Thread 2: MOVE EAX, [i]
Thread 2: INC EAX
Thread 1: MOVE[i], EAX
Thread 2: MOVE[i], EAX
经过这种执行方式,i的值将被少加一次,这不会是我们希望的结果。
我们需要使用线程同步,线程同步的方式有很多,但多数需要使用到内核对象,这些同步方式速度会比较慢。用户模式的线程同步方式包括互锁函数家族和临界区,我将分别对这两种同步方式进行介绍。
互锁函数家族
互锁函数家族的操作都是原子操作,也就是可以保证此函数在执行过程中,不会被其他线程打断。 使用互锁家族函数,需要包含Windows.h,几个主要的互锁家族函数如下:
// 加1操作,返回加1后的结果 LONG __cdecl InterlockedIncrement( LONG volatile* Addend ); // 减1操作,返回减1后的结果 LONG __cdecl InterlockedDecrement( LONG volatile* Addend ); // 将Target中的值设置为Value,返回Target中原先的值 LONG __cdecl InterlockedExchange( LONG volatile* Target, LONG Value ); // 将Addend加上Value,返回Addend中原先的值 LONG __cdecl InterlockedExchangeAdd( LONG volatile* Addend, LONG Value );
互锁操作是用户模式下线程同步的基础,我们可以使用互锁函数来实现线程同步,如下:
BOOL g_iFlag = FALSE;
void Func()
{
while(InterlockedExchange(&g_iFlag, TRUE) == TRUE)
Sleep(0);
// operactions
InterlockedExchange(&g_iFlag, FALSE);
}
以上函数具有简单的线程同步功能,是线程安全的。
临界区 CriticalSection
临界区是用户模式下的第二种线程同步的方式,其原理正如其名,对于要操作临界资源(也就是需要同步的资源)的代码区,我们将其定义为临界区。在进入临界区时,我们调用EnterCriticalSection函数,防止其他线程重入,在退出临界区时,我们调用 LeaveCriticalSection函数退出临界区,以允许其他线程访问临界区代码。
临界区内部实际上也是使用互锁操作实现,但因为其使用更加方便灵活,功能更加强大,因而得到更加广泛的应用。
使用临界区,首先需要定义一个临界区结构: CRITICAL_SECTION,我们并不需要深究这个结构,只需要知道如何使用它。
我们定义了一个临界区结构以后,需要调用
InitializeCriticalSection(CRITICAL_SECTION* pSection);
来对其进行初始化。对任何未经初始化的临界区进行操作,其行为将是不可知的。
在临界区初始化完毕以后,即可以使用这个临界区变量来对临界区代码进行保护。在进入临界区代码之前,我们调用
EnterCriticalSection(CRITICAL_SECTION* pSection)
函数,获取临界区的访问权。这个函数会负责查看CRITICAL_SECTION结构中的成员变量,用于指明当前是哪个变量正在访问该资源。EnterCriticalSection函数行为如下:
1. 如果没有线程访问该资源,EnterCriticalSection便更新成员变量,以指明调用线程已被赋予访问权并立即返回,使该线程能够继续运行。
2. 如果成员变量指明,调用线程已经被赋予对资源的访问权,那么EnterCriticalSection便更新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使该线程能够继续运行。也就是说,某一线程多次调用EnterCriticalSection不会导致线程内部死锁,这一点与某些内核同步方式是不一样的。
3. 如果成员变量指明,一个线程(非调用者)已经被赋予对资源的访问权,那么EnterCriticalSection将调用线程置于等待状态。一旦目前访问该资源的线程调用了 LeaveCriticalSection,则系统从等待线程中,依照某种算法选出一个线程来运行。
EnterCriticalSection 在获取资源访问权限前,会一直等待下去,实际上我们是可以在系统中设置其等待超时时间的,但这个时间对于一般的程序设计者来说,没有太大意义。如果我们不希望程序一直等待下去,可以使用如下函数:
BOOL TryEnterCriticalSection(CRITICAL_SECTION* pSection);
这个函数不会使线程进入等待状态,如果资源已经被其他线程访问,则函数返回FALSE,其他情况则返回TRUE。
在这里需要提出一点,EnterCriticalSection函数在资源已经被其他线程访问时,会进行线程调度操作,将当前线程切换到等待状态。而线程调度操作会从用户状态切换到内核状态,大概需要1000个CPU周期,这个操作是非常浪费资源的。如果占用资源的线程很快就能完成操作,从而释放资源,那么我们就不需要将当前线程转入内核方式,以节约资源。
我们可以采用如下方式:
多次调用 TryEnterCriticalSection 函数,如果在N次调用之后,我们仍然无法取得资源访问权,那么就认为资源占用线程不会短期结束,于是我们可以转入内核方式。如果在N次调用之内,我们成功地取得了资源访问权,那么我们就可以顺利地房屋呢临界资源,而不必转入内核方式。
这种方式的一个缺点是,如果当前系统是单CPU的,那么这种操作无效,因为如果当前线程不退出执行,资源占用线程就无法被调度,从而结束资源的占用。因此,我们还需要考虑系统是单CPU还是多CPU。
实际上微软已经提供了封装好的函数,来为我们解决这个问题:
// 设置EnterCriticalSection的等待循环次数,只有当等待循环超过这个次数时,线程才会转入内核方式 BOOL InitializeCriticalSectionAndSpinCount(CRITICAL_SECTION* pSection, DWORD dwSpinCount); // 设置等待循环的次数 DWORD SetCriticalSectionSpinCount(CRITICAL_SECTION* pSection, DWORD dwSpinCount);
上面两个函数可以设置等待循环的次数,如果当前系统是单CPU,那么循环次数会被忽略掉。
使用CriticalSection的一大忌讳就是只调用了EnterCriticalSection而忽略掉了LeaveCriticalSection:
VOID LeaveCriticalSection(CRITICAL_SECTION* pSection);
这个函数查看结构中的成员变量,将本线程对资源访问的计数减1,如果计数大于0,则LeaveCriticalSection不作其他操作,只是返回,否则查看是否有其他线程正在等待,如果有,则选择一个线程,使其处于可调度状态。如果此时没有线程正在等待,则LeaveCriticalSection更新成员变量,指明没有线程正在访问资源。
在CriticalSection使用完成以后,我们还需要调用 DeleteCriticalSection来清除CRITICAL_SECTION结构中的变量。这个操作和InitializeCriticalSection是相对应的。
同样的,我们在DeleteCriticalSection之后,就不可以再使用被删除的变量来进行线程同步,否则结果是不可预料的。
总结
互锁函数和临界区是用户模式下进行线程同步的基本方法。他们并没有使用到任何的内核对象,我们无法跨进程共享全局变量或者是CRITICAL_SECTION结构的变量,因此我们无法使用这两种线程同步方式实现跨进程的同步。
但这两种同步方式也有其非常显著的好处,即速度快,使用方便。我们在进程内部进行简单的进程同步时,建议使用这两种方式。
