聊聊.net 并发控制,lock,Monitor,Semaphore,BlockingQueue,乐观锁串讲
面试(对,最近在找工作面试...)被问到,.net 并发控制怎么做,BlockingQueue和ConcurrentQueue有什么区别?
多线程问题的核心是控制对临界资源的访问,接下来我们聊聊.net并发控制,可能除了第一个”lock”,对于其他的几个概念都很陌生,那么这篇文章应该对你有帮助。
lock
Monitor
Semaphore
ConcurrentQueue
BlockingQueue
BlockingCollection
一、lock
说到并发控制,我们首先想到的肯定是 lock关键字。
这里要说一下,lock锁的究竟是什么?是lock下面的代码块吗,不,是locker对象。
我们想象一下,locker对象相当于一把门锁(或者钥匙),后面代码块相当于屋里的资源。
哪个线程先控制这把锁,就有权访问代码块,访问完成后再释放权限,下一个线程再进行访问。
注意:如果代码块中的逻辑执行时间很长,那么其他线程也会一直等下去,直到上一个线程执行完毕,释放锁。
1 object locker = new object(); 2 3 private void Add() 4 { 5 lock (locker) 6 { 7 Thread.Sleep(1000); 8 counter++; 9 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Add counter={counter}."); 10 } 11 }
二、Moniter
Monitor是一个静态类(System.Threading.Monitor),功能与lock关键字基本一样,也是加锁,控制并发。
有两个重要的方法:
Monitor.Enter() //获取一个锁
Monitor.Exit() //释放一个锁
另外几个方法:
public static bool TryEnter(object obj, int millisecondsTimeout) //相比于 public static void Enter(object obj) 方法,多了超时时间设置,如果等待超过一定时间,就不再等待了,另外,只有TryEnter()返回值为true时,才能进入代码块。
public static bool Wait(object obj, int millisecondsTimeout) //这个方法在已经获得锁权限的代码块中调用时,或暂时释放锁,等待一定时间后,重新获取锁权限,继续执行Wait后面的代码。(真想不明怎么会有这种相互礼让的操作)
public static void Pulse(object obj) //这个方法的解释是,通知在等待队列中的线程,锁对象状态改变。(测试发现,此方法并不会真正改变锁定状态,只是通知的作用)
TryEnter代码示例:
1 int counter = 0; 2 object locker = new object(); 3 4 private void Minus() 5 { 6 //加上try -catch-finally,防止由于异常,锁无法释放,这也是为什么我们更多使用lock而不是Moniter的原因。 7 try 8 { 9 //只有TryEnter()返回值为true时,才能进入代码块,与Enter()方法不一样 10 if (Monitor.TryEnter(locker, 5000)) 11 { 12 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Minus in"); 13 Thread.Sleep(1000); 14 counter--; 15 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Minus counter={counter}."); 16 } 17 } 18 catch (Exception ex) 19 { 20 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Minus Exception {ex.Message}"); 21 } 22 finally 23 { 24 Monitor.Exit(locker); 25 } 26 }
通过上面的代码,我们可以看出Monitor和lock实现的功能基本一致,但Monitor的使用要明显比lock更复杂,也行这就是我们平时更多的使用lock,而不是Monitor的原因。
三、Semaphore 信号量
System.Threading.Semaphore
lock和Monitor加锁之后,每次只能有一个线程访问临界代码,信号量类似于一个线程池,线程访问之前获取一个信号,访问完成释放信号,只要信号量内有可用信号便可以访问,否则等待。
构造函数:
public Semaphore(int initialCount, int maximumCount) //创建一个信号量,指定初始信号数量和最大信号数量。
几个重要方法:
public int Release() //代码注释的意思是:退出信号量,并返回之前的(可用信号)数量。实际上,除了退出,这个方法每调用一次会增加一个可用信号,但数量达到最大数量时会抛异常。
public int Release(int releaseCount) //和上面的方法类似,上面的方法每次只释放一个信号,这个方法可以指定信号数量。
public virtual bool WaitOne() //等待一个可用信号
看下面的示例代码,如果只初始一个信号量,new Semaphore(1, 100),运行结果与lock和Monitor是一样的,两个方法交替执行,如果初始信号量为多个时,new Semaphore(3, 100),执行效率高的方法要占用更多的信号,从而执行更多次。
1 int counter = 0; 2 int semaphoreCount = 0; 3 Semaphore semaphore = new Semaphore(3, 100); 4 5 private void Add() 6 { 7 semaphore.WaitOne(); 8 Thread.Sleep(1000); 9 counter++; 10 semaphoreCount = semaphore.Release(); 11 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Add counter={counter}.SemaphoreCount:{semaphoreCount}"); 12 } 13 14 private void Minus() 15 { 16 semaphore.WaitOne(); 17 Thread.Sleep(2000); 18 counter--; 19 semaphore.Release(); 20 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Minus counter={counter}.SemaphoreCount:{semaphoreCount}"); 21 }
Semaphore在生产者/消费者模式下的应用
生产者每次添加一个信号,消费者每次消耗一个信号,如果信号量为0,则消费者进入等待状态。
1 int counter = 0; 2 int semaphoreCount = 0; 3 Semaphore semaphore = new Semaphore(0, int.MaxValue); 4 5 private void Product() 6 { 7 semaphoreCount = semaphore.Release(); 8 Thread.Sleep(1000); 9 counter++; 10 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Product counter={counter}.SemaphoreCount:{semaphoreCount}"); 11 } 12 13 private void Consume() 14 { 15 semaphore.WaitOne(); 16 Thread.Sleep(2000); 17 counter--; 18 this.logger.LogDebug($"{DateTime.Now.ToLongTimeString()} Consume counter={counter}.SemaphoreCount:{semaphoreCount}"); 19 }
四、ConcurrentQueue 和 Queue
.net 集合中有一类线程安全的集合 System.Collections.Concurrent,ConcurrentQueue 就是其中的一个,线程安全的队列,有普通队列Queue先进先出的特点,同时又具备多线程安全。
测试过程中发现:
Queue 类的两个出队列方法 Dequeue() 和 TryDequeue(out result),在多线程环境下,Dequeue() 会出现并发访问错误,但TryDequeue(out result)不会,即TryDequeue(out result)即使不加锁,在多线程环境下也运行正常。
ConcurrentQueue 类只有一个出队列方法 TryDequeue(out result),当然,是线程安全的。
五、BlockingQueue
BlockingQueue并不是.net内置的类,如果有人问这个类,那么他多半是在说BlockingCollection
关于 BlockingQueue 有一篇很不错的文章,可以参考一下:
https://docs.microsoft.com/zh-cn/archive/blogs/toub/blocking-queues
六、BlockingCollection
BlockingCollection是.net内置的类,相当于带有阻塞功能的 ConcurrentQueue ,数据先进先出,相比较ConcurrentQueue ,BlockingCollection在从队列中读取数据时,如果队列为空,那么它会等待(block),直到有数据可读取。
而ConcurrentQueue ,需要我们自行判断是否读取了数据,并且控制循环读取的频率。
.net 文档对这个类解释的非常详细,可以仔细阅读:
七、乐观锁
前面讲的这些,都是属于.net提供的并发控制方案,还有另一种更常用的并发控制方式,乐观锁。
乐观锁本质上并不是加锁,而是数据版本控制。乐观锁的出发点是假定并发错误发生的概率很小,从而允许程序并发执行。
首先,数据要有一个版本号,每次数据更新,要产生一个新的版本号。
其次,进入数据处理逻辑之前,记录该数据的版本号,数据处理结束后,重新读取数据,比较前后两个版本号是否一致,如果一致,则提交,处理完成,如果不一致,说明产生了并发错误,则抛出异常或已其他方式终止程序执行,从而保证数据的一致性。
总结
lock是最常用的并发控制方式,Monitor的功能与lock类似,但使用复杂,非必须不建议使用。
Semaphore,信号量,是一个不错的功能,特定应用场景下非常实用。
ConcurrentQueue 是一个线程安全的队列,在多线程并发环境下使用,可避免由于并发引起的错误。(我们可以使用lock+Queue,实现ConcurrentQueue,自己感兴趣可以试一下)
BlockingCollection 带阻塞功能的 ConcurrentQueue ,没有可用数据的情况下,进入等待状态,防止循环访问,减少CPU资源浪费。(我们可以通过Semaphore+ConcurrentQueue ,实现BlockingCollection ,自己感兴趣可以试一下)
最后,祝大家祝编程快乐。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律