生产者消费者模型
生产者消费者队列是一个很常见的任务调度执行模型,它的优点不仅是可以释放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 }
以上的代码很简单,甚至没有锁。只有一个线程负责向队列中插入数据,同时也只有一个线程负责从队列中取数据,那会不会存在安全隐患呢?我想很多人的一个误区是只有当两个或两个以上的生产者(消费者)同时操作队列的时候,才会出现线程同步的问题(因为同时存,或者同时取会产生覆盖或者取相同值的情况),如果只有一个线程存,一个线程取是不会有线程安全问题的。有这个误区是因为不了解队列的存取逻辑。简单的说,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 }
这里加入了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 }
我们先来看一下队列中的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 }
进阶需求:
- 想要知道一个任务现在执行的状态。
- 可以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/ (墙裂推荐)