做工业上位机开发这么多年多线程绝对是又爱又恨的东西。没有它界面卡死、数据采集慢、设备响应不及时这些问题能把你逼疯但用不好它死锁、竞态条件、内存泄漏这些坑能让你通宵达旦地排查。上周我就遇到了一个极其诡异的死锁问题花了整整两天时间才彻底解决。今天把整个排查过程和解决方案分享出来希望能帮到同样在坑里挣扎的兄弟们。一、问题爆发凌晨三点的紧急电话事情是这样的我们给某汽车零部件厂做的产线监控系统在连续运行了72小时后突然卡死了。界面完全不动数据不更新设备也无法控制。现场工程师重启软件后恢复正常但谁也不知道什么时候会再次发生。这种偶发的、无法复现的问题最让人头疼。更要命的是客户那边马上要进行量产爬坡如果系统在生产过程中再次卡死造成的损失将不可估量。我连夜赶到客户现场通过远程桌面连接到工控机打开任务管理器一看CPU占用率只有5%左右内存也很正常但软件的UI线程完全被阻塞了。这明显不是性能问题而是典型的死锁现象。二、死锁原理四个必要条件在讲排查过程之前先简单回顾一下死锁的基本原理。死锁是指两个或多个线程在执行过程中因争夺资源而造成的一种互相等待的现象若无外力作用它们都将无法推进下去。死锁的发生必须同时满足以下四个必要条件互斥条件一个资源每次只能被一个线程使用请求与保持条件一个线程因请求资源而阻塞时对已获得的资源保持不放不剥夺条件线程已获得的资源在未使用完之前不能强行剥夺循环等待条件若干线程之间形成一种头尾相接的循环等待资源关系这四个条件缺一不可只要破坏其中任何一个死锁就不会发生。下面这张图清晰地展示了死锁的形成过程持有等待持有等待被持有被持有线程A锁1锁2线程B三、排查过程抽丝剥茧找真凶3.1 初步分析代码审查首先我对系统中所有使用lock关键字的地方进行了全面审查。我们的系统主要有以下几个线程UI线程负责界面更新和用户交互数据采集线程定时从PLC读取数据报警处理线程处理设备报警信息日志写入线程将日志写入文件设备控制线程发送控制指令给PLC经过初步审查我发现数据采集线程和UI线程之间存在频繁的锁竞争。数据采集线程会将采集到的数据存入一个共享的字典而UI线程会定时从这个字典中读取数据并更新界面。3.2 工具排查使用Visual Studio调试死锁光靠代码审查很难发现隐藏的死锁问题这时候就需要借助工具了。我使用Visual Studio的调试-附加到进程功能将调试器附加到卡死的软件进程上。然后点击调试-“全部中断”此时调试器会暂停所有线程的执行。接下来打开线程窗口调试-窗口-线程可以看到所有线程的状态。我发现UI线程和数据采集线程都处于等待状态。双击UI线程查看它的调用堆栈System.Threading.Monitor.Wait(object obj, int millisecondsTimeout, bool exitContext) System.Threading.Monitor.Wait(object obj, int millisecondsTimeout) System.Windows.Threading.DispatcherSynchronizationContext.Wait(IntPtr[] waitHandles, Boolean waitAll, Int32 millisecondsTimeout) System.Threading.SynchronizationContext.WaitHelper(IntPtr[] waitHandles, Boolean waitAll, Int32 millisecondsTimeout) System.Threading.WaitHandle.WaitMultiple(WaitHandle[] waitHandles, Boolean waitAll, Int32 millisecondsTimeout, Boolean exitContext) System.Threading.WaitHandle.WaitAll(WaitHandle[] waitHandles, Int32 millisecondsTimeout, Boolean exitContext) System.Threading.WaitHandle.WaitAll(WaitHandle[] waitHandles) MyApp.MainWindow.UpdateUI() // 我们自己的代码再看数据采集线程的调用堆栈System.Threading.Monitor.Enter(object obj, ref bool lockTaken) System.Threading.Monitor.Enter(object obj) MyApp.DataCollector.CollectData() // 我们自己的代码 MyApp.DataCollector.ThreadProc()很明显UI线程在等待某个锁而数据采集线程也在等待某个锁。这就是典型的死锁现象。3.3 深入分析找到循环等待为了找到具体是哪个锁导致了死锁我在代码中加入了详细的日志记录记录每个线程获取锁和释放锁的时间和顺序。经过多次复现和分析我终于找到了问题所在。我们的代码中有两个关键的锁_dataLock保护共享数据字典_uiLock保护UI更新操作数据采集线程的执行流程是这样的privatevoidCollectData(){lock(_dataLock){// 从PLC读取数据vardataReadFromPLC();// 更新共享数据字典_dataDictionary[data.Key]data.Value;// 如果数据异常更新UI显示报警if(data.Value.IsAbnormal){lock(_uiLock){UpdateAlarmUI(data);}}}}而UI线程的执行流程是这样的privatevoidUpdateUI(){lock(_uiLock){// 从共享数据字典读取数据lock(_dataLock){foreach(vardatain_dataDictionary){// 更新界面UpdateDataDisplay(data);}}}}看到问题了吗数据采集线程先获取_dataLock然后尝试获取_uiLock而UI线程先获取_uiLock然后尝试获取_dataLock。这就形成了一个完美的循环等待UI线程_uiLock_dataLock数据采集线程UI线程_uiLock_dataLock数据采集线程死锁发生获取锁获取锁尝试获取锁等待尝试获取锁等待当数据采集线程在持有_dataLock的同时需要更新UI报警信息而尝试获取_uiLock时如果此时UI线程正好持有_uiLock并尝试获取_dataLock死锁就发生了。四、解决方案从根本上破坏死锁条件找到了问题的根源接下来就是解决问题了。根据死锁的四个必要条件我们可以通过破坏其中任何一个来解决死锁问题。下面我将介绍几种常见的解决方案并分析它们的优缺点。4.1 方案一统一锁的获取顺序这是最常用也是最有效的解决方案。如果所有线程都按照相同的顺序获取锁就不会形成循环等待条件。在我们的例子中我们可以规定所有线程都必须先获取_dataLock再获取_uiLock。修改后的UI线程代码如下privatevoidUpdateUI(){lock(_dataLock){lock(_uiLock){foreach(vardatain_dataDictionary){UpdateDataDisplay(data);}}}}优点简单有效性能影响小缺点需要严格遵守锁的获取顺序容易在代码维护过程中被破坏4.2 方案二使用超时机制我们可以使用Monitor.TryEnter方法代替lock关键字并设置一个超时时间。如果在指定时间内无法获取锁就放弃并释放已经持有的锁过一段时间再重试。修改后的数据采集线程代码如下privatevoidCollectData(){if(Monitor.TryEnter(_dataLock,1000)){try{vardataReadFromPLC();_dataDictionary[data.Key]data.Value;if(data.Value.IsAbnormal){if(Monitor.TryEnter(_uiLock,1000)){try{UpdateAlarmUI(data);}finally{Monitor.Exit(_uiLock);}}else{// 获取_uiLock超时记录日志并稍后重试Log.Warn(获取_uiLock超时报警信息将稍后更新);}}}finally{Monitor.Exit(_dataLock);}}else{// 获取_dataLock超时记录日志Log.Warn(获取_dataLock超时本次数据采集跳过);}}优点可以有效避免死锁即使锁的获取顺序被破坏缺点增加了代码复杂度超时时间的设置需要权衡4.3 方案三使用细粒度锁将大的锁拆分成多个小的锁减少锁的持有时间和竞争范围。在我们的例子中我们可以将数据字典按设备ID进行分片每个分片使用一个独立的锁。privatereadonlyobject[]_dataLocksnewobject[10];privateDictionarystring,Data[]_dataDictionariesnewDictionarystring,Data[10];// 初始化for(inti0;i10;i){_dataLocks[i]newobject();_dataDictionaries[i]newDictionarystring,Data();}privatevoidCollectData(){vardataReadFromPLC();intindexMath.Abs(data.Key.GetHashCode())%10;lock(_dataLocks[index]){_dataDictionaries[index][data.Key]data.Value;if(data.Value.IsAbnormal){// 使用Dispatcher.BeginInvoke异步更新UI避免持有锁的同时等待UI锁Application.Current.Dispatcher.BeginInvoke(newAction((){UpdateAlarmUI(data);}));}}}优点减少了锁的竞争提高了并发性能缺点增加了代码复杂度需要合理设计分片策略4.4 方案四使用异步编程这是我最终采用的解决方案。使用C#的async/await特性将同步的UI更新操作改为异步操作避免在持有锁的同时等待UI线程。修改后的数据采集线程代码如下privateasyncvoidCollectData(){lock(_dataLock){vardataReadFromPLC();_dataDictionary[data.Key]data.Value;if(data.Value.IsAbnormal){// 使用Dispatcher.BeginInvoke异步更新UI// 不要在这里等待直接释放锁_Application.Current.Dispatcher.BeginInvoke(newAction((){UpdateAlarmUI(data);}));}}}同时UI线程的代码也可以改为异步privateasyncvoidUpdateUI(){// 先复制一份数据避免在UI更新过程中持有锁Dictionarystring,DatadataCopy;lock(_dataLock){dataCopynewDictionarystring,Data(_dataDictionary);}// 在没有锁的情况下更新UIforeach(vardataindataCopy){UpdateDataDisplay(data);}}优点从根本上避免了在持有锁的同时等待其他锁彻底破坏了循环等待条件缺点需要对代码进行较大的修改需要理解异步编程的原理五、最佳实践如何预防死锁死锁问题最好的解决方法是预防。以下是我总结的一些预防死锁的最佳实践尽量避免嵌套锁这是预防死锁最有效的方法。如果必须使用嵌套锁一定要严格遵守锁的获取顺序。减少锁的持有时间只在必要的代码块中使用锁不要在锁中执行耗时操作如IO操作、网络请求等。使用细粒度锁将大的锁拆分成多个小的锁减少锁的竞争范围。使用超时机制使用Monitor.TryEnter或Mutex.WaitOne等方法并设置合理的超时时间。使用异步编程使用async/await特性避免在持有锁的同时等待其他操作完成。使用线程安全的集合在.NET Framework 4.0及以上版本中可以使用System.Collections.Concurrent命名空间下的线程安全集合如ConcurrentDictionary、ConcurrentQueue等它们内部已经实现了高效的线程安全机制。定期进行代码审查重点审查多线程和锁的使用情况及时发现潜在的死锁问题。六、总结这次死锁问题的排查过程让我深刻体会到多线程编程确实是一把双刃剑。它能给我们带来性能上的提升但也带来了很多潜在的问题。在解决死锁问题时我们首先要理解死锁的四个必要条件然后通过破坏其中任何一个条件来解决问题。同时我们更应该注重预防在代码编写阶段就遵循最佳实践从根本上避免死锁问题的发生。最后希望这篇文章能给正在被多线程问题困扰的兄弟们带来一些帮助。如果你们有更好的解决方案或者其他踩坑经历欢迎一起交流讨论。