C#线程 线程进阶

第四部分:高级线程

 

非阻塞同步

前面我们说过,即使在分配或增加字段的简单情况下,也需要同步。尽管锁定始终可以满足此需求,但是竞争性锁定意味着线程必须阻塞,从而遭受上下文切换的开销和调度的延迟,这在高度并发且对性能至关重要的情况下是不希望的。 .NET Framework的非阻塞同步结构可以执行简单的操作,而无需阻塞,暂停或等待。

 

正确编写非阻塞或无锁的多线程代码非常棘手!特别是,内存障碍很容易出错(volatile关键字甚至更容易出错)。在解除普通锁之前,请仔细考虑是否真的需要性能优势。请记住,在2010年时代的台式机上,获取和释放无竞争的锁仅需要20ns。

 

非阻塞方法也可以跨多个进程工作。在读取和写入进程共享内存中的一个例子

 

内存壁垒和波动性

考虑以下示例:

 

class Foo
{
  int _answer;
  bool _complete;
 
  void A()
  {
    _answer = 123;
    _complete = true;
  }
 
  void B()
  {
    if (_complete) Console.WriteLine (_answer);
  }
}

如果方法A和B同时在不同的线程上运行,那么B可以写“ 0”吗?答案是肯定的,原因如下:

 

  • 编译器,CLR或CPU可能会重新排序程序的指令,以提高效率。
  • 编译器,CLR或CPU可能会引入缓存优化,以使对变量的分配不会立即对其他线程可见。

C#和运行时非常小心,以确保此类优化不会破坏普通的单线程代码或正确使用锁的多线程代码。在这些方案之外,您必须通过创建内存屏障(也称为内存屏障)来明确限制这些优化,以限制指令重新排序和读/写缓存的影响。

 

全围栏

最简单的内存屏障是全内存屏障(全隔离栅),它可以防止任何类型的指令在该隔离栅周围重新排序或缓存。调用Thread.MemoryBarrier会生成一个完整的篱笆;我们可以通过应用四个完整的围栏来修复示例,如下所示:

 

class Foo
{
  int _answer;
  bool _complete;
 
  void A()
  {
    _answer = 123;
    Thread.MemoryBarrier();    // Barrier 1
    _complete = true;
    Thread.MemoryBarrier();    // Barrier 2
  }
 
  void B()
  {
    Thread.MemoryBarrier();    // Barrier 3
    if (_complete)
    {
      Thread.MemoryBarrier();       // Barrier 4
      Console.WriteLine (_answer);
    }
  }
}

屏障1和4阻止此示例写入“ 0”。屏障2和3提供了新鲜度保证:它们确保如果B在A之后跑,则读取_complete将评估为true。

 

在2010年时代的台式机上,完整的围栏大约需要10纳秒。

 

以下隐式生成全围栏:

  • C#的lock语句(Monitor.Enter / Monitor.Exit)
  • Interlocked类中的所有方法(我们将在稍后介绍)
  • 使用线程池的异步回调-其中包括异步委托,APM回调和任务延续
  • 设置并等待信令构造
  • 任何依赖信令的内容,例如启动或等待任务
  • 根据最后一点,以下内容是线程安全的:
int x = 0;
Task t = Task.Factory.StartNew (() => x++);
t.Wait();
Console.WriteLine (x);    // 1

您不一定需要对每个人的读或写都有完整的栅栏。如果我们有三个答案字段,那么我们的示例仍然只需要四个围栏:

 

class Foo
{
  int _answer1, _answer2, _answer3;
  bool _complete;
 
  void A()
  {
    _answer1 = 1; _answer2 = 2; _answer3 = 3;
    Thread.MemoryBarrier();
    _complete = true;
    Thread.MemoryBarrier();
  }
 
  void B()
  {
    Thread.MemoryBarrier();
    if (_complete)
    {
      Thread.MemoryBarrier();
      Console.WriteLine (_answer1 + _answer2 + _answer3);
    }
  }
}

  

一个好的方法是从在读取或写入共享字段的每条指令之前和之后放置内存屏障开始,然后去除不需要的指令。如果不确定,请将它们留在里面。或更妙的是:改回使用锁!

 

我们真的需要锁和壁垒吗?

 

使用没有锁或栅栏的共享可写字段会带来麻烦。关于此主题的信息有很多误导性-包括MSDN文档,其中指出仅在内存顺序较弱的多处理器系统(例如,使用多个Itanium处理器的系统)上才需要MemoryBarrier。通过下面的简短程序,我们可以证明内存屏障对于普通的Intel Core-2和Pentium处理器很重要。您需要在启用优化的情况下且没有调试器的情况下运行它(在Visual Studio中,在解决方案的配置管理器中选择“发布模式”,然后在不进行调试的情况下启动):

 

static void Main()
{
  bool complete = false; 
  var t = new Thread (() =>
  {
    bool toggle = false;
    while (!complete) toggle = !toggle;
  });
  t.Start();
  Thread.Sleep (1000);
  complete = true;
  t.Join();        // Blocks indefinitely
}

该程序永远不会终止,因为complete变量被缓存在CPU寄存器中。在while循环内插入对Thread.MemoryBarrier的调用(或锁定读取完成)可修复错误。

 

volatile关键字

解决此问题的另一种(更高级的)方法是将volatile关键字应用于_complete字段:

 

volatile bool _complete;

volatile关键字指示编译器在对该字段的每次读取中生成一个获取围栏,并在对该字段的每次写入中生成一个释放围栏。获取栅栏可防止其他读取/写入在栅栏之前移动;释放栅栏可防止在栅栏之后移动其他读取/写入操作。这些“半栅栏”比完整的栅栏要快,因为它们为运行时和硬件提供了更大的优化空间。

 

碰巧的是,英特尔的X86和X64处理器始终将获取栅栏应用于读取,将释放栅栏应用于写入(无论是否使用volatile关键字),因此,如果您使用这些处理器,则此关键字对硬件没有影响。但是,volatile确实会影响编译器和CLR以及64位AMD和(在更大程度上)Itanium处理器执行的优化。这意味着您不能因为客户端运行特定类型的CPU而更加放松。

 

(即使您确实使用挥发物,也应该保持健康的焦虑感,我们很快就会看到!)

 

将volatile应用于字段的效果可总结如下:

第一指令 第二条指令 它们可以互换吗?
N
N
N(CLR确保即使没有volatile关键字也不会交换写操作)
是!

 

请注意,应用volatile并不能阻止写入和读取之间的交换,这可能会引起脑筋急转弯。 Joe Duffy通过以下示例很好地说明了该问题:如果Test1和Test2同时在不同的线程上运行,则a和b的最终值都可能为0(尽管x和y都使用了volatile):

 

class IfYouThinkYouUnderstandVolatile
{
  volatile int x, y;
 
  void Test1()        // Executed on one thread
  {
    x = 1;            // Volatile write (release-fence)
    int a = y;        // Volatile read (acquire-fence)
    ...
  }
 
  void Test2()        // Executed on another thread
  {
    y = 1;            // Volatile write (release-fence)
    int b = x;        // Volatile read (acquire-fence)
    ...
  }
}

MSDN文档指出,使用volatile关键字可确保始终在字段中显示最新值。这是不正确的,因为正如我们所看到的,写入和读取之后可以重新排序。

 

这为避免波动提供了强有力的理由:即使您理解了此示例中的精妙之处,其他从事您代码工作的开发人员也会理解吗? Test1和Test2中的两个分配中的每个分配之间都有一个完整的栅栏(或一个锁)可以解决此问题。

 

传递引用参数或捕获的局部变量不支持volatile关键字:在这些情况下,必须使用VolatileRead和VolatileWrite方法。

 

VolatileRead和VolatileWrite

Thread类中的静态VolatileRead和VolatileWrite方法在执行volatile关键字所作的保证(从技术上讲,是其的超集)时,读/写一个变量。但是,它们的实现效率相对较低,因为它们实际上会生成完整的篱笆。这是它们对整数类型的完整实现:

 

public static void VolatileWrite (ref int address, int value)
{
  MemoryBarrier(); address = value;
}
 
public static int VolatileRead (ref int address)
{
  int num = address; MemoryBarrier(); return num;
}

从中可以看出,如果先调用VolatileWrite,再调用VolatileRead,则中间不会产生障碍:这将实现与我们之前看到的相同的难题。

 

记忆障碍和锁定

如前所述,Monitor.Enter和Monitor.Exit都生成完整的围栏。因此,如果我们忽略了锁的互斥保证,我们可以这样说:

 

lock (someField) { ... }

等效于此:

 

Thread.MemoryBarrier(); {...} 
Thread.MemoryBarrier();

联锁

在无锁代码中读取或写入字段时,使用内存屏障并不总是足够的。对64位字段,增量和减量的操作需要使用Interlocked helper类的更重方法。互锁还提供了Exchange和CompareExchange方法,后者启用了无锁的读取-修改-写入操作,并带有一些额外的编码。

 

如果语句在基础处理器上作为一条不可分割的指令执行,则该语句本质上是原子的。严格的原子性排除了任何抢占的可能性。对32位或更少位的字段进行简单的读取或写入始终是原子的。保证仅在64位运行时环境中,对64位字段的操作才是原子的,并且结合多个读/写操作的语句绝不会是原子的:

 

class Atomicity
{
  static int _x, _y;
  static long _z;
 
  static void Test()
  {
    long myLocal;
    _x = 3;             // Atomic
    _z = 3;             // Nonatomic on 32-bit environs (_z is 64 bits)
    myLocal = _z;       // Nonatomic on 32-bit environs (_z is 64 bits)
    _y += _x;           // Nonatomic (read AND write operation)
    _x++;               // Nonatomic (read AND write operation)
  }
}

在32位环境中,读取和写入64位字段是非原子的,因为它需要两条单独的指令:每个32位存储器位置对应一条指令。因此,如果线程X在更新线程Y时读取了一个64位值,则线程X可能会以旧值和新值的按位组合结束(读取结果为撕裂)。

 

编译器通过读取,处理变量然后将其写回来实现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
 
    // Add/subtract a value:
    Interlocked.Add (ref _sum, 3);                                 // 3
 
    // Read a 64-bit field:
    Console.WriteLine (Interlocked.Read (ref _sum));               // 3
 
     //在读取前一个值的同时写一个64位字段:
  //(在将_sum更新为10时显示“ 3”)
  Console.WriteLine(Interlocked.Exchange(ref _sum,10)); // 10

    //仅当字段匹配特定值(10)时才更新:


    Console.WriteLine(Interlocked.CompareExchange(ref _sum, 123,10); // 123

  }

}


   

  

  

互锁的所有方法都会产生完整的围栏。因此,您通过互锁访问的字段不需要附加的防护,除非您在程序中的其他位置没有互锁或锁定就可以访问它们。

 

互锁的数学运算仅限于递增,递减和加法。如果要乘法(或执行任何其他计算),则可以使用CompareExchange方法(通常与自旋等待结合使用)以无锁的方式进行乘法。我们在并行编程部分给出一个示例。

 

互锁通过使操作系统和虚拟机了解原子性的需求来工作。

 

互锁方法的典型开销为10 ns,是无竞争锁的一半。此外,它们永远不会因阻塞而遭受上下文切换的额外费用。不利的一面是,在循环中多次使用互锁可能比在循环中获得单个锁的效率低(尽管互锁可实现更大的并发性)。

 

等待和脉冲信号

前面我们讨论了事件等待句柄—一种简单的信号机制,其中一个线程阻塞直到它从另一个线程接收通知为止。

 

Monitor类通过静态方法Wait和Pulse(和PulseAll)提供了更强大的信令构造。原理是您自己使用自定义标志和字段(包含在锁语句中)编写信令逻辑,然后引入Wait和Pulse命令以防止旋转。仅通过这些方法和lock语句,就可以实现AutoResetEvent,ManualResetEvent和Semaphore的功能,以及(有些警告)WaitHandle的静态方法WaitAll和WaitAny。此外,在所有等待句柄都受到挑战的情况下,Wait and Pulse也可以使用。

 

但是,“等待和脉冲”信令比事件等待句柄有一些缺点:

 

  • 等待/脉冲不能跨越计算机上的应用程序域或进程。
  • 您必须记住要使用锁保护与信令逻辑相关的所有变量。
  • 等待/脉冲程序可能会使依赖Microsoft文档的开发人员感到困惑。

出现文档问题的原因是,即使您已经阅读了它们的工作原理,也不清楚应该如何使用Wait和Pulse。 Wait and Pulse也特别喜欢涉水者:他们会找出您理解上的任何漏洞,然后乐于折磨您!幸运的是,有一个简单的使用模式可以驯服Wait和Pulse。

 

在性能方面,在2010年时代的台式机上调用Pulse大约需要100纳秒,大约是在等待句柄上调用Set所需时间的三分之一。等待无竞争信号的开销完全取决于您,因为您可以使用普通的字段和变量自己实现逻辑。在实践中,这非常简单,并且仅相当于锁的成本。

 

如何使用等待和脉冲

以下是使用等待和脉冲的方法:

 

定义一个字段用作同步对象,例如:

定义要在您的自定义阻止条件中使用的字段。例如:
readonly  object _locker = new object();
每当您要阻止时,请包含以下代码:
 bool _go; or:int _semaphoreCount;
每当您更改(或可能更改)阻止条件时,请包含以下代码:
lock(_locker)

 while(<阻止条件> Monitor.Wait(_locker);


(如果您更改了阻止条件并想等待,则可以将第3步和第4步合并到一个锁中。)

 

lock
(_locker) { //更改可能会影响阻止条件的字段或数据 // ...  Monitor.Pulse(_locker); //或:Monitor.PulseAll(_locker);  }

此模式允许任何线程在任何时间等待任何条件。这是一个简单的示例,其中工作线程等待,直到_go字段设置为true:

 

class SimpleWaitPulse

{

 static readonly object _locker = new object(); 

 static bool _go;

 

  static void Main()

  {//新线程将阻塞

  new Thread(Work).Start(); //因为_go == false。

 

    Console.ReadLine(); //等待用户点击Enter

 

    lock(_locker)//现在通过以下方式唤醒线程

    {//设置_go = true并发出脉冲。

      _go = true;

      Monitor.Pulse(_locker);

    }

  }

 

  static void Work()

  {

    lock(_locker)

     while(!_go)

        Monitor.Wait(_locker); //等待期间释放锁

 

    Console.WriteLine(“ Woken !!!”);

  }

}
Woken !!(按Enter后)

为了线程安全,我们确保在一个锁中访问所有共享字段。因此,我们围绕读取和更新_go标志添加了锁语句。这是必不可少的(除非您愿意遵循无阻塞同步原则)。

 

Work方法是我们阻塞的地方,等待_go标志变为true。 Monitor.Wait方法按顺序执行以下操作:

 

释放_locker上的锁。

阻止直到_locker被“脉冲化”。

重新获取_locker上的锁。如果锁被争用,则它将阻塞直到锁可用为止。

这意味着,尽管有出现,但Monitor.Wait等待脉冲时,同步对象上没有任何锁定:

 

lock (_locker)
{
  while (!_go)
    Monitor.Wait (_locker);  // _lock is released
  // lock is regained
  ...
}

然后执行将在下一条语句处继续。 Monitor.Wait设计用于在lock语句中使用;否则调用将引发异常。 Monitor.Pulse也是如此。

 

在Main方法中,我们通过设置_go标志(处于锁定状态)并调用Pulse来向工作人员发出信号。释放锁后,工作程序将继续执行,并重复其while循环。

 

Pulse和PulseAll方法释放在Wait语句上阻塞的线程。脉冲最多释放一个线程。 PulseAll将全部释放。在我们的示例中,只有一个线程被阻塞,因此它们的效果是相同的。如果有多个线程在等待,则使用我们建议的使用模式通常最安全地调用PulseAll。

 

为了使Wait与Pulse或PulseAll通信,同步对象(在本例中为_locker)必须相同。

 

在我们的模式中,脉冲表示某些内容可能已更改,并且正在等待的线程应重新检查其阻塞条件。在Work方法中,此检查是通过while循环完成的。然后,服务员决定是否继续,而不是通知者。如果将脉冲本身作为继续执行的指令,则将Wait结构中的任何实际值去除;您最终得到的是AutoResetEvent的较低版本。

 

如果我们放弃模式,删除while循环,_go标志和ReadLine,我们将得到一个准系统的Wait / Pulse示例:

 

static void Main()
{
  new Thread (Work).Start();
  lock (_locker) Monitor.Pulse (_locker);
}
 
static void Work()
{
  lock (_locker) Monitor.Wait (_locker);
  Console.WriteLine ("Woken!!!");
}

由于不确定,因此无法显示输出!随后在主线程和工作线程之间发生了种族冲突。如果先执行“等待”,则信号起作用。如果先执行Pulse,则脉冲将丢失,并且工作人员永远卡住。这不同于AutoResetEvent的行为,在AutoResetEvent中,其Set方法具有记忆或“闩锁”效果,因此,如果在WaitOne之前调用它仍然有效。

 

Pulse没有锁存效果,因为您希望像我们之前那样使用“ go”标志自己编写锁存器。这就是使Wait and Pulse变得通用的原因:使用布尔标志,我们可以使其用作AutoResetEvent;使用整数字段,我们可以编写CountdownEvent或Semaphore。使用更复杂的数据结构,我们可以走得更远,并编写诸如生产者/消费者队列之类的构造。

 

生产者/消费者队列

之前,我们描述了生产者/消费者队列的概念,以及如何使用AutoResetEvent编写队列。现在,我们将使用Wait and Pulse编写功能更强大的版本。

 

这次,我们将允许任意数量的工作程序,每个工作程序都有自己的线程。我们将跟踪数组中的线程:

 

Thread[] _workers;

这使我们可以选择在以后关闭队列时加入那些线程。

 

每个工作线程将执行一个称为Consume的方法。我们可以创建线程并在一个循环中启动它们,如下所示:

 

public PCQueue (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();
}

与其使用简单的字符串来描述任务,我们不如使用委托来使用更灵活的方法。我们将在.NET Framework中使用System.Action委托,该委托的定义如下:

public delegate void Action();

该委托匹配任何无参数的方法,就像ThreadStart委托一样。但是,我们仍然可以通过使用匿名委托或lambda表达式包装调用来表示使用参数调用方法的任务:

 

Action myFirstTask = delegate
{
    Console.WriteLine ("foo");
};
 
Action mySecondTask = () => Console.WriteLine ("foo");

为了表示任务队列,我们​​将像以前一样使用Queue <T>集合:

Queue<Action> _itemQ = new Queue<Action>();

在进入EnqueueItem和Consume方法之前,让我们先看完整的代码:

using System;
using System.Threading;
using System.Collections.Generic;
 
public class PCQueue
{
  readonly object _locker = new object();
  Thread[] _workers;
  Queue<Action> _itemQ = new Queue<Action>();
 
  public PCQueue (int workerCount)
  {
    _workers = new Thread [workerCount];
 
      //为每个工人创建并启动一个单独的线程
    for (int i = 0; i < workerCount; i++)
      (_workers [i] = new Thread (Consume)).Start();
  }
 
  public void Shutdown (bool waitForWorkers)

    //每个工作者排队一个空项目,使每个出口都退出。

    foreach(_workers中的线程工作者)

      EnqueueItem (null);

    //等待工人完成

    if(waitForWorkers)

      

     foreach (Thread worker in _workers)
        worker.Join();
  }
  public void EnqueueItem (Action item){
    lock (_locker){

      _itemQ.Enqueue(item);

     //我们必须脉动,因为我们

      Monitor.Pulse(_locker); //更改阻止条件。

   }

  }

void Consume()
  {
    while (true)                       //持续消费直到

    {//否则告诉。

      Action item;
   lock (_locker)
      {
        while (_itemQ.Count == 0) Monitor.Wait (_locker);
        item = _itemQ.Dequeue();
      }
      if (item == null) return;         //这表示我们退出了。
      item();                           // Execute item.
    }
  }
}s

 

再次,我们有一个退出策略:将一个空项目放入队列表示消费者在完成所有未完成的项目后完成操作(如果我们希望它尽快退出,则可以使用独立的“取消”标志)。因为我们支持多个使用者,所以我们必须为每个使用者排队一个空项目,以完全关闭队列。

 

这是一个Main方法,用于启动生产者/消费者队列,指定两个并发的使用者线程,然后让10个委托在两个使用者之间共享:

 

static void Main()
{
  PCQueue q = new PCQueue (2);
 
  Console.WriteLine ("Enqueuing 10 items...");
 
  for (int i = 0; i < 10; i++)
  {
    int itemNumber = i;      // //避免捕获变量陷阱
    q.EnqueueItem (() =>
    {
      Thread.Sleep (1000);          // 模拟耗时的工作
      Console.Write (" Task" + itemNumber);
    });
  }
 
  q.Shutdown (true);
  Console.WriteLine();
  Console.WriteLine ("Workers complete!");
}


---------------------------------------
>>

Enqueuing 10 items...
 Task1 Task0 (pause...) Task2 Task3 (pause...) Task4 Task5 (pause...)
 Task6 Task7 (pause...) Task8 Task9 (pause...)
Workers complete!
 
 

现在让我们看一下EnqueueItem方法:

 

public void EnqueueItem (Action item)
  {
    lock (_locker)
    {
      _itemQ.Enqueue (item);           // We must pulse because we're
      Monitor.Pulse (_locker);         // changing a blocking condition.
    }
  }

因为该队列被多个线程使用,所以我们必须将所有读取/写入包装在一个锁中。而且,由于我们正在修改阻止条件(消费者可能会由于排队任务而采取行动),因此我们必须进行脉动。

 

为了提高效率,在将项目放入队列时,我们将其称为Pulse而不是PulseAll。这是因为(最多)每个商品需要唤醒一个消费者。如果您只有一个冰淇淋,就不会叫醒30个熟睡的孩子排队等候;同样,有30个消费者,将他们全部唤醒没有任何好处-只能让29个消费者在其while循环中旋转一次无用的迭代,然后再进入睡眠状态。但是,通过将Pulse替换为PulseAll,我们不会破坏任何功能。

 

现在让我们看一下Consume方法,其中一个工作程序从队列中拾取并执行一个项目。我们希望工人在无事可做时封锁自己;换句话说,当队列中没有项目时。因此,我们的阻止条件是_itemQ.Count == 0:

 

 Action item;
      lock (_locker)
      {
        while (_itemQ.Count == 0) Monitor.Wait (_locker);
        item = _itemQ.Dequeue();
      }
      if (item == null) return;         // This signals our exit
      item();                           // Perform task.

当_itemQ.Count不为零时,while循环退出,这意味着(至少)一项未完成。在释放锁之前,我们必须使该项目出队-否则,该项目可能不在那里供我们出队(存在其他线程意味着当您眨眼时事情可能会改变!)。特别是,如果我们没有抓住锁,另一个刚刚完成以前工作的消费者可能会偷偷溜进我们的产品并出队,而是做了这样的事情:

 

 Action item;
      lock (_locker)
      {
        while (_itemQ.Count == 0) Monitor.Wait (_locker);
      }
      lock (_locker)    // WRONG!
      {
        item = _itemQ.Dequeue();    // Item may not longer be there!
      }
      ...

物品出队后,我们立即释放锁。如果我们在执行任务时坚持下去,我们将不必要地阻止其他消费者和生产者。我们不会在出队后立即进行脉动,因为队列中的物品较少,因此其他任何消费者都无法解除封锁。

 

使用Wait和Pulse(通常)时,短暂锁定是有利的,因为它避免了不必要地阻塞其他线程。跨很多代码行锁定就可以了,只要它们都能快速执行即可。请记住,Monitor帮助您。等待脉搏时释放基础锁!

 

等待超时

您可以在调用Wait时指定一个超时,以毫秒为单位或作为TimeSpan。如果由于超时而放弃,则Wait方法然后返回false。超时仅适用于等待阶段。因此,等待超时将执行以下操作:

 

  1. 释放基础锁
  2. 阻塞,直到产生脉冲或超时
  3. 重新获取基础锁

指定超时就像在超时间隔之后要求CLR给您“虚拟脉冲”一样。超时的“等待”仍将执行步骤3并重新获取锁定-就像脉冲一样。

 

如果应该在步骤3中等待块(在重新获得锁的同时),则任何超时都将被忽略。但是,这很少有问题,因为在设计良好的“等待/脉冲”应用程序中,其他线程只会短暂锁定。因此,重新获得锁定应该是近乎即时的操作。

 

等待超时有一个有用的应用程序。有时,每当出现疏通条件时,就可能不合理或无法产生脉冲。一个示例可能是阻塞条件涉及调用从周期性查询数据库中获取信息的方法。如果延迟不是问题,则解决方案很简单-您可以在调用Wait时指定超时,如下所示:

lock (_locker)
  while ( <blocking-condition> )
    Monitor.Wait (_locker, <timeout> );

这将强制在超时指定的时间间隔以及在触发脉冲时重新检查阻塞条件。阻塞条件越简单,超时时间越短,而不会造成效率低下。在这种情况下,我们不在乎等待是脉冲还是超时,因此我们忽略了它的返回值。

 

如果由于程序中的错误而缺少脉冲,则相同的系统同样可以很好地工作。值得在同步特别复杂的程序中为所有“等待”命令添加一个超时,以作为模糊脉冲错误的最终备份。如果程序后来被不在Pulse上的人修改,它还提供一定程度的漏洞免疫。

 

Monitor.Wait返回一个bool值,指示它是否收到了“真实”脉冲。如果返回false,则表明它超时:有时,将其记录下来或在超时超出预期时引发异常可能很有用。

 

等待队列

 

当多个线程等待同一个对象时,同步对象后面会形成一个“等待队列”(这不同于用于授予锁访问权限的“就绪队列”)。然后,每个Pulse在等待队列的头部释放一个线程,因此它可以进入就绪队列并重新获取锁。可以把它想象成一个自动停车场:您首先在付款站排队等候验证您的票(等待排队);您再次在屏障门前排队等候放行(就绪队列)。

 

但是,队列结构中固有的顺序在Wait / Pulse应用程序中通常并不重要,在这些情况下,可以更容易地想象一个等待线程的“池”。然后,每个脉冲从池中释放一个等待线程。

 

PulseAll释放等待线程的整个队列或池。但是,脉冲线程并不会完全完全同时开始执行,而是按有序顺序执行,因为它们的每个Wait语句都试图重新获取相同的锁。实际上,PulseAll将线程从等待队列移至就绪队列,因此它们可以有序地恢复。

 

双向信令和种族

Monitor.Pulse的一个重要功能是它异步执行,这意味着它本身不会以任何方式阻塞或暂停。如果另一个线程正在等待脉冲对象,则该线程将被阻止。否则,脉冲将无效,并且将被静默忽略。

 

因此Pulse提供了单向通信:脉冲线程(潜在地)向等待线程发送信号。没有内在的确认机制:Pulse不返回指示是否接收到其脉冲的值。此外,当通知者跳动并释放其锁时,不能保证合格的服务员会立即投入生活。根据线程调度程序的判断,可能会有一个小的延迟,在此期间,两个线程均未锁定。这意味着脉冲发生器无法知道服务员是否或何时恢复工作,除非您专门进行编码(例如,使用另一个标志和另一个倒数,即“等待”和“脉冲”)。

 

依靠没有任何自定义确认机制的侍者的及时行动,就算是“等待”和“脉动”。你会输!

 

为了说明这一点,假设我们要连续五次向线程发送信号:

class Race
{
  static readonly 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.PulseAll (_locker); }
  }
 
  static void SaySomething()
  {
    for (int i = 0; i < 5; i++)
      lock (_locker)
      {
        while (!_go) Monitor.Wait (_locker);
        _go = false;
        Console.WriteLine ("Wassup?");
      }
  }
}

预期产量:


 


Wassup?
Wassup?
Wassup?
Wassup?
Wassup?

实际输出:


 


Wassup? (hangs)
 

 

该程序存在缺陷,并演示了一种竞态条件:主线程中的for循环可以在工人没有握住锁的任何时候(甚至可能在工人开始之前)通过其五次迭代自由滑行!生产者/消费者示例没有遇到此问题,因为如果主线程位于工作线程之前,则每个请求都将排队。但是在这种情况下,如果工作人员仍在忙于上一个任务,则需要在每次迭代时阻塞主线程。

 

我们可以通过在工作人员控制的类中添加_ready标志来解决此问题。然后,主线程会等到工作人员准备就绪后再设置_go标志。

 

这类似于先前的示例,该示例使用两个AutoResetEvents执行相同的操作,但更具扩展性。

 

这里是:

class Solved
{
  static readonly object _locker = new object();
  static bool _ready, _go;
 
  static void Main()
  {
    new Thread (SaySomething).Start();
 
    for (int i = 0; i < 5; i++)
      lock (_locker)
      {
        while (!_ready) Monitor.Wait (_locker);
        _ready = false;
        _go = true;
        Monitor.PulseAll (_locker);
      }
  }
 
  static void SaySomething()
  {
    for (int i = 0; i < 5; i++)
      lock (_locker)
      {
        _ready = true;
        Monitor.PulseAll (_locker);           // Remember that calling
        while (!_go) Monitor.Wait (_locker);  // Monitor.Wait releases
        go = false;                           // and reacquires the lock.
        Console.WriteLine ("Wassup?");
 }
  }
}

>>
Wassup? (repeated five times)

 在Main方法中,我们清除_ready标志,设置_go标志和脉动,所有这些操作均在同一lock语句中。这样做的好处是,如果我们稍后在方程式中引入第三个线程,它将提供鲁棒性。想象另一个线程试图同时向工作人员发出信号。在这种情况下,我们的逻辑是水密的。实际上,我们正在自动清除_ready并设置_go。

模拟等待句柄

您可能在前面的示例中注意到了一种模式:两个等待循环都具有以下结构:

lock (_locker)
{
  while (!_flag) Monitor.Wait (_locker);
  _flag = false;
  ...
}

其中_flag在另一个线程中设置为true。实际上,这是在模仿AutoResetEvent。如果我们省略了_flag = false,那么我们将以ManualResetEvent为基础。

让我们使用Wait和Pulse充实ManualResetEvent的完整代码:

readonly object _locker = new object();
bool _signal;
 
void WaitOne()
{
  lock (_locker)
  {
    while (!_signal) Monitor.Wait (_locker);
  }
}
 
void Set()
{
  lock (_locker) { _signal = true; Monitor.PulseAll (_locker); }
}
 
void Reset() { lock (_locker) _signal = false; }

我们使用PulseAll是因为可以有任意数量的阻止的服务员。

编写AutoResetEvent只是用以下方法替换WaitOne中的代码即可:

lock (_locker)
{
  while (!_signal) Monitor.Wait (_locker);
  _signal = false;
}

并在Set方法中将PulseAll替换为Pulse:

lock (_locker) { _signal = true; Monitor.Pulse (_locker); }

lock (_locker) { _signal = true; Monitor.Pulse (_locker); }

使用PulseAll将在积压的服务员队列中放弃公平性,因为每次调用PulseAll都会导致队列中断,然后重新形成。

用整数字段替换_signal将构成信号量的基础。

在简单的场景中,模拟在一组等待句柄上工作的静态方法很容易。等效于调用WaitAll只是阻止条件,该条件并入了用于代替等待句柄的所有标志:

lock (_locker)
  while (!_flag1 && !_flag2 && !_flag3...)
    Monitor.Wait (_locker);

鉴于WaitAll通常由于COM遗留问题而无法使用,因此这特别有用。模拟WaitAny只需将&&运算符替换为||即可。操作员。

如果您有数十个标志,则此方法的效率会降低,因为它们必须全部共享一个同步对象才能使信号原子地工作。这是等待句柄具有优势的地方。

编写一个CountdownEvent

使用Wait和Pulse,我们可以实现CountdownEvent的基本功能,如下所示:

public class Countdown
{
  object _locker = new object ();
  int _value;
  
  public Countdown() { }
  public Countdown (int initialCount) { _value = initialCount; }
 
  public void Signal() { AddCount (-1); }
 
  public void AddCount (int amount)
  {
    lock (_locker) 
    { 
      _value += amount;
      if (_value <= 0) Monitor.PulseAll (_locker);
    }
  }
 
  public void Wait()
  {
    lock (_locker)
      while (_value > 0)
        Monitor.Wait (_locker);
  }
}

除了我们的阻止条件基于整数字段之外,该模式类似于我们之前看到的模式。

 

线程交合Thread Rendezvous

我们可以使用我们刚刚编写的Countdown类集合一对线程,就像我们之前使用WaitHandle.SignalAndWait所做的那样

class Rendezvous
{
  static object _locker = new object();
 
  // In Framework 4.0, we could instead use the built-in CountdownEvent class.
  static Countdown _countdown = new Countdown(2);
 
  public 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));
 
    _countdown.Signal();
    _countdown.Wait();
 
    Console.Write ("Mate! ");
  }
 
  static void Mate (object delay)
  {
    Thread.Sleep ((int) delay);
 
    _countdown.Signal();
    _countdown.Wait();
    
    Console.Write ("Mate! ");
  }
}

在此示例中,每个线程睡眠随机的时间,然后等待另一个线程,从而导致它们都(几乎)同时写入“ Mate”。这称为线程执行障碍,可以扩展到任意数量的线程(通过调整初始倒计时值)。

当您希望在处理一系列任务时使多个线程保持同步时,线程执行障碍很有用。但是,我们当前的解决方案受到限制,因为我们无法再次使用同一Countdown对象来第二次集合线程-至少在没有其他信令构造的情况下也是如此。为了解决这个问题,Framework 4.0提供了一个称为Barrier的新类。

 Barrier类

Barrier类是Framework 4.0的新信令结构。它实现了一个线程执行屏障,该屏障使许多线程可以在某个时间点集合。该类非常快速且高效,并且建立在Wait,Pulse和Spinlocks的基础上。

要使用此类:

  1. 1.实例化它,指定应参与集合的线程数(您可以稍后通过调用AddParticipants / RemoveParticipants进行更改)。
  2. 2.让每个线程在要集合时调用SignalAndWait。

用3实例化Barrier会导致SignalAndWait阻塞,直到该方法被调用了3次为止。但是与CountdownEvent不同,它随后自动重新开始:再次调用SignalAndWait会阻塞,直到又调用了三次。这样,您就可以在处理一系列任务时使多个线程彼此“步调一致”。

 

在以下示例中,三个线程中的每个线程都写入数字0到4,同时与其他线程保持同步:

static Barrier _barrier = new Barrier (3);
 
static void Main()
{
  new Thread (Speak).Start();
  new Thread (Speak).Start();
  new Thread (Speak).Start();
}
 
static void Speak()
{
  for (int i = 0; i < 5; i++)
  {
    Console.Write (i + " ");
    _barrier.SignalAndWait();
  }
}

>>>
0 0 0 1 1 1 2 2 2 3 3 3 4 4 4
Barrier一个真正有用的功能是,您还可以在构造时指定后阶段操作。这是一个在SignalAndWait被调用n次之后但在线程被解除阻止之前运行的委托。在我们的示例中,如果我们按以下方式实例化障碍:

static Barrier _barrier = new Barrier (3, barrier => Console.WriteLine());

then the output is:

0 0 0 
1 1 1 
2 2 2 
3 3 3 
4 4 4 

后阶段操作对于合并每个工作线程中的数据很有用。不必担心抢占,因为所有员工在执行任务时都会被阻止。

Reader/Writer Locks

 

通常,类型的实例对于并发读取操作是线程安全的,但对于并发更新(对于并发读取和更新)则不是线程安全的。对于资源(例如文件)也是如此。尽管通常通过对所有访问模式使用简单的互斥锁来保护此类类型的实例通常可以解决问题,但是如果有很多读者并且只是偶尔进行更新,它可能会不合理地限制并发性。一个可能发生这种情况的示例是在业务应用程序服务器中,其中缓存了常用数据以便在静态字段中快速检索。 ReaderWriterLockSlim类旨在在这种情况下提供最大可用性锁定。

 

ReaderWriterLockSlim是在Framework 3.5中引入的,并且替代了较旧的“胖” ReaderWriterLock类。后者的功能类似,但是速度慢了好几倍,并且在处理锁升级的机制上存在固有的设计错误。

 

与普通锁(Monitor.Enter / Exit)相比,ReaderWriterLockSlim的速度慢两倍。

 

对于这两种类,都有两种基本类型的锁-读锁和写锁:

 

写锁是通用的。

读锁与其他读锁兼容。

因此,持有写锁的线程会阻止所有其他试图获得读或写锁的线程(反之亦然)。但是,如果没有线程持有写锁,那么任何数量的线程都可以同时获得读锁。

 

ReaderWriterLockSlim定义了以下用于获取和释放读/写锁的方法:

public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();

此外,所有EnterXXX方法的“尝试”版本都接受Monitor.TryEnter样式的超时参数(如果资源竞争激烈,则超时很容易发生)。 ReaderWriterLock提供了类似的方法,称为AcquireXXX和ReleaseXXX。如果发生超时,它们将抛出ApplicationException,而不是返回false。

 

下面的程序演示ReaderWriterLockSlim。三个线程连续枚举一个列表,而另外两个线程每秒将一个随机数附加到列表中。读锁保护列表读取器,写锁保护列表写入器:

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); }
}

在生产代码中,通常会添加try / finally块,以确保在引发异常时释放锁。

 

结果如下:

Thread B added 61
Thread A added 83
Thread B added 55
Thread A added 33
...

ReaderWriterLockSlim比简单的锁允许更多的并发读取活动。我们可以通过在while循环的开始处在Write方法中插入以下行来说明这一点:

Console.WriteLine (_rw.CurrentReadCount + " concurrent readers");这几乎总是打印“ 3个并发读取器”(Read方法将大部分时间都花在foreach循环内)。除了CurrentReadCount之外,ReaderWriterLockSlim还提供以下用于监视锁的属性:
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; }

Upgradeable Locks and Recursion

有时,在单个原子操作中将读锁换成写锁很有用。例如,假设您只想将某个商品添加到列表中。理想情况下,您希望将花费在(排他的)写锁上的时间减至最少,因此您可以按照以下步骤进行操作:

 

  1. 获取读锁。
  2. 测试该项目是否已存在于列表中,如果存在,则释放锁并返回。
  3. 释放读取锁。
  4. 获取写锁。
  5. 添加项目。

问题在于,另一个线程可能会潜入并在步骤3和步骤4之间修改列表(例如,添加相同的项目)。ReaderWriterLockSlim通过第三种称为可升级锁的锁来解决此问题。可升级锁类似于读取锁,除了以后可以通过原子操作将其升级为写入锁。使用方法如下:

 

  • 调用EnterUpgradeableReadLock。
  • 执行基于阅读的活动(例如,测试列表中是否已存在该项目)。
  • 调用EnterWriteLock(将可升级锁转换为写锁)。
  • 执行基于写的活动(例如,将项目添加到列表中)。
  • 调用ExitWriteLock(将写锁转换回可升级的锁)。
  • 执行任何其他基于读取的活动。
  • 调用ExitUpgradeableReadLock。

从调用者的角度来看,它就像是嵌套或递归锁定。从功能上讲,尽管在第3步中,ReaderWriterLockSlim原子地释放了您的读取锁并获得了一个新的写入锁。

 

可升级锁和读取锁之间还有另一个重要区别。尽管可升级锁可以与任何数量的读取锁共存,但一次只能取出一个可升级锁。这可以通过序列化竞争转换来防止转换死锁-就像SQL Server中的更新锁一样:

SQL Server

ReaderWriterLockSlim

Share lock

Read lock

Exclusive lock

Write lock

Update lock

Upgradeable lock

我们可以通过更改上一示例中的Write方法来演示可升级的锁,以使其仅在尚不存在的情况下才添加数字以列出:

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也可以进行锁转换-但不可靠,因为它不支持可升级锁的概念。这就是为什么ReaderWriterLockSlim的设计师必须重新开始一个新类的原因。

Lock recursion

通常,ReaderWriterLockSlim禁止嵌套或递归锁定。因此,以下引发异常

var rw = new ReaderWriterLockSlim();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();

但是,如果您按以下方式构造ReaderWriterLockSlim,它将运行无错误:

var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion);这样可以确保仅当您计划时才可以进行递归锁定。递归锁定会带来不希望的复杂性,因为它可能获得不止一种锁:
rw.EnterWriteLock();
rw.EnterReadLock();
Console.WriteLine (rw.IsReadLockHeld);     // True
Console.WriteLine (rw.IsWriteLockHeld);    // True
rw.ExitReadLock();
rw.ExitWriteLock();

基本规则是,一旦获得了锁,随后的递归锁可以在以下范围内变小但不能变大:

    读锁,可升级锁,写锁

但是,将可升级锁升级为写锁的请求始终是合法的。

Suspend and Resume 暂停和恢复

可以通过不赞成使用的方法Thread.Suspend和Thread.Resume显式暂停和恢复线程。这种机制与阻塞是完全分开的。两种系统都是独立的,并且可以并行运行。

一个线程可以挂起自身或另一个线程。调用Suspend会导致线程短暂地进入SuspendRequested状态,然后在到达可以进行垃圾回收的安全点时,它将进入Suspended状态。从那里开始,只能通过另一个调用其Resume方法的线程来恢复它。恢复仅适用于挂起的线程,而不适用于阻塞的线程。

从.NET 2.0开始,不赞成使用Suspend和Resume,因为任意地挂起另一个线程具有固有的危险,因此不建议使用它们。如果持有关键资源锁的线程被挂起,则整个应用程序(或计算机)可能会死锁。这比调用Abort危险得多,后者会导致代码块(至少在理论上)通过finally块中的代码被释放。

不过,在当前线程上调用Suspend是安全的-这样,您就可以实现简单的同步机制-循环中的工作线程,执行任务,自行调用Suspend,然后等待恢复(“当另一个任务准备就绪时,由主线程唤醒。但是,困难在于确定工人是否被停职。考虑以下代码:

worker.NextTask = "MowTheLawn";
 
if ((worker.ThreadState & ThreadState.Suspended) > 0)
  worker.Resume;
else
  // We cannot call Resume as the thread's already running.
  // Signal the worker with a flag instead:
  worker.AnotherTaskAwaits = true;

这是可怕的线程不安全的:在这五行代码中的任何时候,代码都可以被抢占,在此期间,工作人员可以继续前进并更改其状态。尽管可以解决,但该解决方案比其他解决方案更为复杂-使用诸如AutoResetEvent或Wait and Pulse之类的同步结构。这使“挂起”和“恢复”在所有方面均无用。

 

不推荐使用的Suspend和Resume方法有两种模式:危险和无用!

 

Aborting Threads中止线程

 

您可以通过Abort方法强制结束线程:

 

class Abort
{
  static void Main()
  {
    Thread t = new Thread (delegate() { while(true); } );   // Spin forever
    t.Start();
    Thread.Sleep (1000);        // Let it run for a second...
    t.Abort();                  // then abort it.
  }
}

一旦中止的线程立即进入AbortRequested状态。如果它随后按预期终止,它将进入已停止状态。调用方可以通过调用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
  }
}

中止会导致在目标线程上抛出ThreadAbortException,在大多数情况下恰好是该线程当时正在执行的位置。被中止的线程可以选择处理该异常,但是该异常会在catch块的末尾自动重新抛出(以帮助确保该线程确实按预期结束)。但是,可以通过在catch块中调用Thread.ResetAbort来防止自动重新抛出。然后线程然后重新进入运行状态(从中可能再次中止运行状态)。在以下示例中,每次尝试中止时,工作线程都会从死机中返回:

 

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几乎可以在任何状态下运行线程—运行,阻塞,挂起或停止。但是,如果挂起的线程被中止,则抛出ThreadStateException-这次在调用线程上-并且中止不会启动,直到随后恢复该线程为止。以下是中止挂起线程的方法:

 

try { suspendedThread.Abort(); }
catch (ThreadStateException) { suspendedThread.Resume(); }
// Now the suspendedThread will abort.

Thread.Abort并发症

 

假设中止的线程没有调用ResetAbort,您可能希望它很快终止。但是碰巧的是,有了一个好的律师,线程可能会在死囚牢房中停留相当长的时间!以下是一些可能会使它在AbortRequested状态中徘徊的因素:

 

  • 静态类构造函数绝不会中途退出(以免在应用程序域的剩余生命周期中潜在地毒害该类)
  • 所有捕获/最终阻止都受到尊重,并且从未中途中断
  • 如果线程在中止时正在执行非托管代码,则执行将继续,直到到达下一个托管代码语句为止

最后一个因素可能特别麻烦,因为.NET框架本身通常会调用非托管代码,有时会保留很长时间。例如,使用网络或数据库类时。如果网络资源或数据库服务器死掉或响应速度很慢,则执行可能会完全保留在非托管代码中,可能持续几分钟,具体取决于类的实现。在这些情况下,肯定不希望加入中止的线程-至少没有超时!

 

只要合并try / finally块或using语句以确保在抛出ThreadAbortException时进行适当的清理,中止纯.NET代码的问题就不会那么多。但是,即使那样,仍然可能容易受到令人讨厌的惊喜的影响。例如,考虑以下内容:

 

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();            } 

创建StreamWriter之后但在try块开始之前,可能会中止触发。实际上,通过深入了解IL,您可以发现它也有可能在正在创建并分配给w的StreamWriter之间触发:

 

IL_0001:  ldstr      "myfile.txt"
IL_0006:  call       class [mscorlib]System.IO.StreamWriter
                     [mscorlib]System.IO.File::CreateText(string)
IL_000b:  stloc.0
.try
{
  ...

无论哪种方式,都将规避对finally块中的Dispose方法的调用,从而导致放弃打开的文件句柄,从而阻止后续尝试创建myfile.txt直到该过程结束。

 

实际上,此示例中的情况仍然更糟,因为中止很可能会在File.CreateText的实现中发生。这称为不透明代码-我们没有源代码。幸运的是,.NET代码从未真正变得不透明:我们可以再次输入ILDASM(或者更好的是Lutz Roeder的Reflector),然后看看File.CreateText调用StreamWriter的构造函数,该逻辑具有以下逻辑:

 

public StreamWriter (string path, bool append, ...)
{
  ...
  ...
  Stream stream1 = StreamWriter.CreateFile (path, append);
  this.Init (stream1, ...);
}

在此构造函数中没有try / catch块,这意味着如果Abort在(非平凡的)Init方法内的任意位置激发,则将放弃新创建的流,而无法关闭基础文件句柄。

 

这就提出了一个问题,即如何编写一个友好的中止方法。最常见的解决方法是根本不放弃另一个线程,而是实现协作取消模式,如前所述。

 

结束应用程序域

实现中止友好型工作程序的另一种方法是使其线程在其自己的应用程序域中运行。调用Abort之后,您需要拆除并重新创建应用程序域。这可以解决由于部分或不正确的初始化而导致的不良状态(尽管遗憾的是,它不能保证针对上述最坏情况提供保护-中止StreamWriter的构造函数仍可能泄漏不受管的句柄)。

 

严格来说,不需要第一步-中止线程-因为卸载应用程序域时,该域中执行代码的所有线程都会自动中止。但是,依赖于此行为的缺点是,如果中止的线程没有及时退出(可能是由于finally块中的代码,或者由于先前讨论的其他原因),则应用程序域将不会卸载,并且CannotUnloadAppDomainException将退出。被扔给来电者。因此,最好在退出应用程序域之前,显式中止工作线程,然后在超时(您可以控制)的情况下调用Join。

 

在线程活动的世界中,创建和销毁应用程序域是相对耗时的(花费几毫秒),因此有利于不定期地而不是循环地进行!同样,由应用程序域引入的分隔会引入另一个元素,该元素可能是有益的,也可能是有害的,这取决于多线程程序将要实现的目标。例如,在单元测试上下文中,在单独的应用程序域上运行线程是有好处的。

 

结束线程

线程可以终止的另一种方式是父进程终止。一个示例是工作线程的IsBackground属性设置为true,而主线程在工作线程仍在运行时结束。后台线程无法使应用程序保持活动状态,因此该进程随同后台线程一起终止。

当线程由于其父进程而终止时,它将停止死亡,并且不会执行finally块。

 

当用户通过Windows任务管理器终止无响应的应用程序,或者通过Process.Kill以编程方式终止进程时,也会出现相同的情况。

原文地址:https://www.cnblogs.com/wxs121/p/12545952.html