同步可以保证在一个时间内只有一个线程对某个共享资源有控制权。共享资源包括全局变量、公共数据成员或者句柄等。临界区内核对象和事件内核对象可以很好地用于多线程同步和它们之间的通信。本节将结合各种简单的例子来讨论产生同步问题的根本原因,进而提出相应的解决方案。
当多个线程在同一个进程中执行时,可能有不止一个线程同时执行同一段代码,访问同一段内存中的数据。多个线程同时读共享数据没有问题,但如果同时读和写,情况就不同了。下面是一个有问题的程序,该程序用两个线程来同时增加全局变量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是全局变量,属于该进程内所有线程共有的资源。多线程同步就要保证在一个线程占有公共资源的时候,其他线程不会再次占有这个资源。所以,解决同步问题,就是保证整个存取过程的独占性。在一个线程对某个对象进行操作的过程中,需要有某种机制阻止其他线程的操作,这就用到了临界区对象。
临界区对象是定义在数据段中的一个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 程序正确的输出
临界区对象能够很好地保护共享数据,但是它不能够用于进程之间资源的锁定,因为它不是内核对象。如果要在进程间维持线程的同步,可以使用事件内核对象。
互锁函数为同步访问多线程共享变量提供了一个简单的机制。如果变量在共享内存,不同进程的线程也可以使用此机制。用于互锁的函数有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;
}
多线程程序设计大多会涉及线程间相互通信。使用编程就要涉及到线程的问题。主线程在创建工作线程的时候,可以通过参数给工作线程传递初始化数据,当工作线程开始运行后,还需要通过通信机制来控制工作线程。同样,工作线程有时候也需要将一些情况主动通知主线程。一种比较好的通信方法是使用事件内核对象。
事件对象(event)是一种抽象的对象,它也有未受信(nonsignaled)和受信(signaled)两种状态,编程人员也可以使用WaitForSingleObject函数等待其变成受信状态。不同于其他内核对象的是,一些函数可以使事件对象在这两种状态之间转化。可以把事件对象看成是一个设置在Windows内部的标志,它的状态设置和测试工作由Windows来完成。
事件对象包含3个成员:nUsageCount (使用计数)、bManualReset(是否人工重置)和bSignaled(是否受信)。成员nUsageCount记录当前的使用计数,当使用计数为0的时候,Windows就会销毁此内核对象占用的资源;成员bManualReset指定在一个事件内核对象上等待的函数返回之后,Windows是否重置这个对象为未受信状态;成员bSignaled指定当前事件内核对象是否受信。下面要介绍的操作事件内核对象的函数会影响这些成员的值。
如果想使用事件对象,需要首先用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会自动重置此事件对象。
下面例子中,主线程通过将事件状态设为“受信”来通知子线程开始工作。这是事件内核对象一个很重要的用途,示例代码如下:
#include <stdio.h> // 03EventDemo工程下
#include <windows.h>
#include <process.h>
HANDLE g_hEvent;
UINT __stdcall ChildFunc(LPVOID);
int main(int argc, char* argv[])
{ HANDLE hChildThread;
UINT uId;
// 创建一个自动重置的(auto-reset events),未受信的(nonsignaled)事件内核对象
g_hEvent = ::CreateEvent(NULL, FALSE, FALSE, NULL);
hChildThread = (HANDLE)::_beginthreadex(NULL, 0, ChildFunc, NULL, 0, &uId);
// 通知子线程开始工作
printf("Please input a char to tell the Child Thread to work: /n");
getchar();
::SetEvent(g_hEvent);
// 等待子线程完成工作,释放资源
::WaitForSingleObject(hChildThread, INFINITE);
printf("All the work has been finished. /n");
::CloseHandle(hChildThread);
::CloseHandle(g_hEvent);
return 0;
}
UINT __stdcall ChildFunc(LPVOID)
{ ::WaitForSingleObject(g_hEvent, INFINITE);
printf(" Child thread is working...... /n");
::Sleep(5*1000); // 暂停5秒,模拟真正的工作
return 0;
}
运行程序,输入一个字符通知子线程开始工作,结果如图3.6所示。
图3.6 使用事件内核对象通信
主线程一开始,就创建了一个自动重置的(auto-reset),未受信的(nonsignaled)事件内核对象,并用全局变量g_hEvent保存对象的句柄。这样做会使本进程的其他线程访问此内核对象更加容易。接着子线程被创建,并等待主线程的通知来开始真正的工作。最后,子线程工作结束,主线程退出。
事件对象主要用于线程间通信,因为它是一个内核对象,所以也可以跨进程使用。依靠在线程间通信就可以使各线程的工作协调进行,达到同步的目的。
信号量(Semaphore)内核对象对线程的同步方式与前面几种方法不同,它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。在用CreateSemaphore函数创建信号量时,即要同时指出允许的最大资源计数和当前可用资源计数。一般是将当前可用资源计数设置为最大资源计数,每增加一个线程对共享资源的访问,当前可用资源计数就会减1,只要当前可用资源计数是大于0的,就可以发出信号量信号。但是当前可用计数减小到0时则说明当前占用资源的线程数已经达到了所允许的最大数目,不能再允许其他线程的进入,此时的信号量信号将无法发出。线程在处理完共享资源后,应在离开的同时通过ReleaseSemaphore函数将当前可用资源计数加1。在任何时候当前可用资源计数决不可能大于最大资源计数。图3.7显示了信号量对象对资源的控制。
图3.7 使用信号量对象控制资源
在图3.7中,以箭头和白色箭头表示共享资源所允许的最大资源计数和当前可用资源计数。初始如图(a)所示,最大资源计数和当前可用资源计数均为4,此后每增加一个对资源进行访问的线程(用黑色箭头表示)当前资源计数就会相应减1,图(b)即表示的在3个线程对共享资源进行访问时的状态。当进入线程数达到4个时,将如图(c)所示,此时已达到最大资源计数,而当前可用资源计数也已减到0,其他线程无法对共享资源进行访问。在当前占有资源的线程处理完毕而退出后,将会释放出空间,图(d)已有两个线程退出对资源的占有,当前可用计数为2,可以再允许2个线程进入到对资源的处理。可以看出,信号量是通过计数来对线程访问资源进行控制的,而实际上信号量确实也被称作Dijkstra计数器。
使用信号量内核对象进行线程同步主要会用到CreateSemaphore、OpenSemaphore、ReleaseSemaphore、WaitForSingleObject和WaitForMultipleObjects等函数。其中,CreateSemaphore用来创建一个信号量内核对象,其函数原型为:
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 安全属性指针
LONG lInitialCount, // 初始计数
LONG lMaximumCount, // 最大计数
LPCTSTR lpName // 对象名指针
);
参数lMaximumCount是一个有符号32位值,定义了允许的最大资源计数,最大取值不能超过4294967295。lpName参数可以为创建的信号量定义一个名字,由于其创建的是一个内核对象,因此在其他进程中可以通过该名字而得到此信号量。OpenSemaphore()函数即可用来根据信号量名打开在其他进程中创建的信号量,函数原型如下:
HANDLE OpenSemaphore(
DWORD dwDesiredAccess, // 访问标志
BOOL bInheritHandle, // 继承标志
LPCTSTR lpName // 信号量名
);
在线程离开对共享资源的处理时,必须通过ReleaseSemaphore来增加当前可用资源计数。否则将会出现当前正在处理共享资源的实际线程数并没有达到要限制的数值,而其他线程却因为当前可用资源计数为0而仍无法进入的情况。ReleaseSemaphore的函数原型为:
BOOL ReleaseSemaphore(
HANDLE hSemaphore, // 信号量句柄
LONG lReleaseCount, // 计数递增数量
LPLONG lpPreviousCount // 先前计数
);
该函数将lReleaseCount中的值添加给信号量的当前资源计数,一般将lReleaseCount设置为1,如果需要也可以设置其他的值。WaitForSingleObject和WaitForMultipleObjects主要用在试图进入共享资源的线程函数入口处,主要用来判断信号量的当前可用资源计数是否允许本线程的进入。只有在当前可用资源计数值大于0时,被监视的信号量内核对象才会得到通知。
信号量的使用特点使其更适用于对Socket(套接字)程序中线程的同步。例如,网络上的HTTP服务器要对同一时间内访问同一页面的用户数加以限制,这时可以为没一个用户对服务器的页面请求设置一个线程,而页面则是待保护的共享资源,通过使用信号量对线程的同步作用可以确保在任一时刻无论有多少用户对某一页面进行访问,只有不大于设定的最大用户数目的线程能够进行访问,而其他的访问企图则被挂起,只有在有用户退出对此页面的访问后才有可能进入。下面给出的示例代码即展示了类似的处理过程:
// 信号量对象句柄
HANDLE hSemaphore;
UINT ThreadProc15(LPVOID pParam)
{
// 试图进入信号量关口
WaitForSingleObject(hSemaphore, INFINITE);
// 线程任务处理
AfxMessageBox("线程一正在执行!");
// 释放信号量计数
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
UINT ThreadProc16(LPVOID pParam)
{
// 试图进入信号量关口
WaitForSingleObject(hSemaphore, INFINITE);
// 线程任务处理
AfxMessageBox("线程二正在执行!");
// 释放信号量计数
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
UINT ThreadProc17(LPVOID pParam)
{
// 试图进入信号量关口
WaitForSingleObject(hSemaphore, INFINITE);
// 线程任务处理
AfxMessageBox("线程三正在执行!");
// 释放信号量计数
ReleaseSemaphore(hSemaphore, 1, NULL);
return 0;
}
……
void CSample08View::OnSemaphore()
{
// 创建信号量对象
hSemaphore = CreateSemaphore(NULL, 2, 2, NULL);
// 开启线程
AfxBeginThread(ThreadProc15, NULL);
AfxBeginThread(ThreadProc16, NULL);
AfxBeginThread(ThreadProc17, NULL);
}