从零开始学.net多线程系列(三)——同步

来源:互联网 发布:潍坊seo林晟科技 编辑:程序博客网 时间:2024/04/30 01:33

本文将涉及如下内容

Wait Handles

EventWaitHandle

Seamphores

Mutex

Critical Sections

Miscellaneous Objects

这篇文章重点说明多个不同的线程之间的同步问题。

WaitHandles

首先,我们必须认识到,当你尝试着理解怎么才能使多个线程在一起协调地很好,最关键的问题是怎样排序这些操作。例如,我们有如下的这些问题:

1、  我们需要创建一个订单

2、  我们需要保存订单,但是除非我们获得了订单号,否则我们无法进行保存操作

3、  我们需要打印订单,但也仅在其被保存到数据库时打印一次

看起来,这些都是非常简单的任务,甚至根本不需要使用到线程。但是为了演示这个例子,让我们假设其中的每一步都是一个很费时的操作,包含了对一个虚拟数据库的许多调用。

从上面我们的问题列表中我们可以看到,除非步骤一完成了,否则我们无法完成步骤二,除非步骤二完成了我们无法完成步骤三。这就像一个相互依赖,进退两难的困境。我们当然可以,把这几个操作放在同一个代码段中,但这违背了并发的思想,我们需要尽量保持我们应用程序的高可相应性。(记住,这里面的每一个步骤都是非常的耗时)。

所以,我们能做些什么呢?我们知道,应该把这三个步骤分配给不同的线程,但似乎有不少的问题,因为我们无法保证哪个线程将率先完成,就像我们在Parttwo中看到的那样。幸运的是,我们能够得到一些帮助。。。。

在.net中有一个WaitHandle,它允许线程等待一个特殊的WaitHandle,仅在当WaitHandle告诉正在等待的线程,它已经可以继续进行的时候才会继续进行。这种方案被称之为信号与等待。当一个线程正在等待一个WaitHandle的时候,它将被阻塞直到在某个时候WaitHandle获得信号,它允许等待线程解除阻塞,继续执行它的工作。

我喜欢将包含在WaitHandle背后的思想想象成在一个交通拥挤地带的一系列的交通灯。当栅栏(WaitHandle)没有获得信号的时候,我们(处于等待的线程)必须等待栅栏升起(获得信号)。

这就是我类比它的一种方式,而.net又是如何提供这种WaitHandles的方式给我们使用的呢?

接下来的类,是为我们使用它提供的:

l System.Threading.WaitHandle

         Ø  System.Threading.EventWaitHandle

         Ø  System.Threading.Mutex

         Ø  System.Threading.Semaphore

就像我们在这个层次结构中看到的一样,System.Threading.WaitHandle是一些其他System.Threading.WaitHandle派生类的基类。这里有一些特别的东西需要在我们涉及到这些派生类之前被预先解释。

一些重要的公共方法(并非所有的)如下:

SignalAndWait

该方法有几个重载,但基本的思想是——一个System.Threading.WaitHandle获得信号,而另一个System.Threading.WaitHandle将被置为等待状态直到接受一个信号。

WaitAll(WaitHandle类的静态方法)

该方法同样有几个重载,但基本的思想是——一组System.Threading.WaitHandle被压入WaitAll方法,所有的这些System.Threading.WaitHandles将被置为等到状态直到接受到一个信号。

WaitAny(WaitHandle类的静态方法)

该方法同样有几个重载,但基本的思想是——一组System.Threading.WaitHandle被压入WaitAny方法,并且任何System.Threading.WaitHandle将被置于等待直到接受到一个信号。

WaitOne

该方法同样有几个重载,但基本的思想是——当前的System.Threading.WaitHandle将被置于等待,直到接受到一个信号。

让我们集中来讲解一下System.Threading.EventWaitHandle

EventWaitHandle

EventWaitHandle是一个WaitHandle并且又有两个特别的派生类:ManualResetEvent以及AutoResetEvent,它们更加通用。这两个子类我将花些时间来讨论。所有你需要注意的是——一个EventWaitHandle对象能够充当它的两个子类的其中任何一个,通过使用EventResetMode的其中一个枚举值。在创建一个新的EventWaitHandle对象的时候,可能用得上。

现在,我们更深入地讲解一下ManualResetEvent和AutoResetEvent对象,因为他们更通用。

AutoResetEvent

来自MSDN:

        “通过在AutoReseEventt上调用WaitOne,让一个线程等待一个信号。如果AutoResetEvent处于未获得信号的状态,那么线程将被阻塞,当前线程的的等待是通过调用Set方法来设置控制的资源已经可用

调用Set信号AutoReseEventt来释放一个正处于等待的线程。AutoResetEvent会保持着一个信号的状态直到一个等待线程被释放,然后自动地返回到一个非信号状态。如果没有线程当前正在等待,信号状态将会被无限期地保持。”

在外行人的术语中,当使用一个AutoResetEvent,当AutoResetEvent被设置为信号状态,第一个停止阻塞(停止等待)的线程将导致AutoResetEvent被置为一个复位状态,因此任何其他正在在AutoResetEvent上等待的线程必须等待它再次被置为信号状态。

让我们看一个小例子。启动两个线程,第一个线程将运行一段时间,然后将一个AutoResetEvent从非信号状态设置为信号状态(通过调用Set方法)。然后第二个线程将等待AutoResetEvent被再次置为信号状态。第二个线程也将等待另一个AutoResetEvent。唯一的不同是,第二个AutoResetEvent以一个信号状态为默认启动状态,所以它将无需等待。

这里是关于这个例子的代码:


它将产生下面的输出,从这里可以看出T1等待5秒(模拟操作),然后T2等待名为ar1的AutoResetEvent被置为信号状态,但不需要等待名为ar2的AutoResetEvent,因为在它被构造的时候,它已经是信号状态了。


ManualResetEvent

来自MSDN:

         “当一个线程在其他线程继续之前开启一个必须完成的活动(相当于该活动具有原子性),它就可以调用Reset将ManualResetEvent置为非信号状态。该线程可以被认为是控制ManualResetEvent的线程。在ManualResetEvent上调用WaitOne的线程将被阻塞,等待信号。当控制线程完成活动,它调用Set向等待线程发出信号,让它们可以继续进行。所有的等待线程会被释放。

一旦它变成信号状态,ManualResetEvent会保持信号状态直到它被人为地重置。那便是调用WaitOne,即可直接返回

不用术语来解释就是,在使用ManualResetEvent的时候,当ManualResetEvent被置为信号状态,所有的正处于阻塞(等待)的线程都将被允许继续进行,直到ManualReset被重置。

看下面的代码片段:


它将有可能产生下面截图中的输出:


可以看到这里启动了三个线程(T1-T3),T2与T3都在等待名为“mr1”的ManualResetEvent,它在线程T1的阻塞代码块中被标识为信号状态。当T1将mr1置为信号状态(通过调用Set()方法)时,就允许等待中的线程继续运行。当T2与T3在mr1上都被阻塞而处于等待的时候,它被置为信号状态,这样T2与T3都得以继续向下执行。而名为“mr1”的ManualResetEvent将从不会被重置(即被置为非信号状态),所以无论是线程T2还是T3都可以自由地继续运行它们的代码块。

返回到原来的问题

再次返回到开始的问题:

1、  我们需要创建一个订单

2、  我们需要保存该订单,但除非我们得到该订单的编号,否则我们不能进行保存操作。

3、  我们需要打印该订单,但它仅在被保存到数据库中的时候才会被打印一次。

现在该问题可以被很容易地解决了。一切我们需要的只是一些WaitHandles,我们需要它来控制那些执行的命令,在步骤二中需要等待步骤一中的WaitHandle信号,同时步骤三也需要等待步骤二的一个WaitHandle信号。简单吗?我们来看某些示例代码如何?下面是的。我简单得选择AutoResetEvent:


运行的结果如下图所示:


Semaphores

Semaphore继承自System,Threading.WaitHandle;因此,它有一个WaitOne()方法。你也可以使用静态的System.Threading.WaitHandle,WaitAny(),WaitAll(),SignalAndWait()方法来进行更为复杂的任务。


我读过一些文章中将Semaphore比作一个夜总会。它有一个相当大的地方能容纳很多人,当满了的时候,就不允许更多的人进入,除非有某个人离开了俱乐部,在这个时候更多的人才能够进入。

让我们来看一个简单的例子,这里Semaphore被建立来处理两个并发的请求,并且有一个总的容量为5.


它将产生下面截图所示的输出:


该实例展示了一个怎样限制并发线程数的示例,它并非一个非常有用的示例。我将提供一个完整的代码,用它来展示如何使用一个Semaphore来限制尝试访问数据库的线程数(通过限制可访问的连接数)。数据库仅提供最多三个并发连接。就像我说的,这段代码是不完整的,并且它当前不是可以执行的状态。它仅仅是作为一个示例的目的来展示的,它也不是下载代码的一部分。


从这个简单的例子中可以很清楚地看到,Semaphore仅允许最多三个线程(这是在Semaphore中的构造器中设置的),所以我们可以确信的是,数据库的连接也将会被限制。

Mutex

Mutex很大程度上像以lock状态(我们将在接下来的后面的章节进行讲解)的方式工作着,所以暂时我不想涉及太多。但Mutex比起lock以及Monitor最主要的优势是它能够跨多进程运行,它提供一种计算机级别的lock而不是应用程序级别。

应用程序的单个实例

Mutex最通用的一个用途就是确保一个应用程序在运行的时候仅有一个实例。

让我们看看一些代码。该代码可以确保一个应用程序的单个实例。任何一个新的实例将等待5秒钟(除非当前正在运行的实例处于关闭中),假设在这之前已经有一个正在运行的应用程序。


所以,如果我们启动一个应用程序的实例,我们能够看到如下的截图:


当我们尝试运行另一份拷贝的话,我们将得到下面的结果(在五分钟的延迟之后):


临界区域

锁是一种能够确保某一时刻仅有一个线程能够访问一个特殊的代码区域的解决方案。这就是锁的作用。总是被锁住的代码段被称之为“临界区域”。有很多不同的方式可以锁住一段临界区域,下面将有所说明。

但在这之前,让我们来看看为什么我们需要这些所谓的“临界区域”中的代码。考虑一下下面的代码:


这段代码并非是线程安全的,因为它能够被两个不同的模拟线程访问。一个线程能将item2设置为0,此刻当另一个线程正尝试做除法的时候,将导致一个DivideByZeroException异常。

现在,当你运行这段代码,这个问题可能会每500ms出现一次,但这是一种很自然的线程问题。它们仅重现很短的时间,所以很难找到原因。你真的需要想到方方面面的情况,在你写代码之前,并且确保你在正确的地方进行安全防卫。

很幸运的是,我们可以使用锁机制来解决该问题。下面是一个简易的Demo:


在这个例子中,我们引进了第一种可行的技术来创建一个临界区,通过使用lock关键字。一次仅一个线程能够锁住同步对象(syncLock,在这个示例中)。任何其他的竞争线程都会被阻塞直到锁被释放。任何其他的竞争线程都被加入到一个“就绪队列中”,其给予访问的机制基于FCFS(First come first serviced,先来先服务)。

某些人对同步对象使用lock(this)或者lock(typeof(MyClass))。这是一个糟糕的想法,因为它们都是公有成员对象,所以,一个外部实体可以用它们来同步以及作为你线程的一个“接口”,从而导致一些很奇怪的问题。所以,最好的实践是使用一个私有的同步对象。

现在,我们来很认真地讨论一下你可以使用锁的几种不同的方式。

Lock关键字

我们已经看了一个简单的例子,在例子中我们使用了lock关键字,我们可以使用lock来“锁住”一个特殊对象。这是一种很通用的方案。

值得一提的是,lock关键字确实只是一种使用Monitor类的“快捷方式”。

Monitor类

System.Threading命名空间包含有一个叫做Monitor的类,它能够完全做到lock关键字一样的事情。如果我们考虑我们上面使用lock关键字锁住的相同的代码段,你可以看到Monitor能够做到同样的工作。


这里,lock关键字仅仅是下面这段Monitor代码的“语法糖”


Lock关键字确实等效于这段代码。

MethodImpl.Synchronized特性

最后一种方式是依赖一个特性的使用,该特性能够告诉编译器将其当做一个同步方法。


这个简单的例子展示了你可以使用System.Runtime.CompilerService.MethodImplAttribute来将一个方法标识为同步(临界区域)。

有件事情需要注意的是,如果你锁住整个方法,你可能会失去更多的并发编程的机会,因为它比起单线程模型运行方法的方式没有太多的性能优势。出于这个原因,你应该尽可能尝试将临界区域“圈定”为那些在多线程访问时需要实现线程安全的字段上。所以在你读写公共字段的时候,尝试使用锁机制。

当然,还是存在某些场景,你可能需要将整个方法标注为临界区域,但这是你的决定。只是,你需要意识到,锁住的区域应该越小越好。

Miscellaneous Objects

这里有两个外部的类,我觉得值得一提:

Interlocked

“一个声明操作是原子的,如果把它作为一个单一不可分割的指令的话。严格的原子性将排除任何抢占的可能性。在C#中,对一个32位或者更少的bits的字段简单的读取或者分配是原子的(假设在一个32位CPU上)。而对一个更大的字段的操作则是非原子的,因为它们可能包含了不止一个读/写操作。”

考虑下面的代码:


解决这种问题的方法可以是,将非原子性的操作包裹到一个lock定义中。然而,有一个.net的类提供一个更简单也更快速的方案。那就是Interlocked类。使用Interlocked类比使用lock声明更安全,因为Interlocked永远都不会阻塞。

MSDN声明:

         “当调度器在一个线程正在更新一个可被另一个线程访问的变量时切换上下文,或者当两个线程在隔离的处理器上并发执行时,该类的方法能够防止错误的发生。该类的成员不会抛出任何异常。”

这里有一个使用Interlocked演示的小例子:


这将会产生如下的输出:


Interlocked类也提供各种其他的方法,例如:

l  CompareExchange(location1,value,comparand):如果comparand与location1里的值相等,value将会被存入location1。否则,没有操作执行。比较和交换操作被作为一个原子操作执行。CompareExchange的返回值是location1里面的值,无论是否有任何的交换发生。

l  Exchange(location1,value):作为一个原子操作,将一个特殊值设置到某个位置,并返回原始的值。

这两个Interlocked中的方法能够实现无锁(无等待)的算法以及数据结构,否则你就得实现完整的锁机制来处理。

Volatile

Volatile关键字可以用来共享一个字段。Volatile关键字指示一个字段在程序中可以被操作系统、硬件或者一个并发执行的线程修改。

MSDN的说法:

         “系统总是在它需要的时候读取一个volatile对象的当前值,甚至在之前的指令刚刚请求了相同的对象。给对象分配的值也会被实时地写入。

Volatile修改器通常用来对一个可以被多线程访问而没有使用lock生命的字段的顺序访问。使用volatile修改器能够确保一个线程检索到被另一个线程最近更新的值。”

ReaderWriterLockSlim

经常存在这样一种情况——一个类型的实例对于读操作是类型安全的,但对于更新操作却不是。尽管这个问题可以使用lock声明来解决,但当有很多读取操作而没有太多写操作的情况下,这种解决方案可能相当有限制性。而ReaderWriterLockSlim类就是被设计用来处理该问题的。

ReaderWriterLockSlim类(该类为.net3.5新增)提供了两种锁的方式:一个读锁和一个写锁。写锁是独占的,而一个读锁是兼容其他读锁的。

说白点,一个线程占用一个写锁,它将阻塞所有其他线程设置尝试读或写锁。如果当前没有一个线程把持一个写锁,那么任何数量的线程都可以把持一个读锁。

该类主要的方法给使用这些锁提供了方便,这些方法如下:

1、  EnterReadLock(*)

2、  ExitReadLock

3、  EnterWriteLock(*)

4、  ExitWriteLock(*)

这里*表示也存在一个更安全点的“尝试版本”可以使用,它支持设置一个超时时间。

让我们来看一个简单的例子:


有可能会产生如下的输出:


需要注意的是,在System.Threading命名空间下,也存在一个ReaderWriterLock类。这就是为什么MSDN不得不说明ReaderWriterLockSlim(.net 3.5)与ReaderWriterLock(.net2.0)不同之处的原因:

         “ReaderWriterLockSlim与ReaderWriterLock类似,但它简化了对递归与锁状态升级与降级的规则。ReaderWriterLockSlim避免了许多死锁的情况。另外其性能也明显好过ReaderWriterLock。在所有新的开发中,推荐使用ReaderWriterLockSlim类”

原创粉丝点击