生产者消费者模型

生产者消费者队列是一个很常见的任务调度执行模型,它的优点不仅是可以释放UI线程,让任务在平行的后台工作线程中进行,而且还可以对工作线程数做精准的控制。甚至可以动态的增加或减少工作线程数来达到效率和资源的平衡,被广泛的用于后台任务执行以及线程数据交互等。
 
生产者消费者模型一般有3个组成部分:
  • 一个用于存放任务(数据)的队列。
  • 一个将任务插入队列的方法。
  • 一个或者更多个工作(消费)线程从队列中取出任务并执行。
以下是一个最基础的生产者消费者模型框架(此代码存在线程安全问题)
 1 public class ProducerConsumerQueue
 2 {
 3     //一个存放任务的队列
 4     private Queue<string> _queue;
 5 
 6     public ProducerConsumerQueue()
 7     {
 8         _queue = new Queue<string>();
 9 
10         //一个工作线程启动执行队列中的任务
11         new Thread(Consume).Start();
12     }
13 
14     //一个插入队列的公共方法
15     public void EnQueue(string task)
16     {
17         _queue.Enqueue(task);
18     }
19 
20         //从队列中取任务执行
21     private void Consume()
22     {
23         while (true)
24         {
25             //如果队列有数据,就从队列中取出数据,否则空循环
26             if (_queue.Count > 0)
27             {
28                 var task = _queue.Dequeue();
29                 if (task == null)
30                 {
31                     Console.WriteLine(
32                         string.Format("{0}:{1}:{2}", 
33                         Thread.CurrentThread.ManagedThreadId, 
34                         "Over", 
35                         _queue.Count));
36                     break;
37                 }
38                 Console.WriteLine(
39                     string.Format("{0}:{1}", 
40                     Thread.CurrentThread.ManagedThreadId, 
41                     task));
42             }
43         }
44     }
45 }
46 
47 class Program
48 {
49     static void Main(string[] args)
50     {
51         var queue = new ProducerConsumerQueue();
52 
53         for (int i = 0; i < 10; i++)
54         {
55             var temp = i;
56             queue.EnQueue(temp + "");
57         }
58 
59         Console.ReadLine();
60     }
61 }
模型0(此代码存在安全隐患)
以上的代码很简单,甚至没有锁。只有一个线程负责向队列中插入数据,同时也只有一个线程负责从队列中取数据,那会不会存在安全隐患呢?我想很多人的一个误区是只有当两个或两个以上的生产者(消费者)同时操作队列的时候,才会出现线程同步的问题(因为同时存,或者同时取会产生覆盖或者取相同值的情况),如果只有一个线程存,一个线程取是不会有线程安全问题的。有这个误区是因为不了解队列的存取逻辑。简单的说,Queue队列是用数组实现的,如果Enqueue的时候发现数组的大小不够大,就会重新new数组,转移旧数据等。如果在这个过程中,取队列的线程取数据,就有可能发生dequeue返回为null的情况。
拿我们上面的例子来说:调用的时候只插入10个数据,假如这里把new queue改为赋值了默认为20的队列
1 _queue = new Queue<string>(); ==> _queue = new Queue<string>(20); //没有参数,默认的数组大小为4
那么,如果只有一个线程取,只有一个线程存,并且可以保证queue不会扩容的情况下,确实是不需要加锁去控制的。
 
但很显然,这种情况不通用,所以我们要修改上面的代码让其变得线程安全。并且希望当队列中没有数据的时候,取线程可以休息,而不是像现在这样一直空跑消耗资源。
 1 public class ProducerConsumerQueue : IDisposable
 2 {
 3     //一个存放任务的队列
 4     private Queue<string> _queue;
 5     private Thread _worker;
 6     private readonly object _locker = new object();
 7     private AutoResetEvent _resetEvent = new AutoResetEvent(false);
 8 
 9     public ProducerConsumerQueue()
10     {
11         _queue = new Queue<string>();
12         _worker = new Thread(Consume);
13         //一个工作线程启动执行队列中的任务
14         _worker.Start();
15     }
16 
17     //一个插入队列的公共方法
18     public void EnQueue(string task)
19     {
20         lock (_locker)
21         {
22             _queue.Enqueue(task);
23         }
24         _resetEvent.Set();
25     }
26 
27     //从队列中取任务执行
28     private void Consume()
29     {
30         while (true)
31         {
32             string task = null;
33             //如果队列有数据,就从队列中取出数据,否则空循环
34             lock (_locker)
35             {
36                 if (_queue.Count > 0)
37                 {
38                     task = _queue.Dequeue();
39 
40                     if (string.Equals(task, "Over"))
41                     {
42                         return;
43                     }
44                 }
45             }
46             
47             if (task != null)
48             {
49                 Console.WriteLine(
50                     string.Format("{0}:{1}",
51                         Thread.CurrentThread.ManagedThreadId,
52                         task));
53             }
54             else
55             {
56                 //队列中已经没有数据,等待信号。
57                 _resetEvent.WaitOne();
58             }
59         }
60     }
61 
62     public void Dispose()
63     {
64         EnQueue("Over");
65         //等待线程结束
66         _worker.Join();
67         //释放资源
68         _resetEvent.Close();
69     }
70 }
71 
72 class Program
73 {
74     static void Main(string[] args)
75     {
76         using (ProducerConsumerQueue q = new ProducerConsumerQueue())
77         {
78             q.EnQueue("Hello");
79             for (int i = 0; i < 10; i++)
80             {
81                 q.EnQueue(string.Format("Say {0}", i));
82 
83             }
84             q.EnQueue("Goodbye!");
85         }
86         Console.ReadKey();
87     }
88 }
模型1
这里加入了Dispose方法用来结束队列(插入“Over”)并且释放信号量资源。以上是一个基础且实用的生产消费模型。下面会根据需求的演变,我们来逐渐将其优化。
 
新需求:
1. 更多的消费者为我们工作。
Thread _worker => Thread[] _workers.
2. 队列中的任务变成更灵活的action,而不仅仅是个打印输出。
Queue<string> => Queue<Action>
3. 当队列中没有任务的时候,blocking。
 1 public class PCQueue
 2 {
 3     private Thread[] _workers;
 4     private Queue<Action> _queue = new Queue<Action>();
 5     readonly object _lock = new object();
 6 
 7     public PCQueue(int count)
 8     {
 9         _workers = new Thread[count];
10         for (int i = 0; i < count; i++)
11         {
12             _workers[i] = new Thread(Consume);
13             _workers[i].Start();
14         }
15     }
16 
17     public void EnQueueTask(Action action)
18     {
19         lock (_lock)
20         {
21             _queue.Enqueue(action);
22             Monitor.Pulse(_lock);
23         }
24     }
25 
26     private void Consume()
27     {           
28         while (true)
29         {
30             Action action = null;
31             lock (_lock)
32             {
33                 while (_queue.Count==0)
34                 {
35                     Monitor.Wait(_lock);//_lock is released
36                     // _lock is regained
37                 }
38                 action = _queue.Dequeue();
39             }
40             if (action == null) return;
41             action();
42         }
43     }
44 
45     public void Shutdown(bool waitForWorkers)
46     {
47         foreach (Thread worker in _workers)
48         {
49             EnQueueTask(null);
50         }
51 
52         if (waitForWorkers)
53         {
54             foreach (Thread worker in _workers)
55             {
56                 worker.Join();
57             }
58         }
59     }
60 }
模型2
我们先来看一下队列中的Consume方法。首先判断队列中是否有要操作的数据(_queue.Count==0)如果没有,则等待Monitor.wait(_locker)。这里需要注意的是:根据Monitor的特性,虽然在lock(){}代码块中,当Monitor.wait(_locker)时,但是会block住(等待)并且释放_locker锁。这时生产者可以获得锁并且通过Pluse来改变block的条件。并让消费者通过While循环自己来判断是否应该继续执行。
主程序调用代码如下:
 1 static void Main(string[] args)
 2 {
 3     PCQueue q = new PCQueue(2);
 4 
 5     Console.WriteLine("Enqueueing 10 items...");
 6 
 7     for (int i = 0; i < 10; i++)
 8     {
 9         int itemNumber = i;
10         q.EnQueueTask(()=> {
11             Console.WriteLine(string.Format("{0} Say {1}", Thread.CurrentThread.ManagedThreadId, itemNumber));
12             Thread.Sleep(100);
13         });
14     }
15 
16     q.EnQueueTask(()=> {
17         Console.WriteLine("{0} Goodbey",Thread.CurrentThread.ManagedThreadId);
18     });
19 
20     q.Shutdown(true);
21     Console.ReadKey();
22 }
模型2-调用代码

 

进阶需求:

  • 想要知道一个任务现在执行的状态。
  • 可以Cancel一个没有开始的任务。
  • 可以处理任务执行中的异常。
引入例子之前,介绍一个接口IProducerConsumerCollection<T>和一个自带Block属性的集合BlockingCollection<T>
 
IProducerConsumerCollection<T>
从名字就可以看出,这个接口是用来定义生产者/消费者模型中使用的集合。正如前文介绍的:模型的3个要素之一就是任务队列。当然这里说是队列,其实就是任务存放的容器(Collection)。它可以是队列,也可以是堆栈,还可以是无序列表。这就需要看具体需求而定了。
以下的三个类已经实现了这个接口,并且是线程安全的。
1 ConcurrentStack<T> //堆栈,FIFO
2 ConcurrentQueue<T> //队列, FILO
3 ConcurrentBag<T> // No order
如果以上的三个类还不能满足你插入集合和出集合的顺序需求,你可以自己实现IProducerConsumerCollection接口来满足自定义的需求。
 
BlockingCollection<T>
从命名就可以知道,BlockingCollection是有Block的功能的。BlockingCollection可以包装一个IProducerConsumerCollection,并使其在容器没元素时或者超过容器最大值是Block。
默认的情况下(构造参数没有传参new BlockingCollection())BlockingCollection会构建一个ConcurrentQueue。除了常规的Add方法之外,它拥有比较特别的取出元素和结束的方法。分别是

1. GetConsumingEnumerable //在foreach中使用

  • 从集合中取出元素
  • 如果结合空,Block住
  • 当CompleteAdding被调用,则结束foreach遍历

2. CompleteAdding

阻止继续Enqueue集合。
 
有了以上的铺垫,我们来看看优化后的模型:
 1 public class PCQueue : IDisposable
 2 {
 3     public class WorkItem
 4     {
 5         public readonly TaskCompletionSource<object> TaskSource;
 6         public readonly Action Action;
 7         public readonly CancellationToken? CancelToken;
 8 
 9         public WorkItem(
10           TaskCompletionSource<object> taskSource,
11           Action action,
12           CancellationToken? cancelToken)
13         {
14             TaskSource = taskSource;
15             Action = action;
16             CancelToken = cancelToken;
17         }
18     }
19 
20     BlockingCollection<WorkItem> _taskQ = null;
21 
22     public PCQueue(int workerCount)
23     {
24         _taskQ = new BlockingCollection<WorkItem>();
25         // Create and start a separate Task for each consumer:
26         for (int i = 0; i < workerCount; i++)
27             Task.Factory.StartNew(Consume);
28     }
29 
30     public PCQueue(int workerCount, IProducerConsumerCollection<WorkItem> collection, int boundedCapacity)
31     {
32         _taskQ = new BlockingCollection<WorkItem>(collection, boundedCapacity);
33         // Create and start a separate Task for each consumer:
34         for (int i = 0; i < workerCount; i++)
35             Task.Factory.StartNew(Consume);
36     }
37 
38     public void Dispose() { _taskQ.CompleteAdding(); }
39 
40     public Task EnqueueTask(Action action)
41     {
42         return EnqueueTask(action, null);
43     }
44 
45     public Task EnqueueTask(Action action, CancellationToken? cancelToken)
46     {
47         var tcs = new TaskCompletionSource<object>();
48         _taskQ.Add(new WorkItem(tcs, action, cancelToken));
49         return tcs.Task;
50     }
51 
52     void Consume()
53     {
54         foreach (WorkItem workItem in _taskQ.GetConsumingEnumerable())
55             if (workItem.CancelToken.HasValue &&
56                 workItem.CancelToken.Value.IsCancellationRequested)
57             {
58                 workItem.TaskSource.SetCanceled();
59             }
60             else
61                 try
62                 {
63                     workItem.Action();
64                     workItem.TaskSource.SetResult(null);   // Indicate completion
65                 }
66                 catch (OperationCanceledException ex)
67                 {
68                     if (ex.CancellationToken == workItem.CancelToken)
69                         workItem.TaskSource.SetCanceled();
70                     else
71                         workItem.TaskSource.SetException(ex);
72                 }
73                 catch (Exception ex)
74                 {
75                     workItem.TaskSource.SetException(ex);
76                 }
77     }
78 }
优化后模型
再详细说明一下里面的WorkItem,包含了一个目标执行的Delegate以及TaskCompletionSource,通过在Enqueue时将TaskCompletionSource.Task返回给生产者,这样生产者可以通过返回的Task来知道现在WorkItem运行到了什么状态。
 
在Consume中,首先check task是否被cancel,这是通过传进来的Token的校验来完成的。在Enqueue时,可以使用如下代码传入Token。
 1 var cancelSource = new CancellationTokenSource();
 2 using (PCQueue queue = new PCQueue(1))
 3 {
 4     queue.EnqueueTask(() =>
 5     {
 6         Thread.Sleep(3000);
 7     }, cancelSource.Token);
 8 
 9 
10 }
调用代码
并在需要Cancel时,调用cancelSource.Cancel(),来实现未开始工作项的取消。
1 cancelSource.Cancel();
总结:本文由浅入深通过逐渐演化的方式实现了3种生产者消费者模型。用到的知识点有AutoResetEvent,Monitor.Wait & Monitor.Pluse,BlockingCollection,IProducerConsumerCollection,ConcurrentQueue...
 
Reference:
http://www.albahari.com/threading/ (墙裂推荐)
 
 
 
posted @ 2021-01-30 13:36  何事惊慌?  阅读(286)  评论(0编辑  收藏  举报