一、非阻止同步
.NET framework 非阻止同步结构完成一些简单操作而不 用阻止,暂停或等待。它涉及到如何使用 严格地原子操作,告诉编译器用 "volatile" 读和写的语法,有时候这种方式要比用锁还 要简单。
原子和互锁
如果一个语句执行一个单独不可分割的指令,那么它是原子的。 严格的原子操作排除了任何抢占的可能性。在C#中,一个简单 的读操作或给一个少于等与32位的字段赋值 是原子操作(假设为32位CPU)。更大的字段以及大于一个 的读/写操作的组合的操作都是非原子的:
class Atomicity { static int x, y; static long z; static void Test() { long myLocal; x = 3; // 原子的 z = 3; // 非原子的 (z 是 64 位) myLocal = z; // 非原子的 (z 是 64 位) y += x; // 非原子的 (读和写的操作) x++; // 非原子的 (读和写的操作) } }
在32位的计算机上读和写64位字段是非原子的是因为2个不同的32位的存储单元是息息相关的。如果线程A读一个64位的值, 而另一个线程B正在更新它,线程A会最后得到一个按位组合的老值和新值的结合体。
像x++这样的一元运算符需要首先读变量,然后处理它,再写回值给它。考虑下面的类:
class ThreadUnsafe { static int x = 1000; static void Go() { for (int i = 0; i < 100; i++) x--; } }
你可能会期待如果10个线程并行运行Go,然后x最后得到0。 但这并得不到保证,因为一个线程抢占了另一个正在检索x的 值,或减少它,或写回它(导致一个过期的值被写入)。
解决这个问题的一个方式就是在语句周围套上lock 语句。锁定,实际上是模拟原子操作。 Interlocked类提供了一个简单快 速的简单原子锁的方案:
class Program { static long sum; static void Main() { // sum // 简单地增/减操作: Interlocked.Increment(ref sum); // 1 Interlocked.Decrement(ref sum); // 0 // 加减一个值: Interlocked.Add(ref sum, 3); // 3 // 读一个64位字段: Console.WriteLine(Interlocked.Read(ref sum)); // 3 // 当正在读之前的值时同时写一个64位的值: // (当正在更新sum为10的时候,这里打印的是3) Console.WriteLine(Interlocked.Exchange(ref sum, 10)); // 10 // 更新一个字段仅当它符合一个特定的值时(10): Interlocked.CompareExchange(ref sum, 123, 10); // 123 } }
使用 Interlocked比用lock更有效,因为它从不阻止也没有临时操作线程带来的系统开销。
Interlocked也对跨多个进程有效,这与只能在当前进程中跨线程的lock形成鲜明的对比。一个例子就是这对读和写共享内存 是非常有用的。
内存屏障和易变(Volatility)
考虑这个类:
class Unsafe { static bool endIsNigh, repented; static void Main() { new Thread(Wait).Start(); // Start up the spinning waiter Thread.Sleep(1000); // Give it a second to warm up! repented = true; endIsNigh = true; Console.WriteLine("Going..."); } static void Wait() { while (!endIsNigh) ; // Spin until endIsNigh Console.WriteLine("Gone, " + repented); } }
这儿有个问题:是否能有效地将"Going..." 和 "Gone"剥离开,换言之,是否有可能endIsNigh被设置 为true后,Wait方法仍 然在执行while循环?此外,是否有可能Wait 方法输出"Gone, false"?
这2个问题的答案,理论上是肯定的:在一个多处理器的计算机,如果线程协调程序将这2个线程分配给不同 的CPU,repented 和endIsNigh字段可以被缓存到CPU寄存器中来提升性能,在它们的更新值被写回内存之前有可能延 迟,当CPU寄存器被写回 到内存时,它没必要按原来的顺序进行更新。
这个缓存过程可以用静态方法Thread.VolatileRead 和 Thread.VolatileWrite 来包围住来读和写这些字 段。VolatileRead意味着“读最近的值”,VolatileWrite意味着 “立即写入内存”。相同的功能可以用volatile修饰符更优雅 的实现:
class ThreadSafe { // Always use volatile read/write semantics: volatile static bool endIsNigh, repented; ... }
相同的效果可以通过用lock语句包围住 repented 和 endIsNigh来实现。锁定的副作用(有意的)是引起了内存屏障——一 个保证被用于lock中的易变字段不超出lock语句的范围。换言之,字段在进入锁之前被刷新(volatile read),在离开锁时被写 入内存(volatile write)。
在我们需要以原子方式进入字段repented 和 endIsNigh时,lock是必要的,比如运行像这样的事情:
lock (locker) { if (endIsNigh) repented = true; }
当一个字段在一个循环中被使用多次,lock可能更是可取的方案。尽管一个volatile read/write在性能上击败了一个锁, 但不代 表数千个volatile read/write操作能击败一把锁。
易变方式仅适合于基本的整型(和不安全的指针),其它的类型不缓存在CPU寄存器上,也不能用 volatile关键字 声明。当字段通过Interlocked类访问的时候将自动地使用易变的读和写的语法。
二、Wait 和 Pulse
早些时候我们讨论了事件等待句柄 ——一个线程被阻止 直到它收到另一个发来的信号的简单信号机制。
一个更强大的信号机制被Monitor类通过两个静态方法Wait 和 Pulse 提供。原理是你自己写一个使用自定义的标志和字段的 信号逻辑(与lock语句协作),然后传入Wait 和 Pulse命令减轻CPU的轮询。
Wait 和 Pulse 的定义
Wait 和 Pulse的目标是提供一种简单的信号模式: Wait阻止直到收到其它线程的通知;Pulse提供了这个通知。 为了信号系统正常工作,Wait必须在Pulse之前执行。如果Pulse先执行了,它的pulse就会丢失,之后的wait必须等待一个 新的pulse,否则它将永远被阻止。这和AutoResetEvent不同,AutoResetEvent的Set方法有一种“锁存”效果,当它先 于WaitOne调用时也同样有效。
在调用Wait 或 Pulse 的时候,你必须定义个同步对象,两个线程使用相同的对象,它们才能彼此发信号。在调用Wait 或 Pulse之前同步对象必须被lock。例如:如果x如此被声明: class Test { // 任何引用类型对象都可以作为同步对象 object x = new object(); } 然后在进入Monitor.Wait前的代码: lock (x) Monitor.Wait (x); 下面的代码释放了被阻止的线程(稍后在另一个线程上执行): lock (x) Monitor.Pulse (x);
切换锁
为了完成工作,在等待的时候Monitor.Wait临时的释放或切换当前的锁,所以另一个线程(比如执行Pulse的这个)可以获 得它。Wait方法可以被想象扩充为下面的伪代码。 Monitor.Exit (x); // 释放锁 等待到x发的信号后 Monitor.Enter (x); // 收回锁
因此一个Wait阻止两次:一次是等待信号,另一次是重新获取排它锁。这也意味着 Pulse本身不同完全解锁:只有当 用Pulse发信号的线程退出它的锁语句的时候,等待的线程实际上才能继续运行。 Wait的锁切换对嵌套锁也是有效的, 如果Wait在两个嵌套的lock语句中被调用: lock (x) lock (x) Monitor.Wait (x); 那么Wait逻辑上展开如下: Monitor.Exit (x); Monitor.Exit (x); // Exit两次来释放锁 wait for a pulse on x Monitor.Enter (x); Monitor.Enter (x); //还原之前的排它锁 与普通锁结构一致,只有在第一次调用Monitor.Enter时提供了阻止的时机。
为什么要阻止?
为什么Wait 和 Pulse 被设计成只有在锁内才能工作呢?最主要的理由是Wait能够被有条件的调用——而不损害线程安全。
来个例子说明,设想我们要只有在bool字段available为false时调用Wait,下面的代码是线程安全的:
lock (x) { if (!available) Monitor.Wait (x); available = false; }
几个线程并行运行这段代码,没有哪个可以在检查available字段和调用Monitor.Wait之间抢占了另一个。这两个语句是有效的原子操作,一个相应的通告程序也是同样地线程安全的:
lock (x) if (!available) { available = true; Monitor.Pulse (x); }
定义超时
在调用Wait时可以定义一个超时参数,可以是毫秒或TimeSpan值。如果超时发生了,Wait 将返回false。超时仅用于“等待”阶段(等待信号pulse):超时的Wait 仍然继续执行以便重新得到锁,而不管花费了多长时间。例如:
lock (x) { if (!Monitor.Wait (x, TimeSpan.FromSeconds (10))) Console.WriteLine ("Couldn't wait!"); Console.WriteLine ("But hey, I still have the lock on x!"); }
这性能的理论基础是有一个良好设计的Wait/Pulse的程序, 调用 Wait 和 Pulse的对象只是暂时地被锁定,所以重新获得锁应当是一个极短时间的操作。
脉冲和确认 (Pulsing and acknowledgement)
Monitor.Pulse的一个重要特性是它以异步方式执行,意味着它本身不以任何方式暂定或阻止。如果另一个线程在等待脉冲 对象,在它被通知的时候,脉冲本身没有效果而被悄悄地忽略。 Pulse 提供了单向通信:一个脉冲的线程给等待线程发信号,没有内部的确认机制。Pulse不返回值来指明它的信号是否被收 到了。此外,当一个提示脉冲并释放了它的锁,不保证一个符合要求的等待线程能马上进入它的生命周期。在线程调度程序的 判断上,可能存在任意的延迟,在两个线程都没有锁的期间。这就难以知道等待线程时候已确切的重新开始了,除非等待线程 会明确确认,比如通过一个自定义的标志位。
从一个没有自定义的确认机制的工作线程中,依靠即时的动作会“弄乱”Pulse 和 Wait。
等待队列和PulseAll
当多于一个线程同时Wait相同的对象——也就是在同步对象上形成了“等待队列”(这和有权访问某个锁的“就绪队列”明显不 同)。每个Pulse然后释放在等待队列头上的单个线程,所以它可以进入就绪队列并重新得到锁。可以把这个过程想象成一个 停车场:你排在收费处的第一个来确认你的票(等待队列);你再一次排队在格挡门前来被放掉(就绪队列)。
队列结构有它固有的顺序,但对于Wait/Pulse程序来说通常是不重要的; 在这些场合中它容易被想成一个等待线程“池”,每 次pulse都从池中释放了一个等待线程。 Monitor也提供了PulseAll方法在一刹那之间通过这等待线程释放整个队列,或池。已pulse的线程不会在同一时刻同时开始 执行,而是在一个顺序队列中,每次Wait语句试图重新回去那个相同的锁。实际上,PulseAll将线程从等待队列移到就绪队 列中,所以它们可以以顺序的方式继续执行。
如何使用 Pulse 和 Wait
这展示我们如何开始,设想有两条规则:
- 同步结构仅在也被称为Monitor.Enter 和 Monitor.Exit的lock语句中。
- 在CPU轮询上没有限制!
有了它们在脑子里,让我们做一个简单的例子:一个工作线程暂停直到收到主线程的发的信号:
class SimpleWaitPulse { bool go; object locker = new object(); void Work() { Console.Write("Waiting... "); lock (locker) { // 开始轮询! while (!go) { // 释放锁,以让其它线程可以更改go标志 Monitor.Exit(locker); // 收回锁,以便我们可以在循环中重测go的值 Monitor.Enter(locker); } } Console.WriteLine("Notified!"); } void Notify()// 从另一个线程调用 { lock (locker) { Console.Write("Notifying... "); go = true; } } }
让事情可以运转的主方法:
static void Main() { SimpleWaitPulse test = new SimpleWaitPulse(); // 在单独的线程中运行Work方法 new Thread(test.Work).Start(); // "Waiting..." // 暂停一秒,然后通过我们的主线程通知工作线程: Thread.Sleep(1000); test.Notify(); // "Notifying... Notified!" }
我门所轮询的Work方法——使用循环挥霍着CPU的时间直到go标志变为true! 在这个循环中我们必须切换锁——释放和重新得到它通过Monitor的Exit 和 Enter 方法——以便另一个运行Notify方法的线程可以修改go标志。共享的go字段必须总是可以在一个锁内访问,来避免易变问题。(要记得所有的同步结构,比如volatile关键字,在这个阶段的设计超出范围了!)
下一步是去运行它并测试它是否可以工作,下面是是测试Main方法的输出结果:
Waiting... (pause) Notifying... Notified!
现在我们来介绍Wait 和 Pulse,我们由:
用Monitor.Wait替换切换锁(Monitor.Exit和Monitor.Enter)
在阻止条件改变后,插入调用Monitor.Pulse(比如go字段被修改)
下面是更新后的类,Console语句被省略了:
class SimpleWaitPulse { bool go; object locker = new object(); void Work() { lock (locker) while (!go) Monitor.Wait(locker); } void Notify() { lock (locker) { go = true; Monitor.Pulse(locker); } } }
这个类与之前的表现一致,但没有CPU的轮询,Wait命令立即执行我们移除的代码——Monitor.Exit 和之后的Monitor.Enter,但中间由个扩充步骤:当锁被释放,它等待另一个线程调用Pulse。提示方法完成这个功能,在设置go为true后,工作就做完了。
Pulse 和 Wait 的归纳
我们现在来扩充这个模式。在之前的例子中,我们的阻止条件以一个bool字段——go标志来实现。我们可以换种设定,需要一
个额外的标志来表明等待线程它是就绪或完成了。如果根据我们的推断,将有很多字段来实现很多的阻止条件,程序可以被归
纳为下面的伪代码(以轮询模式):
就像之前做的一样,我们将之应用到 Pulse 和 Wait 上:
在等待循环中,用Monitor.Wait替换锁切换
无论何时阻止条件被改变了,在释放锁之前调用Pulse
这是更新后的伪代码:
这提供了一个在使用Wait and Pulse时的健壮模式。这有些对这个模式的关键特征:
- 使用自定义字段来实现阻止条件(也可以不用Wait 和 Pulse,虽然会轮询)
- Wait总是在while循环内调用来检查条件(它本身又在lock中)
- 一个单一的同步对象(在上面的例子里是locker),被用于所有的Wait 和 Pulse, 并且来保护访问所有实现阻止条件的对象。
- 锁的掌控只是暂时地
这种模式最重要的是pulse不强迫等待线程继续执行,代替为它通知等待线程有些东西改变了,建议它重新检查它的阻止条件,等待线程然后决定是否需要它该继续进行(通过另一个while循环),而不是脉冲发生器。这个方式的好处是它允许复杂的阻止条件,而没有复杂的同步逻辑。这个模式的另一个好处是对丢失的脉冲具有抗扰性。当Pulse在Wait之前被调用的时候,脉冲发生丢失——可能归咎于提示线程和等待线程的竞争。当时因为在这个模式里一个脉冲意味着“重新检查你的阻止条件”(而不是“继续运行”),早的脉冲可以被
安全的忽略,因为阻止条件总是在调用Wait之前检查,这要感谢while语句。依托这种设计,你可以定义多个阻止字段,让它们参与到多个阻止条件之中,并且在这期间只需要用一个单一的同步对象(在我们例子里是locker)。这经常优于在lock, Pulse 和 Wait上有各自的同步对象,因为这样有效的避免了死锁。此外使用了同步锁定对象,所有阻止字段被以单元模式读和写,就避免了微秒的原子错误。这是一个好主意,但是,不要试图必要的区域之外用同步对象(这可以用private声明同步对象来实现,对阻止字段来说也是一样)。
生产者/消费者队列
一个普通的Wait/Pulse程序是一个生产消费队列——我们之前用AutoResetEvent来写的一种结构。生产者入队任务(通 常在主线程中),同时一个或多个消费者运行工作线程来一个接一个地摘掉和执行任务。在这个例子中我们将用字符串来表示任务,我们的任务队列看起来会像这样: Queue<string> taskQ = new Queue<string>(); 因为队列用于多线程,我们必须用lock来包住所有读写队列的语句。这是如何入队任务:
lock (locker) { taskQ.Enqueue ("my task"); Monitor.PulseAll (locker); // 我们改变阻止条件 }
因为我们潜在修改了阻止条件,我们必须脉冲。我们调用PulseAll代替Pulse,因为我们将允许多个消费者。多于一个线程可能正在等待。我们要让工作线程阻止当它没有什么可做的时候,换句话说就是队列里没有条目了。
因此我们的阻止条件是taskQ.Count==0。
这是实现了一个等待语句:
lock (locker) while (taskQ.Count == 0) Monitor.Wait (locker);
下一步是工作线程出列任务并执行它:
lock (locker) while (taskQ.Count == 0) Monitor.Wait (locker); string task; lock (locker) task = taskQ.Dequeue();
但是这个逻辑是非线程安全的:我们以一个在旧的信息上的出列为判定基础——从之前的锁结构获得的。考虑当我们并行地打开2个消费者线程,对一个已在队列上的单一条目,可能没有线程会进入while循环来阻止——当然他们在队列中都看到这个单一的条目的时候。它们都试图出列相同的条目,在第二个实例中将抛出异常!为了修复这个问题,我们简单地将lock扩大一点直到我们完成与队列的结合:
string task; lock (locker) { while (taskQ.Count == 0) Monitor.Wait (locker); task = taskQ.Dequeue(); }
(我们不需要在出列之后调用Pulse,因为没有消费者在队列有较少的的条目时可以永远处于非阻止状态。) 一旦任务出列后,没必要在保持锁了,这时就释放它以允许消费者去执行一个可能耗时的任务,而没必要去阻止其它线程。
这里是完整的程序。与AutoResetEvent 版本一样,我们入列一个null任务来通知消费者退出(在完成所有任务之后)。因 为我们支持多个消费者,我们必须为每个消费者入列一个null任务来关闭队列:
using System; using System.Threading; using System.Collections.Generic; public class TaskQueue : IDisposable { object locker = new object(); Thread[] workers; Queue<string> taskQ = new Queue<string>(); public TaskQueue(int workerCount) { workers = new Thread[workerCount]; // Create and start a separate thread for each worker for (int i = 0; i < workerCount; i++) (workers[i] = new Thread(Consume)).Start(); } public void Dispose() { // Enqueue one null task per worker to make each exit. foreach (Thread worker in workers) EnqueueTask(null); foreach (Thread worker in workers) worker.Join(); } public void EnqueueTask(string task) { lock (locker) { taskQ.Enqueue(task); Monitor.PulseAll(locker); } } void Consume() { while (true) { string task; lock (locker) { while (taskQ.Count == 0) Monitor.Wait(locker); task = taskQ.Dequeue(); } if (task == null) return; // This signals our exit Console.Write(task); Thread.Sleep(1000); // Simulate time-consuming task } } }
这是一个开始任务队列的主方法,定义了两个并发的消费者线程,然后在两个消费者之间入列10个任务:
static void Main() { using (TaskQueue q = new TaskQueue(2)) { for (int i = 0; i < 10; i++) q.EnqueueTask(" Task" + i); Console.WriteLine("Enqueued 10 tasks"); Console.WriteLine("Waiting for tasks to complete..."); } //使用TaskQueue的Dispose方法退出 //在所有的任务完成之后,它关闭了消费者 Console.WriteLine("\r\nAll tasks done!"); }
输出结果:
Enqueued 10 tasks
Waiting for tasks to complete...
Task1 Task0 (pause...) Task2 Task3 (pause...) Task4 Task5 (pause...)
Task6 Task7 (pause...) Task8 Task9 (pause...)
All tasks done!
Pulse 还是 PulseAll?
这个例子中,进一步的pulse节约成本问题随之而来,在入列一个任务之后,我们可以用调用Pulse来代替PulseAll,这不会破坏什么。让我们看看它们的不同:对于Pulse,最多一个线程会被唤醒(重新检查它的while-loop阻止条件); 对于PulseAll来说,所有的等待线程都被唤醒(并重新检查它们的阻止条件)。如果我们入列一个单一的任务只有一个工作线程能够得到它,所以我们只需要使用一个Pulse唤醒一个工作线程。这就像有一个班级的孩子 ——如果仅仅只有一个冰激凌,没必要把他们都叫醒去排队得到它!
在我们的例子中,我们仅仅使用了两个消费者线程,所以我们不会有什么获利。但是如果我们使用了10个消费者线程,使用Pulse 代替PulseAll可以让我们可能微微获利。这将意味着,我们每入列多个任务,我们必须Pulse多次。这可以在一个单独lock语句中进行,像这样:
lock (locker) { taskQ.Enqueue("task 1"); taskQ.Enqueue("task 2"); Monitor.Pulse(locker); // "发两此信号 Monitor.Pulse(locker); // 给等待线程" }
其中一个Pulse的价值对于一个坚持工作的线程来说价值几乎为零。这也经常出现间歇性的bug,因为它会突然出现仅仅在当一个消费线程处于Waiting状态时,因此你可以扩充之前的信条为“如果对Pulse有疑问”为“如果对PulseAll有疑问!”。对于这个规则的可能出现的异常一般是由于判断阻止条件是耗时的。
使用等待超时
有时候当非阻止条件发生时Pulse是不切实际或不可能的。一个可能的例子就是阻止条件调用一个周期性查询数据库得到信息的方法。如果反应时间不是问题,解决方案就很简单:你可以定义一个timeout在调用Wait的时候,如下:
lock (locker) { while ( blocking condition ) Monitor.Wait (locker, timeout); }
这就强迫阻止条件被重新检查,至少为超时定义一个正确的区间,就可以立刻接受一个pulse。阻止条件越简单,超时越容易造成高效率。
同一系统工作的相当号如果pulse缺席,会归咎于程序的bug!所以值得在程序中的所有同步非常复杂的Wait上加上超时——这可作为复杂的pulse错误最终后备支持。这也提供了一定程度的bug抗绕度,如果程序被稍后修改了Pulse部分!
竞争与确认
如果说,我们想要一个信号,一个工作线程连续5次显示:
class Race { static object locker = new object(); static bool go; static void Main() { new Thread(SaySomething).Start(); for (int i = 0; i < 5; i++) { lock (locker) { go = true; Monitor.Pulse(locker); } } } static void SaySomething() { for (int i = 0; i < 5; i++) { lock (locker) { while (!go) Monitor.Wait(locker); go = false; } Console.WriteLine("Wassup?"); } } }
实际输出:
Wassup?
(终止)
这个程序是有缺陷的:主线程中的for循环可以任意的执行它的5次迭代在工作线程还没得到锁的任何时候内,可能工作线程甚至还没开始的时候!生产者/消费者的例子没有这个问题,因为主线程胜过工作线程,每个请求只会排队。但是在这个情况下,我们需要在工作线程仍然忙于之前的任务的时候,主线程阻止迭代。比较简单的解决方案是让主线程在每次循环后等待,直到go标志而被工作线程清除掉,这样就需要工作线程在清除go标志后调用Pulse:
class Acknowledged { static object locker = new object(); static bool go; static void Main() { new Thread(SaySomething).Start(); for (int i = 0; i < 5; i++) { lock (locker) { go = true; Monitor.Pulse(locker); } lock (locker) { while (go) Monitor.Wait(locker); } } } static void SaySomething() { for (int i = 0; i < 5; i++) { lock (locker) { while (!go) Monitor.Wait(locker); go = false; Monitor.Pulse(locker); // Worker must Pulse } Console.WriteLine("Wassup?"); } } }
这次,Wassup? (重复了5次)
这个程序的一个重要特性是工作线程,在执行可能潜在的耗时工作之前释放了它的锁(此处是发生在我们的Console.WriteLine处)。这就确保了当工作线程仍在执行它的任务时调用者不会被过分的阻止,因为它已经被发过信号了(并且只有在工作线程仍忙于之前的任务时才被阻止)。
在这个例子中,只有一个线程(主线程)给工作线程发信号执行任务,如果多个线程一起发信号给工作线程—— 使用我们的主方法的逻辑——我们将出乱子的。两个发信号线程可能彼此按序执行下面的这行代码:
lock (locker) { go = true; Monitor.Pulse (locker); }
如果工作线程没有发生完成处理第一个的时候,导致第二个信号丢失。我们可以在这种情形下通过一对标记来让我们的设计更健壮些。 “ready”标记指示工作线程能够接受一个新任务;“go”标记来指示继续执行,就像之前的一样。这与之前的执行相同的事情的使用两个AutoResetEvent的例子类似,除了更多的可扩充性。下面是模式,重分解了实例字段:
public class Acknowledged { object locker = new object(); bool ready; bool go; public void NotifyWhenReady() { lock (locker) { // 等待当工作线程已在忙之前的时 while (!ready) Monitor.Wait(locker); ready = false; go = true; Monitor.PulseAll(locker); } } public void AcknowledgedWait() { // 预示我们准备处理一个请求 lock (locker) { ready = true; Monitor.Pulse(locker); } lock (locker) { while (!go) Monitor.Wait(locker); // 等待一个“go”信号 go = false; Monitor.PulseAll(locker); // 肯定信号(确认相应) } Console.WriteLine("Wassup?"); // 执行任务 } }
为了证实,我们启动两个并发线程,每个将通知工作线程5次,期间,主线程将等待10次报告:
public class Test { static Acknowledged a = new Acknowledged(); static void Main() { new Thread(Notify5).Start(); // Run two concurrent new Thread(Notify5).Start(); // notifiers... Wait10(); // ... and one waiter. } static void Notify5() { for (int i = 0; i < 5; i++) a.NotifyWhenReady(); } static void Wait10() { for (int i = 0; i < 10; i++) a.AcknowledgedWait(); } }
输出:
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
Wassup?
在Notify方法中,当离开lock语句时ready标记被清除。这是及其重要的:它保证了两个通告程序持续的发信号而不用重新检查标记。为了简单,我们在相同的lock语句中也设置了go标记并且调用PulseAll语句。
模拟等待句柄
你可能已经注意到了之前的例子里的一个模式:两个等待循环都有下面的结构: lock (locker) { while (!flag) Monitor.Wait (locker); flag = false; ... } flag 在另一个线程里被设置为true,这个作用就是模拟AutoResetEvent。如果我们忽略flag=false,我们就相当于得到 了ManualResetEvent。使用一个整型字段,Pulse 和 Wait 也能被用于模拟Semaphore。实际上唯一用Pulse 和 Wait不能模拟的等待句柄是Mutex,因为这个功能被lock提供。 模拟跨多个等待句柄的工作的静态方法大多数情况下是很容易的。相当于在多个EventWaitHandle间调用WaitAll,无非是 阻止条件囊括了所有用于标识用以代替等待句柄: lock (locker) { while (!flag1 && !flag2 && !flag3...) Monitor.Wait (locker); 这特别有用,假设waitall是在大多数情况由于com遗留问题不可用。模拟WaitAny更容易了,大概只要把&&操作符替换 成||操作符就可以了。
SignalAndWait 是需要技巧的。回想这个顺序发信号一个句柄而同时在同一个原子操作中等待另一个。 我们情形与分布式的 数据库事务操作类似——我们需要双相确(commit)!假定我们想要发信号flagA同时等待flagB,我们必须分开每个标识 为2个,导致代码看起来像这样:
lock (locker) { flagAphase1 = true; Monitor.Pulse(locker); while (!flagBphase1) Monitor.Wait(locker); flagAphase2 = true; Monitor.Pulse(locker); while (!flagBphase2) Monitor.Wait(locker); }
如果第一个Wait语句抛出异常作为中断或终止的结果,多半附加"rollback"逻辑到取消flagAphase1。
等待汇集
就像WaitHandle.SignalAndWait 可以用于汇集一对线程一样,Wait和Pulse它们也可以。接下来这个例子,我们要模拟两个ManualResetEvent(换言之,我们要定义两个布尔标识!)并且然后执行彼此的Wait 和 Pulse,通过设置某个标识同时等待另一个。这个情形下我们在Wait 和 Pulse不需要真正的原子操作,所以我们避免需要“双相确认”。当我们设置我们的标识为true,并且在相同的lock语句中进行等待,汇集就会工作了:
class Rendezvous { static object locker = new object(); static bool signal1, signal2; static void Main() { // Get each thread to sleep a random amount of time. Random r = new Random(); new Thread(Mate).Start(r.Next(10000)); Thread.Sleep(r.Next(10000)); lock (locker) { signal1 = true; Monitor.Pulse(locker); while (!signal2) Monitor.Wait(locker); } Console.Write("Mate! "); } // This is called via a ParameterizedThreadStart static void Mate(object delay) { Thread.Sleep((int)delay); lock (locker) { signal2 = true; Monitor.Pulse(locker); while (!signal1) Monitor.Wait(locker); } Console.Write("Mate! "); } }
结果:Mate! Mate! (几乎同时出现)
Wait 和 Pulse vs. 等待句柄
并且阻止条件在外部为设置为false。仅有的开销就是去掉锁(数十纳秒间),而调用WaitHandle.WaitOne要花费几毫秒,当然这要保证锁是无竞争的锁。明智的原则是使用等待句柄在那些有助于它自然地完成工作的特殊结构中,否则就选择使用Wait 和 Pulse。
三、Suspend 和 Resume
线程可以被明确的挂起和恢复通过Thread.Suspend 和 Thread.Resume 这个机制与之前讨论的阻止完全分离。它们两个是独立的和并发的执行的。一个线程可以挂起它本身或其它的线程,调用Suspend 导致线程暂时进入了 SuspendRequested状态,然后在达到无用单元收集的安全点之前,它进入Suspended状态。从那时起,它只能通过另一个线程调用Resume方法恢复。Resume只对挂起的线程有用,而不是阻止的线程。
从.NET 2.0开始Suspend 和 Resume被不赞成使用了,因为任意挂起起线程本身就是危险的。如果在安全权限评估期间挂起持有锁的线程,整个程序(或计算机)可能会死锁。
四、终止线程
一个线程可以通过Abort方法被强制终止:
class Abort { static void Main() { Thread t = new Thread(delegate() { while (true);}); // 永远轮询 t.Start(); Thread.Sleep(1000); // 让它运行几秒... t.Abort(); // 然后终止它 } }
线程在被终止之前立即进入AbortRequested 状态。如果它如预期一样终止了,它进入Stopped状态。调用者可以通过调用Join来等待这个过程完成:
class Abort { static void Main() { Thread t = new Thread(delegate() { while (true); }); Console.WriteLine(t.ThreadState); // Unstarted t.Start(); Thread.Sleep(1000); Console.WriteLine(t.ThreadState); // Running t.Abort(); Console.WriteLine(t.ThreadState); // AbortRequested t.Join(); Console.WriteLine(t.ThreadState); // Stopped } }
Abort引起ThreadAbortException 异常被抛出在目标线程上, 大多数情况下就使线程执行的那个时候。线程被终止可以选择处理异常,但是异常会自动在catch语句最后被重新抛出(来帮助保证线程确实如期望的结束了)。尽管如此,可能避免自动地重新抛出通过调用Thread.ResetAbort在catch语句块内。线程然后重新进入Running 状态(由于它可能潜在被又一次终止)。在下面的例子中,每当Abort试图终止的时候工作线程都从死恢复回来:
class Terminator { static void Main() { Thread t = new Thread(Work); t.Start(); Thread.Sleep(1000); t.Abort(); Thread.Sleep(1000); t.Abort(); Thread.Sleep(1000); t.Abort(); } static void Work() { while (true) { try { while (true); } catch (ThreadAbortException) { Thread.ResetAbort(); } Console.WriteLine("I will not die!"); } } }
ThreadAbortException被运行时处理过,导致它当没有处理时不会引起整个程序结束,而不像其它类型的线程。Abort几乎对处于任何状态的线程都有效, running,blocked,suspended或 stopped。尽管挂起的线程会失败,但ThreadStateException会被抛出,这时在正调用的线程中, 异常终止不会踢开直到线程随后恢复,这演示了如何终止一
个挂起的线程:
try { suspendedThread.Abort(); } catch (ThreadStateException) //现在suspendedThread将被终止
Thread.Abort的复杂因素
假设一个被终止的线程没有调用ResetAbort,你可能期待它正确地并迅速地结束。但是争先它所发生的那样,懂法规的线程可能驻留在死那行一段时间!有一点原因可能保持它延迟在AbortRequested状态:
- 静态类的构造器执行一半是不能被终止的(免得可能破坏类对于程序域内的生命周期)
- 所有的catch/finally语句块被尊重,不会在这期间终止
- 如果线程正执行到非托管的代码时进行终止,执行会继续直到下次进入托管的代码中
最后因素尤为麻烦,.NET framework本身提供了调用非托管的代码,有时候会持续很长的时间。一个例子就是当使用网络或数据库类的时候。如果网资源或数据库死了或很慢的相应,有可能执行完全的保留在非托管的代码中,至于多长时间依赖于类的实现。在这些情况,确定你不能用Join 来终止线程——至少不能没有超时!
终止纯.NET代码是很少有问题的,在try/finally或using语句组成合体来保证正确时,终止发生应抛出ThreadAbortException异常。但是,即使那样,还是容易出错。比如考虑下面的代码:
using (StreamWriter w = File.CreateText ("myfile.txt")) w.Write ("Abort-Safe?");
C#using语句是简洁地语法操作,可以扩充为下面的:
StreamWriter w; w = File.CreateText ("myfile.txt"); try { w.Write ("Abort-Safe"); } finally { w.Dispose(); }
有可能Abort引发在StreamWriter创建之后,但是在try 之前,也有可能它引发在StreamWriter被创建和赋值给w之间。无论哪种,在finally中的Dispose方法,导致抛弃了打开文件的句柄 —— 应用程序域结束之前一直阻止创建myfile.txt操作。
最温和的退出工作线程是通过调用在它自己的线程上调用Abort——尽管明确地抛出异常,也可以很好的工作。这确保了线程正常终止,在执行任何catch/finally语句的时候——相当像从另一个线程调用终止,除了异常是在设计的地方抛出的:
class ProLife { public static void Main() { RulyWorker w = new RulyWorker(); Thread t = new Thread(w.Work); t.Start(); Thread.Sleep(500); w.Abort(); } public class RulyWorker { // The volatile keyword ensures abort is not cached by a thread volatile bool abort; public void Abort() { abort = true; } public void Work() { while (true) { CheckAbort(); // Do stuff... try { OtherMethod(); } finally { /* any required cleanup */ } } } void OtherMethod() { // Do stuff... CheckAbort(); } void CheckAbort() { if (abort) Thread.CurrentThread.Abort(); } } }
结束应用程序域
另一个方式实现友好的终止工作线程是通过终止持有它的应用程序域。在调用Abort后,你简单地销毁应用程序域,因此释放 了任何不正确引用的资源。 严格地来讲,第一步——终止线程——是不必要的,因为当一个应用程序域卸载之后,所有期内线程都被终止了。尽管如此, 依赖这个特性的缺点是如果被终止的线程没有即时的退出(可能归咎于finally的代码,或之前讨论的理由) 应用程序域不会卸载,CannotUnloadAppDomainException异常将被抛出。因此最好明确终止线程,然后在卸载应用程序域之前带着超 时参数(受你所控)调用Join方法。 在下面的例子里,工作线程访问一个死循环,使用非终止安全的File.CreateText方法创建并关闭一个文件。主线程然后重 复地开始和终止工作线程。它总是在一或两次迭代中失败,CreateText在获取了终止部分通过它的内部实现机制,留下了一 个被抛弃的打开文件的句柄:
using System; using System.IO; using System.Threading; class Program { static void Main() { while (true) { Thread t = new Thread(Work); t.Start(); Thread.Sleep(100); t.Abort(); Console.WriteLine("Aborted"); } } static void Work() { while (true) using (StreamWriter w = File.CreateText("myfile.txt")) { } } }
输出引发异常:
Aborted
Aborted
IOException: The process cannot access the file 'myfile.txt' because it
is being used by another process.
下面是一个经修改类似的例子,工作线程在它自己的应用程序域中运行,应用程序域在线程被终止后被卸载掉。它会永远的运
行而没有错误,因为卸载应用程序域释放了被抛弃的文件句柄:
class Program { static void Main(string[] args) { while (true) { AppDomain ad = AppDomain.CreateDomain("worker"); Thread t = new Thread(delegate() { ad.DoCallBack(Work); }); t.Start(); Thread.Sleep(100); t.Abort(); if (!t.Join(2000)) { // 线程不会结束——这里我们可以放置一些操作, // 如果,实际上,我们不能做任何事,幸运地是 // 这种情况,我们期待*线程*总是能结束。 } AppDomain.Unload(ad); // 卸载“受污染”的应用程序域 Console.WriteLine("Aborted"); } } static void Work() { while (true) using (StreamWriter w = File.CreateText("myfile.txt")) { } } }
输出:
Aborted
Aborted
Aborted
Aborted
...
创建和结束一个应用程序域在线程的世界里是被分类到相关耗时的操作的(数毫秒),所以应该不定期的使用它,而不是把它放入循环中。同时,实行分离,由应用程序域推出的另一项内容可以带来有利或不利,这取决于多线程程序展示出来的实现。在单元测试方面,比如,在分离的应用程序域中运行线程,可以带来极大的好处。
结束进程
另一个线程结束的方式是通过它的父进程被终止掉。当一个线程由于它的父进程被终止了,它突然停止,不会有finally被执行。相同的情形在一个用户通过Windows任务管理器或一个进程被编程的方式通过Process.Kill时发生。