人, 既无虎狼之爪牙,亦无狮象之力量,却能擒狼缚虎,驯狮猎象,无他,唯智慧耳。
全部博文(167)
分类: WINDOWS
2012-04-09 20:19:29
一、概述
多线程编程常常需要考虑线程同步的问题,比如多线程访问一个共享资源,比如线程等待另一个线程的消息事件等。Interlocked系列函数、关键段、Slim读写锁等都属于用户模式下的线程同步技术,它们最大的好处就是不需要在用户-内核模式间切换,从而速度非常快,具备了非常好的性能。但是,实际应用起来仅仅使用用户态同步技术也有不少局限性,比如Interlocked函数只能对一个值进行原子操作,又或者关键段只能在同一个进程的多个线程间实现同步,不能跨进程等。所以,我们常常还需要内核对象的同步技术。使用内核对象唯一的不足就是会牺牲掉性能。作为线程同步的学习笔记,我将自己认为比较重要和常用的知识整理如下,其他的知识留待以后需要时再学习。这篇笔记大致如下:
Ø 等待函数
Ø 事件内核对象
Ø 可等待的计时器内核对象
Ø 信号量内核对象
Ø 互斥量内核对象
Ø 线程同步对象总结
二、等待函数
大多数的内核对象都有触发(signaled)和非触发(nonsignaled)两个状态,状态变化的规则依据对象的不同而略有不同。对于进程和线程内核对象而言,创建时是未触发状态,执行完毕退出则为触发状态。可以这样理解,对于进程和线程的内核对象,触发状态标识该对象已经执行过退出了。线程同步间最常用的函数是允许一个线程等待某个特定的内核对象被触发的函数——等待函数。我们只需要先掌握两个函数即可,它们是
1. WaitForSingleObject()
该函数使一个线程自愿进入等待状态,直到指定的内核对象被触发为止。如果等待函数调用时等待的内核对象已经处于触发状态,线程不会进入等待状态。函数原型为
DWORD WaitForSingleObject(
HANDLE hObject, //标识等待的内核对象,可以处于触发或非触发状态
DWORD dwMilliseconds //指定线程等待的时间
);
EXA: WaitForSingleObject(hProcess, INFINITE); //线程等待直到指定进程终止为止
函数执行成功返回WAIT_OBJECT_0,如果对象触发时超过了指定的时间返回WAIT_TIMEOUT,调用失败返回WAIT_FAILED。
2. WaitForMultipleObjects()
使用这个函数我们可以等待多个内核对象,函数原型为
DWORD WaitForMultipleObjects(
DWORD dwCount, //等待的内核对象个数,1-64
CONST HANDLE* phObjects, //指向一个等待的内核对象数组
BOOL bWaitAll, //是否等待所有内核对象都触发才执行
DWORD dwMilliseconds //等待的时间
);
函数的返回值WAIT_FAILED和WAIT_TIMEOUT同上面的函数一样,当不要求所有对象都触发就返回时,即bWaitAll设为“FALSE”时,执行成功的返回值是触发的内核对象的索引值,形式为WAIT_OBJECT_0 + x----->因phObjects[x]触发返回
等待函数极大的方便了线程间的同步,而且成为经常使用的函数。但是等待成功有时会带来副作用,即等待成功后等待的内核对象的状态也发生改变,如自动重置事件内核对象,触发状态等待返回后,自动重置为非触发,这称之为等待成功所引起的副作用。在编程中我们要小心这种情况可能会引起的意想不到的错误。
三、事件内核对象
在所有的内核对象中,事件比其他对象要简单得多。
1. 事件包含一个使用计数(所有内核对象的共性),一个用来表示事件是自动重置事件还是手动重置事件的布尔值,以及另一个用来表示事件有没有被触发的布尔值。
2. 事件的触发表示一个操作已经完成。手动重置事件被触发时,正在等待该事件的所有线程都将变成可调度状态;而当一个自动重置事件被触发的时候,只有一个正在等待该事件的线程会变成可调度状态。
3. 事件最通常的用途是让一个线程执行初始化工作,然后再触发另一个线程,让它执行剩余的工作。
4. 主要的函数
使用一个事件内核对象前必须先创建一个事件内核对象
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset, //TRUE是手动重置事件
BOOL bInitialState, //TRUE初始化为触发事件
PCTSTR pszName);
可以使用OpenEvent()函数获得该内核对象的句柄
HANDLE OpenEvent(
DWORD dwDesiredAccess,
BOOL bInherit,
PCTSTR pszName
);
一旦创建了事件,我们就可以直接控制它的状态
BOOL SetEvent(HANDLE hEvenet); //将事件设为触发状态
BOOL ResetEvent(HANDLE hEvent); //将事件设为非触发状态
使用完毕记得调用CloseHandle()关闭对象。
四、可等待的计时器内核对象
可等待的计时器是这样一种内核对象,它们会在某个指定的时间触发,或每隔一段事件触发一次。它们通常用来在某个事件执行一些操作。
首先我们需要创建可等待的计时器,
HANDLE CreateWaitableTimer(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
PCTSTR pszName
);
与事件一样,参数bManualReset表示要创建的是一个手动重置计时器还是一个自动重置计时器。当手动重置计时器被触发的时候,正在等待计时器的所有线程都会变成可调度状态。当自动重置计时器被触发的时候,只有一个正在等待该计时器的线程会变成可调度状态。
在创建的时候,可等待的计时器对象总是处于未触发状态(这当然是肯定的!),当我们想要触发计时器的时候必须调用SetWaitableTimer()函数:
BOOL SetWaitableTimer(
HANDLE hTimer,
Const LARGE_INTEGER *pDueTime,
LONG lPeriod,
PTIMERAPCROUTINE pfnCompletionRoutine,
PVOID pvArgToComletionRoutine,
BOOL bResume
);
这里对于这个函数不做详细的介绍,需要时可以问度娘。提一下这里的参数pDueTime表示第一次触发计时器应当在什么时候,lPeriod表示在第一次触发后,计时器应当以怎样的频度触发,单位为毫秒。
五、信号量内核对象
信号量内核对象用来对资源进行计数。与其他所有内核对象相同,它们也包含一个使用计数,但是它还包含另外两个32位值:一个最大资源计数和一个当前资源计数。最大资源计数用来表示信号量可以控制的最大资源数量,当前资源计数表示信号量当前可用资源的数量。
泛泛谈不容易理解信号量到底是干什么的,举个例子吧。
信号量是用来通过对资源计数从而实现控制资源访问的。以并发服务器为例,除了使用多线程,线程池等方法,我们也可以使用信号量来实现。服务器为每一个客户请求分配一个内存空间,我们可以设置信号量控制这个资源的访问,最大计数为5.进入一个客户请求当前计数+1,服务器线程领走一个客户请求将当前计数-1,那么当信号量计数为0时当前资源服务器线程便不可访问该资源,必须等待新的客户请求到达。
这里信号量的规则如下:
1. 如果当前资源计数大于0,那么信号量处于触发状态
2. 如果当前资源计数等于0,那么信号量处于未触发状态
3. 系统绝对不会让当前资源计数变为负数
4. 当前资源计数绝对不会大于最大资源计数
我们来创建一个信号量对象,
HANDLE CreateSemaphore(
PSECURITY_ATTRIBUTES psa,
LONG lInitialCount, //信号量当前资源计数
LONG lMaximumCount, //信号量最大计数
PCTSTR pszName
);
EXA: HANDLE hSemaphore = CreateSemaphore(NULL, 0,5,NULL);
递增信号量的当前计数:
BOOL ReleaseSemaphore(
HANDLE hSemaphore, //信号量对象
LONG lReleaseCounte, //增加到信号量当前计数上的值,一般为1
PLONG plPreviousCount //返回变化前当前的计数,不需要则设为NULL
);
没有在不改变当前计数的前提下得到当前计数的值
六、互斥量对象
互斥量(mutex)内核对象用来确保一个线程独占对一个资源的访问。实际上这也是为什么叫做互斥量的原因。互斥量对象包含一个使用计数、线程ID以及一个递归计数。互斥量与关键段都可以独占当前的资源,但是互斥量是内核对象,而关键段时用户模式下的同步对象。所以一般来说互斥量比关键段要慢,但是不用进程中的线程可以访问同一个互斥量。
线程ID用来标识当前占用这个互斥量的是系统的哪个线程,也就是说,互斥量具有“线程所有权”。递归计数表示这个线程占用该互斥量的次数。这里一个线程可以多次占用一个互斥量从而使得互斥量的递归计数大于1.当然,释放的时候也需要多次调用释放函数才能释放该互斥量。互斥量的规则是:
1. 如果线程ID为0(无效线程ID),那么该互斥量不为任何线程所占用,它处于触发状态。
2. 如果线程ID为非零值,那么有一个线程已经占用了该互斥量,它处于未触发状态。
3. 与所有其他内核对象不同,操作系统对互斥量进行了特殊处理,允许它们违反一些常规的规则。那就是当一个线程的ID和等待的互斥量的线程ID相同时,即它在等待一个“属于”自己的互斥量时,不会进入等待状态,依然保持可调度状态,于是一个线程多次等待一个互斥量会使得属于它的互斥量的递归计数大于1,这也是该计数大于1的唯一情况。
创建一个互斥量的方法是:
HANDLE CreateMutex(
PSECURITY_ATTRIBUTES psa,
BOOL bInitialOwner,
PCTSTR pszName
);
参数bInitialOwner用来控制互斥量的初始状态。如果穿的是FALSE,那么互斥量对象的线程ID和递归计数都将被设为0,表示该互斥量不属于任何一个线程,这是最常用的情况;如果给参数赋值TRUE,那么对象的线程ID将被设为调用线程的线程ID,递归计数将被设为1.由于此时线程ID为非零值,因而互斥量最初处于非触发状态。
释放互斥量使用ReleaseMutex()函数
BOOL ReleaseMutex(HANDLE hMutex);
但是要注意,一般来说只有互斥量的所属线程才可以释放该互斥量,这是显然的,一个线程占用了互斥量,当然也只能由它来释放后其它线程才可以继续访问。然而,如果由于线程意外终止造成该互斥量没有释放,比如调用了ExitThread(), TerminateThread(), ExitProcess(), TerminateProcess()都会造成没有清理释放资源就结束线程。此时残留的互斥量便造成了“遗弃问题”。当然,系统记录了所有的互斥量和线程ID关系,一旦意识到一个互斥量被“遗弃”了,就会自动将该互斥量改为触发状态。此时等待成功的新线程会得到返回值WAIT_ABANDONED告诉它所得到的互斥量为其他线程所占用后遗弃,因此可能会出现诸多的访问问题。
七、线程同步对象总结
最后我们借用《Windows核心编程》上的一张表作为本篇笔记的小结
内核对象与线程同步:
对象 | 何时处于未触发状态 | 何时处于触发状态 | 成功等待的副作用 |
进程 | 仍在运行时 | 终止时(ExitProcess, TerminateProcess) | No |
线程 | 仍在运行时 | 终止时(ExitThread, TerminateThread) | No |
作业 | 尚未超时时 | 作业超时时 | No |
文件 | 有待处理的I/O请求 | I/O请求完成 | No |
控制台输入 | 无输入时 | 有输入时 | No |
文件变更通知 | 无变更 | 有变更 | 重置通知 |
自动重置事件 | ResetEvent或等待成功 | SetEvent | 重置事件 |
手动重置事件 | ResetEvent | SetEvent | No |
自动重置计时器 | CancelWaitableTimer或等待成功 | 时间到时(SetWaitableTimer) | 重置计时器 |
手动重置计时器 | 同上 | 同上 | No |
信号量 | 等待成功时 | 计数大于0(ReleaseSemaphore) | 计数-1 |
互斥量 | 等待成功时 | 不为线程占用时(ReleaseMutex) | 把所有权交给线程 |
关键段(用户模式) | 等待成功时(EnterCriticalSection) | 不为线程占用的时候(LeaveCriticalSection) | 同上 |
SRWLOCK(用户模式) | 等待成功时(AcquireSRWLock(Exclusive)) | 不为线程占用时(ReleaseSRWLock(Exclusive)) | 同上 |
条件变量(用户变量) | 等待成功时(SleepConditionVariable*) | 被唤醒时(Wake(All)ConditionVariable) | No |