做工业上位机开发11年,见过最多的线上事故,90%都和多线程同步有关。很多人觉得同步不就是加个lock吗?哪有那么复杂。但真正踩过坑才知道,选错同步机制,轻则性能拉胯,重则系统死锁,产线停摆。
上周帮一个朋友排查问题,他们的设备监控系统,数据采集频率从100ms改成50ms后,CPU直接飙到100%,界面卡成PPT。查了半天,发现他们为了"线程安全",在每个数据读写的地方都加了Mutex,光一个采集线程每秒就要创建释放上百个内核对象,不卡才怪。
今天就把C#中最常用的三种同步机制lock、Monitor、Mutex掰开揉碎了讲清楚,从底层原理到适用场景,再到常见坑点,帮你彻底搞懂什么时候该用什么。
一、先搞懂:用户模式锁和内核模式锁的本质区别
在讲具体的同步类之前,必须先搞明白一个最核心的概念:用户模式锁和内核模式锁。这是理解它们性能差异和适用场景的基础。
- 用户模式锁:完全在用户态执行,不需要切换到内核态,速度极快。缺点是只能在同一个进程内使用,无法跨进程同步。
- 内核模式锁:由操作系统内核提供支持,需要从用户态切换到内核态,性能开销大。优点是可以跨进程同步,并且支持更多高级功能。
这个区别有多重要?我给你一个直观的数字:在我的i7-12700H上,执行一次空的lock/unlock大约需要10纳秒,而执行一次Mutex的WaitOne/ReleaseMutex大约需要1000纳秒,性能差了100倍。
这就是为什么我那个朋友的系统改了采集频率后直接崩了。他们用错了锁,把本该用用户模式锁的地方用了内核模式锁,性能直接差了两个数量级。
二、lock:90%场景下的首选
lock是C#中最常用的同步机制,也是最简单的。很多人不知道的是,lock其实是Monitor的语法糖,编译器会自动帮我们生成try-finally块,确保锁一定会被释放。
2.1 基本用法
privatereadonlyobject_lockObj=newobject();privateDictionary<string,double>_dataDict=newDictionary<string,double>();publicvoidUpdateData(stringkey,doublevalue){// 最标准的lock用法lock(_lockObj){_dataDict[key]=value;}}编译器会把上面的代码翻译成这样:
publicvoidUpdateData(stringkey,doublevalue){boollockTaken=false;try{Monitor.Enter(_lockObj,reflockTaken);_dataDict[key]=value;}finally{if(lockTaken){Monitor.Exit(_lockObj);}}}2.2 常见坑点
坑1:锁字符串
// 绝对不要这么写!lock("DataLock"){// ...}字符串在CLR中是被拘留的,相同内容的字符串会指向同一个对象。这意味着如果有其他地方也lock了同一个字符串,就会产生意外的锁竞争,甚至导致死锁。
坑2:锁值类型
// 绝对不要这么写!privateint_lockInt=0;lock(_lockInt){// ...}值类型在传递时会被装箱,每次lock都会创建一个新的对象,相当于根本没有加锁。
坑3:锁this
// 不推荐这么写lock(this){// ...}锁当前实例会导致外部代码也可以锁定这个实例,增加了死锁的风险。最佳实践是使用一个私有的、只读的object作为锁对象。
2.3 适用场景
- 同一个进程内的线程同步
- 锁持有时间短(通常在毫秒级以内)
- 并发量不高的场景
一句话总结:90%的情况下,你都应该优先使用lock。
三、Monitor:比lock更灵活的选择
刚才说了,lock是Monitor的语法糖。那什么时候需要直接使用Monitor呢?当你需要更灵活的控制时,比如超时机制、等待/通知机制。
3.1 超时机制:避免死锁的利器
Monitor最有用的功能之一就是TryEnter方法,它可以设置一个超时时间,如果在指定时间内无法获取锁,就返回false。这是避免死锁的一个非常有效的手段。
publicboolTryUpdateData(stringkey,doublevalue,inttimeoutMs=100){boollockTaken=false;try{// 尝试获取锁,最多等待100msMonitor.TryEnter(_lockObj,timeoutMs,reflockTaken);if(lockTaken){_dataDict[key]=value;returntrue;}else{// 获取锁超时,记录日志Log.Warn($"获取数据锁超时,key:{key}");returnfalse;}}finally{if(lockTaken){Monitor.Exit(_lockObj);}}}在上一篇讲死锁的文章中,我提到过使用超时机制来破坏死锁的"请求与保持"条件。在工业上位机开发中,这是一个非常实用的技巧,因为我们绝对不能允许系统因为死锁而完全卡死。
3.2 Wait/Pulse:线程间的协作机制
Monitor还提供了Wait和Pulse方法,用于实现线程间的协作。这在生产者-消费者模式中非常有用。
一个简单的生产者-消费者实现:
privatereadonlyQueue<Data>_queue=newQueue<Data>();privatereadonlyobject_queueLock=newobject();// 生产者线程publicvoidProduce(Datadata){lock(_queueLock){_queue.Enqueue(data);// 通知等待的消费者线程有新数据了Monitor.Pulse(_queueLock);}}// 消费者线程publicvoidConsume(){while(true){Datadata;lock(_queueLock){// 如果队列为空,等待数据while(_queue.Count==0){Monitor.Wait(_queueLock);}data=_queue.Dequeue();}// 处理数据ProcessData(data);}}3.3 适用场景
- 需要超时机制的场景
- 需要线程间协作(生产者-消费者)的场景
- 对性能要求较高,同时需要一定灵活性的场景
四、Mutex:只有跨进程同步时才用它
Mutex是内核模式锁,它的最大特点是可以跨进程同步。但也正因为如此,它的性能开销非常大,而且使用不当很容易出问题。
4.1 基本用法
// 创建一个命名的Mutex,可以跨进程使用using(Mutexmutex=newMutex(false,"Global\\MyAppDataMutex")){try{// 等待获取Mutexif(mutex.WaitOne(1000)){// 访问共享资源UpdateSharedData();}else{Log.Error("获取跨进程Mutex超时");}}finally{// 释放Mutexmutex.ReleaseMutex();}}注意:命名Mutex前面加上"Global\"前缀,可以在终端服务会话之间共享。如果不加,只能在同一个会话内共享。
4.2 常见坑点
坑1:忘记释放Mutex
Mutex是内核对象,如果一个线程获取了Mutex但没有释放,那么这个Mutex会被标记为"遗弃"。当其他线程等待这个被遗弃的Mutex时,会抛出AbandonedMutexException异常。
这在工业上位机开发中是一个非常严重的问题。如果你的程序崩溃了,没有释放Mutex,那么其他进程将永远无法获取这个Mutex,直到系统重启。
坑2:在UI线程中等待Mutex
Mutex的WaitOne方法是阻塞的,而且由于是内核模式锁,等待时间可能会很长。如果在UI线程中调用WaitOne,会导致界面完全卡死。
坑3:滥用Mutex
我见过太多人,不管什么场景都用Mutex,理由是"它最安全"。但实际上,在不需要跨进程同步的场景下使用Mutex,纯粹是给自己找麻烦,不仅性能差,还容易出各种奇怪的问题。
4.3 适用场景
只有当你需要跨进程同步时,才应该使用Mutex。除此之外的所有场景,都应该优先使用lock或Monitor。
五、三者详细对比与选择指南
我做了一个详细的对比表,把这三种同步机制的各个维度都列出来了,方便大家参考。
| 特性 | lock | Monitor | Mutex |
|---|---|---|---|
| 锁类型 | 用户模式 | 用户模式 | 内核模式 |
| 跨进程 | ❌ | ❌ | ✅ |
| 超时机制 | ❌ | ✅ | ✅ |
| 等待/通知 | ❌ | ✅ | ❌ |
| 性能 | 极高 | 极高 | 低(差100倍) |
| 代码复杂度 | 极低 | 中等 | 高 |
| 异常安全 | ✅(自动释放) | ✅(需手动finally) | ❌(遗弃异常) |
| 递归获取 | ✅ | ✅ | ✅ |
5.1 决策流程图
为了让大家能快速做出选择,我画了一个决策流程图,按照这个流程走,基本不会选错。
5.2 工业上位机开发中的特殊考虑
在工业上位机开发中,我们有一些特殊的需求,选择同步机制时需要特别注意:
- 实时性要求高:数据采集和控制指令不能有太大的延迟,所以优先使用性能高的用户模式锁。
- 7x24小时运行:系统不能崩溃,也不能死锁,所以要使用超时机制来避免死锁。
- UI响应性:绝对不能在UI线程中执行长时间的阻塞操作,包括等待锁。
- 异常处理:必须妥善处理各种异常情况,确保锁一定会被释放。
基于这些考虑,我在工业上位机开发中的选择原则是:
- 95%的场景使用lock
- 4%的场景使用Monitor(主要是需要超时机制的地方)
- 1%的场景使用Mutex(只有跨进程同步时)
六、最佳实践:写出健壮的多线程代码
最后,分享一些我多年来总结的多线程同步最佳实践,这些都是踩了无数坑换来的经验。
- 最小化锁的持有时间:只在必要的代码块中加锁,不要在锁中执行耗时操作,比如IO、网络请求、复杂计算等。
// 不好的写法:在锁中执行耗时操作lock(_lockObj){vardata=ReadFromPLC();// 耗时操作_dataDict[key]=data;}// 好的写法:先执行耗时操作,再加锁更新数据vardata=ReadFromPLC();// 耗时操作在锁外执行lock(_lockObj){_dataDict[key]=data;}避免嵌套锁:这是预防死锁最有效的方法。如果必须使用嵌套锁,一定要严格遵守锁的获取顺序。
使用超时机制:在关键路径上使用Monitor.TryEnter或Mutex.WaitOne,并设置合理的超时时间,避免系统完全卡死。
不要在锁中调用外部代码:外部代码可能会获取其他锁,导致死锁。
使用线程安全的集合:在.NET 4.0及以上版本中,优先使用
System.Collections.Concurrent命名空间下的线程安全集合,比如ConcurrentDictionary、ConcurrentQueue等。它们内部已经实现了高效的细粒度锁,比自己用lock包裹普通集合性能好很多。优先使用异步编程:使用async/await代替阻塞等待,避免线程被浪费。
七、总结
多线程同步没有银弹,没有哪种同步机制是万能的。lock简单高效,适合大多数场景;Monitor灵活强大,适合需要超时和线程协作的场景;Mutex功能强大但性能差,只有跨进程同步时才用。
很多人觉得多线程难,其实难的不是语法,而是思维方式。你需要时刻考虑多个线程同时执行的情况,考虑各种边界条件和异常情况。但只要你掌握了基本原理,遵循最佳实践,就能写出健壮、高效的多线程代码。
最后再强调一遍:不要为了"安全"而滥用Mutex,90%的情况下,lock就足够了。