线程同步
线程需要在两种情况下互相进行同步
有多个线程访问共享资源而不使资源被破坏时
当一个线程需要将某个任务以完成的情况通知另外一个或多个线程时
Windows线程通常使用的同步和互斥手段
关键代码段(CriticalSection),
互斥量(Mutex),
信号量(Semaphore),
事件(Event)
用户方式的线程同步
用户方式的线程同步包括互锁函数和关键代码段
用户方式顾名思义是指工作在用户态而不是核心态的。
原子访问:互锁的函数家族。
例:InterlockedIncrement
关键代码段(临界区)
所谓原子访问,是指线程在访问资源的时候能够确保所有的线程都不在同一时间访问相同的资源
关键代码段(临界区)
关键代码段是指一个小的代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。 当然,系统能够抑制你的线程进行运行,但是,在线程退出关键代码段之前,系统不给想要访问相同资源的其他任何线程进行调度
EnterCriticalPolicySection 进入关键代码段
void EnterCriticalSection( LPCRITICAL_SECTION lpCriticalSection );
LeaveCriticalPolicySection 离开关键代码段
void LeaveCriticalSection( LPCRITICAL_SECTION lpCriticalSection );
使用这两个函数来标志一段关键代码段
关键代码段 例子
LPCRITICAL_SECTION g_cs;
long g_alDataList
在Thread1中操作
{
EnterCriticalSection(&g_cs); // 进入关键代码段
......
// 读取或者写入数组g_alDataList
......
LevelCriticalSection(&g_cs);
}
在Thread2中也需要有同样的形式来标志关键代码段.
使用关键代码段
注意:必须所有访问这个共享资源的线程都使用相同的规则,即都使用关键代码段进行保护,才能达到效果。如果Thread1使用了关键代码段进行了保护,但是Thread2没有使用关键代码段进行保护,直接访问了g_alDataList, 也不能达到保护的目的
每个共享资源使用一个LPCRITICAL_SECTION变量。比如有两个共享资源a,b, Thread1只需要访问a就可以,Thread2 和 Thread3 需要访问a, b, 则a和b使用不同的LPCRITICAL_SECTION变量
同时访问多个共享资源,不同的线程按照相同的顺序进入和离开关键代码段
不要长时间运行关键代码段,否则其他线程进入等待状态。会降低应用程序的性能
使用互锁函数和关键代码段的缺点
互锁函数只能对单值变量进行操作,不能对结构体等其他复杂结构进行操作
关键代码段只能在同一个进程之中的线程间保持同步
Critical Section不是一个核心对象,无法获知进入临界区的线程是生是死
不要长时间锁住一份资源。这里的长时间是相对的,视不同程序而定。对一些控制软件来说,可能是数毫秒,但是对另外一些程序来说,可以长达数分钟。但进入临界区后必须尽快地离开,释放资源
使用内核对象进行同步的机制的适应性远远优于用户方式的线程同步
使用内核对象进行同步控制
Win32的各种内核对象如Mutex,Semaphore,Event等,这些内核对象都有两种状态“已通知”和”未通知”
WaitForSingleObject在等待内核对象的时候就是关心这两种状态,在内核对象是未通知状态是,调用这个函数的线程是被挂起的,处于等待态.
当内核对象变成已通知状态时,会导致系统重新调度线程,使等待线程激活,变为运行态,此时WaitForSingleObject函数才能返回
几种内核对象的状态含义
Thread: 当线程结束时,线程对象变成已通知状态,当线程还在运行时,处于未通知状态
Process: 当进程结束时,进程对象即变为已通知状态,当进程还在运行时,则对象处于未通知状态
Console Input:当Console窗口的输入缓冲区有数据时,此对象处于已通知状态(CreateFile或者GetStdFile函数可以取得控制台对象)
Event:Event对象直接受控于SetEvent,PulseEvent,ResetEvent三个Win32API,SetEvent函数使Event对象处于已通知状态,ResetEvent则相反,PulseEvent是SetEvent+ResetEvent
Mutex:当Mutex没有被任何线程拥有,则处于已通知状态,一旦一个等待Mutex的函数返回了,它就处于未通知状态
Semaphore: Semaphore有资源计数器,当计数器大于0的时候,处于已通知状态,计数器等于0的时候,处于未通知状态,一个等待Semaphore的函数会导致Semaphore的资源计数器减少1
互斥内核对象
互斥对象(mutex)内核对象能够确保线程拥有对单个资源的互斥访问权.
互斥量是一种二元的信号量(只允许0,1两值)
互斥对象的行为特征和关键代码段相同, 但是互斥对象属于内核对象, 而关键代码段则属于用户方式对象.
互斥对象的运行速度比关键代码段慢
不同的进程中的多个线程能够访问同一个互斥对象.
创建和释放互斥内核对象
创建互斥信号量
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes, // 安全属性,必须是NULL
BOOL bInitialOwner, // 创建线程是否拥有该互斥量
LPCTSTR lpName ); // 互斥量名称
互斥量在同一时间内,只能由一个线程所拥有.
如果bInitialOwner 是FALSE, 则, 创建该互斥量的线程不拥有该互斥量, 则该互斥量发出通知信号(状态是已通知)
否则,创建该互斥量的线程拥有该互斥量, 则该互斥量状态是未通知.
等待互斥信号量 WaitForSingleObject
释放互斥信号量 ReleaseMutex
在释放的时候,系统检查拥有该Mutex的线程ID和释放这个Mutex的线程的ID是否相同, 只有在相同的情况,才能正确释放.
使用互斥量的例子
Thread1 和 Thread2 需要同时访问一个公共的资源, 比如一个全局变量
POINT g_astPointList[100]; // 一个点的数组
HANDLE g_hMutexObj;
g_hSemaphreObj = CreateSemaphore(NULL, FALSE, NULL);
在Thread1和Thread2中访问这个数组的时候,都需要使用互斥量进行保护.
{
DWORD dwRet = WaitForSingleObject(g_ hMutexObj, INFINITE); // 等待信标
if ( dwRet == WAIT_OBJECT_0)
g_astPointList …. // 访问数组
ReleaseMutex(g_ hMutexObj);
}
}
如果 没有线程拥有这个互斥量, 则 等待函数能够立即返回,
否则, 该线程不能被调度.
ReleaseMutex使 当前线程释放对该互斥量的拥有.
关键代码段和互斥量比较总结
关键代码段不是内核对象
关键代码段不能跨进程
在资源不竞争的情况下,关键代码段速度快
互斥量支持名字,可以跨进程使用
互斥量可以指定TimeOut
同一线程中可以重复进入同一关键代码段,互斥量则不能。
为什么临界段快?
临界段要比其他的核心态同步对象要快,因为EnterCriticalSection和LeaveCriticalSection这两个函数从InterLockedXXX系列函数中得到不少好处(下面的代码演示了临界段是如何使用InterLockedXXX函数的)。InterLockedXXX系列函数完全运行于用户态空间,根本不需要从用户态到核心态之间的切换。所以,进入和离开一个临界段一般只需要10个左右的CPU执行指令。而当调用WaitForSingleObject之流的函数时,因为使用了内核对象,线程被强制的在用户态和核心态之间变换。在x86处理器上,这种变换一般需要600个CPU指令。看到这里面的巨大差距了吧。
临界段是不是真正的“快”?实际上,临界段只在共享资源没有冲突的时候是快的。当一个线程试图进入正在被另外一个线程拥有的临界段,即发生竞争冲突时,临界段还是等价于一个event核心态对象,一样的需要耗时约600个CPU指令。事实上,因为这样的竞争情况相对一般的运行情况来说是很少的(除非人为),所以在大部分的时间里(没有竞争冲突的时候),临界段的使用根本不牵涉内核同步,所以是高速的,只需要10个CPU的指令。
信标内核对象(信号量)
信标内核对象(也称信号量,Semaphore)用于对资源进行计数. 包含一个使用数量, 最大资源数量和当前资源数量
信标是一个N园
信标的使用规则:
如果当前资源数量大于0,则发出信标信号,当前资源数量递减
如果当前资源数量是0, 则不发出信标信号
系统不允许当前的资源数量是负数
当前资源数量不能大于最大资源数量.
创建等待和释放信标对象
使用CreateSemaphore创建信标对象
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 安全属性,通常设定为NULL
LONG lInitialCount, // 初始化资源数量
LONG lMaximumCount, // 最大资源数量
LPCTSTR lpName ); // 信号量名称
等待信标对象WaitForSingleObject
和等待其他的内核对象一样,使用改函数进行等待.
释放信标对象ReleaseSemaphore
线程能够对信标的当前资源数量进行递增.
使用信标的例子
Thread1 和 Thread2 需要同时访问一个公共的资源, 比如一个全局变量
POINT g_astPointList[100]; // 一个点的数组
HANDLE g_hSemaphoreObj;
g_hSemaphreObj = CreateSemaphore(NULL, 2, 2, NULL);
在Thread1和Thread2中访问这个数组的时候,都需要使用信标进行保护.
{
DWORD dwRet = WaitForSingleObject(g_hSemaphoreObj, INFINITE); // 等待信标
if ( dwRet == WAIT_OBJECT_0)
g_astPointList …. // 访问数组
ReleaseSemaphore(g_ hSemaphoreObj, &lReleaseCount , &lPreviousCount );
}
}
如果 这个信标的当前资源数量大于0, 则 等待函数能够立即返回,
否则, 该线程不能被调度.
ReleaseSemaphore 使 该信号量的当前资源数加1.
信号量和互斥量的使用误区- 死锁
同一个线程在一次执行的过程在占用同一个互斥量后,在某个函数中又占用同一个互斥量.造成该线程挂起.
线程占有了一个互斥量后,忘记释放. 造成等待该互斥量的其他线程挂起.
等待了一个永远也等不到的互斥量
(例CreateMutex的时候参数 BOOL bInitialOwner赋值为TRUE, 并且在调用等待函数之前没有释放过. 或者CreateSemaphore的时候LONG lInitialCount 赋值成为了0,并且在调用等待函数前没有释放过.
对于多人读,一人写这样特性的共享资源,为了效率的考虑,使用了N元信号量(Semaphore),但是在写保护的时候,需要占有N元中的全部,此时可能造成两个写入线程互相等待,造成死锁.(Tron中等待信号量的函数一次可以等待N个,就避免了此类情况).
在需要连续占用N个互斥资源的情况下, 不同线程占用的次序不同, 造成互相等待,造成死锁
死锁的解决方法
通过强行规定任务获得资源的方式防止死锁:几个任务要访问资源A,B和C,任务以同样的次序获得和释放资源
任务一次性请求所需要的资源,或要求被拒绝使用某一资源的任务,立即释放它所持有的所有其它资源,然后重新获得。
在申请到了资源, 并且处理完毕后, 立即释放资源,不常时间占用互斥资源。
在Windows中如果又使用Semaphore进行写保护的情况, 写入的线程尽量保证唯一. 在不能保证唯一的情况下, 等待N元Semaphore的时候, 能够做成在等待了若干次,若干时间还没有完全等到的情况下, 放弃所占有的Semaphore.
同一线程中不要连续占用同一个互斥资源而中间没有释放动作.
事件内核对象
事件内核对象(Event)是个最基本的对象,可以通知一个操作已经完成,有两种类型的事件对象:自动重置事件对象,人工重置的事件对象
当人工重置的事件得到通知的时候,等待该事件的所有线程均变成可调度的线程。没有用户调用ResetEvent前,该Event状态一直是已通知状态,用户需要调用ResetEvent将该Event变成未通知状态
当自动重置的事件对象得到通知的时候,等待该事件的线程中只有一个变成可调度的线程,在有一个线程等到了这个Event后,Event状态立刻变成未通知状态.
创建一个事件对象
CreateEvent
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES EventAttributes, // 安全属性
BOOL bManualReset,
// 标志是自动重置还是人工重置
BOOL bInitialState,
// 创建初试状态(已通知或者未通知)
LPTSTR lpName );
// Event 名称
通知事件
调用SetEvent, 可以将事件的内核对象的状态变成 已通知
调用ResetEvent, 可以将事件的内核对象的状态变成 未通知
调用PulseEvent, 将事件对象置为有信号状态,然后立即置为无信号状态,在实际开发中这个函数很少使用
事件使用例子
例, HANDLE hEvtObj; // Event Handle
//在Thread1中 等待一个数据处理完成事件,然后使用该数据.
{
…
DWORD dwRet = WaitForSingleObject(hEvetObj, INFINITE);
If ( dwRet == WAIT_OBJECT_0 ) {
DoAfterPrcessData(); // 处理数据
} else {
//处理例外
}
}
//在Thread2中,处理数据,处理完成之后,设置数据处理完成Event
{
… // process data
ProcessData();
SetEvent(hEvtObj);
}
在 Thread2 调用了SetEvent之后, hEvtObj 的状态变成已通知, Thread1中的等待函数则会返回, 表示得到通知,否则, Thread1不能被调度.
等待多个事件
一个较为复杂的处理事务的线程可能一次等待多个Event内核对象,用来进行对多个事务的处理
线程函数中使用WaitForMultiObjects等待多个Event
例一个线程需要等待外部触发的3中事件
enmu EVENT_KIND{
EVENT_EXIT, // 退出线程事件
EVENT_EVT1, // 消息一
EVENT_EVT2, // 消息二
EVENT_MAX_NUM
};
// 定义了需要等待的EventHandle数组
HANDLE g_ahEventHandle[EVENT_MAX_NUM];
在线程的主函数中使用WaitForMultiObjects进行等待,
直到等到了EVENT_EXIT后,线程主函数结束循环退出。
手动重置和自动重置
CreateEvent中, BOOL bManualReset 标志是自动重置还是人工重置
手动重置的Event在变成了已通知状态后(调用了SetEvent), 一直是已通知的状态直到ResetEvent被调用
自动重置的Event在变成了已通知状态后, 等待函数(WaitForSingleObject或者WaitForMultipleObjects)一返回,就立刻自动变成未通知的状态
常用类型:自动通知
手动通知的一个具体应用的例子:C/S结构的软件
N个Client访问Server,Server启动若干个对应线程进行应答,当Server程序需要退出的时候,需要结束所有的应答线程,每个应答线程则可以等待同一个手动重置的退出Event.
Server程序可以发送这个退出Event,每个线程则都可以等到这个退出Event,知道Server调用了ResetEvent.
等待多个事件
DWORD WaitForMultipleObjects(
DWORD nCount, // 等待内核对象的个数
CONST HANDLE*lpHandles, // 存储内核对象的数组指针
BOOL fWaitAll, // 等到全部内核对象才退出标志
DWORD dwMilliseconds ); // TimeOut
fWaitAll标志设置为TRUE,表示只有等待的全部内核对象都变成已通知状态才返回,FALSE表示有一个内核对象变成已通知状态就返回。
在fWaitAll 为FALSE情况下,如果第一个内核对象变成已通知状态,则返回WAIT_OBJECT_0, 如果第二个内核对象变成已通知状态,则返回WAIT_OBJECT_1, 以此类推。
等待多个事件例子代码
DWORD WINAPI ThreadFunc(LPVOID lpParam)
{
bool bRun = true;
while(bRun) {
dwRet = WaitForMultipleObjects(EVENT_MAX_NUM,
g_ahEvtHandle, // event handle array
FALSE, // 只要有一个事件触发,就可以返回
dwMilliseconds ); // 超时值
if (WAIT_FAILED == dwRet ) {// 错误处理}
if ( WAIT_TIMEOUT==dwRet){// 超时处理}
switch(dwRet-WAIT_OBJECT_0) {
case EVENT_EXIT:
bRun = false; // 做退出处理,结束循环,函数return
case EVENT_EVT1:
// 处理事件1
break;
case EVENT_EVT2:
// 处理事件2
break;
default: // 默认处理
}
}
}
MFC的一些同步和互斥类
MFC封装了一些常用的互斥同步类
CCriticalSection 只允许当前进程中的一个线程访问某个对象的同步类
CMutes 只允许系统中一个进程内的一个线程访问某个对象的同步类
CSymaphore 只允许一到某个指定数目个线程同时访问某个对象的同步类
CEvent 当某个事件发生时通知一个应用程序的同步类