Windows下的多线程与线程同步概述
多任务是一个操作系统可以同时运行多个程序的能力。基本上,操作系统使用一个硬件时钟为同时运行的每个进程分配“时间片”。如果时间片足够小,并且机器也没有由于太多的程序而超负荷,那么在用户看来,所有的这些程序似乎在同试运行着。
多线程是在一个程序内部实现多任务的能力。程序可以把它自己分隔为单独的执行“线程”,这些线程似乎也同时在运行[1]。[g1] 多线程的应用非常广泛,最常见的是在需要进行大量计算的程序中使用辅助线程完成计算工作,而用户界面线程响应用户的操作。
多线程中不同线程之间的通讯通常是使用共享数据对象来实现的,不管是使用全局变量还是线程过程函数的指针参数进行通讯,都可能引发访问冲突[2]。[g2] 而解决这一问题的方法即线程同步。
Windows提供了多种方法来实现线程之间的协调和同步,有临界区、事件对象、互斥量等。这些方法都有各自的特点和适用的场合,下面就让我们看一下经典之作《Windows程序设计》一书中所介绍的使用临界区进行线程同步的方法 [3]。
首先需要定义一个全局的临界区对象,以便在不同的线程中能够访问。例如:CRITICAL_SECTION cs;
然后在某个线程中初始化这个临界区对象:
InitializeCriticalSection(&cs);
这样就创建了一个名为cs的临界区对象。此时,线程可以通过下面的调用进入临界区:
EnterCriticalSection(&cs);
在这时,线程被认为“拥有”临界区对象。没有两个线程可以同时拥有临界区对象,因此,如果一个线程进入了临界区,那么下一个使用同一临界区对象调用EnterCriticalSection的线程将在函数调用中被挂起。只有当第一个线程通过下面的调用离开临界区时,函数才会返回:
LeaveCriticalSection(&cs);
这时,在EnterCriticalSection的调用中被挂起的线程拥有临界区,其函数调用也返回,允许线程继续运行。
当临界区不再需要时,可以调用:
DeleteCriticalSection(&cs);
在进入临界区后,线程可以独占方式访问资源而不用担心其他线程的干扰,当不同的线程共享不同的数据时,还可以通过使用多个临界区来实现。
事件对象和互斥量等在使用上与临界区有所不同,但流程和步骤相似,只不过需要调用WaitForSingleObject来替代EnterCriticalSection函数来阻塞线程,并等待其他线程在执行完毕后释放资源。
可以看出,线程同步是一件比较复杂而又容易出错的工作,既要保证各线程在访问和更新数据中不会冲突,又要防止死锁。在MFC中,为了降低线程同步的复杂性,减少工作量,提供了CCriticalSection、CEvent、CMutex等类封装了Windows API中相关的线程同步函数,方便编程人员使用[4]。[g4] 但是这些类的出现并没有改变基本的线程同步编程的流程和步骤,在具体使用过程中仍需要小心谨慎的使用才能够达到目的。
一种简便的线程同步的实现方法
能否在Windows提供的线程同步的方法上进行改进,提供一种简单方便的实现方法呢,这种方法应该不需要繁琐的步骤,既能够满足我们的要求,同时又足够的灵活。
首先让我们重新审视一下Windows所提供的几种线程同步机制。可以发现,在事件对象、互斥量和信号量的使用中都可以使用一个字符串作为线程同步对象的标识,当创建一个带有名称标识的线程同步对象时,若已存在同名对象则会返回一个已存在对象的句柄,并且可以通过调用GetLastError获得ERROR_ALREADY_EXISTS值来检验是否返回了一个已存在对象的句柄值。
以互斥量举例来说:
//创建第一个互斥量
HANDLE mutex1;
mutex1 = CreateMutex(NULL, TRUE, "mutex");
if(ERROR_ALREADY_EXISTS == GetLastError()) //存在同名互斥量
{
printf("Mutex exist!/n");
}
else //未发现同名互斥量
{
printf("Create mutex!/n");
}
//创建第二个互斥量
HANDLE mutex2;
mutex2 = CreateMutex(NULL, TRUE, "mutex");
if(ERROR_ALREADY_EXISTS == GetLastError()) //存在同名互斥量
{
printf("Mutex exist!/n");
}
else //未发现同名互斥量
{
printf("Create mutex!/n");
}
我们在VC++6.0中编译并运行上述代码,得到的结果输出为:
Create mutex!
Mutex exist!
从而可以看出,当我们使用“mutex”作为名称多次创建互斥量时,通过检查GetLastError的返回值可判断出是否是第一次创建此名称的互斥量。由此便可进一步发展出下面两个函数:
HANDLE Lock(char* name)
{
HANDLE mutex;
// Try to open an exist mutex firstly.
mutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, name);
if(NULL == mutex) // If the mutex does not exist, create it with the certain name.
{
mutex = CreateMutex(NULL, TRUE, name);
}
else // If the mutex already exist, wait for other thread release it.
{
WaitForSingleObject(mutex, INFINITE);
}
return mutex;
}
bool Unlock(HANDLE mutex)
{
if(0 == ReleaseMutex(mutex)) // Failed to release mutex
{
return false;
}
else // Successed in release mutex
{
CloseHandle(mutex);
mutex = NULL;
return true;
}
}
使用时仅需进行如下调用:
HANDLE mutex = Lock("MutexLockName");
……
Unlock(mutex);
在调用Lock时传入互斥量的名称。当不存在同名的互斥量时调用CreateMutex创建一个以name变量值为名称的互斥量;若同名的互斥量已经存在时OpenMutex函数将返回已存在的互斥量的一个句柄,此时通过调用WaitForSingleObject阻塞当前线程,等待同名互斥量被释放后继续运行。
在调用Unlock时传入互斥量的句柄,通过ReleaseMutex释放线程对互斥量的拥有权,并关闭句柄,防止资源泄露。
有了上述两个函数,我们便可通过对同一资源使用相同的名称来进行锁定,实现线程同步了。例如在不同的线程中存在着一个对象,定义如下:
CObject object;
当我们在访问和更改这一对象时只需进行如下调用:
HANDLE mutex = Lock("object");
object.DoSomeThing();
Unlock(mutex);
就可以十分方便的完成针对这一对象的线程同步了。
通过Lock和Unlock函数,我们隐藏了使用互斥量时的一些具体细节,降低了复杂性,但是对于这种方法仍存在着许多不足,下面就一步一步循序渐进的来改进这些问题。
在使用过程中,必须为Lock函数传入一个名称作为参数,并且此名称在锁定同一数据对象时必须是一致的才能保证Lock函数的正常运作,这就为用户带来了不必要的麻烦。当存在如下声明时:
CObject* pObject1 = new CObject;
CObject* pObject2 = pObject1;
CObject* pObject3 = pObject2;
可以看出,pObject1、pObject2、pObject3实际上指向同一对象,这时候如果要进行线程同步必须在锁定这三个指针时使用相同的名称标识。于是就出现了别名问题。
在C++中,在不同的作用域中变量名称是可以相同的,因此同样一个pObject在不同的作用域中可能是指向不同的对象的,也就是同名问题。
针对别名和同名,如何能够简单的识别出同一对象并且为其统一命名呢?在C++ 中每个类都存在一个this指针用以指向自身存储的地址。在一个程序中,每个不同的对象在内存中都有着不同的存储地址。这个地址就好像身份证号码一样标识出了不同的对象,也正是我们所希望得到的针对不同对象的唯一的名称。于是可以将Lock函数改造为:
HANDLE Lock(void* pointer)
{
char name[128];
itoa((DWORD)pointer, name, 16);
// 以下同前述
HANDLE mutex;
// Try to open an exist mutex firstly.
mutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, name);
……
}
这样在调用时就不需要再人为的为每个对象命名了,只需要将对象的地址传入即可:
HANDLE mutex = Lock(pObject);
……
Unlock(mutex);
从而也就免去了命名之苦。
最主要的问题已经通过努力一步步地解决了,但是在程序看起来却依然很丑陋,Lock和Unlock必须作为全局函数,有违面向对象的设计原则,而且每次需要成对的调用,难免会有粗心的程序员调用了Lock[g5] 却忘记Unlock,于是程序就有可能进入死锁[MS6] 。
为了能够更方便的使用,首先创建一个类CMutexLock,将Lock和Unlock作为成员函数,再重新审视前面的程序,不难发现Unlock的参数HANDLE mutex也可以移入CMutexLock成为其成员变量:
class CMutexLock
{
public:
CMutexLock();
virtual ~CMutexLock();
bool Lock(void* pointer);
bool Unlock();
private:
HANDLE m_Mutex;
};
这时,在使用过程中只需先声明一个CMutexLock的对象,然后调用Lock和Unlock函数。
当Lock和Unlock成为一个类的成员函数时,随之而来的一个疑问就是:
“有必要暴露这两个函数成为公共方法吗?”既然Lock和Unlock必须成对的出现,那么不是刚好对应于类的构造函数和析构函数吗?为什么不把它们一个放入构造函数,一个放入析构函数呢?当提出了这些疑问的同时也就找到了解决之道:
class CMutexLock
{
public:
CMutexLock(void* pointer);
virtual ~CMutexLock();
private:
bool Lock(void* pointer);
bool Unlock();
HANDLE m_Mutex;
};
CMutexLock::CMutexLock(void* pointer)
:m_Mutex(NULL)
{
this->Lock(pointer);
}
CMutexLock::~CMutexLock()
{
this->Unlock();
}
……
对CMutexLock的调用也简化为了一句定义语句:
{
CMutexLock lock(pObject);
……
}
当声明lock时,针对传入构造函数的pObject调用Lock函数,当lock离开生存空间销毁时在析构函数里面调用Unlock释放互斥量。在这里通过使用{}来控制CMutexLock析构函数的调用,即释放对pObject的锁定。在C++中{}可以用来控制变量的作用域。在{}内声明的lock在遇到}时即会被销毁,在销毁前析构函数也将会被自动调用。
至此,我们就完成了一个使用起来简单方便的线程同步辅助类。
小结
在本文中,以Windows多线程机制为基础,利用C++的一些最基本的特性一步步的创建出了CMutexLock类,而线程同步也由最初繁琐复杂的过程简化至只需一对{}和一句定义语句就完成了。CMutexLock使用起来简单方便,可以针对不同的数据对象分别进行锁定,并且减少了死锁的出现。在实际程序中运作良好,表明了这种方法的实用性。
但是在使用过程中,由于互斥量本身特性和本文中实现方式带来的一些弊病在所难免,例如不同进程之间的名字冲突、效率问题、构造函数发生错误、别名问题等。限于篇幅和笔者水平,这些问题不在此文中一一详述。如有兴趣,可来信讨论,邮件地址:deeplymove@tom.com 。
参考文献:
[1] P1119 《Windows程序设计》(第5版) Charles Petzold 北京大学出版社
[2] P263 《Visual C++ 6.0 技术内幕》(第5版修订版) David J.Kruglinski Scot Wingo George Shepherd 希望出版社
[3] P1147 《Windows程序设计》(第5版) Charles Petzold 北京大学出版社
[4] MSDN