关于Queue(不安全)和ConcurrentQueue(安全)的总结
最近一直迷茫于两种队列Queue和ConcurrentQueue,不清楚他们的区别,看资料一直说他们一个线程安全【ConcurrentQueue】,一种是线程不安全队列【Queue】,简单的理解就是在多线程的
情况下,ConcurrentQueue是安全的,不会报错,而Queue是不安全的,会报错。那么为什么会出现这种情况呢?啥是线程安全,内部如何实现呢?
先看两种队列的定义吧![Queue] [ConcurrentQueue]
Queue:表示对象的先进先出集合。
using System; using System.Collections; public class SamplesQueue { public static void Main() { // Creates and initializes a new Queue. Queue myQ = new Queue(); myQ.Enqueue("Hello"); myQ.Enqueue("World"); myQ.Enqueue("!"); // Displays the properties and values of the Queue. Console.WriteLine( "myQ" ); Console.WriteLine( "\tCount: {0}", myQ.Count ); Console.Write( "\tValues:" ); PrintValues( myQ ); } public static void PrintValues( IEnumerable myCollection ) { foreach ( Object obj in myCollection ) Console.Write( " {0}", obj ); Console.WriteLine(); } } /* This code produces the following output. myQ Count: 3 Values: Hello World ! */
安全性:
此 (Shared
的成员Visual Basic) 中的公共静态对象是线程安全的。 但不保证所有实例成员都是线程安全的。
若要保证 的线程安全 Queue ,必须通过 方法返回的包装器完成所有 Synchronized(Queue) 操作。
枚举整个集合本质上不是一个线程安全的过程。 即使某个集合已同步,其他线程仍可以修改该集合,这会导致枚举数引发异常。
若要确保枚举过程中的线程安全性,可以在整个枚举期间锁定集合,或者捕获由其他线程进行的更改所导致的异常。
ConcurrentQueue:表示线程安全的先进先出 (FIFO) 集合。
using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; class CQ_EnqueueDequeuePeek { // Demonstrates: // ConcurrentQueue<T>.Enqueue() // ConcurrentQueue<T>.TryPeek() // ConcurrentQueue<T>.TryDequeue() static void Main () { // Construct a ConcurrentQueue. ConcurrentQueue<int> cq = new ConcurrentQueue<int>(); // Populate the queue. for (int i = 0; i < 10000; i++) { cq.Enqueue(i); } // Peek at the first element. int result; if (!cq.TryPeek(out result)) { Console.WriteLine("CQ: TryPeek failed when it should have succeeded"); } else if (result != 0) { Console.WriteLine("CQ: Expected TryPeek result of 0, got {0}", result); } int outerSum = 0; // An action to consume the ConcurrentQueue. Action action = () => { int localSum = 0; int localValue; while (cq.TryDequeue(out localValue)) localSum += localValue; Interlocked.Add(ref outerSum, localSum); }; // Start 4 concurrent consuming actions. Parallel.Invoke(action, action, action, action); Console.WriteLine("outerSum = {0}, should be 49995000", outerSum); } }
线程安全性:
的所有公共和受保护的成员 ConcurrentQueue<T> 都是线程安全的,可从多个线程并发使用。
那么接下来就会有一些实例了:
这个问题是最为经典的多线程应用问题问题就是:有一个或多个线程(生产者线程)产生一些数据,还有一个或者多个线程(消费者线程)要取出这些数据并执行一些相应的工作。
接下来,我们是使用程序去描述这个问题,看下面代码
static void Main(string[] args) { int count = 0; // 临界资源区 var queue = new Queue<string>(); // 生产者线程 Task.Factory.StartNew(() => { while (true) { queue.Enqueue("mesg" + count); count++; } }); // 消费者线程1 Task.Factory.StartNew(() => { while (true) { if (queue.Count > 0) { string value = queue.Dequeue(); Console.WriteLine("Worker A: " + value); } } }); // 消费者线程2 Task.Factory.StartNew(() => { while (true) { if (queue.Count > 0) { string value = queue.Dequeue(); Console.WriteLine("Worker B: " + value); } } }); Thread.Sleep(50000); }
我们使用 Queue 模拟了一个简单的资源池,一个生产者放数据,两个消费者消费数据。
这个程序运行以后会产生异常,异常的原因很简单。当某时刻,第一个消费者判断 queue.Count > 0 为true 时,就会到 Queue 中取数据。但是,此时这个数据可能会被第二个消费者拿走了,因为第二个消费者也判断出此时有数据可取。第一个消费者取取数据时就会发生异常,这就是一个简单的临界资源线程安全问题。[使用Queue类的Enqueue函数来添加,这里定义一个Enqueue函数来添加数据到队列,可以看到有一个object类的锁,它的作用就是防止冲突当读取数据时候,上锁,防止此时写入数据,当线程读取到-1时候,线程退出程序结束]
[说到并发控制,我们首先想到的肯定是 lock关键字。这里要说一下,lock锁的究竟是什么?是lock下面的代码块吗,不,是locker对象。我们想象一下,locker对象相当于一把门锁(或者钥匙),后面代码块相当于屋里的资源。
哪个线程先控制这把锁,就有权访问代码块,访问完成后再释放权限,下一个线程再进行访问。注意:如果代码块中的逻辑执行时间很长,那么其他线程也会一直等下去,直到上一个线程执行完毕,释放锁。
写C#代码时,遇到有过程需要排队执行,就使用了lock方法进行锁定,锁定对象为一List<T>数组,在临界区代码段中对该数据进行读取操作。在某些偶然情况下,会发现该数据在锁定代码段以外进行访问时,会抛出一个异常:“源数组长度不足。请检查 srcIndex 和长度以及数组的下限”,此时再执行其他操作就无效了。后查阅资料发现,lock锁定代码中对该数据的操作尚未执行完毕,别处就已在使用该数组,可能是导致异常的一个原因 ]
知道问题了,那么如何解决呢?有两种方案,接下来进行讲解
1:加锁
这个方案是可行的,很多时候我们也是这么做的,包括微软早期实现线程安全的 ArrayList 和 Hashtable 内部 (Synchronized方法) 也是这么实现的。这个方案适用于只有少量的消费者,并且每个消费者都会执行大量操作的时候,这时 lock 并没什么太大问题,但是,如果是大批量短小精悍的消费者存在的话,lock 会严重影响代码的执行效率。
悲观锁:同一时间只有一个线程具有读写的权限。锁的机制在并发量不大情况下,十分的清晰有效。在并发量较大的时候,会因为对锁的竞争而越发不高效。同时,锁本身也需要维护一定的资源,也需要消耗性能。
乐观锁:每次读写都不考虑锁的存在,那么他是如何知道自己这次操作和其他线程是冲突的呢?这就是Lock-free队列的关键——原子操作。原子操作可以保证一次操作在执行的过程中不会被其他线程打断,因此在多线程程序中也不需要同步操作
我们也要像乐观锁一样,编译一次不过,不行两次,两次不行三次。直到编译通过。
2:线程安全集合
这个就是 .NET4.0 后 System.Collections.Concurrent 命名空间下提供多个线程安全集合类方案。
新的线程安全的这些集合内部不再使用lock机制这种比较低效的方式去实现线程安全,而是转而使用SpinWait 和 Interlocked 等机制,间接实现了线程安全,这种方式的效率要高于使用lock的方式。
var queue = new ConcurrentQueue<string>(); Task.Factory.StartNew(() => { while (true) { queue.Enqueue("msg" + count); count++; } }); Task.Factory.StartNew(() => { while (true) { string value; if (queue.TryDequeue(out value)) { Console.WriteLine("Worker A: " + value); } } }); Task.Factory.StartNew(() => { while (true) { string value; if (queue.TryDequeue(out value)) { Console.WriteLine("Worker B: " + value); } } });
ConcurrentQueue.TryDequeue(T) 方法会尝试获取消费,那能不能不要去判断集合是否为空,集合当自己没有元素的时候自己 Block 一下可以吗?答案是,可以的
针对上面的问题,我们可以使用 BlockingCollection 即可。
var blockingCollection = new BlockingCollection<string>(); Task.Factory.StartNew(() => { while (true) { blockingCollection.Add("msg" + count); count++; } }); Task.Factory.StartNew(() => { while (true) { Console.WriteLine("Worker A: " + blockingCollection.Take()); } }); Task.Factory.StartNew(() => { while (true) { Console.WriteLine("Worker B: " + blockingCollection.Take()); } });
BlockingCollection 集合是一个拥有阻塞功能的集合,它就是完成了经典生产者消费者的算法功能。它没有实现底层的存储结构,而是使用了实现 IProducerConsumerCollection 接口的几个集合作为底层的数据结构,例如 ConcurrentBag, ConcurrentStack 或者是 ConcurrentQueue。你可以在构造BlockingCollection 实例的时候传入这个参数,如果不指定的话,则默认使用 ConcurrentQueue 作为存储结构。
而对于生产者来说,只需要通过调用其Add方法放数据,消费者只需要调用Take方法来取数据就可以了。
当然了上面的消费者代码中还有一点是让人不爽的,那就是 while 语句,可以更优雅一点吗?答案是,可以的。
Task.Factory.StartNew(() => { foreach (string value in blockingCollection.GetConsumingEnumerable()) { Console.WriteLine("Worker A: " + value); } });
BlockingCollection.GetConsumingEnumerable 方法是关键,这个方法会遍历集合取出数据,一旦发现集合空了,则阻塞自己,直到集合中又有元素了再开始遍历。
此时,完美了解决了生产者消费者问题。然而通常来说,还有下面两个问题我们有时需要去控制
1 . 控制集合中数据的最大数量
这个问题由 BlockingCollection 构造函数解决,构造该对象实例的时候,构造函数中的 BoundedCapacity 决定了集合最大的可容纳数据数量,这个比较简单。
2 . 何时停止的问题
这个问题由 CompleteAdding 和 IsCompleted 两个配合解决。CompleteAdding 方法是直接不允许任何元素被加入集合;当使用了 CompleteAdding 方法后且集合内没有元素的时候,另一个属性 IsCompleted 此时会为 True,这个属性可以用来判断是否当前集合内的所有元素都被处理完。生产者修改后的代码:
Task.Factory.StartNew(() => { for (int count = 0; count < 10; count++) { blockingCollection.Add("msg" + count); } blockingCollection.CompleteAdding(); });
当使用了 CompleteAdding 方法后,对象停止往集合中添加数据,这时如果是使用 GetConsumingEnumerable 枚举的,那么这种枚举会自然结束,不会再 Block 住集合,这种方式最优雅,也是推荐的写法。
但是如果是使用 TryTake 访问元素的,则需要使用 IsCompleted 判断一下,因为这个时候使用 TryTake 会抛InvalidOperationException 异常。接着我们看下最后的完整代码:
static void Main(string[] args) { var blockingCollection = new BlockingCollection<string>(); var producer = Task.Factory.StartNew(() => { for (int count = 0; count < 10; count++) { blockingCollection.Add("msg" + count); Thread.Sleep(300); } blockingCollection.CompleteAdding(); }); var consumer1 = Task.Factory.StartNew(() => { foreach (string value in blockingCollection.GetConsumingEnumerable()) { Console.WriteLine("Worker A: " + value); } }); var consumer2 = Task.Factory.StartNew(() => { foreach (string value in blockingCollection.GetConsumingEnumerable()) { Console.WriteLine("Worker B: " + value); } }); Task.WaitAll(producer, consumer1, consumer2); }
此外,需要注意 BlockingCollection 有两种枚举方法,
1 . foreach
首先 BlockingCollection 本身继承自IEnumerable,所以它自己就可以被 foreach 枚举,首先 BlockingCollection 包装了一个线程安全集合,那么它自己也是线程安全的,而当多个线程在同时修改或访问线程安全容器时,BlockingCollection 自己作为 IEnumerable 会返回一个一定时间内的集合片段,也就是只会枚举在那个时间点上内部集合的元素。使用这种方式枚举的时候,不会有 Block 效果。
2 . GetConsumingEnumerable
另外一种方式就是我们上面使用的 GetConsumingEnumerable 方式的枚举,这种方式会有 Block 效果,直到 CompleteAdding 被调用为止。
- 队列 Queue ;
- 泛型队列 Queue<T>;
- 阻塞泛型集合 BlockingCollection<T>
- 以及微软强大的并行库中的并发泛型队列 ConcurrentQueue<T>
总结:单线程用Queue,多线程用ConcurrentQueue,多线程又要限制元素个数用BlockingCollection。
C#中线程安全集合大总: