线程(转载)
线程(一)
文章系参考转载,英文原文网址请参考:http://www.albahari.com/threading/
作者 Joseph Albahari, 翻译 Swanky Wu
中文翻译作者把原文放在了"google 协作"上面,GFW屏蔽,不能访问和查看,因此我根据译文和英文原版整理转载到园子里面。
本系列文章可以算是一本很出色的C#线程手册,思路清晰,要点都有介绍,看了后对C#的线程及同步等有了更深入的理解。
- 入门
- 线程同步基础
- 同步要领
- 锁和线程安全
- Interrupt 和 Abort
- 线程状态
- 等待句柄
- 同步环境
- 使用多线程
- 单元模式和Windows Forms
- BackgroundWorker类
- ReaderWriterLock类
- 线程池
- 异步委托
- 计时器
- 局部储存
- 高级话题
- 非阻止同步
- Wait和Pulse
- Suspend和Resume
- 终止线程
C#支持通过多线程并行地执行代码,一个线程有它独立的执行路径,能够与其它的线程同时地运行。一个C#程序开始于一个单线程,这个单线程是被CLR和操作系统(也称为“主线程”)自动创建的,并具有多线程创建额外的线程。这里的一个简单的例子及其输出:
除非被指定,否则所有的例子都假定以下命名空间被引用了:
using System;
using System.Threading;
1
2
3
4
5
6
7
8
9
10
11
|
class ThreadTest { static void Main() { Thread t = new Thread (WriteY); t.Start(); // Run WriteY on the new thread while ( true ) Console.Write ( "x" ); // Write 'x' forever } static void WriteY() { while ( true ) Console.Write ( "y" ); // Write 'y' forever } } |
主线程创建了一个新线程“t”,它运行了一个重复打印字母"y"的方法,同时主线程重复但因字母“x”。CLR分配每个线程到它自己的内存堆栈上,来保证局部变量的分离运行。在接下来的方法中我们定义了一个局部变量,然后在主线程和新创建的线程上同时地调用这个方法。
1
2
3
4
5
6
7
8
9
|
static void Main() { new Thread (Go).Start(); // Call Go() on a new thread Go(); // Call Go() on the main thread } static void Go() { // Declare and use a local variable - 'cycles' for ( int cycles = 0; cycles < 5; cycles++) Console.Write ( '?' ); } |
变量cycles的副本分别在各自的内存堆栈中创建,输出也一样,可预见,会有10个问号输出。当线程们引用了一些公用的目标实例的时候,他们会共享数据。下面是实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class ThreadTest { bool done; static void Main() { ThreadTest tt = new ThreadTest(); // Create a common instance new Thread (tt.Go).Start(); tt.Go(); } // Note that Go is now an instance method void Go() { if (!done) { done = true ; Console.WriteLine ( "Done" ); } } } 因为在相同的<b>ThreadTest</b>实例中,两个线程都调用了<b>Go()</b>,它们共享了<b>done</b>字段,这个结果输出的是一个 "Done" ,而不是两个。 |
1
|
<a href= "http://images.cnblogs.com/cnblogs_com/miniwiki/WindowsLiveWriter/C_12936/image_6.png" ><img height= "45" width= "640" src= "http://images.cnblogs.com/cnblogs_com/miniwiki/WindowsLiveWriter/C_12936/image_thumb_2.png" align= "left" alt= "image" border= "0" title= "image" style= "display: inline; margin-left: 0px; margin-right: 0px; border-width: 0px;" ></a> |
静态字段提供了另一种在线程间共享数据的方式,下面是一个以done为静态字段的例子:
1
2
3
4
5
6
7
8
9
10
11
12
|
class ThreadTest { static bool done; // Static fields are shared between all threads static void Main() { new Thread (Go).Start(); Go(); } static void Go() { if (!done) { done = true ; Console.WriteLine ( "Done" ); } } } |
上述两个例子足以说明, 另一个关键概念, 那就是线程安全(或反之,它的不足之处! ) 输出实际上是不确定的:它可能(虽然不大可能) , "Done" ,可以被打印两次。然而,如果我们在Go方法里调换指令的顺序, "Done"被打印两次的机会会大幅地上升:
1
2
3
|
static void Go() { if (!done) { Console.WriteLine ( "Done" ); done = true ; } } |
问题就是一个线程在判断if块的时候,正好另一个线程正在执行WriteLine语句——在它将done设置为true之前。
补救措施是当读写公共字段的时候,提供一个排他锁;C#提供了lock语句来达到这个目的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class ThreadSafe { static bool done; static object locker = new object (); static void Main() { new Thread (Go).Start(); Go(); } static void Go() { lock (locker) { if (!done) { Console.WriteLine ( "Done" ); done = true ; } } } } |
当两个线程争夺一个锁的时候(在这个例子里是locker),一个线程等待,或者说被阻止到那个锁变的可用。在这种情况下,就确保了在同一时刻只有一个线程能进入临界区,所以"Done"只被打印了1次。代码以如此方式在不确定的多线程环境中被叫做线程安全。
临时暂停,或阻止是多线程的协同工作,同步活动的本质特征。等待一个排它锁被释放是一个线程被阻止的原因,另一个原因是线程想要暂停或Sleep一段时间:
1
|
Thread.Sleep (TimeSpan.FromSeconds (30)); // Block for 30 seconds |
一个线程也可以使用它的Join方法来等待另一个线程结束:
1
2
3
|
Thread t = new Thread (Go); // Assume Go is some static method t.Start(); t.Join(); // Wait (block) until thread t ends |
一个线程,一旦被阻止,它就不再消耗CPU的资源了。
线程是如何工作的
线程被一个线程协调程序管理着——一个CLR委托给操作系统的函数。线程协调程序确保将所有活动的线程被分配适当的执行时间;并且那些等待或阻止的线程——比如说在排它锁中、或在用户输入——都是不消耗CPU时间的。
在单核处理器的电脑中,线程协调程序完成一个时间片之后迅速地在活动的线程之间进行切换执行。这就导致“波涛汹涌”的行为,例如在第一个例子,每次重复的X 或 Y 块相当于分给线程的时间片。在Windows XP中时间片通常在10毫秒内选择要比CPU开销在处理线程切换的时候的消耗大的多。(即通常在几微秒区间)
在多核的电脑中,多线程被实现成混合时间片和真实的并发——不同的线程在不同的CPU上运行。这几乎可以肯定仍然会出现一些时间切片, 由于操作系统的需要服务自己的线程,以及一些其他的应用程序。
线程由于外部因素(比如时间片)被中断被称为被抢占,在大多数情况下,一个线程方面在被抢占的那一时那一刻就失去了对它的控制权。
线程 vs. 进程
属于一个单一的应用程序的所有的线程逻辑上被包含在一个进程中,进程指一个应用程序所运行的操作系统单元。
线程于进程有某些相似的地方:比如说进程通常以时间片方式与其它在电脑中运行的进程的方式与一个C#程序线程运行的方式大致相同。二者的关键区别在于进程彼此是完全隔绝的。线程与运行在相同程序其它线程共享(堆heap)内存,这就是线程为何如此有用:一个线程可以在后台读取数据,而另一个线程可以在前台展现已读取的数据。
何时使用多线程
多线程程序一般被用来在后台执行耗时的任务。主线程保持运行,并且工作线程做它的后台工作。对于Windows Forms程序来说,如果主线程试图执行冗长的操作,键盘和鼠标的操作会变的迟钝,程序也会失去响应。由于这个原因,应该在工作线程中运行一个耗时任务时添加一个工作线程,即使在主线程上有一个有好的提示“处理中...”,以防止工作无法继续。这就避免了程序出现由操作系统提示的“没有相应”,来诱使用户强制结束程序的进程而导致错误。模式对话框还允许实现“取消”功能,允许继续接收事件,而实际的任务已被工作线程完成。BackgroundWorker恰好可以辅助完成这一功能。
在没有用户界面的程序里,比如说Windows Service, 多线程在当一个任务有潜在的耗时,因为它在等待另台电脑的响应(比如一个应用服务器,数据库服务器,或者一个客户端)的实现特别有意义。用工作线程完成任务意味着主线程可以立即做其它的事情。
另一个多线程的用途是在方法中完成一个复杂的计算工作。这个方法会在多核的电脑上运行的更快,如果工作量被多个线程分开的话(使用Environment.ProcessorCount属性来侦测处理芯片的数量)。
一个C#程序称为多线程的可以通过2种方式:明确地创建和运行多线程,或者使用.NET framework的暗中使用了多线程的特性——比如BackgroundWorker类, 线程池,threading timer,远程服务器,或Web Services或ASP.NET程序。在后面的情况,人们别无选择,必须使用多线程;一个单线程的ASP.NET web server不是太酷,即使有这样的事情;幸运的是,应用服务器中多线程是相当普遍的;唯一值得关心的是提供适当锁机制的静态变量问题。
何时不要使用多线程
多线程也同样会带来缺点,最大的问题是它使程序变的过于复杂,拥有多线程本身并不复杂,复杂是的线程的交互作用,这带来了无论是否交互是否是有意的,都会带来较长的开发周期,以及带来间歇性和非重复性的bugs。因此,要么多线程的交互设计简单一些,要么就根本不使用多线程。除非你有强烈的重写和调试欲望。
当用户频繁地分配和切换线程时,多线程会带来增加资源和CPU的开销。在某些情况下,太多的I/O操作是非常棘手的,当只有一个或两个工作线程要比有众多的线程在相同时间执行任务块的多。稍后我们将实现生产者/耗费者 队列,它提供了上述功能。
线程用Thread类来创建, 通过ThreadStart委托来指明方法从哪里开始运行,下面是ThreadStart委托如何定义的:
1
|
public delegate void ThreadStart(); |
调用Start方法后,线程开始运行,线程一直到它所调用的方法返回后结束。下面是一个例子,使用了C#的语法创建TheadStart委托:
1
2
3
4
5
6
7
|
class ThreadTest { static void Main() { Thread t = new Thread ( new ThreadStart (Go)); t.Start(); // Run Go() on the new thread. Go(); // Simultaneously run Go() in the main thread. } static void Go() { Console.WriteLine ( "hello!" ); } |
在这个例子中,线程t执行Go()方法,大约与此同时主线程也调用了Go(),结果是两个几乎同时hello被打印出来:
一个线程可以通过C#堆委托简短的语法更便利地创建出来:
1
2
3
4
5
6
7
|
static void Main() { Thread t = new Thread (Go); // No need to explicitly use ThreadStart t.Start(); ... } static void Go() { ... } 在这种情况,ThreadStart被编译器自动推断出来,另一个快捷的方式是使用匿名方法来启动线程: |
1
2
3
4
|
static void Main() { Thread t = new Thread ( delegate () { Console.WriteLine ( "Hello!" ); }); t.Start(); } |
线程有一个IsAlive属性,在调用Start()之后直到线程结束之前一直为true。一个线程一旦结束便不能重新开始了。
将数据传入ThreadStart中
话又说回来,在上面的例子里,我们想更好地区分开每个线程的输出结果,让其中一个线程输出大写字母。我们传入一个状态字到Go中来完成整个任务,但我们不能使用ThreadStart委托,因为它不接受参数,所幸的是,.NET framework定义了另一个版本的委托叫做ParameterizedThreadStart, 它可以接收一个单独的object类型参数:
1
2
|
public delegate void ParameterizedThreadStart ( object obj); 之前的例子看起来是这样的: |
1
|
|
1
2
3
4
5
6
7
8
9
10
|
class ThreadTest { static void Main() { Thread t = new Thread (Go); t.Start ( true ); // == Go (true) Go ( false ); } static void Go ( object upperCase) { bool upper = ( bool ) upperCase; Console.WriteLine (upper ? "HELLO!" : "hello!" ); } |
在整个例子中,编译器自动推断出ParameterizedThreadStart委托,因为Go方法接收一个单独的object参数,就像这样写:
1
2
|
Thread t = new Thread ( new ParameterizedThreadStart (Go)); t.Start ( true ); |
ParameterizedThreadStart的特性是在使用之前我们必需对我们想要的类型(这里是bool)进行装箱操作,并且它只能接收一个参数。
一个替代方案是使用一个匿名方法调用一个普通的方法如下:
1
2
3
4
5
|
static void Main() { Thread t = new Thread ( delegate () { WriteText ( "Hello" ); }); t.Start(); } static void WriteText ( string text) { Console.WriteLine (text); } |
优点是目标方法(这里是WriteText),可以接收任意数量的参数,并且没有装箱操作。不过这需要将一个外部变量放入到匿名方法中,向下面的一样:
1
2
3
4
5
6
7
|
static void Main() { string text = "Before" ; Thread t = new Thread ( delegate () { WriteText (text); }); text = "After" ; t.Start(); } static void WriteText ( string text) { Console.WriteLine (text); } |
匿名方法打开了一种怪异的现象,当外部变量被后来的部分修改了值的时候,可能会透过外部变量进行无意的互动。有意的互动(通常通过字段)被认为是足够了!一旦线程开始运行了,外部变量最好被处理成只读的——除非有人愿意使用适当的锁。
另一种较常见的方式是将对象实例的方法而不是静态方法传入到线程中,对象实例的属性可以告诉线程要做什么,如下列重写了原来的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class ThreadTest { bool upper; static void Main() { ThreadTest instance1 = new ThreadTest(); instance1.upper = true ; Thread t = new Thread (instance1.Go); t.Start(); ThreadTest instance2 = new ThreadTest(); instance2.Go(); // 主线程——运行 upper=false } void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!" ); } |
命名线程
线程可以通过它的Name属性进行命名,这非产有利于调试:可以用Console.WriteLine打印出线程的名字,Microsoft Visual Studio可以将线程的名字显示在调试工具栏的位置上。线程的名字可以在被任何时间设置——但只能设置一次,重命名会引发异常。
程序的主线程也可以被命名,下面例子里主线程通过CurrentThread命名:
1
2
3
4
5
6
7
8
9
10
11
12
|
class ThreadNaming { static void Main() { Thread.CurrentThread.Name = "main" ; Thread worker = new Thread (Go); worker.Name = "worker" ; worker.Start(); Go(); } static void Go() { Console.WriteLine ( "Hello from " + Thread.CurrentThread.Name); } } |
前台和后台线程
线程默认为前台线程,这意味着任何前台线程在运行都会保持程序存活。C#也支持后台线程,当所有前台线程结束后,它们不维持程序的存活。
改变线程从前台到后台不会以任何方式改变它在CPU协调程序中的优先级和状态。
线程的IsBackground属性控制它的前后台状态,如下实例:
1
2
3
4
5
6
7
|
class PriorityTest { static void Main ( string [] args) { Thread worker = new Thread ( delegate () { Console.ReadLine(); }); if (args.Length > 0) worker.IsBackground = true ; worker.Start(); } } |
如果程序被调用的时候没有任何参数,工作线程为前台线程,并且将等待ReadLine语句来等待用户的触发回车,这期间,主线程退出,但是程序保持运行,因为一个前台线程仍然活着。
另一方面如果有参数传入Main(),工作线程被赋值为后台线程,当主线程结束程序立刻退出,终止了ReadLine。
后台线程终止的这种方式,使任何最后操作都被规避了,这种方式是不太合适的。好的方式是明确等待任何后台工作线程完成后再结束程序,可能用一个timeout(大多用Thread.Join)。如果因为某种原因某个工作线程无法完成,可以用试图终止它的方式,如果失败了,再抛弃线程,允许它与 与进程一起消亡。(记录是一个难题,但这个场景下是有意义的)
拥有一个后台工作线程是有益的,最直接的理由是它当提到结束程序它总是可能有最后的发言权。交织以不会消亡的前台线程,保证程序的正常退出。抛弃一个前台工作线程是尤为险恶的,尤其对Windows Forms程序,因为程序直到主线程结束时才退出(至少对用户来说),但是它的进程仍然运行着。在Windows任务管理器它将从应用程序栏消失不见,但却可以在进程栏找到它。除非用户找到并结束它,它将继续消耗资源,并可能阻止一个新的实例的运行从开始或影响它的特性。
对于程序失败退出的普遍原因就是存在“被忘记”的前台线程。
线程优先级
线程的Priority 属性确定了线程相对于其它同一进程的活动的线程拥有多少执行时间,以下是级别:
1
|
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest } |
只有多个线程同时为活动时,优先级才有作用。
设置一个线程的优先级为高一些,并不意味着它能执行实时的工作,因为它受限于程序的进程的级别。要执行实时的工作,必须提升在System.Diagnostics 命名空间下Process的级别,像下面这样:(我没有告诉你如何做到这一点:))
1
|
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; |
ProcessPriorityClass.High 其实是一个短暂缺口的过程中的最高优先级别:Realtime。设置进程级别到Realtime通知操作系统:你不想让你的进程被抢占了。如果你的程序进入一个偶然的死循环,可以预期,操作系统被锁住了,除了关机没有什么可以拯救你了!基于此,High大体上被认为最高的有用进程级别。
如果一个实时的程序有一个用户界面,提升进程的级别是不太好的,因为当用户界面UI过于复杂的时候,界面的更新耗费过多的CPU时间,拖慢了整台电脑。(虽然在写这篇文章的时候,在互联网电话程序Skype侥幸地这么做, 也许是因为它的界面相当简单吧。) 降低主线程的级别、提升进程的级别、确保实时线程不进行界面刷新,但这样并不能避免电脑越来越慢,因为操作系统仍会拨出过多的CPU给整个进程。最理想的方案是使实时工作和用户界面在不同的进程(拥有不同的优先级)运行,通过Remoting或共享内存方式进行通信,共享内存需要Win32 API中的 P/Invoking。(可以搜索看看CreateFileMapping 和 MapViewOfFile)
异常处理
任何线程创建范围内try/catch/finally块,当线程开始执行便不再与其有任何关系。考虑下面的程序:
1
2
3
4
5
6
7
8
9
10
11
|
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // 不会在这得到异常 Console.WriteLine ( "Exception!" ); } static void Go() { throw null ; } } |
1
|
这里 try / catch 语句一点用也没有,新创建的线程将引发NullReferenceException异常。当你考虑到每个线程有独立的执行路径的时候,便知道这行为是有道理的, |
1
|
补救方法是在线程处理的方法内加入他们自己的异常处理: |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public static void Main() { new Thread (Go).Start(); } static void Go() { try { ... throw null ; // 这个异常在下面会被捕捉到 ... } catch (Exception ex) { 记录异常日志,并且或通知另一个线程 我们发生错误 ... } |
从.NET 2.0开始,任何线程内的未处理的异常都将导致整个程序关闭,这意味着忽略异常不再是一个选项了。因此为了避免由未处理异常引起的程序崩溃,try/catch块需要出现在每个线程进入的方法内,至少要在产品程序中应该如此。对于经常使用“全局”异常处理的Windows Forms程序员来说,这可能有点麻烦,像下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
using System; using System.Threading; using System.Windows.Forms; static class Program { static void Main() { Application.ThreadException += HandleError; Application.Run ( new MainForm()); } static void HandleError ( object sender, ThreadExceptionEventArgs e) { 记录异常或者退出程序或者继续运行... } } |
Application.ThreadException事件在异常被抛出时触发,以一个Windows信息(比如:键盘,鼠标活着 "paint" 等信息)的方式,简言之,一个Windows Forms程序的几乎所有代码。虽然这看起来很完美,它使人产生一种虚假的安全感——所有的异常都被中央异常处理捕捉到了。由工作线程抛出的异常便是一个没有被Application.ThreadException捕捉到的很好的例外。(在Main方法中的代码,包括构造器的形式,在Windows信息开始前先执行)
.NET framework为全局异常处理提供了一个更低级别的事件:AppDomain.UnhandledException,这个事件在任何类型的程序(有或没有用户界面)的任何线程有任何未处理的异常触发。尽管它提供了好的不得已的异常处理解决机制,但是这不意味着这能保证程序不崩溃,也不意味着能取消.NET异常对话框。
在产品程序中,明确地使用异常处理在所有线程进入的方法中是必要的,可以使用包装类和帮助类来分解工作来完成任务,比如使用BackgroundWorker类(在第三部分进行讨论)
1.同步要领
下面的表格列展了.NET对协调或同步线程动作的可用的工具:
简易阻止方法
构成 |
目的 |
Sleep |
阻止给定的时间周期 |
Join |
等待另一个线程完成 |
锁系统
构成 |
目的 |
跨进程? |
速度 |
lock |
确保只有一个线程访问某个资源或某段代码。 |
否 |
快 |
Mutex |
确保只有一个线程访问某个资源或某段代码。可被用于防止一个程序的多个实例同时运行。 |
是 |
中等 |
Semaphore |
确保不超过指定数目的线程访问某个资源或某段代码。 |
是 |
中等 |
(同步的情况下也提够自动锁。)
信号系统
构成 |
目的 |
跨进程? |
速度 |
EventWaitHandle |
允许线程等待直到它受到了另一个线程发出信号。 |
是 |
中等 |
Wait 和 Pulse* |
允许一个线程等待直到自定义阻止条件得到满足。 |
否 |
中等 |
非阻止同步系统*
构成 |
目的 |
跨进程? |
速度 |
Interlocked* |
完成简单的非阻止原子操作。 |
是(内存共享情况下) |
非常快 |
volatile* |
允许安全的非阻止在锁之外使用个别字段。 |
非常快 |
* 代表页面将转到第四部分
1.1 阻止 (Blocking)
当一个线程通过上面所列的方式处于等待或暂停的状态,被称为被阻止。一旦被阻止,线程立刻放弃它被分配的
CPU时间,将它的ThreadState属性添加为WaitSleepJoin状态,不在安排时间直到停止阻止。停止阻止在任意四种
情况下发生(关掉电脑的电源可不算!):
- 阻止的条件已得到满足
- 操作超时(如果timeout被指定了)
- 通过Thread.Interrupt中断了
- 通过Thread.Abort放弃了
当线程通过(不建议)Suspend 方法暂停,不认为是被阻止了。
调用Thread.Sleep阻止当前的线程指定的时间(或者直到中断):
1
2
3
4
5
6
|
static void Main() { Thread.Sleep (0); // 释放CPU时间片 Thread.Sleep (1000); // 休眠1000毫秒 Thread.Sleep (TimeSpan.FromHours (1)); // 休眠1小时 Thread.Sleep (Timeout.Infinite); // 休眠直到中断 } |
更确切地说,Thread.Sleep放弃了占用CPU,请求不在被分配时间直到给定的时间经过。Thread.Sleep(0)放弃
CPU的时间刚刚够其它在时间片队列里的活动线程(如果有的话)被执行。
Thread.Sleep在阻止方法中是唯一的暂停汲取Windows Forms程序的Windows消息的方法,或COM环境中用于
单元模式。这在Windows Forms程序中是一个很大的问题,任何对主UI线程的阻止都将使程序失去相应。因此一般避
免这样使用,无论信息汲取是否被“技术地”暂定与否。由COM遗留下来的宿主环境更为复杂,在一些时候它决定停止,
而却保持信息的汲取存活。微软的 Chris Brumm 在他的博客中讨论这个问题。(搜索: 'COM "Chris Brumme"')
线程类同时也提供了一个SpinWait方法,它使用轮询CPU而非放弃CPU时间的方式,保持给定的迭代次数进行“无用
地繁忙”。50迭代可能等同于停顿大约一微秒,虽然这将取决于CPU的速度和负载。从技术上讲,SpinWait并不是一个阻
止的方法:一个处于spin-waiting的线程的ThreadState不是WaitSleepJoin状态,并且也不会被其它的线程过早的中断
(Interrupt)。SpinWait很少被使用,它的作用是等待一个在极短时间(可能小于一微秒)内可准备好的可预期的资源,
而不用调用Sleep方法阻止线程而浪费CPU时间。不过,这种技术的优势只有在多处理器计算机:对单一处理器的电脑,
直到轮询的线程结束了它的时间片之前,一个资源没有机会改变状态,这有违它的初衷。并且调用SpinWait经常会花费较
长的时间这本身就浪费了CPU时间。
线程可以等待某个确定的条件来明确轮询使用一个轮询的方式,比如:
1
|
while (!proceed); |
或者:
1
|
while (DateTime.Now < nextStartTime); |
这是非常浪费CPU时间的:对于CLR和操作系统而言,线程进行了一个重要的计算,所以分配了相应的资源!在这种状态
下的轮询线程不算是阻止,不像一个线程等待一个EventWaitHandle(一般使用这样的信号任务来构建)。
阻止和轮询组合使用可以产生一些变换:
1
|
while (!proceed) Thread.Sleep (x); // "轮询休眠!" |
x越大,CPU效率越高,折中方案是增大潜伏时间,任何20ms的花费是微不足道的,除非循环中的条件是极其复杂的。
除了稍有延迟,这种轮询和休眠的方式可以结合的非常好。(但有并发问题,在第四部分讨论)可能它最大的用处在于
程序员可以放弃使用复杂的信号结构 来工作了。
你可以通过Join方法阻止线程直到另一个线程结束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class JoinDemo { static void Main() { Thread t = new Thread ( delegate () { Console.ReadLine();}); t.Start(); t.Join(); // 等待直到线程完成 Console.WriteLine ( "Thread t's ReadLine complete!" ); } } |
Join方法也接收一个使用毫秒或用TimeSpan类的超时参数,当Join超时是返回false,如果线程已终止,则返回true 。
Join所带的超时参数非常像Sleep方法,实际上下面两行代码几乎差不多:
1
2
3
|
Thread.Sleep (1000); Thread.CurrentThread.Join (1000); |
(他们的区别明显在于单线程的应用程序域与COM互操作性,源于先前描述Windows信息汲取部分:在阻止时,Join
保持信息汲取,Sleep暂停信息汲取。)
2. 锁和线程安全
锁实现互斥的访问,被用于确保在同一时刻只有一个线程可以进入特殊的代码片段,考虑下面的类:
1
2
3
4
5
6
7
8
|
class ThreadUnsafe { static int val1, val2; static void Go() { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } } |
这不是线程安全的:如果Go方法被两个线程同时调用,可能会得到在某个线程中除数为零的错误,因为val2可能被一个
线程设置为零,而另一个线程刚好执行到if和Console.WriteLine语句。
下面用lock来修正这个问题:
1
2
3
4
5
6
7
8
9
10
11
12
|
class ThreadSafe { static object locker = new object (); static int val1, val2; static void Go() { lock (locker) { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } } } |
在同一时刻只有一个线程可以锁定同步对象(在这里是locker),任何竞争的的其它线程都将被阻止,直到这个锁被释放。如果有大于一个的线程竞争这个锁,那么他们将形成称为“就绪队列”的队列,以先到先得的方式授权锁。互斥锁有时被称之对由锁所保护的内容强迫串行化访问,因为一个线程的访问不能与另一个重叠。在这个例子中,我们保护了Go方法的逻辑,以及val1 和val2字段的逻辑。
一个等候竞争锁的线程被阻止将在ThreadState上为WaitSleepJoin状态。稍后我们将讨论一个线程通过另一个线程调用
Interrupt或Abort方法来强制地被释放。这是一个相当高效率的技术可以被用于结束工作线程。
C#的lock 语句实际上是调用Monitor.Enter和Monitor.Exit,中间夹杂try-finally语句的简略版,下面是实际发生在之前例
子中的Go方法:
1
2
3
4
5
6
7
8
|
Monitor.Enter (locker); try { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } finally { Monitor.Exit (locker); } |
在同一个对象上,在调用第一个之前Monitor.Enter而先调用了Monitor.Exit将引发异常。
Monitor 也提供了TryEnter方法来实现一个超时功能——也用毫秒或TimeSpan,如果获得了锁返回true,反之没有获得返回false,因为超时了。TryEnter也可以没有超时参数,“测试”一下锁,如果锁不能被获取的话就立刻超时。
2.1 选择同步对象
任何对所有有关系的线程都可见的对象都可以作为同步对象,但要服从一个硬性规定:它必须是引用类型。也强烈建议同步对象最好私有在类里面(比如一个私有实例字段)防止无意间从外部锁定相同的对象。服从这些规则,同步对象可以兼对象和保护两种作用。比如下面List :
1
2
3
4
5
6
7
8
9
10
11
|
class ThreadSafe { List < string > list = new List < string >(); void Test() { lock (list) { list.Add ( "Item 1" ); ... |
一个专门字段是常用的(如在先前的例子中的locker) , 因为它可以精确控制锁的范围和粒度。用对象或类本身的类型作为一个同步对象,即:
lock (this) { ... }
或:
lock (typeof (Widget)) { ... } // 保护访问静态
是不好的,因为这潜在的可以在公共范围访问这些对象。
锁并没有以任何方式阻止对同步对象本身的访问,换言之,x.ToString()不会由于另一个线程调用lock(x) 而被阻止,两者都要调用ock(x) 来完成阻止工作。
2.2 嵌套锁定
线程可以重复锁定相同的对象,可以通过多次调用Monitor.Enter或lock语句来实现。当对应编号的Monitor.Exit被调用或最外面的lock语句完成后,对象那一刻被解锁。这就允许最简单的语法实现一个方法的锁调用另一个锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
static object x = new object (); static void Main() { lock (x) { Console.WriteLine ( "I have the lock" ); Nest(); Console.WriteLine ( "I still have the lock" ); } 在这锁被释放 } static void Nest() { lock (x) { ... } 释放了锁?没有完全释放! } |
线程只能在最开始的锁或最外面的锁时被阻止。
作为一项基本规则,任何和多线程有关的会进行读和写的字段应当加锁。甚至是极平常的事情——单一字段的赋值操作,都必须考虑到同步问题。在下面的例子中Increment和Assign 都不是线程安全的:
1
2
3
4
5
|
class ThreadUnsafe { static int x; static void Increment() { x++; } static void Assign() { x = 123; } } |
下面是Increment 和 Assign 线程安全的版本:
1
2
3
4
5
6
7
|
class ThreadUnsafe { static object locker = new object (); static int x; static void Increment() { lock (locker) x++; } static void Assign() { lock (locker) x = 123; } } |
作为锁定另一个选择,在一些简单的情况下,你可以使用非阻止同步,在第四部分讨论(即使像这样的语句需要同步的原因)。
如果有很多变量在一些锁中总是进行读和写的操作,那么你可以称之为原子操作。我们假设x 和 y不停地读和赋值,他们在锁内通过
locker锁定:
lock (locker) { if (x != 0) y /= x; }
你可以认为x 和 y 通过原子的方式访问,因为代码段没有被其它的线程分开 或 抢占,别的线程改变x 和 y是无效的输出,你永远不会得到除数为零的错误,保证了x 和 y总是被相同的排他锁访问。
2.5 性能考量
锁定本身是非常快的,一个锁在没有堵塞的情况下一般只需几十纳秒(十亿分之一秒)。如果发生堵塞,任务切换带来的开销接近于数微秒(百万分之一秒)的范围内,尽管在线程重组实际的安排时间之前它可能花费数毫秒(千分之一秒)。而相反,与此相形见绌的是该使用锁而没使用的结果就是带来数小时的时间,甚至超时。如果耗尽并发,锁定会带来反作用,死锁和争用锁,耗尽并发由于太多的代码被放置到锁语句中了,引起其它线程不必要的被阻止。死锁是两线程彼此等待被锁定的内容,导致两者都无法继续下去。争用锁是两个线程任一个都可以锁定某个内容,如果“错误”的线程获取了锁,则导致程序错误。
对于太多的同步对象死锁是非常容易出现的症状,一个好的规则是开始于较少的锁,在一个可信的情况下涉及过多的阻止出现时,增加锁的粒度。
线程安全的代码是指在面对任何多线程情况下,这代码都没有不确定的因素。线程安全首先完成锁,然后减少在线程间交互的可能性。
一个线程安全的方法,在任何情况下可以可重入式调用。通用类型在它们中很少是线程安全的,原因如下:
-
- 完全线程安全的开发是重要的,尤其是一个类型有很多字段(在任意多线程上下文中每个字段都有潜在的交互作用)的情况下。
- 线程安全带来性能损失(要付出的,在某种程度上无论与否类型是否被用于多线程)。
- 一个线程安全类型不一定能使程序使用线程安全,有时参与工作后者可使前者变得冗余。
因此线程安全经常只在需要实现的地方来实现,为了处理一个特定的多线程情况。
不过,有一些方法来“欺骗”,有庞大和复杂的类安全地运行在多线程环境中。一种是牺牲粒度包含大段的代码——甚至在排他锁中访问全局对象,迫使在更高的级别上实现串行化访问。这一策略也很关键,让非线程安全的对象用于线程安全代码中,避免了相同的互斥锁被用于保护对在非线程安全对象的所有的属性、方法和字段的访问。
原始类型除外,很少的.NET framework类型实例相比于并发的只读访问,是线程安全的。责任在开放人员实现线程安全代表性地使用互斥锁。
另一个方式欺骗是通过最小化共享数据来最小化线程交互。这是一个很好的途径,被暗中地用于“弱状态”的中间层程序和web服务器。自多个客户端请求同时到达,每个请求来自它自己的线程(效力于ASP.NET,Web服务器或者远程体系结构),这意味着它们调用的方法一定是线程安全的。弱状态设计(因伸缩性好而流行)本质上限制了交互的能力,因此类不能够在每个请求间持久保留数据。线程交互仅限于可以被选择创建的静态字段,多半是在内存里缓存常用数据和提供基础设施服务,例如认证和审核。
2.7线程安全与.NET Framework类型
锁定可被用于将非线程安全的代码转换成线程安全的代码。比较好的例子是在.NET framework方面,几乎所有非基本类型的实例都不是线程安全的,而如果所有的访问给定的对象都通过锁进行了保护的话,他们可以被用于多线程代码中。看这个例子,两个线程同时为相同的List增加条目,然后枚举它:.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class ThreadSafe { static List < string > list = new List < string >(); static void Main() { new Thread (AddItems).Start(); new Thread (AddItems).Start(); } static void AddItems() { for ( int i = 0; i < 100; i++) lock (list)list.Add ( "Item " + list.Count); string [] items; lock (list) items = list.ToArray(); foreach ( string s in items) Console.WriteLine (s); } } |
在这种情况下,我们锁定了list对象本身,这个简单的方案是很好的。如果我们有两个相关的list,也许我们就要锁定一个共同的目标——单独的一个字段,如果没有其它的list出现,显然锁定它自己是明智的选择。枚举.NET的集合也不是线程安全的,在枚举的时候另一个线程改动list的话,会抛出异常。为了不直接锁定枚举过程,在这个例子中,我们首先将项目复制到数组当中,这就避免了固定住锁因为我们在枚举过程中有潜在的耗时。
这里的一个有趣的假设:想象如果List实际上为线程安全的,如何解决呢?代码会很少!举例说明,我们说我们要增加一个项目到我们假象的线程安全的list里,如下:
if (!myList.Contains (newItem)) myList.Add (newItem);
无论与否list是否为线程安全的,这个语句显然不是!(因此,可以说完全线程安全的通用集合类是基本不存在的。.net4.0中,微软提供了一组线程安全的并行集合类,但是都是特殊的经过处理过的,访问方式都经过了限定。),上面的语句要实现线程安全,整个if语句必须放到一个锁中,用来保护抢占在判断有无和增加新的之间。上述的锁需要用于任何我们需要修改list的地方,比如下面的语句需要被同样的锁包括住:
myList.Clear();
来保证它没有抢占之前的语句,换言之,我们必须锁定差不多所有非线程安全的集合类们。内置的线程安全,显而易见是浪费时间!
在写自定义组件的时候,你可能会反对这个观点——为什么建造线程安全让它容易的结果会变的多余呢 ?
有一个争论:在一个对象包上自定义的锁仅在所有并行的线程知道、并使用这个锁的时候才能工作,而如果锁对象在更大的范围内的时候,这个锁对象可能不在这个锁范围内。最糟糕的情况是静态成员在公共类型中出现了,比如,想象静态结构在DateTime上,DateTime.Now不是线程安全的,当有2个并发的调用可带来错乱的输出或异常,补救方式是在其外进行锁定,可能锁定它的类型本身—— lock(typeof(DateTime))来圈住调用DateTime.Now,这会工作的,但只有所有的程序员同意这样做的时候。然而这并靠不住,锁定一个类型被认为是一件非常不好的事情。由于这些理由,DateTime上的静态成员是保证线程安全的,这是一个遍及.NET framework一个普遍模式——静态成员是线程安全的,而一个实例成员则不是。从这个模式也能在写自定义类型时得到一些体会,不要创建一个不能线程安全的难题!
当写公用组件的时候,好的习惯是不要忘记了线程安全,这意味着要单独小心处理那些在其中或公共的静态成员。
3. Interrupt 和 Abort
一个被阻止的线程可以通过两种方式被提前释放:
- 通过 Thread.Interrupt
- 通过 Thread.Abort
这必须通过另外活动的线程实现,等待的线程是没有能力对它的被阻止状态做任何事情的。
在一个被阻止的线程上调用Interrupt 方法,将强迫释放它,抛出ThreadInterruptedException异常,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
class Program { static void Main() { Thread t = new Thread ( delegate () { try { Thread.Sleep (Timeout.Infinite); } catch (ThreadInterruptedException) { Console.Write ( "Forcibly " ); } Console.WriteLine ( "Woken!" ); }); t.Start(); t.Interrupt(); } } Forcibly Woken! |
中断一个线程仅仅释放它的当前的(或下一个)等待状态:它并不结束这个线程(当然,除非未处理
ThreadInterruptedException异常)。
如果Interrupt被一个未阻止的线程调用,那么线程将继续执行直到下一次被阻止时,它抛出
ThreadInterruptedException异常。用下面的测试避免这个问题:
if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
worker.Interrupt();
这不是一个线程安全的方式,因为可能被抢占了在if语句和worker.Interrupt间。
随意中断线程是危险的,因为任何框架或第三方方法在调用堆栈时可能会意外地在已订阅的代码上收到中断。这一切将被认为是线程被暂时阻止在一个锁中或同步资源中,并且所有挂起的中断将被踢开。如果这个方法没有被设计成可以被中断(没有适当处理finally块)的对象可能剩下无用的状态,或资源不完全地被释放。
中断一个线程是安全的,当你知道它确切的在哪的时候。稍后我们讨论 信号系统,它提供这样的一种方式。
被阻止的线程也可以通过Abort方法被强制释放,这与调用Interrupt相似,除了用ThreadAbortException异常代替了
ThreadInterruptedException异常,此外,异常将被重新抛出在catch里(在试图以有好方式处理异常的时候),直到Thread.ResetAbort在catch中被调用;在这期间线程的ThreadState为AbortRequested。
在Interrupt 与 Abort 之间最大不同在于它们调用一个非阻止线程所发生的事情。Interrupt继续工作直到下一次阻止发生,Abort在线程当前所执行的位置(可能甚至不在你的代码中)抛出异常。终止一个非阻止的线程会带来严重的后果,这在后面的 “终止线程”章节中将详细讨论。
4 线程状态
图1: 线程状态关系图
你可以通过ThreadState属性获取线程的执行状态。图1将ThreadState列举为“层”。ThreadState被设计的很恐怖,它以按位计算的方式组合三种状态“层”,每种状态层的成员它们间都是互斥的,下面是所有的三种状态“层”:
- 运行 (running) / 阻止 (blocking) / 终止 (aborting) 状态(图1显示)
- 后台 (background) / 前台 (foreground) 状态 (ThreadState.Background)
- 不建议使用的Suspend 方法(ThreadState.SuspendRequested 和 ThreadState.Suspended)挂起的过程
总的来说,ThreadState是按位组合零或每个状态层的成员!一个简单的ThreadState例子:
Unstarted
Running
WaitSleepJoin
Background, Unstarted
SuspendRequested, Background, WaitSleepJoin
(所枚举的成员有两个从来没被用过,至少是当前CLR实现上:StopRequested 和 Aborted。)
还有更加复杂的,ThreadState.Running潜在的值为0 ,因此下面的测试不工作:
if ((t.ThreadState & ThreadState.Running) > 0) ...
你必须用按位与非操作符来代替,或者使用线程的IsAlive属性。但是IsAlive可能不是你想要的,它在被阻止或挂起的时候返回true(只有在线程未开始或已结束时它才为true)。
假设你避开不推荐使用的Suspend 和 Resume方法,你可以写一个helper方法除去所有除了第一种状态层的成员,允许简单测试计算完成。线程的后台状态可以通过IsBackground 独立地获得,所以实际上只有第一种状态层拥有有用的信息。
1
2
3
4
5
6
7
|
public static ThreadState SimpleThreadState (ThreadState ts) { return ts & (ThreadState.Aborted | ThreadState.AbortRequested | ThreadState.Stopped | ThreadState.Unstarted | ThreadState.WaitSleepJoin); } |
ThreadState对调试或程序概要分析是无价之宝,与之不相称的是多线程的协同工作,因为没有一个机制存在:通过判断ThreadState来执行信息,而不考虑ThreadState期间的变化。
5 等待句柄
lock语句(也称为Monitor.Enter / Monitor.Exit)是线程同步结构的一个例子。当lock对一段代码或资源实施排他访问时, 但有些同步任务是相当笨拙的或难以实现的,比如说需要传输信号给等待的工作线程使其开始任务执行。
Win32 API拥有丰富的同步系统,这在.NET framework以EventWaitHandle, Mutex 和 Semaphore类展露出来。而一些比有些更有用:例如Mutex类,在EventWaitHandle提供唯一的信号功能时,大多会成倍提高lock的效率。
这三个类都依赖于WaitHandle类,尽管从功能上讲, 它们相当的不同。但它们做的事情都有一个共同点,那就是,被“点名”,这允许它们绕过操作系统进程工作,而不是只能在当前进程里绕过线程。
EventWaitHandle有两个子类:AutoResetEvent 和 ManualResetEvent(不涉及到C#中的事件或委托)。这两个类都派生自它们的基类:它们仅有的不同是它们用不同的参数调用基类的构造函数。性能方面,使用Wait Handles系统开销会花费在微秒间,不会在它们使用的上下文中产生什么后果。
AutoResetEvent在WaitHandle中是最有用的的类,它连同lock 语句是一个主要的同步结构。
AutoResetEvent就像一个用票通过的旋转门:插入一张票,让正确的人通过。类名字里的“auto”实际上就是旋转门自动关闭或“重新安排”后来的人让其通过。一个线程等待或阻止通过在门上调用WaitOne方法(直到等到这个“one”,门才开) ,票的插入则由调用Set方法。如果由许多线程调用WaitOne,在门前便形成了队列,一张票可能来自任意某个线程——换言之,任何(非阻止)线程要通过AutoResetEvent对象调用Set方法来释放一个被阻止的的线程。
也就是调用WaitOne方法的所有线程会阻塞到一个等待队列,其他非阻塞线程通过调用Set方法来释放一个阻塞。然后AutoResetEvent继续阻塞后面的线程。
如果Set调用时没有任何线程处于等待状态,那么句柄保持打开直到某个线程调用了WaitOne 。这个行为避免了在线程起身去旋转门和线程插入票(哦,插入票是非常短的微秒间的事,真倒霉,你将必须不确定地等下去了!)间的竞争。但是在没人等的时候重复地在门上调用Set方法不会允许在一队人都通过,在他们到达的时候:仅有下一个人可以通过,多余的票都被“浪费了"。
WaitOne 接受一个可选的超时参数——当等待以超时结束时这个方法将返回false,WaitOne在等待整段时间里也通知离开当前的同步内容,为了避免过多的阻止发生。
Reset作用是关闭旋转门,也就是无论此时是否已经set过,都将阻塞下一次WaitOne——它应该是开着的。
AutoResetEvent可以通过2种方式创建,第一种是通过构造函数:
1
|
EventWaitHandle wh = new AutoResetEvent ( false ); |
如果布尔参数为真,Set方法在构造后立刻被自动的调用,也就是说第一个WaitOne会被放行,不会被阻塞,另一个方法是通过它的基类EventWaitHandle:
1
|
EventWaitHandle wh = new EventWaitHandle ( false , EventResetMode.Auto); |
EventWaitHandle的构造器也允许创建ManualResetEvent(用EventResetMode.Manual定义).
在Wait Handle不在需要时候,你应当调用Close方法来释放操作系统资源。但是,如果一个Wait Handle将被用于程序(就像这一节的大多例子一样)的生命周期中,你可以发点懒省略这个步骤,它将在程序域销毁时自动的被销毁。
接下来这个例子,一个线程开始等待直到另一个线程发出信号。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class BasicWaitHandle { static EventWaitHandle wh = new AutoResetEvent ( false ); static void Main() { new Thread (Waiter).Start(); Thread.Sleep (1000); // 等一会... wh.Set(); // OK ——唤醒它 } static void Waiter() { Console.WriteLine ( "Waiting..." ); wh.WaitOne(); // 等待通知 Console.WriteLine ( "Notified" ); } } Waiting... (pause) Notified. |
EventWaitHandle的构造器允许以“命名”的方式进行创建,它有能力跨多个进程。名称是个简单的字符串,可能会无意地与别的冲突!如果名字使用了,你将引用相同潜在的EventWaitHandle,除非操作系统创建一个新的,看这个例子:
1
2
|
EventWaitHandle wh = new EventWaitHandle ( false , EventResetMode.Auto, "MyCompany.MyApp.SomeName" ); |
如果有两个程序都运行这段代码,他们将彼此可以发送信号,等待句柄可以跨这两个进程中的所有线程。
设想我们希望在后台完成任务,但又不在每次我们得到任务时再创建一个新的线程。我们可以通过一个轮询的线程来完成:等待一个任务,执行它,然后等待下一个任务。这是一个普遍的多线程方案。也就是在创建线程上切分内务操作,任务执行被序列化,在多个工作线程和过多的资源消耗间排除潜在的不想要的操作。 我们必须决定要做什么,但是,如果当新的任务来到的时候,工作线程已经在忙之前的任务了,设想这种情形下我们需选择阻止调用者直到之前的任务被完成。像这样的系统可以用两个AutoResetEvent对象实现:一个“ready”AutoResetEvent,当准备好的时候,它被工作线程调用Set方法;和“go”AutoResetEvent,当有新任务的时候,它被调用线程调用Set方法。在下面的例子中,一个简单的string字段被用于决定任务(使用了volatile 关键字声明,来确保两个线程都可以看到相同版本):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
class AcknowledgedWaitHandle { static EventWaitHandle ready = new AutoResetEvent ( false ); static EventWaitHandle go = new AutoResetEvent ( false ); static volatile string task; static void Main() { new Thread (Work).Start(); // Signal the worker 5 times for ( int i = 1; i <= 5; i++) { ready.WaitOne(); // First wait until worker is ready task = "a" .PadRight (i, 'h' ); // Assign a task go.Set(); // Tell worker to go! } // Tell the worker to end using a null-task ready.WaitOne(); task = null ; go.Set(); } static void Work() { while ( true ) { ready.Set(); // Indicate that we're ready go.WaitOne(); // Wait to be kicked off... if (task == null ) return ; // Gracefully exit Console.WriteLine (task); } } } ah ahh ahhh ahhhh |
注意我们要给task赋null来告诉工作线程退出。在工作线程上调用Interrupt 或Abort 效果是一样的,倘若我们先调用ready.WaitOne的话。因为在调用ready.WaitOne后我们就知道工作线程的确切位置,不是在就是刚刚在go.WaitOne语句之前,因此避免了中断任意代码的复杂性。调用 Interrupt 或 Abort需要我们在工作线程中捕捉异常。
另一个普遍的线程方案是在后台工作进程从队列中分配任务。这叫做生产者/消费者队列:在工作线程中生产者入列任务,消费者出列任务。这和上个例子很像,除了当工作线程正忙于一个任务时调用者没有被阻止之外。
生产者/消费者队列是可缩放的,因为多个消费者可能被创建——每个都服务于相同的队列,但开启了一个分离的线程。这是一个很好的方式利用多处理器的系统来限制工作线程的数量一直避免了极大的并发线程的缺陷(过多的内容切换和资源连接)。
在下面例子里,一个单独的AutoResetEvent被用于通知工作线程,它只有在用完任务时(队列为空)等待。一个通用的集合类被用于队列,必须通过锁
控制它的访问以确保线程安全。工作线程在队列为null任务时结束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
using System; using System.Threading; using System.Collections.Generic; class ProducerConsumerQueue : IDisposable { EventWaitHandle wh = new AutoResetEvent ( false ); Thread worker; object locker = new object (); Queue< string > tasks = new Queue< string >(); public ProducerConsumerQueue() { worker = new Thread (Work); worker.Start(); } public void EnqueueTask ( string task) { lock (locker) tasks.Enqueue (task); wh.Set(); } public void Dispose() { EnqueueTask ( null ); // Signal the consumer to exit. worker.Join(); // Wait for the consumer's thread to finish. wh.Close(); // Release any OS resources. } void Work() { while ( true ) { string task = null ; lock (locker) if (tasks.Count > 0) { task = tasks.Dequeue(); if (task == null ) return ; } if (task != null ) { Console.WriteLine ( "Performing task: " + task); Thread.Sleep (1000); // simulate work... } else wh.WaitOne(); // No more tasks - wait for a signal } } } Here's a main method to test the queue: class Test { static void Main() { using (ProducerConsumerQueue q = new ProducerConsumerQueue()) { q.EnqueueTask ( "Hello" ); for ( int i = 0; i < 10; i++) q.EnqueueTask ( "Say " + i); q.EnqueueTask ( "Goodbye!" ); } // Exiting the using statement calls q's Dispose method, which // enqueues a null task and waits until the consumer finishes. } } |
Performing task: Hello
Performing task: Say 1
Performing task: Say 2
Performing task: Say 3
...
...
Performing task: Say 9
Goodbye!
注意我们明确的关闭了Wait Handle在ProducerConsumerQueue被销毁的时候,因为在程序的生命周期中我们可能潜在地创建和销毁许多这个类的实例。
5.4 ManualResetEvent
ManualResetEvent是AutoResetEvent变化的一种形式,它的不同之处在于:在线程被WaitOne的调用而通过的时候,它不会自动地reset,这个过程就像大门一样——调用Set打开门,允许任何数量的已执行WaitOne的线程通过;调用Reset关闭大门,可能会引起一系列的“等待者”直到下次门打开。
你可以用一个布尔字段"gateOpen" (用 volatile 关键字来声明)与"spin-sleeping" – 方式结合——重复地检查标志,然后让线程休眠一段时间的方式,来模拟这个过程。
ManualResetEvent有时被用于给一个完成的操作发送信号,又或者一个已初始化正准备执行工作的线程。
Mutex提供了与C#的lock语句同样的功能,这使它大多时候变得的冗余了。它的优势在于它可以跨进程工作——提供了一计算机范围的锁而胜于程序范围的锁。
Mutex是相当快的,而lock 又要比它快上数百倍,获取Mutex需要花费几微秒,获取lock需花费数十纳秒(假定没有阻止)。
对于一个Mutex类,WaitOne获取互斥锁,当被抢占后时发生阻止。互斥锁在执行了ReleaseMutex之后被释放,就像C#的lock语句一样,Mutex只
能从获取互斥锁的这个线程上被释放。
Mutex在跨进程的普遍用处是确保在同一时刻只有一个程序的的实例在运行,下面演示如何使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
class OneAtATimePlease { // Use a name unique to the application (eg include your company URL) static Mutex mutex = new Mutex ( false , "oreilly.com OneAtATimeDemo" ); static void Main() { // Wait 5 seconds if contended – in case another instance // of the program is in the process of shutting down. if (!mutex.WaitOne (TimeSpan.FromSeconds (5), false )) { Console.WriteLine ( "Another instance of the app is running. Bye!" ); return ; } try { Console.WriteLine ( "Running - press Enter to exit" ); Console.ReadLine(); } finally { mutex.ReleaseMutex(); } } } |
Mutex有个好的特性是,如果程序结束时而互斥锁没通过ReleaseMutex首先被释放,CLR将自动地释放Mutex。
Semaphore就像一个夜总会:它有固定的容量,这由保镖来保证,一旦它满了就没有任何人可以再进入这个夜总会,并且在其外会形成一个队列。然后,当人一个人离开时,队列头的人便可以进入了。构造器需要至少两个参数——夜总会的活动的空间,和夜总会的容量。
Semaphore 的特性与Mutex 和 lock有点类似,除了Semaphore没有“所有者”——它是不可知线程的,任何在Semaphore内的线程都可以调用Release,而Mutex 和 lock仅有那些获取了资源的线程才可以释放它。
在下面的例子中,10个线程执行一个循环,在中间使用Sleep语句。Semaphore确保每次只有不超过3个线程可以执行Sleep语句:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class SemaphoreTest { static Semaphore s = new Semaphore (3, 3); // Available=3; Capacity=3 static void Main() { for ( int i = 0; i < 10; i++) new Thread (Go).Start(); } static void Go() { while ( true ) { s.WaitOne(); Thread.Sleep (100); // Only 3 threads can get here at once s.Release(); } } } |
5.7 WaitAny, WaitAll 和 SignalAndWait
除了Set 和 WaitOne方法外,在类WaitHandle中还有一些用来创建复杂的同步过程的静态方法。
WaitAny, WaitAll 和 SignalAndWait使跨多个可能为不同类型的等待句柄变得容易。
SignalAndWait可能是最有用的了:他在某个WaitHandle上调用WaitOne,并在另一个WaitHandle上自动地调用Set。你可以在一对EventWaitHandle上装配两个线程,而让它们在某个时间点“相遇”,这马马虎虎地合乎规范。AutoResetEvent 或 ManualResetEvent都无法使用这个技巧。第一个线程像这样:
WaitHandle.SignalAndWait (wh1, wh2);
同时第二个线程做相反的事情:
WaitHandle.SignalAndWait (wh2, wh1);
WaitHandle.WaitAny等待一组等待句柄任意一个发出信号,WaitHandle.WaitAll等待所有给定的句柄发出信号。与票据旋转门的例子类似,这些方法可能同时地等待所有的旋转门——通过在第一个打开的时候(WaitAny情况下),或者等待直到它们所有的都打开(WaitAll情况下)。
WaitAll 实际上是不确定的值,因为这与单元模式线程——从COM体系遗留下来的问题,有着奇怪的联系。WaitAll 要求调用者是一个多线程单元——刚巧是单元模式最适合——尤其是在 Windows Forms程序中,需要执行任务像与剪切板结合一样庸俗!
幸运地是,在等待句柄难使用或不适合的时候,.NET framework提供了更先进的信号结构——Monitor.Wait 和 Monitor.Pulse。
6 同步环境
与手工的锁定相比,你可以进行说明性的锁定,用衍生自ContextBoundObject 并标以Synchronization特性的类,
它告诉CLR自动执行锁操作,看这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
using System; using System.Threading; using System.Runtime.Remoting.Contexts; [Synchronization] public class AutoLock : ContextBoundObject { public void Demo() { Console.Write ( "Start..." ); Thread.Sleep (1000); // We can't be preempted here Console.WriteLine ( "end" ); // thanks to automatic locking! } } public class Test { public static void Main() { AutoLock safeInstance = new AutoLock(); new Thread (safeInstance.Demo).Start(); // Call the Demo new Thread (safeInstance.Demo).Start(); // method 3 times safeInstance.Demo(); // concurrently. } } |
Start... end
Start... end
Start... end
CLR确保了同一时刻只有一个线程可以执行 safeInstance中的代码。它创建了一个同步对象来完成工作,并在每次调
用safeInstance的方法和属性时在其周围只能够行锁定。锁的作用域——这里是safeInstance对象,被称为同步环境。
那么,它是如何工作的呢?Synchronization特性的命名空间:System.Runtime.Remoting.Contexts是一个线索。
ContextBoundObject可以被认为是一个“远程”对象,这意味着所有方法的调用是被监听的。让这个监听称为可能,
就像我们的例子AutoLock,CLR自动的返回了一个具有相同方法和属性的AutoLock对象的代理对象,它扮演着一个中间
者的角色。总的来说,监听在每个方法调用时增加了数微秒的时间。
自动同步不能用于静态类型的成员,和非继承自 ContextBoundObject(例如:Windows Form)的类。
锁在内部以相同的方式运作,你可能期待下面的例子与之前的有一样的结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
[Synchronization] public class AutoLock : ContextBoundObject { public void Demo() { Console.Write ( "Start..." ); Thread.Sleep (1000); Console.WriteLine ( "end" ); } public void Test() { new Thread (Demo).Start(); new Thread (Demo).Start(); new Thread (Demo).Start(); Console.ReadLine(); } public static void Main() { new AutoLock().Test(); } } |
(注意我们放入了Console.ReadLine语句。)因为在同一时刻的同一个此类的对象中只有一个线程可以执行代码,
三个新线程将保持被阻止在Demo 放中,直到Test 方法完成,需要等待ReadLine来完成。因此我们以与之前的有相同
结果而告终,但是只有在按完Enter键之后。这是一个线程安全的手段,差不多足够能在类中排除任何有用的多线程!
此外,我们仍未解决之前描述的一个问题:如果AutoLock是一个集合类,比如说,我们仍然需要一个像下面一样的锁,
假设运行在另一个类里:
if (safeInstance.Count > 0) safeInstance.RemoveAt (0);
除非使用这代码的类本身是一个同步的ContextBoundObject!
同步环境可以扩展到超过一个单独对象的区域。默认地,如果一个同步对象被实例化从在另一段代码之内,它们拥有
共享相同的同步环境(换言之,一个大锁!)。这个行为可以由改变Synchronization特性的构造器的参数来指定。使用
SynchronizationAttribute类定义的常量之一:
常量 |
含义 |
NOT_SUPPORTED |
相当于不使用同步特性 |
SUPPORTED |
如果从另一个同步对象被实例化,则合并已存在的同步环境,否则只剩下非同步。 |
REQUIRED |
如果从另一个同步对象被实例化,则合并已存在的同步环境,否则创建一个新的同步环境。 |
REQUIRES_NEW |
总是创建新的同步环境 |
所以如果SynchronizedA的实例被实例化于SynchronizedB的对象中,如果SynchronizedB像下面这样声明的话,
它们将有分离的同步环境:
[Synchronization (SynchronizationAttribute.REQUIRES_NEW)]
public class SynchronizedB : ContextBoundObject { ...
越大的同步环境越容易管理,但是减少机会对有用的并发。换个有限的角度,分离的同步环境会造成死锁,看这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
[Synchronization] public class Deadlock : ContextBoundObject { public DeadLock Other; public void Demo() { Thread.Sleep (1000); Other.Hello(); } void Hello() { Console.WriteLine ( "hello" ); } } public class Test { static void Main() { Deadlock dead1 = new Deadlock(); Deadlock dead2 = new Deadlock(); dead1.Other = dead2; dead2.Other = dead1; new Thread (dead1.Demo).Start(); dead2.Demo(); } } |
因为每个Deadlock的实例在Test内创建——一个非同步类,每个实例将有它自己的同步环境,因此,有它自己的锁。
当它们彼此调用的时候,不会花太多时间就会死锁(确切的说是一秒!)。如果Deadlock 和 Test是由不同开发团队来
写的,这个问题特别容易发生。别指望Test知道如何产生的错误,更别指望他们来解决它了。在死锁显而易见的情况下,
这与使用明确的锁的方式形成鲜明的对比。
6.1 可重入性问题
线程安全方法有时候也被称为可重入式的,因为在它执行的时候可以被抢占部分线路,在另外的线程调用也不会带来坏效果。从某个意义上讲,术语线程安全和 可重入式的是同义的或者是贴义的。
不过在自动锁方式上,如果Synchronization的参数可重入式的 为true的话,可重入性会有潜在的问题:
[Synchronization(true)]
同步环境的锁在执行离开上下文时被临时地释放。在之前的例子里,这将能预防死锁的发生;很明显很需要这样的功能。然而一个副作用是,在这期间,任何线程都可以自由的调用在目标对象(“重进入”的同步上下文)的上任何方法,而非常复杂的多线程中试图避免不释放资源是排在首位的。这就是可重入性的问题。 因为[Synchronization(true)]作用于类级别,这特性打开了对于非上下文的方法访问,由于可重入性问题使它们混入类的调用。
虽然可重入性是危险的,但有些时候它是不错的选择。比如:设想一个在其内部实现多线程同步的类,将逻辑工作线程运行在不同的语境中。在没有可重入性问题的情况下,工作线程在它们彼此之间或目标对象之间可能被无理地阻碍。
这凸显了自动同步的一个基本弱点:超过适用的大范围的锁定带来了其它情况没有带来的巨大麻烦。这些困难:死锁,可重入性问题和被阉割的并发,使另一个更简单的方案——手动的锁定变得更为合适
第三部分:使用多线程
1. 单元模式和Windows Forms
单元模式线程是一个自动线程安全机制, 非常贴近于COM——Microsoft的遗留下的组件对象模型。尽管.NET最大地放弃摆脱了遗留下的模型,但很多时候它也会突然出现,这是因为有必要与旧的API 进行通信。单元模式线程与Windows Forms最相关,因为大多Windows Forms使用或包装了长期存在的Win32 API——连同它的单元传统。
单元是多线程的逻辑上的“容器”,单元产生两种容量——“单的”和“多的”。单线 程单元只包含一个线程;多线程单元可以包含任何数量的线程。单线程模式更普遍 并且能与两者有互操作性。
就像包含线程一样,单元也包含对象,当对象在一个单元内被创建后,在它的生命周期中它将一直存在在那,永远也“居家不出”地与那些驻留线程在一起。这类似于被包含在.NET 同步环境中 ,除了同步环境中没有自己的或包含线程。任何线程可以访问在任何同步环境中的对象 ——在排它锁的控制中。但是单元内的对象只有单元内的线程才可以访问。
想象一个图书馆,每本书都象征着一个对象;借出书是不被允许的,书都在图书馆 创建并直到它寿终正寝。此外,我们用一个人来象征一个线程。
一个同步内容的图书馆允许任何人进入,同时同一时刻只允许一个人进入,在图书馆外会形成队列。
单元模式的图书馆有常驻维护人员——对于单线程模式的图书馆有一个图书管理员, 对于多线程模式的图书馆则有一个团队的管理员。没人被允许除了隶属与维护人员的人 ——资助人想要完成研究就必须给图书管理员发信号,然后告诉管理员去做工作!给管理员发信号被称为调度编组——资助人通过调度把方法依次读出给一个隶属管理员的人(或,某个隶属管理员的人!)。 调度编组是自动的,在Windows Forms通过信息泵被实现在库结尾。这就是操作系统经常检查键盘和鼠标的机制。如果信息到达的太快了,以致不能被处理,它们将形成消息队列,所以它们可以以它们到达的顺序被处理。
1.1 定义单元模式
.NET线程在进入单元核心Win32或旧的COM代码前自动地给单元赋值,它被默认地指定为多线程单元模式,除非需要一个单线程单元模式,就像下面的一样:
1
2
|
Thread t = new Thread (...); t.SetApartmentState (ApartmentState.STA); |
你也可以用STAThread特性标在主线程上来让它与单线程单元相结合:
1
2
3
4
|
class Program { [STAThread] static void Main() { ... |
线程单元设置对纯.NET代码没有效果,换言之,即使两个线程都有STA 的单元状态,也可以被相同的对象同时调用相同的方法,就没有自动的信号编组或锁定发生了, 只有在执行非托管的代码时,这才会发生。
在System.Windows.Forms名称空间下的类型,广泛地调用Win32代码, 在单线程单元下工作。由于这个原因,一个Windos Forms程序应该在它的主方法上贴上 [STAThread]特性,除非在执行Win32 UI代码之前以下二者之一发生了:
- 它将调度编组成一个单线程单元
- 它将崩溃
在多线程的Windows Forms程序中,通过非创建控件的线程调用控件的的属性和方法是非法的。所有跨进程的调用必须被明确地排列至创建控件的线程中(通常为主线程),利用Control.Invoke 或 Control.BeginInvoke方法。你不能依赖自动调度编组因为它发生的太晚了,仅当执行刚好进入了非托管的代码它才发生,而.NET已有足够的时间来运行“错误的”线程代码,那些非线程安全的代码。
一个优秀的管理Windows Forms程序的方案是使用BackgroundWorker, 这个类包装了需要报道进度和完成度的工作线程,并自动地调用Control.Invoke方法作为需要。
BackgroundWorker是一个在System.ComponentModel命名空间 下帮助类,它管理着工作线程。它提供了以下特性:
- "cancel" 标记,对于给工作线程打信号让它结束而没有使用 Abort的情况
- 提供报道进度,完成度和退出的标准方案
- 实现了IComponent接口,允许它参与Visual Studio设计器
- 在工作线程之上做异常处理
- 更新Windows Forms控件以应答工作进度或完成度的能力
最后两个特性是相当地有用:意味着你不再需要将try/catch语句块放到 你的工作线程中了,并且更新Windows Forms控件不需要调用 Control.Invoke了。BackgroundWorker使用线程池工作, 对于每个新任务,它循环使用避免线程们得到休息。这意味着你不能在 BackgroundWorker线程上调用 Abort了。
下面是使用BackgroundWorker最少的步骤:
- 实例化 BackgroundWorker,为DoWork事件增加委托。
- 调用RunWorkerAsync方法,使用一个随便的object参数。
这就设置好了它,任何被传入RunWorkerAsync的参数将通过事件参数的Argument属性,传到DoWork事件委托的方法中,下面是例子:
1
2
3
4
5
6
7
8
9
10
11
12
|
class Program { s tatic BackgroundWorker bw = new BackgroundWorker(); static void Main() { bw.DoWork += bw_DoWork; bw.RunWorkerAsync ( "Message to worker" ); Console.ReadLine(); } static void bw_DoWork ( object sender, DoWorkEventArgs e) { // 这被工作线程调用 Console.WriteLine (e.Argument); // 写"Message to worker" // 执行耗时的任务... } |
BackgroundWorker也提供了RunWorkerCompleted事件,它在DoWork事件完成后触发,处理RunWorkerCompleted事件并不是强制的,但是为了查询到DoWork中的异常,你通常会这么做的。RunWorkerCompleted中的代码可以更新Windows Forms 控件,而不用显示的信号编组,而DoWork中就可以这么做。
添加进程报告支持:
- 设置WorkerReportsProgress属性为true
- 在DoWork中使用“完成百分比”周期地调用ReportProgress方法,以及可选用户状态对象
- 处理ProgressChanged事件,查询它的事件参数的 ProgressPercentage属性
ProgressChanged中的代码就像RunWorkerCompleted一样可以自由地与UI控件进行交互,这在更性进度栏尤为有用。
添加退出报告支持:
- 设置WorkerSupportsCancellation属性为true
- 在DoWork中周期地检查CancellationPending属性:如果为true,就设置事件参数的Cancel属性为true,然后返回。(工作线程可能会设置Cancel为true,并且不通过CancellationPending进行提示——如果判定工作太过困难并且它不能继续运行)
- 调用CancelAsync来请求退出
下面的例子实现了上面描述的特性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
using System; using System.Threading; using System.ComponentModel; class Program { static BackgroundWorker bw; static void Main() { bw = new BackgroundWorker(); bw.WorkerReportsProgress = true ; bw.WorkerSupportsCancellation = true ; bw.DoWork += bw_DoWork; bw.ProgressChanged += bw_ProgressChanged; bw.RunWorkerCompleted += bw_RunWorkerCompleted; bw.RunWorkerAsync ( "Hello to worker" ); Console.WriteLine ( "Press Enter in the next 5 seconds to cancel" ); Console.ReadLine(); if (bw.IsBusy) bw.CancelAsync(); Console.ReadLine(); } static void bw_DoWork ( object sender, DoWorkEventArgs e) { for ( int i = 0; i <= 100; i += 20) { if (bw.CancellationPending) { e.Cancel = true ; return ; } bw.ReportProgress (i); Thread.Sleep (1000); } e.Result = 123; // This gets passed to RunWorkerCompleted } static void bw_RunWorkerCompleted ( object sender, RunWorkerCompletedEventArgs e) { if (e.Cancelled) Console.WriteLine ( "You cancelled!" ); else if (e.Error != null ) Console.WriteLine ( "Worker exception: " + e.Error.ToString()); else Console.WriteLine ( "Complete - " + e.Result); // from DoWork } static void bw_ProgressChanged ( object sender, ProgressChangedEventArgs e) { Console.WriteLine ( "Reached " + e.ProgressPercentage + "%" ); } } |
1.4 BackgroundWorker的子类
BackgroundWorker不是密封类,它提供OnDoWork为虚方法,暗示着另一个模式可以它。 当写一个可能耗时的方法,你可以或最好写个返回BackgroundWorker子类的等方法,预配置完成异步的工作。使用者只要处理RunWorkerCompleted事件和ProgressChanged事件。比如,设想我们写一个耗时 的方法叫做GetFinancialTotals:
1
2
3
4
5
|
public class Client { Dictionary < string , int > GetFinancialTotals ( int foo, int bar) { ... } ... } |
我们可以如此来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
public class Client { public FinancialWorker GetFinancialTotalsBackground ( int foo, int bar) { return new FinancialWorker (foo, bar); } } public class FinancialWorker : BackgroundWorker { public Dictionary < string , int > Result; // We can add typed fields. public volatile int Foo, Bar; // We could even expose them // via properties with locks! public FinancialWorker() { WorkerReportsProgress = true ; WorkerSupportsCancellation = true ; } public FinancialWorker ( int foo, int bar) : this () { this .Foo = foo; this .Bar = bar; } protected override void OnDoWork (DoWorkEventArgs e) { ReportProgress (0, "Working hard on this report..." ); Initialize financial report data while (!finished report ) { if (CancellationPending) { e.Cancel = true ; return ; } Perform another calculation step ReportProgress (percentCompleteCalc, "Getting there..." ); } ReportProgress (100, "Done!" ); e.Result = Result = completed report data; } } |
无论谁调用GetFinancialTotalsBackground都会得到一个FinancialWorker——一个用真实地可用地包装了管理后台操作。它可以报告进度,被取消,与Windows Forms交互而不用使用Control.Invoke。它也有异常句柄,并且使用了标准的协议(与使用BackgroundWorker没任何区别!)
这种BackgroundWorker的用法有效地回避了旧有的“基于事件的异步模式”。
2 ReaderWriterLockSlim类
//注意还有一个老的ReaderWriterLock类,Slim类为.net 3.5新增,提高了性能。
通常来讲,一个类型的实例对于并行的读操作是线程安全的,但是并行地更新操作则不是(并行地读与更新也不是)。 这对于资源(比如一个文件)也是一样的。使用一个简单的独占锁来锁定所有可能的访问能够解决实例的线程安全为问题,但是当有很多的读操作而只是偶然的更新操作的时候,这就很不合理的限制了并发。一个例子就是这在一个业务程序服务器中,为了快速查找把数据缓存到静态字段中。在这样的情况下,ReaderWriterLockSlim类被设计成提供最大可能的锁定。
ReaderWriterLockSlim有两种基本的Lock方法:一个独占的Wirte Lock ,和一个与其他Read lock相容的读锁定。
所以,当一个线程拥有一个Write Lock的时候,会阻塞所有其他线程获得读写锁。但是当没有线程获得WriteLock时,可以有多个线程同时获得ReadLock,进行读操作。
ReaderWriterLockSlim提供了下面四个方法来得到和释放读写锁:
1
2
3
4
|
public void EnterReadLock(); public void ExitReadLock(); public void EnterWriteLock(); public void ExitWriteLock(); |
另外对于所有的EnterXXX方法,还有”Try”版本的方法,它们接收timeOut参数,就像Monitor.TryEnter一样(在资源争用严重的时候超时发生相当容易)。另外ReaderWriterLock提供了其他类似的AcquireXXX 和 ReleaseXXX方法,它们超时退出的时候抛出异常而不是返回false。
下面的程序展示了ReaderWriterLockSlim——三个线程循环地枚举一个List,同时另外两个线程每一秒钟添加一个随机数到List中。一个read lock保护List的读取线程,同时一个write lock保护写线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
class SlimDemo { static ReaderWriterLockSlim rw = new ReaderWriterLockSlim(); static List< int > items = new List< int >(); static Random rand = new Random(); static void Main() { new Thread (Read).Start(); new Thread (Read).Start(); new Thread (Read).Start(); new Thread (Write).Start ( "A" ); new Thread (Write).Start ( "B" ); } static void Read() { while ( true ) { rw.EnterReadLock(); foreach ( int i in items) Thread.Sleep (10); rw.ExitReadLock(); } } static void Write ( object threadID) { while ( true ) { int newNumber = GetRandNum (100); rw.EnterWriteLock(); items.Add (newNumber); rw.ExitWriteLock(); Console.WriteLine ( "Thread " + threadID + " added " + newNumber); Thread.Sleep (100); } } static int GetRandNum ( int max) { lock (rand) return rand.Next (max); } } <em><span style= "font-family: YaHei Consolas Hybrid;" > //在实际的代码中添加try/finally,保证异常情况写lock也会被释放。</span></em> |
结果为:
Thread B added 61 Thread A added 83 Thread B added 55 Thread A added 33 ...
ReaderWriterLockSlim比简单的Lock允许更大的并发读能力。我们能够添加一行代码到Write方法,在While循环的开始:
1
|
Console.WriteLine (rw.CurrentReadCount + " concurrent readers" ); |
基本上总是会返回“3 concurrent readers”(读方法花费了更多的时间在Foreach循环),ReaderWriterLockSlim还提供了许多与CurrentReadCount属性类似的属性来监视lock的情况:
1
2
3
4
5
6
7
8
9
10
11
|
public bool IsReadLockHeld { get ; } public bool IsUpgradeableReadLockHeld { get ; } public bool IsWriteLockHeld { get ; } public int WaitingReadCount { get ; } public int WaitingUpgradeCount { get ; } public int WaitingWriteCount { get ; } public int RecursiveReadCount { get ; } public int RecursiveUpgradeCount { get ; } public int RecursiveWriteCount { get ; } |
有时候,在一个原子操作里面交换读写锁是非常有用的,比如,当某个item不在list中的时候,添加此item进去。最好的情况是,最小化写如锁的时间,例如像下面这样处理:
1 获得一个读取锁
2 测试list是否包含item,如果是,则返回
3 释放读取锁
4 获得一个写入锁
5 写入item到list中,释放写入锁。
但是在步骤3、4之间,当另外一个线程可能偷偷修改List(比如说添加同样一个Item),ReaderWriterLockSlim通过提供第三种锁来解决这个问题,这就是upgradeable lock。一个可升级锁和read lock 类似,只是它能够通过一个原子操作,被提升为write lock。使用方法如下:
-
- 调用 EnterUpgradeableReadLock
- 读操作(e.g. test if item already present in list)
- 调用 EnterWriteLock (this converts the upgradeable lock to a write lock)
- 写操作(e.g. add item to list)
- 调用ExitWriteLock (this converts the write lock back to an upgradeable lock)
- 其他读取的过程
- 调用ExitUpgradeableReadLock
从调用者的角度,这非常想递归(嵌套)锁。实际上第三步的时候,通过一个原子操作,释放了read lock 并获得了一个新的write lock.
upgradeable locks 和read locks之间另外还有一个重要的区别,尽管一个upgradeable locks 能够和任意多个read locks共存,但是一个时刻,只能有一个upgradeable lock自己被使用。这防止了死锁。这和SQL Server的Update lock类似
我们可以改变前面例子的Write方法来展示upgradeable lock:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
while ( true ) { int newNumber = GetRandNum (100); rw.EnterUpgradeableReadLock(); if (!items.Contains (newNumber)) { rw.EnterWriteLock(); items.Add (newNumber); rw.ExitWriteLock(); Console.WriteLine ( "Thread " + threadID + " added " + newNumber); } rw.ExitUpgradeableReadLock(); Thread.Sleep (100); } |
ReaderWriterLock 没有提供upgradeable locks的功能。
2.1 递归锁 Lock recursion
Ordinarily, nested or recursive locking is prohibited with ReaderWriterLockSlim. Hence, the following throws an exception:
默认情况下,递归(嵌入)锁被ReaderWriterLockSlim禁止,因为下面的代码可能抛出异常。
1
2
3
4
5
|
var rw = new ReaderWriterLockSlim(); rw.EnterReadLock(); rw.EnterReadLock(); rw.ExitReadLock(); rw.ExitReadLock(); |
但是显示地声明允许嵌套的话,就能正常工作,不过这带来了不必要的复杂性。
1
|
var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion); |
1
2
3
4
5
6
|
rw.EnterWriteLock(); rw.EnterReadLock(); Console.WriteLine (rw.IsReadLockHeld); // True Console.WriteLine (rw.IsWriteLockHeld); // True rw.ExitReadLock(); rw.ExitWriteLock(); |
使用锁的顺序大致为:Read Lock --> Upgradeable Lock --> Write Lock
3 线程池
如果你的程序有很多线程,导致花费了大多时间在等待句柄的阻止上,你可以通过 线程池来削减负担。线程池通过合并很多等待句柄在很少的线程上来节省时间。
使用线程池,你需要注册一个连同将被执行的委托的Wait Handle,在Wait Handle发信号时。这个工作通过调用ThreadPool.RegisterWaitForSingleObject来完成,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class Test { static ManualResetEvent starter = new ManualResetEvent ( false ); public static void Main() { ThreadPool.RegisterWaitForSingleObject (starter, Go, "hello" , -1, true ); Thread.Sleep (5000); Console.WriteLine ( "Signaling worker..." ); starter.Set(); Console.ReadLine(); } public static void Go ( object data, bool timedOut) { Console.WriteLine ( "Started " + data); // Perform task... } } |
除了等待句柄和委托之外,RegisterWaitForSingleObject也接收一个“黑盒”对象,它被传递到你的委托方法中( 就像用ParameterizedThreadStart一样),拥有一个毫秒级的超时参数(-1意味着没有超时)和布尔标志来指明请求是一次性的还是循环的。
所有进入线程池的线程都是后台的线程,这意味着 它们在程序的前台线程终止后将自动的被终止。但你如果想等待进入线程池的线程都完成它们的重要工作在退出程序之前,在它们上调用Join是不行的,因为进入线程池的线程从来不会结束!意思是说,它们被改为循环,直到父进程终止后才结束。所以为知道运行在线程池中的线程是否完成,你必须发信号——比如用另一个Wait Handle。
在线程池中的线程上调用Abort 是一个坏主意,线程需要在程序域的生命周期中循环。
你也可以用QueueUserWorkItem方法而不用等待句柄来使用线程池,它定义了一个立即执行的委托。你不必在多个任务中节省共享线程,但有一个惯例:线程池保持一个线程总数的封顶(默认为25),在任务数达到这个顶值后将自动排队。这就像程序范围的有25个消费者的生产者/消费者队列。在下面的例子中,100个任务入列到线程池中,而一次只执行 25个,主线程使用Wait 和 Pulse来等待所有的任务完成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
class Test { static object workerLocker = new object (); static int runningWorkers = 100; public static void Main() { for ( int i = 0; i < 100; i++) { ThreadPool.QueueUserWorkItem (Go, i); } Console.WriteLine ( "Waiting for threads to complete..." ); lock (workerLocker) { while (runningWorkers > 0) Monitor.Wait (workerLocker); } Console.WriteLine ( "Complete!" ); Console.ReadLine(); } public static void Go ( object instance) { Console.WriteLine ( "Started: " + instance); Thread.Sleep (1000); Console.WriteLine ( "Ended: " + instance); lock (workerLocker) { runningWorkers--; Monitor.Pulse (workerLocker); } } } |
为了传递多个对象给目标方法,你可以定义个拥有所有需要属性的自定义对象,或者调用一个匿名方法。比如如果Go方法接收两个整型参数,会像下面这样:
1
|
ThreadPool.QueueUserWorkItem ( delegate ( object notUsed) { Go (23,34); }); |
另一个进入线程池的方式是通过异步委托。
4. 异步委托
在第一部分我们描述如何使用 ParameterizedThreadStart把数据传入线程中。有时候 你需要通过另一种方式,来从线程中得到它完成后的返回值。异步委托提供了一个便利的机制,允许许多参数在两个方向上传递 。此外,未处理的异常在异步委托中在原始线程上被重新抛出,因此在工作线程上不需要明确的处理了。异步委托也提供了计入 线程池的另一种方式。
对此你必须付出的代价是要跟从异步模型。为了看看这意味着什么,我们首先讨论更常见的同步模型。我们假设我们想比较 两个web页面,我们按顺序取得它们,然后像下面这样比较它们的输出:
1
2
3
4
5
6
|
static void ComparePages() { WebClient wc = new WebClient (); Console.WriteLine (s1 == s2 ? "Same" : "Different" ); } |
如果两个页面同时下载当然会更快了。问题在于当页面正在下载时DownloadString阻止了继续调用方法。如果我们能 调用 DownloadString在一个非阻止的异步方式中会变的更好,换言之:
1. 我们告诉 DownloadString 开始执行
2. 在它执行时我们执行其它任务,比如说下载另一个页面
3. 我们询问DownloadString的所有结果
WebClient类实际上提供一个被称为DownloadStringAsync的内建方法 ,它提供了就像异步函数的功能。而眼下,我们忽略这个问题,集中精力在任何方法都可以被异步调用的机制上。
第三步使异步委托变的有用。调用者汇集了工作线程得到结果和允许任何异常被重新抛出。没有这步,我们只有普通多线程。虽然也可能不用汇集方式使用异步委托,你可以用ThreadPool.QueueWorkerItem 或 BackgroundWorker。
下面我们用异步委托来下载两个web页面,同时实现一个计算:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
delegate string DownloadString ( string uri); static void ComparePages() { // Instantiate delegates with DownloadString's signature: DownloadString download1 = new WebClient().DownloadString; DownloadString download2 = new WebClient().DownloadString; // Start the downloads: IAsyncResult cookie1 = download1.BeginInvoke (uri1, null , null ); IAsyncResult cookie2 = download2.BeginInvoke (uri2, null , null ); // Perform some random calculation: double seed = 1.23; for ( int i = 0; i < 1000000; i++) seed = Math.Sqrt (seed + 1000); // Get the results of the downloads, waiting for completion if necessary. // Here's where any exceptions will be thrown: string s1 = download1.EndInvoke (cookie1); string s2 = download2.EndInvoke (cookie2); Console.WriteLine (s1 == s2 ? "Same" : "Different" ); } |
我们以声明和实例化我们想要异步运行的方法开始。在这个例子中,我们需要两个委托,每个引用不同的WebClient的对象(WebClient 不允许并行的访问,如果它允许,我们就只需一个委托了)。
我们然后调用BeginInvoke,这开始执行并立刻返回控制器给调用者。依照我们的委托,我们必须传递一个字符串给 BeginInvoke (编译器由生产BeginInvoke 和 EndInvoke在委托类型强迫实现这个).
BeginInvoke 还需要两个参数:一个可选callback和数据对象;它们通常不需要而被设置为null, BeginInvoke返回一个 IASynchResult对象,它担当着调用 EndInvoke所用的数据。IASynchResult 同时有一个IsCompleted属性来检查进度。
之后我们在委托上调用EndInvoke ,得到需要的结果。如果有必要,EndInvoke会等待, 直到方法完成,然后返回方法返回的值作为委托指定的(这里是字符串)。 EndInvoke一个好的特性是DownloadString有任何的引用或输出参数, 它们会在 EndInvoke结构赋值,允许通过调用者多个值被返回。
在异步方法的执行中的任何点发生了未处理的异常,它会重新在调用线程在EndInvoke中抛出。 这提供了精简的方式来管理返回给调用者的异常。
如果你异步调用的方法没有返回值,你也(理论上)应该调用EndInvoke,在部分意义上 在开放了误判;MSDN上辩论着这个话题。如果你选择不调用EndInvoke,你需要考虑在工作方法中的异常。
4.1 异步方法
.NET Framework 中的一些类型提供了某些它们方法的异步版本,它们使用"Begin" 和 "End"开头。它们被称之为异步方法,它们有与异步委托类似的特性,但异步委托存在着一些待解决的困难的问题:允许比你所拥有的线程还多的并发活动率。 比如一个web或TCP Socket服务器,如果用NetworkStream.BeginRead 和 NetworkStream.BeginWrite 来写的话,就可能在仅仅线程池线程中处理数百个并发的请求。
除非你正在写一个专门的高并发程序,否则不应该过多地使用异步方法。理由如下:
- 不像异步委托,异步方法实际上可能没有与调用者同时执行
- 如果你未能小心翼翼地遵从它的模式异步方法的好处被侵腐或消失了,
- 当你恰当地遵从了它的模式,事情立刻变的复杂了
如果你只是像简单地获得并行执行的结果,你最好远离调用异步版本的方法(比如NetworkStream.Read) 而通过异步委托。另一个选项是使用ThreadPool.QueueUserWorkItem或BackgroundWorker,又或者只是简单地创建新的线程。
4.2 异步事件
另一种模式存在,就是为什么类型可以提供异步版本的方法。这就是所谓的“基于事件的异步模式”,这些的方法以"Async"结束,相对应的事件以"Completed"结束。WebClient使用这个模式在它的DownloadStringAsync 方法中。 为了使用它,你要首先处理"Completed" 事件(例如:DownloadStringCompleted),然后调用"Async"方法(例如:DownloadStringAsync)。当方法完成后,它调用你事件句柄。不幸的是,WebClient的实现是有缺陷的:像DownloadStringAsync 这样的方法对于下载的一部分时间阻止了调用者的线程。
基于事件的模式也提供了报道进度和取消操作,被友好地设计成可对Windows程序可更新forms和控件。如果在某个类型中你需要这些特性 ,而它却不支持(或支持的不好)基于事件的模式,你没必要去自己实现它(你也根本不想去做!)。尽管如此,所有的这些通过BackgroundWorker这个帮助类便可轻松完成。
5. 计时器
周期性的执行某个方法最简单的方法就是使用一个计时器,比如System.Threading 命名空间下Timer类。线程计时器利用了线程池,允许多个计时器被创建而没有额外的线程开销。 Timer 算是相当简易的类,它有一个构造器和两个方法(这对于极简主义者来说是最高兴不过的了)。
1
2
3
4
5
6
7
8
9
|
public sealed class Timer : MarshalByRefObject, IDisposable { public Timer (TimerCallback tick, object state, 1st, subsequent); public bool Change (1st, subsequent); // To change the interval public void Dispose(); // To kill the timer } 1st = time to the first tick in milliseconds or a TimeSpan subsequent = subsequent intervals in milliseconds or a TimeSpan (use Timeout.Infinite for a one-off callback) |
接下来这个例子,计时器5秒钟之后调用了Tick 的方法,它写"tick...",然后每秒写一个,直到用户敲 Enter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
using System; using System.Threading; class Program { static void Main() { Timer tmr = new Timer (Tick, "tick..." , 5000, 1000); Console.ReadLine(); tmr.Dispose(); // End the timer } static void Tick ( object data) { // This runs on a pooled thread Console.WriteLine (data); // Writes "tick..." } } |
.NET framework在System.Timers命名空间下提供了另一个计时器类。它完全包装自System.Threading.Timer,在使用相同的线程池时提供了额外的便利——相同的底层引擎。下面是增加的特性的摘要:
- 实现了Component,允许它被放置到Visual Studio设计器中
- Interval属性代替了Change方法
- Elapsed 事件代替了callback委托
- Enabled属性开始或暂停计时器
- 提够Start 和 Stop方法,万一对Enabled感到迷惑
- AutoReset标志来指示是否循环(默认为true)
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
using System; using System.Timers; // Timers namespace rather than Threading class SystemTimer { static void Main() { Timer tmr = new Timer(); // Doesn't require any args tmr.Interval = 500; tmr.Elapsed += tmr_Elapsed; // Uses an event instead of a delegate tmr.Start(); // Start the timer Console.ReadLine(); tmr.Stop(); // Pause the timer Console.ReadLine(); tmr.Start(); // Resume the timer Console.ReadLine(); tmr.Dispose(); // Permanently stop the timer } static void tmr_Elapsed ( object sender, EventArgs e) { Console.WriteLine ( "Tick" ); } } |
.NET framework 还提供了第三个计时器——在System.Windows.Forms 命名空间下。虽然类似于System.Timers.Timer 的接口,但功能特性上有根本的不同。一个Windows Forms 计时器不能使用线程池,代替为总是在最初创建它的线程上触发 "Tick"事件。假定这是主线程——负责实例化所有Windows Forms程序中的forms和控件,计时器的事件能够操作forms和控件而不违反线程安全——或者强加单元线程模式。Control.Invoke是不需要的。它实质上是一个单线程timer
Windows Forms计时器必须迅速地执行来更新用户接口。迅速地执行是非常重要的,因为Tick事件被主线程调用,如果它有停顿, 将使用户接口变的没有响应。
6. 局部存储
每个线程与其它线程数据存储是隔离的,这对于“不相干的区域”的存储是有益的,它支持执行路径的基础结构,如通信,事务和安全令牌。 通过方法参数传递这些数据是十分笨拙的。存储这些数据到静态域意味着这些数据可以被所有线程共享。
Thread.GetData从一个线程的隔离数据中读,Thread.SetData 写入数据。 两个方法需要一个LocalDataStoreSlot对象来识别内存槽——这包装自一个内存槽的名称的字符串,这个名称 你可以跨所有的线程使用,它们将得到不各自的值,看这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class ... { // 相同的LocalDataStoreSlot 对象可以用于跨所有线程 LocalDataStoreSlot secSlot = Thread.GetNamedDataSlot ( "securityLevel" ); // 这个属性每个线程有不同的值 int SecurityLevel { get { object data = Thread.GetData (secSlot); return data == null ? 0 : ( int ) data; // null == 未初始化 } set { Thread.SetData (secSlot, value); } } ... |
Thread.FreeNamedDataSlot将释放给定的数据槽,它跨所有的线程——但只有一次,当所有相同名字LocalDataStoreSlot对象作为垃圾被回收时退出作用域时发生。这确保了线程不得到数据槽从它们的脚底下撤出——也保持了引用适当的使用之中的LocalDataStoreSlot对象。