使用互锁函数和临界区(CriticalSection)实现线程同步

2010年8月12日 | by luckzj | 标签:

线程同步是多线程编程中比较重要的操作。在多个线程同时访问某个资源时,如果没有做好同步操作,则轻可以导致程序运行无法实现预定的目标,重则可能导致程序异常崩溃。

例如对于 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结构的变量,因此我们无法使用这两种线程同步方式实现跨进程的同步。

但这两种同步方式也有其非常显著的好处,即速度快,使用方便。我们在进程内部进行简单的进程同步时,建议使用这两种方式。