多线程程序设计(二)

通过前面的介绍,我们了解到了通过线程的并行和异步执行,给我们的程序带来的好处。但正如事物都是有两面性的,在享受线程给我们的应用程序带来的好处的时候,我们同时也应该解决它给我们所带来的问题,即线程同步问题。试想当有一个读线程和一个写线程同时操作一个链表,当读线程读取链表的一个节点的时候,由于系统的调度,此时写线程获得了执行的机会,并对同一个节点执行了写操作,并且最后完成了该操作。当系统再次调度读线程读取链表该节点内容的时候,该内容由于已经被写线程修改,导致我们读出了错误的数据。为了解决线程的同步问题,人们定义了临界资源这个概念。临界资源规定该资源一次只允许一个进程/ 线程对其进行访问,在此基础上,人们又定义出了临界区(critical section )这个概念,即在每个进程/ 线程中访问临界资源的那段代码叫做临界区。在Windows 平台下面,微软已经为我们实现了临界区,以方便我们进行线程间的同步。与其它的同步方式相比,由于临界区工作在用户模式下,不需要在同步的时候进行用户模式到内核模式下的切换,所以它比其它使用内核的同步方式比较起来效率更高。但也因为其没有工作在内核模式下的原因,临界区不能够跨越进程进行同步,它只能同步同一个进程中的各个线程。下面我们来看一下操作临界区所需要了解的几个函数:

我们使用InitializeCriticalSection 或者InitializeCriticalSectionAndSpinCount 来初始化临界区结构CRITICAL_SECTION 。然后在需要访问临界资源的时候,我们需要调用EnterCriticalSection 或者TryEnterCriticalSection 来独占访问该资源。在我们完成对临界资源的访问后,我们需要调用LeaveCriticalSection ,以便让其它的线程能够访问该临界资源。在不需要该临界区对象后,我们需要调用DeleteCriticalSection 来删除该临界区。

为了使程序更加的高效,我们可以使用旋转锁和临界区进行配合。由于系统在进行线程调度的时候会将正在运行线程的状态保存下来,例如指令指针和函数调用栈等。以便在调度程序再次调度该线程执行的时候,可以让线程恢复到上次执行时的状态再次执行。这个过程叫做线程的上下文切换。由于这个过程需要使用大量的CPU 指令,所以我们需要尽量减少这个切换。这也是为什么在一个真实的服务器程序中,并不能为大量同时到来的每一个请求创建一个线程的原因。所以,为了减少线程的上下文切换,我们需要在那个线程中做一些事情,例如做一些空的循环来进行等待。是的,这就是旋转锁的概念,比起旋转锁这个看起来高深的词,实际上它确实很简单。不过在使用旋转锁的时候,我们需要注意两点,一是对于临界资源,你需要确定你可以在很少的循环次数内获得该资源,以免因为做大量的循环进行等待而消耗了大量的CPU 资源。二是对于单核CPU 的时候,绝对不要使用旋转锁。因为在旋转锁的循环结束之前,调度程序是不会调度另外一个线程执行的。

如前面看到的,Windows 为我们提供了API 函数InitializeCriticalSectionAndSpinCount 来将旋转锁和临界区结合起来,如果我们在旋转锁结束之后获得了临界资源,则我们的线程不必进入阻塞状态,而能够直接对临界资源进行访问。如果不幸在旋转锁结束之后,我们的线程还是没有能够获得对临界资源的独占访问权限,那我们的线程就不得不进入阻塞状态,等待获得该临界资源的独占访问权限后再次执行。对于旋转锁所需要循环的值,需要对自己程序做出各种各样的测试才能得到一个完美的数字。如果你没有时间来做这样一个测试,那么对你来说,4000 是一个不错的选择。

下面就让我们来看一个示例程序,看临界区和旋转锁是如何工作的。见程序CriticalSection


在介绍完临界区过后,下面让我们来看一个经典的同步问题,生产者消费者问题。该问题的描述如下:假设有X 个生产者和Y 个消费者同时操作一块大小为Z 的缓冲区。生产者可以在缓冲区未满的情况下,向缓冲区中生产物品。而消费者可以在缓冲区中有物品的时候,消费缓冲区中的物品。分析这个问题后我们可以发现,对于生产者来说,我们需要在缓冲区未满的情况下生产物品,而在缓冲区已满的情况下,我们的生产者应该停下来,等待消费者消费了物品后,在进行生产。而对于消费者来说,我们需要在缓冲区中有物品的情况下消费物品,而在缓冲区中没有物品的情况下,我们的消费者应该停下来,等待生产者生产了物品后,再进行消费。

通过以上分析可以发现,使用临界区是无法解决该问题的。因为临界区的功能是使临界资源只能被一个线程所访问,而不能描述在该问题中类似缓冲区以及物品这样的资源的数量。所以为了解决此问题,在1965 年的时候,迪科斯彻(E.W.dikstra )提出了信号量(semaphore )理论以及其相关操作。迪科斯彻规定信号量在内部用整数来表示临界资源的数目,当要访问该临界资源的时候,使用P 操作来获取该临界资源,并使信号量的值减1 ;当访问完该临界资源后,使用V 操作来释放该临界资源,并使信号量的值加1 。如果信号量的值已经为0 ,则在使用P 操作时不在使信号量的值减1 ,并且访问该资源的线程进入阻塞状态,直到使用该临界资源的其它线程使用V 操作释放至少1 个临界资源为止。此时处于阻塞状态的线程再次进入可调度的准备状态(ready ),并使信号量的值减1

与信号量一起提出来的还有互斥量(mutex )这个概念。互斥量是一个特殊的信号量,因为其表示资源数目的最大值为1 。从这里可以看出来,实际上互斥量和前面提到的临界区是相似的概念,它们都是用来保证临界资源只能被唯一访问。

跟临界区一样,微软在Windows 平台下面已经为我们实现了信号量和互斥量。我们只需要使用它给我们提供的API 函数,就能使用信号量和互斥量对线程或者进程进行同步。相对于临界区,由于信号量和互斥量属于内核对象,在使用信号量或者互斥量进行同步的时候,程序会在用户模式和内核模式之间切换,导致程序的运行速度受到影响。但也因为信号量和互斥量属于内核对象,这让它们同临界区只能在同一个进程中使用不同,他们可以跨越不同的进程使用。

我们可以用CreateSemaphoreCreateMutex 函数来创建一个信号量或者互斥量。使用OpenSemaphoreOpenMutex 来打开一个已经存在的信号量或者互斥量。使用WaitForSingleObject 来当作P 操作,使用ReleaseSemaphoreReleaseMutex 来当作V 操作。

下面就让我们来看如何在Windows 中实现生产者和消费者问题。见程序ProducerConsumer


下面让我们来看一下在Windows 下的另外一种用于同步的内核对象,事件(Event )对象。该对象一般用来表示某种同步事件,例如处于阻塞状态的线程A 等待线程B 构造好一个对象后,再次执行。事件内核对象有两种状态,一种为有信号(signal )状态,另一种为无信号(non-signal )状态。当事件内核对象处于无信号状态的时候,当使用等待函数(wait function ),例如WaitForSingleObject 函数操作该内核对象的时候,调用该函数的线程进入阻塞状态。当事件内核对象处于有信号状态的时候,前面所述的线程重新进入准备状态。一共有两种类型事件类型,一类为自动重置事件(Auto-reset event ),另一类为手动重置事件(Manual-reset event )。当使用等待函数操作自动重置事件的时候,等待函数返回的时候会将有信号的事件内核对象置于无信号状态。当使用等待函数操作手动重置事件的时候,等待函数不会将有信号的内核对象置于无信号状态,你必须使用API 函数ResetEvent 将事件置于无信号状态。我们使用CreateEvent 创建时间内核对象,然后使用SetEvent 函数将事件置于有信号状态,或者使用ResetEvent 将事件设置为无信号状态,再不需要事件内核对象的时候,我们使用CloseHandle 函数关闭事件内核对象。

示例代码如下所示。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值