上一篇 中我介绍了一种通过封闭 Critical Section 对象而方便的使用互斥锁的方式,文中所有的例子是两个线程对同一数据一读一写,因此需要让它们在这里互斥,不能同时访问。而在实际情况中可能会有更复杂的情况出现,就是多个线程访问同一数据,一部分是读,一部分是写。我们知道只有读 - 写或写 - 写同时进行时可能会出现问题,而读 - 读则可以同时进行,因为它们不会对数据进行修改,所以也有必要在 C++ 中封装一种方便的允许读 - 读并发、读 - 写与写 - 写互斥的锁。要实现这种锁,使用临界区就很困难了,不如改用内核对象,这里我使用的是互斥量( Mutex )。 总体的结构与 上一篇 中的类似,都是写出一个对锁进行封装的基类,再写一个用于调用加、解锁函数的类,通过对第二个类的生命周期的管理实现加锁和解锁。这里涉及到两个新问题,一是加锁、解锁动作都有两种,一种是加 / 解读锁,一种是加 / 解写锁;二是为了允许读 - 读并发,这里只声明一个 Mutex 是不够的,必须要声明多个 Mutex ,而且有多少个 Mutex 就同时允许多少个读线程并发,之所以这么说,是因为我们要使用的 API 函数是 WaitForMultipleObjects 。 WaitForMultipleObjects 函数的功能就是等待对象状态被设置, MSDN 中对它的说明为: Waits until one or all of the specified objects are in the signaled state or the time-out interval elapses. 这是个很好用的函数,我们可以用它来等待某个或某几个对象,并且允许设置超时时间,等待成功时与超时时返回的值是不同的。如果返回的值比 WAIT_ABANDONED 小则表示等待成功。 “ 等待成功 ” 对于不同类型的内核对象有不同的意义,例如对于进程或线程对象,等待成功就表示进程或线程执行结束了;对于互斥量对象,则表示此对象现在不被任何其他线程拥有,并且一旦等待成功,当前线程即拥有了此互斥量,其他线程则不能同时拥有,直接调用 ReleaseMutex 函数主动释放互斥量。 与 WaitForMultipleObjects 类似的还有一个函数 WaitForSingleObject ,它的功能比较简单,只针对单一个对象,而 WaitForMultipleObjects 可以同时等待多个对象,并且可以设置是否等待所有对象。 上一篇 文章中用的 InstanceLockBase 类里面封装了一个 Critical Section 对象,这里则要封装一组 Mutex 的 Handle ,那么这一组是多少个呢?它应该由使用此类的程序中定义,例如可以用动态数组的方法:
// 基类: class RWLockBase // 表示 Read/Write Lock ...{ HANDLE* handles; protected: RWLockBase(int handleCount) ...{ handles = new HANDLE[handleCount]; } … }; // 子类: class MyClass: public RWLockBase ...{ MyClass(): RWLockBase(3) ...{} … };
这确实是个不错的办法,通过在子类构造函数的初始化段中调用基类构造函数并传参,使得这个动态数组得以正确初始化,不过这样看着不太爽,子类必须两次出现 “RWLockBase” 一词,能不能像 InstanceLockBase 那样只要继承了就好呢?答案是肯定的,只要用 C++ 模板即可:
template <int maxReadCount> class RWLockBase ...{ HANDLE handles[maxReadCount]; … };
使用模板附带这么一个好处,因为模板参数是在编译期可以确定的,所以无需再用动态数组,直接在栈上分配即可。而使用模板引出一个新问题,就是相应的 Lock 类( RWLock )在构造时传的对象指针时的类型声明,直接写成 RWLock(RWLockBase* pObj) 肯定是不行的,因为必须指定模板参数,并且其值还必须与声明 RWLockBase 时所指定的值一致才行,从而客户端代码就必须两次指定模板参数值,不爽!解决的办法也是有一个,就是把 RWLockBase 变成夹层类,为它再声明一个基类,让 RWLock 接收的是基类指针,并把 Lock 、 Unlock 等函数放在基类中,声明为纯虚函数,实现写在夹层类中:
class _RWLockBase ...{ friend class RWLock; protected: virtual DWORD ReadLock(int timeout) = 0; virtual void ReadUnlock(int handleIndex) = 0; virtual DWORD WriteLock(int timeout) = 0; virtual void WriteUnlock() = 0; };
模板类 RWLockBase 从 _RWLockBase 继承,并对四个函数写出实现:
template <int maxReadCount = 3> // 这里给一个缺省参数,尽量减少客户端代码量 class RWLockBase: public _RWLockBase ...{ HANDLE handles[maxReadCount]; DWORD ReadLock(int timeout) // 加读锁,只要等到一个互斥量返回即可 ...{ return ::WaitForMultipleObjects (maxReadCount, handles, FALSE, timeout); } void ReadUnlock(int handleIndex) // 解读锁,释放已获得的互斥量 ...{ ::ReleaseMutex(handles[handleIndex]); } DWORD WriteLock(int timeout) // 加写锁,等到所有互斥量,从而与其他所有线程互斥 ...{ return ::WaitForMultipleObjects (maxReadCount, handles, TRUE, timeout); } void WriteUnlock() // 解写锁,释放所有的互斥量 ...{ for(int i = 0; i < maxReadCount; i++) ::ReleaseMutex(handles[i]); } protected: WLockBase() // 构造函数,初始化每个互斥量 ..{ for(int i = 0; i < maxReadCount; i++) handles[i] = ::CreateMutex(0, FALSE, 0); } ~RWLockBase() // 析构函数,销毁对象 ...{ for(int i = 0; i < maxReadCount; i++) ::CloseHandle(handles[i]); } };
而相应的锁类也会稍复杂一些:
class RWLock ...{ bool lockSuccess; // 因为有可能超时,需要保存是否等待成功 int readLockHandleIndex; // 对于读锁,需要知道获得的是哪个互斥量 _RWLockBase* _pObj; // 目标对象基类指针 public: // 这里通过第二个参数决定是加读锁还是写锁,第三个参数为超时的时间 RWLock(_RWLockBase* pObj, bool readLock = true, int timeout = 3000) ...{ _pObj = pObj; lockSuccess = FALSE; readLockHandleIndex = -1; if(NULL == _pObj) return; if(readLock) // 读锁 ...{ DWORD retval = _pObj->ReadLock(timeout); if(retval < WAIT_ABANDONED) // 返回值小于 WAIT_ABANDONED 表示成功 ...{ // 其值减 WAIT_OBJECT_0 就是数组下标 readLockHandleIndex = retval - WAIT_OBJECT_0; lockSuccess = TRUE; } } else ...{ WORD retval = _pObj->WriteLock(timeout); if(retval < WAIT_ABANDONED) // 写锁时获得了所有互斥量,无需保存下标 lockSuccess = TRUE; } } ~RWLock() ...{ if(NULL == _pObj) return; if(readLockHandleIndex > -1) _pObj->ReadUnlock(readLockHandleIndex); else _pObj->WriteUnlock(); } bool IsLockSuccess() const ...{ return lockSuccess; } };
这样一来,读 / 写锁的类也就完成了,使用时与 InstanceLock 类似: 1 、被锁对象从 RWLockBase<> 类继承 2 、需要加读锁时,声明一个 RWLock 实例,并指出要加的是读锁 3 、需要加写锁时,声明一个 RWLock 实例,并指出要加的是写锁 这里还是要多说两句,虽然使用纯虚函数结合模板类,使得客户端代码量减到最少,但性能上有一些影响,因为声明了虚函数,则实例中必然存在 4 个字节的 VPTR ,调用虚函数时则要查找 VTABLE ,空间和时间上都有微小的牺牲。而如果不使用模板类,则没有虚函数的代价,但也有牺牲:不使用模板类则需要使用动态数组,动态数组本身需要程序运行时在堆上分配,这也需要时间;指向动态数组的指针也需要占用内存,所以空间上的开锁是一样的,时间上虽然动态分配内存需要的时间应该比虚函数的调用要慢一点,但初始化只需要一次,总体来说也是值得的。所以最终要使用哪一种,就看具体需要了。 这里也给出一个实验。这里所用的被锁类也上一篇类似,简单的从 RWLockBase 类继承:
class MyClass2: public RWLockBase<> ...{}; MyClass2 mc2;
看看两个线程函数:
// 读线程 DWORD CALLBACK ReadThreadProc(LPVOID param) ...{ int i = (int)param; RWLock lock(&mc2); // 加读锁 if(lock.IsLockSuccess()) // 如果加锁成功 { Say("read thread %d started", i); // 为了代码短一些,假设 Say 函数有这种能力 Sleep(1000); Say("read thread %d ended", i); } else // 加锁超时,则显示超时信息 Say("read thread %d timeout", i); return 0; } // 写线程 DWORD CALLBACK WriteThreadProc(LPVOID param) ...{ int i = (int)param; RWLock lock(&mc2, false); // 加写锁。 if(lock.IsLockSuccess()) ...{ Say("write thread %d started", i); Sleep(600); Say("write thread %d ended", i); } else Say("write thread %d timeout", i); return 0; }
主线程:
int i; for(i = 0; i < 5; i++) ::CreateThread(0, 0, ReadThreadProc, (LPVOID)i, 0, 0); for(i = 0; i < 5; i++) ::CreateThread(0, 0, WriteThreadProc, (LPVOID)i, 0, 0);
程序共开 10 个线程, 5 个读 5 个写。从 RWLockBase 类继承时我们使用了默认的模板参数,所以最多同时允许 3 个读线程。程序的运行结果如下:
001 [15:07:28.484]read thread 0 started 002 [15:07:28.484]read thread 1 started 003 [15:07:28.484]read thread 2 started 004 [15:07:29.484]read thread 0 ended 005 [15:07:29.484]read thread 3 started 006 [15:07:29.484]read thread 1 ended 007 [15:07:29.484]read thread 4 started 008 [15:07:29.484]read thread 2 ended 009 [15:07:30.484]read thread 3 ended 010 [15:07:30.484]read thread 4 ended 011 [15:07:30.484]write thread 0 started 012 [15:07:31.078]write thread 0 ended 013 [15:07:31.078]write thread 1 started 014 [15:07:31.484]write thread 2 timeout 015 [15:07:31.484]write thread 3 timeout 016 [15:07:31.484]write thread 4 timeout 017 [15:07:31.687]write thread 1 ended
前三行三个读线程取得读锁,之后等一秒(第 4-8 行),三个读线程都结束了,并且余下的两个读线程取得读锁,虽然这时剩下了一个互斥量没有使用,但因为其他的线程都请求加写锁,写锁与其他所有线程互斥,所以还不能取得写锁。再过一秒(第 9-11 行),后来的两个取得读锁的线程也结束了,则第一个写线程取得写锁。 600 毫秒之后(第 12-13 行)第一个写线程结束,第二个写线程开始。 400 毫秒之后(第 14-16 行)余下的三个写线程都超时了,再后第二个写线程也结束了。