Bootstrap

C#多线程同步2

生产者/消费者 队列

一个普通的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任务来关闭队列:

Wait/Pulse 样板 #2: 生产者/消费者 队列

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!

与我们的设计模式一致,如果我们移除了PulseAll,并用Wait和切换锁替换它,我们将得到相同的输出结果。

节省脉冲开销

让我们回顾一下生产者入队一个任务:

lock (locker) {
taskQ.Enqueue (task);
Monitor.PulseAll (locker);
}

严格地来讲,在只有空闲的被阻止的工作线程时,我们可以节省pulse:

lock (locker) {
taskQ.Enqueue (task);
if (taskQ.Count <= workers.Length) Monitor.PulseAll (locker);
}

我们节省了一点点,可是因为脉冲一般花费在微秒间,并招致繁忙的工作线程没有系统开销,因此它们总是被忽略了!精简任何没必要的多线程逻辑代码是一个好的策略:仅仅为了一毫秒性能的节省而产生归咎于愚蠢错误的间歇的bug是一个沉重的代价!为了证明这一点,这里引入了个间歇性的“坚持工作者”bug,它很可能规避最开始的测试(注意不同点):

lock (locker) {
taskQ.Enqueue (task);
if (taskQ.Count < workers.Length) Monitor.PulseAll (locker);
}

脉冲无条件地从这种类型的bug保护我们。

如果对Pulse有疑问,使用这种设计模式,你就很少会犯错了。

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次显示:Let's say we want a signal a worker five times in a row:

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?
Wassup?
Wassup?
Wassup?
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的例子类似,除了更多的可扩充性。下面是模式,重分解了实例字段:

Wait/Pulse 样板 #3: 双向信号

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?
(重复10次)

Notify方法中,当离开lock语句时ready标记被清除。这是及其重要的:它保证了两个通告程序持续的发信号而不用重新检查标记。为了简单,我们也设置了go标记并且调用PulseAll语句在相同的lock语句中——尽管我们也无妨吧这对语句放在分离的锁中,没有什么不同的。

模拟等待句柄

你可能已经注意到了之前的例子里的一个模式:两个等待循环都有下面的结构:

lock (locker) {
while (!flag) Monitor.Wait (locker);
flag = false;
...
}

flag 在另一个线程里被设置为true,这个作用就是模拟 AutoResetEvent。如果我们忽略flag=false,我们就相当于得到了ManualResetEvent。使用一个整型字段,PulseWait 也能被用于模拟Semaphore。实际上唯一用PulseWait不能模拟的等待句柄是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);
}

多半附加"rollback"逻辑到取消flagAphase1,如果第一个Wait语句抛出异常作为中断或终止的结果。这个方案使用等待句柄是多么的简单啊!真正原子操作的 Wait 和 Pulse,然而却是罕见的需求。

等待汇集

就像WaitHandle.SignalAndWait 可以用于汇集一对线程一样,WaitPulse它们也可以。接下来这个例子,我们要模拟两个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. 等待句柄

因为WaitPulse 是最灵活的同步结构,所以它们可以用于几乎任何情况下。尽管如此Wait Handles有两个优势:

  • 他们有跨进程工作的能力
  • 它们更容易理解,并更难于被破坏

加之,等待句柄是更适合共同使用,它们能通过方法的参数进行传递。在线程池中,这个技术非常值得使用。

在性能方面,如果你遵从wait的设计模式,WaitPulse有轻微的优势,如下:

lock (locker)
while ( blocking condition ) Monitor.Wait (locker);

并且阻止条件在外部为设置为false。仅有的开销就是去掉锁(数十纳秒间),而调用WaitHandle.WaitOne要花费几毫秒,当然这要保证锁是无竞争的锁。甚至简短的锁条件将太多了使事情完成;频繁的锁条件使等待句柄更快!

鉴于不用的CPU,操作系统,CLR版本和程序逻辑潜在的变化;在任何情况下,几毫秒对于在Wait 语句之前的任何逻辑判定是不可靠的,用性能选择WaitPulse代替等待句柄,可能是不确定的理由,反之亦然。

明智的原则是使用等待句柄在那些有助于它自然地完成工作的特殊结构中,否则就选择使用WaitPulse

Suspend 和 Resume

线程可以被明确的挂起和恢复通过Thread.SuspendThread.Resume 这个机制与之前讨论的阻止完全分离。它们两个是独立的和并发的执行的。

一个线程可以挂起它本身或其它的线程,调用Suspend 导致线程暂时进入了 SuspendRequested状态,然后在达到无用单元收集的安全点之前,它进入Suspended状态。从那时起,它只能通过另一个线程调用Resume方法恢复。Resume只对挂起的线程有用,而不是阻止的线程。

从.NET 2.0开始SuspendResume被不赞成使用了,因为任意挂起起线程本身就是危险的。如果在安全权限评估期间挂起持有锁的线程,整个程序(或计算机)可能会死锁。这远比调用Abort危险——Abort依靠finally块中的代码,导致任何这样的锁被释放。

但是,在当期的线程上安全地调用Suspend,这样做你可以实现一个简单的同步机制:在一个循环中使用工作线程,执行一个任务,在它自己上调用Suspend,然后等待在另一个任务准备号之后通过主线程被恢复(“唤醒”)。难点是判断工作线程是否被挂起了,考虑下面的代码:

worker.NextTask = "MowTheLawn";
if ((worker.ThreadState & ThreadState.Suspended) > 0)
worker.Resume;
else
//我们不能调用Resume当线程正在运行的时候
//代替以用一个标志来告诉工作线程:
worker.AnotherTaskAwaits = true;

这是可怕的非线程安全的,在工作线程向前推进或改变它状态的时候,代码可能抢占这五行的任意一点。尽管它可以工作,但是和另一个方案——使用同步结构比如AutoResetEventMonitor.Wait比起来还是太复杂了。这就使SuspendResume不在有任何用处了。

不赞同使用的SuspendResume方法有两个模式:危险和无用!

原文地址 http://knowledge.swanky.wu.googlepages.com/threading_in_c_sharp_part_4.html

;