bindsang

工作五年,长期从事于asp.net方面的编程,业余爱好VC编程,温和、谦虚、自律、自信、善于与人交往沟通
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

.NET中几种基本的线程同步方法

Posted on 2008-12-24 11:44    阅读(4296)  评论(7编辑  收藏  举报

      每个程序员学编程的时候都是从单线程的程序入手的,等到了具有比较多的编程实践经验后才开始接触到多线程编程,多线程的技术在程序使用上带来新的友好体验的同是也带来了一系列的问题,其中最大的一个问题就是“同步/死锁”。 在C#中提供了多种实现同步的类和方法,下面就分别对每种方式作一个说明。

      首先我把同实现同步的所有方式分了几个类,第一类我称作线程本身的同步,就是指线程本身发起的同步行为。这类里的代表就是Thread.Join和ProcessInfo.WaitForExit。其实WaitForExit是等待进程执行完成的操作,严格来说不属于线程同步操作,之所以我把这个方法放到这一类里面,是因为它的行为和Thread.Join的方式基本上是一模一样的。另外需要说明的是Thread.Join这个方法,MSDN上的解释是“阻塞调用线程,直到某个线程终止时为止”,也许这句话是从英文直接翻译过来的,在英语里有上下文语境,可以知道“调用线程”和“某个线程”到底指的是哪一个,可是到翻译成中文后,这些信息就不清楚了。网上很多人都在说这里描述得很模糊。我一开始也被弄晕了,后来自己写了个demo程序亲自验证了一下就弄清楚了。所谓的“调用线程”不是指.Join()方法前面的那个线程引用,而是指当前的代码正在运行的线程,“某个线程”才是指.Join()方法前面的那个线程引用。所以Join方法在这里的作用是指阻塞当前代码运行的线程,直到发起Join操作的那个线程引用所代表的线程退出后才停止阻塞(使用无限期阻塞的方式的时候)。明白了这里后就里就有一个问题了,当我们调用一个当前代码所在的线程的引用上的Join方法时,会发生什么现象呢?比如说在任何地方调用Thread.CurrentThread.Join方法。结果会是两种,当我们调用的是有超时限制的方法的时候,感觉就会像调用Sleep方法一样,当前代码执行的线程会阻塞给定的时间后停止阻塞(我曾经遇到过总是喜欢使用这种方式的人,但现在我也说不清楚和Sleep方式到底有什么具体的区别,既然MS提供了一个Sleep方法专门来实现线程的休眠我们还是老老实实的用Sleep方法吧,另外从逻辑上来讲,一个线程等待自己);第二种结果是永久性阻塞。这也可以算是我们遇到的第一种死锁的情况吧,这种还是比较容易避免的,就算是遇到了也容易解决。

      第二类我称作原子操作类别,这一类的特点是所有操作都被当作是原子操作,一旦开始执行除非是相应的代码块运行结束,操作系统是不会打断该线程的执行的。这一类里面包括了Interlocked,MethodImplAttribute这两个类。其中Interlocked实现的是最基本的变量赋值,比较等操作的原子同步,一般大家用得比较多,也比较熟悉了,而另一个MethodImplAttribute可以大家就比较陌生了。其实我第一次见到这个Attribute是在看.Net内部的源代码的时候,当时见到得最多的就是MethodImpAttribute(MethodImplOptions.InternalCall),也就是内部调用,按MSDN的说法就是调用实现CLR的基础组件中的方法,有点DllImport的味道。不过这不是我们关心的重点,我所关心的是参数里枚举的另一个值,MethodImplOptions.Synchronized这个值的作用是告诉CLR当前MethodImpAttribute所描述的方法需要当作同步块执行,也就是说一次只能有一个线程进入到方法中。直到该方法返回。不过使用这个特性有一个不爽的地方,调式的时候没法跟踪进入到同步块中。

      第三类同步方式我称作对Windows的API包装的方式。 这一类里面有Semaphore,Mutex,ManualResetEvent/AutoResetEvent/EventWaitHandle/WaitHandle,以及同WaitHandle派生的一系列子类型。每一个类的使用方法基本上与Windows API的操作相同,这方面的参考资料网上有一大堆,具体用法MSDN上面都有说明,这里也就不在叙述了。这一类最大的特点就是大多数情况需要在三个不同的地方执行“设置”,“复位”,“等待”三种操作。当然具体的每个类型都有各自特有的一些方法和特点,每一种类型都有一种特定的应用场景。

简单来说ManualResetEvent/AutoResetEvent/EventWaitHandle/WaitHandle多用于事件通知模式的同步方式,两个或多个线程通过对事件的完成状态设置值来进行同步,ManualResetEvent在必要的时候需要进行手动复位事件完成状态,而AutoResetEvent不需要手动设置,当且仅当有一个个线程从阻塞等待中返回时,就自动复位了,也就是说如果有两个或两个以上线程同时在等待AutoResetEvent事件完成的时候,一次最多只可以有一个等待线程返回。

Semaphore为控制一个具有有限数量用户资源而设计,与操作系统P.V的概念一样,用在限制能够同时访问一个资源的最大次数。当Semaphore初始化的时候设置的最大同时访问的资源数等于1的时候,其表现出的功能就和ManualResetEvent基本上没有什么区别了。

Mutex在语义上是互斥量,为协调共同对一个共享资源的单独访问而设计的。多数情况下是用于进程级别的同步,比如一个程序只允许在一台机子上执行一次就可以用这个类来判断,以一个GUID命名一个Mutex,如果之前打开过的话,说明程序已经启动了就无需再次启动,直接退出就行了。当然也可以把Mutex用作一段代码的同步,使用方法与Semaphore类似。

  这里需要注意的是信号量或事件完成状态的设置放的位子不正确很有可能会导致出现很怪异的错误。

 

 1 /// <summary>
 2 /// 获取或设置当前处理的指令上下文信息,设置的时候如果_task不为空则启动任务
 3 /// </summary>
 4 /// <remarks>只有线程池中的任务调度线程执行</remarks>
 5 public ITask<T> Task
 6 {
 7     get { return _task; }
 8     set
 9     {
10         if (_task != null
11             throw new InvalidOperationException("线程正在执行任务");
12         if (value == nullreturn;
13         _task = value;
14         OnNewTask();
15     }
16 }
17 
18 private void OnNewTask()
19 {
20     _taskStartTime = DateTime.Now;
21     if (NewTask != null)
22         NewTask(this, EventArgs.Empty);
23     // 为了保证这里的代码执行完了后工作线程才开始执行,这一句必须放到最后
24     _newTaskEvent.Set();
25     // 任务完成状态复位
26     _stateNotify.Reset();
27 }

如上面的代码所示,这是一个线程池的工作线程上执行任务的类的部分代码,这段代码是在线程池的统一调度线程上运行的,而不是在工作线程上运行的,_newTaskEvent和_stateNotify都是ManualResetEvent的实例。其中_newTaskEvent是代表是否有任务需要被马上执行的信号量,_stateNotify是任务执行完成时的通知信号量,每一个当前类的实例都对应了一个执行任务的工作线程,这个线程在没有任务提时候会一直阻塞等待newTaskEvent的状态被设置,然后立即开始执行任务,而管理线程池的调度线程会时刻监视每个工作线程的stateNotify状态是否是被设置,也就是说任务是否是执行完成以便分配新的任务。这里的操作是想在加入一个新的任务的时候通知有新任务需要执行,同时复位上一个任务完成状态的信号量。本来这两步在一般理解上好像谁先谁后都没有什么关系,可是按照最新的代码编译后运行,程序在执行几秒钟后总是会时不时的出现工作线程死掉的现象(就是有新任务,却因为工作线程一直没有完成,没法分配出去)。后来想了很久才发现了原因,_newTaskEvent一旦设置了值,工作线程就马上开始执行任务,假如说执行任务需要的时候很短,短到调度线程还来不及执行下一句stateNotify.Reset()就完成了,而在任务完成的时候会在工作线程上主动调用下面的方法:

 1 /// <summary>
 2 /// 任务完成,通知管理线程,并将当前工作线程设置为等待任务状态
 3 /// </summary>
 4 private void OnFinishedTask()
 5 {
 6     // 获取得了任务后的结束
 7     if (_task != null)
 8     {
 9         _lastTask = _task;
10         _task = null;
11         if (FinishedTask != null)
12             FinishedTask(this, EventArgs.Empty);
13         _taskEndTime = DateTime.Now;
14         ChangeToState(ThreadWorkerState.TaskFinished);
15         // 通知任务完成
16         _stateNotify.Set();
17     }
18 }

 

去通知调度线程“任务执行完了”可以分配下一个任务了,可是调度线程正处理执行上面的OnNewTask方法内,这个时候主线程再调用了_stateNotify.Reset();这一句就直接把这个工作线程永远的阻塞了,因为现在再也没有办法重新执行到新任务。如果把第24和26句换一下就解决问题了。

信号量是一种很灵活的线程同步方式,没有必须完全位于某个特定的代码段中的限制,可以在程序中任何可以访问到的地方进行同步调用。用好了对于多线程编程有很大的好处,可是使用不当的话也会带来很大的麻烦,有可能会发生一些莫名其妙的错误,如上面的例子;因为跨的代码范围比较大,也很有可能发生一些很隐蔽的死锁。

      第四类同步方式我称作“锁”同步。包括了Monitor,ReaderWriterLock/ReaderWriterLockSlim(ver3.5)三种类型,其中后两种实际上原理是一样的,都是读写锁,只不过一个是2.0版本的,一个是3.5版本的,按MSDN的说法,3.5版本的在使用上更方便,更安全。Monitor同步的作用是在任何情况下都只允许一个线程进入Monitor的Enter/Exit之间的代码块,当Monitor同步的块包括了一个方法体中所有的语句时,就有点类似于前面的MethodImplAttribute方式了。Monitor类是一个静态类,用过JAVA的读者会发现这个类的方法与JAVA里面的同步锁很类似。大家知道在C#里面Monitor的Enter/Exit方法可以用lock关键字来替代,相当于java里面的synchronized关键字,要同步的对象可以也只能是任何非空引用类型。Monitor里面的Pulse和PulseAll方法分别对应于JAVA里面的object上的notify和notifyAll方法。Pulse和PulseAll方法的作用MSDN里面说的又是比较模糊“通知等待队列中的/所有的 等待线程对象状态的更改”,感觉跟没说一样。我们还是结合代码来看看究竟是怎么回事吧。

 1 private T BlockDequeue()
 2 {
 3     lock (_lockObject)
 4     {
 5         while (true)
 6         {
 7             T first = _priorityQueue.Peek();
 8             if (first == null)
 9             {
10                 Monitor.Wait(_lockObject);
11             }
12             else
13             {
14                 T x = _priorityQueue.Dequeue();
15                 Debug.Assert(x != null);
16                 if (_priorityQueue.Count != 0)
17                     Monitor.PulseAll(_lockObject); // wake up other takers
18                 return x;
19             }
20         }
21     }
22 }

 

通常情况下如果我们只使用了Enter/Exit或者说lock关键字的话,是用不到Pulse/PulseAll这两个方法的,因为这种情况下我们没有等待的线程。Pulse/PulseAll这两个方法不能单独使用的,必须结合Wait方法一起使用。而Wait方法只能是在Monitor的同步块中,也就是说超出同步块之外执行这两个方法的话会出异常的,它的作用是使当前线程从同步块中退出,同时记住同步的嵌套层次,并且当前线程进入阻塞等待状态,这时候其它需要进入该同步区的线程就可以有一个线程进入了(就像上面的第10行代码)。这时候在其它任何地方调用一个Pulse/PulseAll(当然,同步的对象必须是同一个,如上面的第17号代码),就会把之前由Wait方法阻塞等待的线程重新变成等待锁的状态,其优先级和其它同时等待的线程(如果有的话)没有区别。一旦前面的线程退出了同步块(不管是同Exit退出还是由Wait退出)就会从等待线程队列中挑一个进入。如果挑中的线程是之前由Wait退出的那个线程的话,这时该线程会重新获得锁,并且原来的同步嵌套层次会原封不动的恢复,代码也会转到Wait后面的语句执行。由此可以看出这种方式在加锁/解锁的过程中会额外花费一定的时间和资源去处理锁的状态嵌套层次问题。

几个使用Monitor方式同步的典型例子

同步单键模式

同步单键模式

简单的线程池

简单的线程池

 
使用Monitor的方式不管我们在同步块中有没有修改过数据,一次都只能有一个线程进入访问。这样在大多数只有读取的线程,少量写线程的情况下比较浪费时间,因此也就有了下面要提到的读写锁。

读写锁本身应该算作是Monitor形式的一个升级版,它最主要的作用是解决了大部分时间只是读取数据,极少部分时间修改数据的情况。一般情况下, 在同一个读写锁对象上多个读锁可以同时共存,但同一时间最多只能有一个写锁存在,并且写锁与读锁也不能同时存在。并且任何一个读锁都可以请求升级为写锁。 这部分说明在MSDN上面有很详细的叙述,这里就不在重复了。

WaitHandle实现的线程池

对读写锁的封装

      读写锁虽然相对于Monitor来说性能有一定的提升,但是在使用上很不方便,每次都得用try{}finally{}语句块,而且涉及到锁请求/释放, 升级/降级的时候还得一一匹配,保存状态,而这部分代码又基本上是完全一样的,纯粹是CTRL+C和CTRL+V的操作,而且也不利于维护。我的方式是把 所有的请求锁的操作封装成对象:

 因为UpgradeToWriterLocker实现了IDisposable接口,所以在使用的时候一个using语句块就搞定了,简单明了。

几个比较常见的死锁情况

1. 在使用Monitor或读写锁方式同步块中等待信号量被设置,同时信号量的设置放在了另一线程执行的同步块代码中。总结:最好不要在同步上下文中进等阻塞

2. 几个信号量的设置和复位相互间有直接或间接的关联时,语句执行的顺序搞错了,如本文的第一段代码。总结,在用Set设置信号量的时候要确保在当前运行的线程上需要一次执行完成的代码已经执行完了,也就是说Set语句尽量放到最后执行,只有这一句是立即会对其它线程的状态产生影响的。

3. 代码块同步的时候没有放在try{}finally{}中,一旦同步块中出现异常就永远不能退出同步块了。总结,所有形如BeginXXX/EndXXX,EnterXXX/ExitXXX之类的同步块都应该放到try{}finally{}块中。

4. 两个线程分等待进入两个不同的同步块,而各自所等待的信号量/事件完成需要在对方的同步块内部设置;或者两个线程已经各自进入了两个不同的同步块,但接下来要进入的同步块刚好是对方线程已经进入的同步块。

Code

总结,这种情况只能是在不改变程序逻辑的情况下尽量调整代码执行的顺序,使其避免产生上面的状况。

补充一下,经朋友提醒发现漏掉了一种临界区同步方式。这也是Windows API里面有,C#里面对应的方法是Thread.BeginCriticalRegion和Thread.EndCriticalRegion两个方法。这种同步方式因为是内核级别的同步,所以速度应该是最快的,但是不知为什么在平时看到的代码里面,这种方式却比较少见到人使用。

// 2008-12-24 最后修改