C#中的多线程状态

 

图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 独立地获得,所以实际上只有第一种状态层拥有有用的信息。

public static ThreadState SimpleThreadState (ThreadState ts)
{
  return ts & (ThreadState.Aborted | ThreadState.AbortRequested |
               ThreadState.Stopped | ThreadState.Unstarted |
               ThreadState.WaitSleepJoin);
}
ThreadState对调试或程序概要分析是无价之宝,与之不相称的是多线程的协同工作,因为没有一个机制存在:通过判断ThreadState来执行信息,而不考虑ThreadState期间的变化。

等待句柄
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
AutoResetEvent就像一个用票通过的旋转门:插入一张票,让正确的人通过。类名字里的“auto”实际上就是旋转门自动关闭或“重新安排”后来的人让其通过。一个线程等待或阻止通过在门上调用WaitOne方法(直到等到这个“one”,门才开) ,票的插入则由调用Set方法。如果由许多线程调用WaitOne,在门前便形成了队列,一张票可能来自任意某个线程——换言之,任何(非阻止)线程要通过AutoResetEvent对象调用Set方法来释放一个被阻止的的线程。

如果Set调用时没有任何线程处于等待状态,那么句柄保持打开直到某个线程调用了WaitOne 。这个行为避免了在线程起身去旋转门和线程插入票(哦,插入票是非常短的微秒间的事,真倒霉,你将必须不确定地等下去了!)间的竞争。但是在没人等的时候重复地在门上调用Set方法不会允许在一队人都通过,在他们到达的时候:仅有下一个人可以通过,多余的票都被“浪费了"。

WaitOne 接受一个可选的超时参数——当等待以超时结束时这个方法将返回false,WaitOne在等待整段时间里也通知离开当前的同步内容,为了避免过多的阻止发生。

Reset方法提供在没有任何等待或阻止的时候关闭旋转门——它应该是开着的。

AutoResetEvent可以通过2种方式创建,第一种是通过构造函数:

EventWaitHandle wh = new AutoResetEvent (false);
如果布尔参数为真,Set方法在构造后立刻被自动的调用,另一个方法是通过它的基类EventWaitHandle:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);
EventWaitHandle的构造器也允许创建ManualResetEvent(用EventResetMode.Manual定义).

在Wait Handle不在需要时候,你应当调用Close方法来释放操作系统资源。但是,如果一个Wait Handle将被用于程序(就像这一节的大多例子一样)的生命周期中,你可以发点懒省略这个步骤,它将在程序域销毁时自动的被销毁。

接下来这个例子,一个线程开始等待直到另一个线程发出信号。

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的构造器允许以“命名”的方式进行创建,它有能力跨多个进程。名称是个简单的字符串,可能会无意地与别的冲突!如果名字使用了,你将引用相同潜在的EventWaitHandle,除非操作系统创建一个新的,看这个例子:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
  "MyCompany.MyApp.SomeName");
如果有两个程序都运行这段代码,他们将彼此可以发送信号,等待句柄可以跨这两个进程中的所有线程。

任务确认
设想我们希望在后台完成任务,不在每次我们得到任务时再创建一个新的线程。我们可以通过一个轮询的线程来完成:等待一个任务,执行它,然后等待下一个任务。这是一个普遍的多线程方案。也就是在创建线程上切分内务操作,任务执行被序列化,在多个工作线程和过多的资源消耗间排除潜在的不想要的操作。

我们必须决定要做什么,但是,如果当新的任务来到的时候,工作线程已经在忙之前的任务了,设想这种情形下我们需选择阻止调用者直到之前的任务被完成。像这样的系统可以用两个AutoResetEvent对象实现:一个“ready”AutoResetEvent,当准备好的时候,它被工作线程调用Set方法;和“go”AutoResetEvent,当有新任务的时候,它被调用线程调用Set方法。在下面的例子中,一个简单的string字段被用于决定任务(使用了volatile 关键字声明,来确保两个线程都可以看到相同版本):

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();
 
    // 给工作线程发5次信号
    for (int i = 1; i <= 5; i++) {
      ready.WaitOne();                // 首先等待,直到工作线程准备好了
      task = "a".PadRight (i, 'h');   // 给任务赋值
      go.Set();                       // 告诉工作线程开始执行!
    }
 
    // 告诉工作线程用一个null任务来结束
    ready.WaitOne(); task = null; go.Set();
  }
 
  static void Work() {
    while (true) {
      ready.Set();                          // 指明我们已经准备好了
      go.WaitOne();                         // 等待被踢脱...
      if (task == null) return;             // 优雅地退出
      Console.WriteLine (task);
    }
  }
}
ah
ahh
ahhh
ahhhh

注意我们要给task赋null来告诉工作线程退出。在工作线程上调用Interrupt 或Abort 效果是一样的,倘若我们先调用ready.WaitOne的话。因为在调用ready.WaitOne后我们就知道工作线程的确切位置,不是在就是刚刚在go.WaitOne语句之前,因此避免了中断任意代码的复杂性。调用 Interrupt 或 Abort需要我们在工作线程中捕捉异常。

生产者/消费者队列
另一个普遍的线程方案是在后台工作进程从队列中分配任务。这叫做生产者/消费者队列:在工作线程中生产者入列任务,消费者出列任务。这和上个例子很像,除了当工作线程正忙于一个任务时调用者没有被阻止之外。

生产者/消费者队列是可缩放的,因为多个消费者可能被创建——每个都服务于相同的队列,但开启了一个分离的线程。这是一个很好的方式利用多处理器的系统 来限制工作线程的数量一直避免了极大的并发线程的缺陷(过多的内容切换和资源连接)。

在下面例子里,一个单独的AutoResetEvent被用于通知工作线程,它只有在用完任务时(队列为空)等待。一个通用的集合类被用于队列,必须通过锁控制它的访问以确保线程安全。工作线程在队列为null任务时结束:

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);     // 告诉消费者退出
    worker.Join();          // 等待消费者线程完成
    wh.Close();             // 释放任何OS资源
  }
 
  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);  // 模拟工作...
      }
      else
        wh.WaitOne();         // 没有任务了——等待信号
    }
  }
}
下面是一个主方法测试这个队列:

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!");
    }
    // 使用using语句的调用q的Dispose方法,
    // 它入列一个null任务,并等待消费者完成
  }
}
Performing task: Hello
Performing task: Say 1
Performing task: Say 2
Performing task: Say 3
...
...
Performing task: Say 9
Goodbye!

注意我们明确的关闭了Wait Handle在ProducerConsumerQueue被销毁的时候,因为在程序的生命周期中我们可能潜在地创建和销毁许多这个类的实例。

ManualResetEvent
ManualResetEvent是AutoResetEvent变化的一种形式,它的不同之处在于:在线程被WaitOne的调用而通过的时候,它不会自动地reset,这个过程就像大门一样——调用Set打开门,允许任何数量的已执行WaitOne的线程通过;调用Reset关闭大门,可能会引起一系列的“等待者”直到下次门打开。

你可以用一个布尔字段"gateOpen" (用 volatile 关键字来声明)与"spin-sleeping" – 方式结合——重复地检查标志,然后让线程休眠一段时间的方式,来模拟这个过程。

ManualResetEvent有时被用于给一个完成的操作发送信号,又或者一个已初始化正准备执行工作的线程。

互斥(Mutex)
Mutex提供了与C#的lock语句同样的功能,这使它大多时候变得的冗余了。它的优势在于它可以跨进程工作——提供了一计算机范围的锁而胜于程序范围的锁。

Mutex是相当快的,而lock 又要比它快上数百倍,获取Mutex需要花费几微秒,获取lock需花费数十纳秒(假定没有阻止)。

对于一个Mutex类,WaitOne获取互斥锁,当被抢占后时发生阻止。互斥锁在执行了ReleaseMutex之后被释放,就像C#的lock语句一样,Mutex只能从获取互斥锁的这个线程上被释放。

Mutex在跨进程的普遍用处是确保在同一时刻只有一个程序的的实例在运行,下面演示如何使用:

class OneAtATimePlease {
  // 使用一个应用程序的唯一的名称(比如包括你公司的URL)
  static Mutex mutex = new Mutex (false, "oreilly.com OneAtATimeDemo");
  
  static void Main() {
    //等待5秒如果存在竞争——存在程序在
    // 进程中的的另一个实例关闭之后
 
    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就像一个夜总会:它有固定的容量,这由保镖来保证,一旦它满了就没有任何人可以再进入这个夜总会,并且在其外会形成一个队列。然后,当人一个人离开时,队列头的人便可以进入了。构造器需要至少两个参数——夜总会的活动的空间,和夜总会的容量。

Semaphore 的特性与Mutex 和 lock有点类似,除了Semaphore没有“所有者”——它是不可知线程的,任何在Semaphore内的线程都可以调用Release,而Mutex 和 lock仅有那些获取了资源的线程才可以释放它。

在下面的例子中,10个线程执行一个循环,在中间使用Sleep语句。Semaphore确保每次只有不超过3个线程可以执行Sleep语句:

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);   // 每次只有3个线程可以到达这里
      s.Release();
    }
  }
}
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。

同步环境
与手工的锁定相比,你可以进行说明性的锁定,用衍生自ContextBoundObject 并标以Synchronization特性的类,它告诉CLR自动执行锁操作,看这个例子:

using System;
using System.Threading;
using System.Runtime.Remoting.Contexts;
 
[Synchronization]
public class AutoLock : ContextBoundObject {
  public void Demo() {
    Console.Write ("Start...");
    Thread.Sleep (1000);           // 我们不能抢占到这
    Console.WriteLine ("end");     // 感谢自动锁!
  } 
}
 
public class Test {
  public static void Main() {
    AutoLock safeInstance = new AutoLock();
    new Thread (safeInstance.Demo).Start();     // 并发地
    new Thread (safeInstance.Demo).Start();     // 调用Demo
    safeInstance.Demo();                        // 方法3次
  }
}
Start... end
Start... end
Start... end

CLR确保了同一时刻只有一个线程可以执行 safeInstance中的代码。它创建了一个同步对象来完成工作,并在每次调用safeInstance的方法和属性时在其周围只能够行锁定。锁的作用域——这里是safeInstance对象,被称为同步环境。

那么,它是如何工作的呢?Synchronization特性的命名空间:System.Runtime.Remoting.Contexts是一个线索。ContextBoundObject可以被认为是一个“远程”对象,这意味着所有方法的调用是被监听的。让这个监听称为可能,就像我们的例子AutoLock,CLR自动的返回了一个具有相同方法和属性的AutoLock对象的代理对象,它扮演着一个中间者的角色。总的来说,监听在每个方法调用时增加了数微秒的时间。

自动同步不能用于静态类型的成员,和非继承自 ContextBoundObject(例如:Windows Form)的类。

锁在内部以相同的方式运作,你可能期待下面的例子与之前的有一样的结果:

[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 { ...
越大的同步环境越容易管理,但是减少机会对有用的并发。换个有限的角度,分离的同步环境会造成死锁,看这个例子:

[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知道如何产生的错误,更别指望他们来解决它了。在死锁显而易见的情况下,这与使用明确的锁的方式形成鲜明的对比。

可重入性问题
线程安全方法有时候也被称为可重入式的,因为在它执行的时候可以被抢占部分线路,在另外的线程调用也不会带来坏效果。从某个意义上讲,术语线程安全 和 可重入式的是同义的或者是贴义的。

不过在自动锁方式上,如果Synchronization的参数可重入式的 为true的话,可重入性会有潜在的问题:

[Synchronization(true)]
同步环境的锁在执行离开上下文时被临时地释放。在之前的例子里,这将能预防死锁的发生;很明显很需要这样的功能。然而一个副作用是,在这期间,任何线程都可以自由的调用在目标对象(“重进入”的同步上下文)的上任何方法,而非常复杂的多线程中试图避免不释放资源是排在首位的。这就是可重入性的问题。

因为[Synchronization(true)]作用于类级别,这特性打开了对于非上下文的方法访问,由于可重入性问题使它们混入类的调用。

虽然可重入性是危险的,但有些时候它是不错的选择。比如:设想一个在其内部实现多线程同步的类,将逻辑工作线程运行在不同的语境中。在没有可重入性问题的情况下,工作线程在它们彼此之间或目标对象之间可能被无理地阻碍。

这凸显了自动同步的一个基本弱点:超过适用的大范围的锁定带来了其它情况没有带来的巨大麻烦。这些困难:死锁,可重入性问题和被阉割的并发,使另一个更简单的方案——手动的锁定变得更为合适。

posted @ 2016-07-28 15:17  隐逸天涯  阅读(2212)  评论(0编辑  收藏  举报