【WIN32进阶之路】:线程同步技术纲要

前面博客讲了互斥量(MUTEX)和关键段(CRITICAL SECTION)的使用,想来总觉不妥,就如盲人摸象一般,窥其一脚而言象,难免以偏概全,追加一篇博客查遗补漏。

win32下的线程同步技术分为用户模式下的线程同步和用内核对象进行线程同步两大类。

用户模式下的线程同步和用内核对象进行线程同步有以下的明显差异:

1.用户模式下的线程同步不需要进入操作系统核心,直接在用户模式就可以进行操作。

2.用内核对象进行线程同步需要进入操作系统核心,用户模式切换至核心模式大约花费1000个CPU周期。

3.用户模式下的线程同步只能在同一个进程中使用。

4.用内核对象进行线程同步可以被其他进程使用,因为内核对象的名称是整个操作系统唯一且可见的。

5.用户模式下的线程同步不能指定等待超时时间。

6.用内核对象进行线程同步的获取拥有权/控制权函数可以设定等待超时时间。

7.用户模式下的线程同步对象因进程异常终止而被废弃时用户无法获取相关信息也不返回。

8.用内核对象进行线程同步的获取控制权/拥有权函数在等待的内核对象被废弃时会立马返回,并可以通过返回值和GetLastError获取返回具体原因。

用户模式下的线程同步技术列表及概述:

1.原子访问

指一个线程在访问某一个资源的同时保证没有其他线程会在同一时刻访问同一资源,比如c++;p->next = p->next->next;这样的操作对线程来说是分成好几步进行的,并不是原子操作,而我们对线程共享的全局资源进行操作很有可能因为线程切换出现不可预料的结果,原子访问就是用来保护这些操作一次性完成,不管有多少个cpu、多少个线程,如果这些操作执行那就一定一次性完成。

常用API:

InterLockedExchange;

InterLockedExchangePointer;

InterLockedExchangeAdd;

InterLockedExchangeAdd64;

InterLockedIncrement。

2.关键段

一小段代码,执行之前需要独占对一些共享资源的访问权,这种方式可以让多行代码以“原子方式”来对资源进行操控,在当前线程离开关键段之前,系统不会去调度任何想要访问统一资源的其他线程。

常用API:

InitializeCriticalSection初始化CRITICAL_SECTION结构体,结构体中保存有计数器和是否有线程正在访问,应杜绝手动更新结构体的成员变量;

EnterCriticalSection获取访问权并将计数器+1,如果线程已获取访问权则只将计数器+1,等待超时时间视注册表项而定,默认为30天;

TryEnterCriticalSection尝试获取访问权,其他线程在在访问资源则返回FALSE,否则获取访问权并返回TRUE;

LeaveCriticalSection将计数器-1并检查计数器是否等于0,计数器等于0就更新成员变量表示释放访问权,否则不做额外处理;

DeleteCriticalSection重置CRITICAL_SECTION结构中的成员变量.

3.旋转锁

关键段是win32中很常用的一个线程同步工具,非常方便但来回切换等待状态开销非常大,会浪费巨量的CPU时间,比如线程A尝试进入关键段失败就会切换至等待模式,线程必须切换到内核模式,而在多CPU的处理器上,访问资源的线程B在其他CPU运行并在A切换等待模式完成前就释放资源并离开关键段,这样就造成了巨大的CPU时间浪费。

为了提高关键段的性能,microsoft把旋转锁合并到了关键段,当EnterCriticalSection的时候,会用一把旋转锁不停循环,尝试在一段时间获取对资源的访问权,只有尝试失败时,线程才切换至内核模式并进入等待状态。关键段+旋转锁作为对关键段的补充,我们应该首先使用和入了旋转锁的关键段。

和关键段不同的API:

InitializeCriticalSectionAndSpinCount,初始化关键段同时使用旋转锁,第二个参数指定旋转锁循环的次数,这个值介于0-0x00ffffff,在单CPU的机器上函数会忽略第二个参数,因为毫无意义,关于旋转次数的设置,jeffrey大神建议保护进程堆的关键段所使用的旋转次数是4000.

4.slim读/写锁

SRWLock的目的和关键段相同,对一个资源进行保护,不让其他线程访问它,但是与关键段不同的是,SRWLock允许我们区分那些想要读取资源的值的线程(读取者线程)和想要更新资源的值的线程(写入者线程)。让所有的读取者线程在同一时间访问共享资源应该是可行的,因为并不会破坏数据。只有当写入者线程想要对资源进行更新的时候才需要进行同步。因此,我们需要写入者线程独占资源的访问权,任何其他线程,无论是读取者还是写入者线程都不允许访问资源。

常用API:

InitializeSRWLock,初始化SRWLOCK结构,SRWLOCK在WinBase.h中被定义为RTL_SRWLOCK。

AcquireSRWLockExclusive,(写入者线程)获取被保护资源的独占访问权。

ReleaseSRWLockExclusive,(写入者线程)完成资源更新后,通过ReleaseSRWLockExclusive解除对资源的独占访问权。

AcquireSRWLockShared,(读取者线程)获取被保护资源的共享访问权。

ReleaseSRWLockShared,(读取者线程)完成资源读取后,释放共享访问权。

SRWLock不必手动销毁或删除,由操作系统负责自动清理。

和关键段相比,SRWLock缺乏以下两个特性。

1.不存在TryEnter(Shared/Exclusive)SRWLock之类函数,如果锁已经被占用,那么调用AcquireSRWLock(Shared/Exclusive)会阻塞调用线程。

2.不能递归获得SRWLOCK,一个线程不能为了多次写入资源而多次锁定资源,再多次调用ReleaseSRWLock*来释放资源的锁定。

5.条件变量

条件变量经常和关键段、锁联合使用,用于线程将自己阻塞,直到满足了某个条件才唤醒自己并锁定资源进行工作。

常用API:

InitializeConditionVariable,初始化一个CONDITION_VARIABLE结构体。

SleepConditionVairableCS,三个参数分别为已初始化的CONDITION_VARIABLE、等待的关键段、等待超时时长,等待超时时长可以设置为INFINITE表示一直等下去,当指定的等待时间用完的时候条件变量仍然未被触发就返回FALSE,否则返回TRUE,返回TRUE表示自动EnterCriticalSection。

SleepConditionVariableSRW,四个参数分别为已初始化的CONDITION_VARIABLE、等待的SRW锁、等待超时时长,对SRW锁的访问方式,前三个参数和上面一个函数是基本一样的,最后一个参数写入者线程传入0,表示独占资源的访问;读取者线程应该传入CONDITION_VARIABLE_LOCKMODE_SHARED,表示希望共享对资源的访问。返回结果类似上一个函数。

WakeConditionVariable,使一个SleepConditionVariable*函数中等待同一个条件变量被处罚的线程得到锁或关键段并返回,当这个线程释放同一个锁的时候,不会唤醒其他正在等待同一个条件变量的线程。

WakeAllConditionVariable, 唤醒一个或几个在SleepConditionVariable*函数中等待这个条件变量被触发的线程对资源的访问权并返回,应用于唤醒读取者线程。

PS:条件变量的使用稍微有点绕,不同于关键段或读写锁,条件变量在处理生产者/消费者模型时经常使用到两个条件变量,一个用于生产者,一个用于消费者 ,当生产者线程访问完毕时调用WakeAllConditionVariable唤醒所有消费者线程;消费者线程取完最后一个产品时调用WakeConditionVariable唤醒一个生产者。

PS:如果线程A和线程B都要求获取资源C和D的独占权,最好在A和B的代码中保证资源获取顺序一致。

PS:不要长时间锁定资源,尽可能少的在独占资源处理代码中加入Sleep和一些需要等待对端响应返回的函数。

用内核对象进行线程同步的技术列表及概述: 

1.互斥量内核对象

互斥量内核对象用来确保一个线程对资源的独占访问,互斥量对象包含一个引用计数、线程ID以及一个递归计数。互斥量与关键段的行为完全相同,但是互斥量是内核对象,关键段是用户模式下的同步对象,互斥量将比关键段慢并且互斥量可以被不同进程中的线程访问,互斥量可以指定申请访问权等待时间。

常用API:

CreateMutex,创建一个互斥量对象,参数分别指定安全属性、创建线程是否初始拥有互斥量的访问权、互斥量的名字。

OpenMutex,打开一个已经存在的互斥量对象,通常用于Client端访问Server端的资源时,OpenMutex和获取互斥量访问权没有任何关系,简单表示打开一个互斥量内核对象而已。

WaitForSingleObject,等待一个内核对象被激发并返回,此处即等待获取互斥量的访问权。

WaitForMultipleObjects,等待多个内核对象被激发并返回,此处等待多个互斥量的访问权。

MsgWaitForMultipleObjects,等待消息到来或者多个内核对象被激发,通常用于界面进程,保证等待互斥锁的同时不会影响线程消息处理。

ReleaseMutex,将互斥量对象的递归计数减1,如果线程成功的等待了互斥量对象不止一次,那么线程必须调用ReleaseMutex相同的次数才能使对象的递归计数变为0,递归计数变为0时,函数会将线程ID设为0,互斥量就会保持在触发状态,下一个等待它的线程可以立即得到它。

CloseHandle,将互斥量对象的引用计数 减1,当引用计数为0时对象被删除。

PS:按照Jeferry大神对ReleaseMutex的解释,一个线程WaitForSingleObject等待互斥量数次,那么必须手动调用ReleaseMutex相应的次数才能释放掉资源的访问权,但测试的时候并不是这样的,同时关于ReleaseMutex的描述Jefery大神和MSDN有所出入,MSDN描述:Releases ownership of the specified mutex object.

下面是测试代码:

unsigned int _stdcall ThreadExFunc(void* p)

{

HANDLE hMutex = OpenMutex(SYNCHRONIZE , TRUE, TEXT("foo46"));

if(hMutex == NULL)

printf("Wrong Mutex");

else {

//WaitforsingleObject将等待指定的一个mutex,直至获取到拥有权 //通过互斥锁保证除非输出工作全部完成,否则其他线程无法输出。 WaitForSingleObject(hMutex, 1000);

WaitForSingleObject(hMutex, 1000);

WaitForSingleObject(hMutex, 1000);

WaitForSingleObject(hMutex, 1000);

for(int i = 0; i < 10; i++)

{

printf("%d%d%d%d%d%d%d%d%d%d\n", p, p, p, p, p, p, p, p, p,p);

Sleep(10);

}

ReleaseMutex(hMutex);

}

CloseHandle(hMutex);

return 0; }

这是线程执行代码,在win7+vs2012环境测试,所有的线程都正确打印,这里到底是大神笔误还是我没理解透彻呢,还望有人指点一下。

2.信号量内核对象

信号量内核对象用来对资源进行计数,一个信号量内核对象包括使用计数、最大资源计数、当前资源计数。最大资源计数表示信号量可以控制的最大资源数量,当前资源计数表示信号量当前可用资源的数量。

信号量的规则如下:

1.如果当前资源计数大于0,那么信号量处于触发状态。

2.如果当前资源计数等于0,那么信号量处于未触发状态。

3.系统绝对不会让当前资源计数变为负数。

4.当前资源计数绝对不会大于最大资源计数。

常用API:

CreateSemaphore,创建一个信号量内核对象。

OpenSemaphore,打开一个已经存在的信号量内核对象。

WaitForSingleObject,使用和等待其他内核对象一样,差别在于semaphore并不存在拥有权的说法,对一个semaphore可以重复Wait...以获取多份资源。

WaitForMultipleObjects, 参照WaitForSingleObject介绍。

ReleaseSemaphore,将指定的信号量当前资源计数增加N个,同时获取改变资源计数之前的可用资源计数。

CloseHandle,对每个内核对象都要做的,大家懂。

PS:想要在Semaphore创建后继续做一些初始化工作,比如分配内存等,该怎么办?CreateSemaphore可以指定当前可用的资源计数为0,这样其他的线程就无法获取访问权,等到初始化工作完成调用ReleaseSemaphore将可用资源计数加至最大。

3.事件内核对象

所有的内核对象中,事件比其他对象要简单的多,事件包含一个内核对象都有的引用计数,一个用来表示事件是自动重置事件还是手动重置事件的布尔值,以及另一个用来表示事件有没有被触发的布尔值。

事件的触发表示一个操作已经完成,有两种不同类型的事件对象:手动重置事件和自动重置事件,当一个手动重置事件被触发时,正在等待该事件的所有线程都变成可调度状态;而当一个自动重置事件被触发的时候,只有一个正在等待该事件的线程会变成可调度状态。

事件最通常的用途:让一个线程执行初始化工作,然后再触发另一个线程,让它执行其余的工作。一开始我们将事件初始化为未触发状态,然后当线程完成初始化工作的时候触发事件。此时,另一个线程一直在等待该事件,它发现该事件被触发,于是变成可调度状态,第二个线程知道第一个线程已经完成了它的工作。

常用API:

CreateEvent,创建一个事件内核对象,4个参数分别表示安全属性、手动重置事件(TRUE)或者自动重置事件(FALSE)、触发状态(TRUE表示触发,FALSE表示未触发)、 事件内核对象名称。

OpenEvent,打开一个已经存在的事件内核对象,也可以用CreateEvent指定已经存在的事件名称来打开,此外CreateEventEx可以用减少权限的方式打开事件内核对象。

WaitForSingleObject,通用的内核对象等待方法,成功等待一个自动重置事件时会自动将事件设置为未触发状态,所以自动重置事件通常不须调用ResetEvent。

WaitForMultipleObjects,通用的内核对象等待方法,同上。

SetEvent,把事件变成触发状态。

ResetEvent,把事件变成未触发状态。

PulseEvent, 触发事件并立即将其恢复至未触发状态,这个函数通常不太有用,因为无法确定其他等待事件的线程会收到触发信号。

CloseHandle,内核对象引用计数减1,减至0时摧毁该内核对象。

4.可等待的计时器内核对象 

可等待的计时器是这样一种内核对象,它们会在某个指定的时间触发,或每隔一段时间触发一次,听起来很像消息队列中的SetTimer和KillTimer组合,从建立以及触发方式来看应该适用于对时间精度要求不是很高的场合。

常用API:

CreateWaitableTimer,创建一个可等待的计时器,三个参数分别表示安全属性、自动重置计时器还是手动重置计时器、计时器名称,刚创建的可等待计时器总是处于未触发状态。

OpenWaitableTimer,获取一个已经存在的可等待计时器的句柄。

SetWaitableTimer,设置计时器触发时间,最难缠的计时器函数,可以设置首次触发时间以及之后多久触发一次,也可以设置只触发一次,各位网搜一下详细用法吧。

CancelWaitableTimer,取消一个计时器,直到下一次调用SetWaitableTimer才会再次被触发,这个函数不是必须的,每次调用SetWaitableTimer都会在设置新的触发时间之前将原来的触发时间取消。

PS:手动重置计时器被触发时,正在等待该计时器的所有线程都会变成可调度状态,当自动重置计时器被触发时,只有一个正在等待该计时器的线程会变成可调度状态。

小结:终于把纲要做完了,目的只是给各位一个直观的了解,有很多地方讲的太过模糊,有兴趣的朋友可以联系我共同讨论:believing_dan@hotmail.com或者qq:382128698.时间允许的话会对每种技术做一个单章介绍。

上一篇:Codeforces Round #313 (Div. 2) A. Currency System in Geraldion


下一篇:POJ2201+RMQ