3.2 线程同步
同步可以保证在一个时间内只有一个线程对某个共享资源有控制权。共享资源包括全局变量、公共数据成员或者句柄等。临界区内核对象和事件内核对象可以很好地用于多线程同步和它们之间的通信。本节将结合各种简单的例子来讨论产生同步问题的根本原因,进而提出相应的解决方案。
3.2.1 临界区对象
1.为什么要线程同步
当多个线程在同一个进程中执行时,可能有不止一个线程同时执行同一段代码,访问同一段内存中的数据。多个线程同时读共享数据没有问题,但如果同时读和写,情况就不同了。下面是一个有问题的程序,该程序用两个线程来同时增加全局变量g_nCount1和g_nCount2的计数,运行1秒之后打印出计数结果。
#include <stdio.h> // 03CountErr工程下
#include <windows.h>
#include <process.h>
int g_nCount1 = 0;
int g_nCount2 = 0;
BOOL g_bContinue = TRUE;
UINT __stdcall ThreadFunc(LPVOID);
int main(int argc, char* argv[])
{ UINT uId;
HANDLE h[2];
h[0] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
h[1] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
// 等待1秒后通知两个计数线程结束,关闭句柄
Sleep(1000);
g_bContinue = FALSE;
::WaitForMultipleObjects(2, h, TRUE, INFINITE);
::CloseHandle(h[0]);
::CloseHandle(h[1]);
printf("g_nCount1 = %d /n", g_nCount1);
printf("g_nCount2 = %d /n", g_nCount2);
return 0;
}
UINT __stdcall ThreadFunc(LPVOID)
{ while(g_bContinue)
{ g_nCount1++;
g_nCount2++; }
return 0;
}
线程函数ThreadFunc同时增加全局变量g_nCount1和g_nCount2的计数。按道理来说最终在主线程中输出的它们的值应该是相同的,可是结果并不尽如人意,图3.4所示是运行上面的代码,并等待1秒后程序的输出。
图3.4 程序错误的输出
g_nCount1和g_nCount2的值并不相同。出现这种结果主要是因为同时访问g_nCount1和g_nCount2的两个线程具有相同的优先级。在执行过程中如果第一个线程取走g_nCount1的值准备进行自加操作的时候,它的时间片恰好用完,系统切换到第二个线程去对g_nCount1进行自加操作;一个时间片过后,第一个线程再次被调度,此时它会将上次取出的值自加,并放入g_nCount1所在的内存里,这就会覆盖掉第二个线程对g_nCount1的自加操作。变量g_nCount2也存在相同的问题。由于这样的事情的发生次数是不可预知的,所以最终的值就不相同了。
例子中,g_nCount1和g_nCount2是全局变量,属于该进程内所有线程共有的资源。多线程同步就要保证在一个线程占有公共资源的时候,其他线程不会再次占有这个资源。所以,解决同步问题,就是保证整个存取过程的独占性。在一个线程对某个对象进行操作的过程中,需要有某种机制阻止其他线程的操作,这就用到了临界区对象。
2.使用临界区对象
临界区对象是定义在数据段中的一个CRITICAL_SECTION结构,Windows内部使用这个结构记录一些信息,确保在同一时间只有一个线程访问该数据段中的数据。
编程的时候,要把临界区对象定义在想保护的数据段中,然后在任何线程使用此临界区对象之前对它进行初始化。
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection );
// 指向数据段中定义的CRITICAL_SECTION结构
之后,线程访问临界区中数据的时候,必须首先调用EnterCriticalSection函数,申请进入临界区(又叫关键代码段)。在同一时间内,Windows只允许一个线程进入临界区。所以在申请的时候,如果有另一个线程在临界区的话,EnterCriticalSection函数会一直等待下去,直到其他线程离开临界区才返回。EnterCriticalSection函数用法如下:
void EnterCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
当操作完成的时候,还要将临界区交还给Windows,以便其他线程可以申请使用。这个工作由LeaveCriticalSection函数来完成。
void LeaveCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
当程序不再使用临界区对象的时候,必须使用DeleteCriticalSection函数将它删除。
void DeleteCriticalSection( LPCRITICAL_SECTION lpCriticalSection);
现在使用临界区对象来改写上面有同步问题的计数程序。
BOOL g_bContinue = TRUE; // 03CriticalSection工程下
int g_nCount1 = 0;
int g_nCount2 = 0;
CRITICAL_SECTION g_cs; // 对存在同步问题的代码段使用临界区对象
UINT __stdcall ThreadFunc(LPVOID);
int main(int argc, char* argv[])
{ UINT uId;
HANDLE h[2];
// 初始化临界区对象
::InitializeCriticalSection(&g_cs);
h[0] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
h[1] = (HANDLE)::_beginthreadex(NULL, 0, ThreadFunc, NULL, 0, &uId);
// 等待1秒后通知两个计数线程结束,关闭句柄
Sleep(1000);
g_bContinue = FALSE;
::WaitForMultipleObjects(2, h, TRUE, INFINITE);
::CloseHandle(h[0]);
::CloseHandle(h[1]);
// 删除临界区对象
::DeleteCriticalSection(&g_cs);
printf("g_nCount1 = %d /n", g_nCount1);
printf("g_nCount2 = %d /n", g_nCount2);
return 0;
}
UINT __stdcall ThreadFunc(LPVOID)
{ while(g_bContinue)
{ ::EnterCriticalSection(&g_cs);
g_nCount1++;
g_nCount2++;
::LeaveCriticalSection(&g_cs);
}
return 0;
}
运行这段代码,两个值的最终结果是相同的,如图3.5所示。
图3.5 程序正确的输出
临界区对象能够很好地保护共享数据,但是它不能够用于进程之间资源的锁定,因为它不是内核对象。如果要在进程间维持线程的同步,可以使用事件内核对象。
3.2.2 互锁函数
互锁函数为同步访问多线程共享变量提供了一个简单的机制。如果变量在共享内存,不同进程的线程也可以使用此机制。用于互锁的函数有InterlockedIncrement、InterlockedDecrement、InterlockedExchangeAdd、InterlockedExchangePointer等,这里仅介绍前两个。
InterlockedIncrement函数递增(加1)指定的32位变量。这个函数可以阻止其他线程同时使用此变量,函数原型如下:
LONG InterlockedIncrement( LONG volatile* Addend); // 指向要递增的变量
InterlockedDecrement函数同步递减(减1)指定的32位变量,原型如下:
LONG InterlockedDecrement( LONG volatile* Addend); // 指向要递减的变量
函数用法相当简单,例如在03CountErr实例中,为了同步对全局变量g_nCount1、g_nCount2的访问,可以按如下所示修改线程函数:
UINT __stdcall ThreadFunc(LPVOID) // 03InterlockDemo工程下
{ while(g_bContinue)
{ ::InterlockedIncrement((long*)&g_nCount1);
::InterlockedIncrement((long*)&g_nCount2);
}
return 0;
}
3.2.3 事件内核对象
多线程程序设计大多会涉及线程间相互通信。使用编程就要涉及到线程的问题。主线程在创建工作线程的时候,可以通过参数给工作线程传递初始化数据,当工作线程开始运行后,还需要通过通信机制来控制工作线程。同样,工作线程有时候也需要将一些情况主动通知主线程。一种比较好的通信方法是使用事件内核对象。
事件对象(event)是一种抽象的对象,它也有未受信(nonsignaled)和受信(signaled)两种状态,编程人员也可以使用WaitForSingleObject函数等待其变成受信状态。不同于其他内核对象的是,一些函数可以使事件对象在这两种状态之间转化。可以把事件对象看成是一个设置在Windows内部的标志,它的状态设置和测试工作由Windows来完成。
事件对象包含3个成员:nUsageCount (使用计数)、bManualReset(是否人工重置)和bSignaled(是否受信)。成员nUsageCount记录当前的使用计数,当使用计数为0的时候,Windows就会销毁此内核对象占用的资源;成员bManualReset指定在一个事件内核对象上等待的函数返回之后,Windows是否重置这个对象为未受信状态;成员bSignaled指定当前事件内核对象是否受信。下面要介绍的操作事件内核对象的函数会影响这些成员的值。
1.基本函数
如果想使用事件对象,需要首先用CreateEvent 函数去创建它,初始状态下,nUsageCount的值为1。
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // 用来定义事件对象的安全属性
BOOL bManualReset, // 指定是否需要手动重置事件对象为未受信状态。
BOOL bInitialState, // 指定事件对象创建时的初始状态
LPCWSTR lpName); // 事件对象的名称
参数bManualReset对应着内核对象中的bManualReset成员。自动重置(auto-reset)和人工重置(manual-reset)是事件内核对象两种不同的类型。当一个人工重置的事件对象受信以后,所有等待在这个事件上的线程都会变为可调度状态;可是当一个自动重置的事件对象受信以后,Windows仅允许一个等待在该事件上的线程变成可调度状态,然后就自动重置此事件对象为未受信状态。
bInitialState参数对应着bSignaled成员。将它设为TRUE,则表示事件对象创建时的初始化状态为受信(bSignaled = TRUE);设为FALSE时,状态为未受信(bSignaled = FALSE)。
lpName参数用来指定事件对象的名称。为事件对象命名是为了在其他地方(比如,其他进程的线程中)使用OpenEvent或CreateEvent函数获取此内核对象的句柄。
HANDLE OpenEvent (
DWORD dwDesiredAccess, // 指定想要的访问权限
BOOL bInheritHandle, // 指定返回句柄是否可被继承
LPCWSTR lpName); // 要打开的事件对象的名称
系统创建或打开一个事件内核对象后,会返回事件的句柄。当编程人员不使用此内核对象的时候,应该调用CloseHandle函数释放它占用的资源。
事件对象被建立后,程序可以通过SetEvent和ResetEvent函数来设置它的状态。
BOOL SetEvent( HANDLE hEvent ); // 将事件状态设为 “受信(sigaled)”;
BOOL ResetEvent(HANDLE hEvent ); // 将事件状态设为 “未受信(nonsigaled)”;
hEvent参数是事件对象的句柄,这个句柄可以通过CreateEvent或OpenEvent函数获得。
对于一个自动重置类型的事件对象,Microsoft定义了一套比较实用的规则:当在这样的事件对象上等待的函数(比如,WaitForSingleObject函数)返回时,Windows会自动重置事件对象为未受信状态。通常情况下,为一个自动重置类型的事件对象调用ResetEvent函数是不必要的,因为Windows会自动重置此事件对象。