A Case of a Deadlock in a .NET Application






4.63/5 (9投票s)
一个 .NET 应用程序中的死锁案例
我最近在我们一个应用程序中遇到了一个有趣的问题。负责发送和接收 SMS 的 SMS 路由器挂起(hang)了——没有 CPU 使用率,我们也未观察到应用程序日志中有任何活动。我收集了一个完整的内存转储,然后重启了该服务,服务似乎恢复了正常。出于好奇,我在 WinDbg
中打开了转储文件,加载了 PDE 和 SOS,并开始进行调查。
在分析挂起的应用程序时,我通常会先列出所有进程线程及其调用堆栈:~*e!CLRStack
。如果您在堆栈中看不到任何 Monitor.Wait
,您可能需要检查本机堆栈(使用 !DumpStack
或 k
命令)。在我们的案例中,堆栈似乎表明是托管代码中的挂起。
...
OS Thread Id: 0xdb4 (5)
Child SP IP Call Site
04f9ed60 778aca2c [GCFrame: 04f9ed60]
04f9ef00 778aca2c [GCFrame: 04f9ef00]
04f9eeb0 778aca2c [HelperMethodFrame: 04f9eeb0] System.Threading.Monitor.ReliableEnter(System.Object, Boolean ByRef)
04f9ef40 73a2d887 System.Threading.Monitor.Enter(System.Object, Boolean ByRef)
04f9ef50 73483b89 System.Diagnostics.TraceSource.TraceEvent(System.Diagnostics.TraceEventType, Int32, System.String)
...
OS Thread Id: 0x1220 (6)
Child SP IP Call Site
050ded18 778aca2c [GCFrame: 050ded18]
050deebc 778aca2c [GCFrame: 050deebc]
050dee6c 778aca2c [HelperMethodFrame: 050dee6c] System.Threading.Monitor.ReliableEnter(System.Object, Boolean ByRef)
050deefc 72e3b9fa System.Diagnostics.DiagnosticsConfiguration.Initialize()
050def2c 72e3c06c System.Diagnostics.DiagnosticsConfiguration.get_SwitchSettings()
050def34 72e3c01a System.Diagnostics.Switch.InitializeConfigSettings()
050def40 72e3be34 System.Diagnostics.Switch.InitializeWithStatus()
050def84 72e3bd99 System.Diagnostics.Switch.get_SwitchSetting()
050def90 72e38594 System.Diagnostics.SourceSwitch.ShouldTrace(System.Diagnostics.TraceEventType)
050def98 72e43e64 System.Diagnostics.TraceSource.TraceEvent(System.Diagnostics.TraceEventType, Int32, System.String, System.Object[])
050deff0 049cf16f MySql.Data.MySqlClient.MySqlTrace.LogWarning(Int32, System.String)
050df008 049ce938 MySql.Data.MySqlClient.MySqlConnection.HandleTimeoutOrThreadAbort(System.Exception)
050df100 03a8d7c7 MySql.Data.MySqlClient.MySqlCommand.ExecuteReader(System.Data.CommandBehavior)
050df218 0479219f MySql.Data.MySqlClient.MySqlCommand.ExecuteNonQuery()
050df248 0556a7cf Dapper.SqlMapper.ExecuteCommand(System.Data.IDbConnection, Dapper.CommandDefinition ByRef, System.Action`2)
050df280 0556777e Dapper.SqlMapper.ExecuteImpl(System.Data.IDbConnection, Dapper.CommandDefinition ByRef)
050df2e4 05567460 Dapper.SqlMapper.Execute(System.Data.IDbConnection, System.String, System.Object, System.Data.IDbTransaction, System.Nullable`1, System.Nullable`1)
...
OS Thread Id: 0xdf0 (7)
Child SP IP Call Site
0521ea44 778aca2c [GCFrame: 0521ea44]
0521eb1c 778aca2c [GCFrame: 0521eb1c]
0521eb38 778aca2c [HelperMethodFrame_1OBJ: 0521eb38] System.Threading.Monitor.ReliableEnter(System.Object, Boolean ByRef)
0521ebb4 72e3bdf9 System.Diagnostics.Switch.InitializeWithStatus()
0521ebf8 72e3bd99 System.Diagnostics.Switch.get_SwitchSetting()
0521ec04 72e38594 System.Diagnostics.SourceSwitch.ShouldTrace(System.Diagnostics.TraceEventType)
0521ec0c 72e43e64 System.Diagnostics.TraceSource.TraceEvent(System.Diagnostics.TraceEventType, Int32, System.String, System.Object[])
0521ec64 049cf16f MySql.Data.MySqlClient.MySqlTrace.LogWarning(Int32, System.String)
0521ec7c 049ce938 MySql.Data.MySqlClient.MySqlConnection.HandleTimeoutOrThreadAbort(System.Exception)
0521ed74 03a8d7c7 MySql.Data.MySqlClient.MySqlCommand.ExecuteReader(System.Data.CommandBehavior)
0521ee8c 0479219f MySql.Data.MySqlClient.MySqlCommand.ExecuteNonQuery()
0521eebc 03a842dd NLog.Targets.DatabaseTarget.WriteEventToDatabase(NLog.LogEventInfo)
0521eef0 03a83fd8 NLog.Targets.DatabaseTarget.Write(NLog.LogEventInfo)
0521ef1c 03a83def NLog.Targets.Target.Write(NLog.Common.AsyncLogEventInfo)
0521ef44 03a83cb6 NLog.Targets.Target.WriteAsyncLogEvent(NLog.Common.AsyncLogEventInfo)
0521ef7c 03a83ad1 NLog.LoggerImpl.WriteToTargetWithFilterChain(NLog.Internal.TargetWithFilterChain, NLog.LogEventInfo, NLog.Common.AsyncContinuation)
0521ef94 03a839f6 NLog.LoggerImpl.Write(System.Type, NLog.Internal.TargetWithFilterChain, NLog.LogEventInfo, NLog.LogFactory)
0521efbc 04607c2f NLog.Logger.WriteToTargets(NLog.LogEventInfo)
0521efd0 04601801 NLog.Logger.Log(NLog.LogEventInfo)
0521efe0 046015d1 NLog.NLogTraceListener.ProcessLogEventInfo(NLog.LogLevel, System.String, System.String, System.Object[], System.Nullable`1, System.Nullable`1, System.Nullable`1)
0521f044 04600efc NLog.NLogTraceListener.TraceEvent(System.Diagnostics.TraceEventCache, System.String, System.Diagnostics.TraceEventType, Int32, System.String, System.Object[])
0521f08c 73483d61 System.Diagnostics.TraceSource.TraceEvent(System.Diagnostics.TraceEventType, Int32, System.String, System.Object[])
...
OS Thread Id: 0xb08 (8)
Child SP IP Call Site
0538efd4 778aca2c [GCFrame: 0538efd4]
0538f084 778aca2c [HelperMethodFrame_1OBJ: 0538f084] System.Threading.Monitor.ObjWait(Boolean, Int32, System.Object)
0538f108 73aa5d16 System.Threading.Monitor.Wait(System.Object, Int32, Boolean)
0538f118 04de1d85 Quartz.Simpl.SimpleThreadPool+WorkerThread.Run()
0538f158 73a1ab43 System.Threading.ThreadHelper.ThreadStart_Context(System.Object)
...
在您的转储文件中,您可能会看到更多等待的线程,对于大多数应用程序来说,这是正常的。所以在我们得出任何结论之前,我们需要再进行两个步骤:分析这些等待线程已获取的锁,并找出导致死锁的干扰锁。
查找锁
要列出我们应用程序中的所有 Monitor 锁,我们可以使用 SOS 扩展中的 !SyncBlk
命令。
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
306 0101284c 3 1 04cfe690 df0 7 017489b8 System.Object
326 01012540 3 1 04cfe158 1220 6 0187258c System.Object
329 010123a0 5 1 04cfe690 df0 7 0176d584 System.Object
339 04cf3784 1 1 04cfdc20 db4 5 017b1bec System.Object
340 04cf37b8 1 1 04cfe690 df0 7 017b1e44 System.Object
342 04cf3820 1 1 04cfe158 1220 6 017b1d38 System.Object
-----------------------------
Total 344
CCW 0
RCW 1
ComClassFactory 0
Free 320
然后,我们将上述命令的结果与线程堆栈上引用的对象(使用 !dso
命令)进行匹配。我们将从线程 6 和 7 开始,因为它们似乎在锁方面最为活跃。
我用颜色标记了线程正在等待的对象,很明显我们这里有一个经典的死锁。尽管我们还没有回答这个问题:是什么操作导致了这种情况?
时间线分析
我们发现第 7 个线程正在等待在地址 0x0187258c 的对象上获取的锁(其堆栈中最顶层的锁)。我们也知道,在过去某个时候,线程 6 成功获取了该锁并且仍然持有它。通过检查负责这些等待的 C# 代码,我们可能会获得更好的理解。对于第 7 个线程来说,这是直接的——在反编译了 System.Diagnostics.Switch.InitializeWithStatus
(即,在 System.Threading.Monitor.ReliableEnter
之后,最顶层的堆栈帧指向的方法)之后,我们得到:
namespace System.Diagnostics {
...
public abstract class Switch {
...
private object m_intializedLock;
...
private object IntializedLock {
[SuppressMessage("Microsoft.Concurrency", "CA8001", Justification = "Reviewed for thread-safety")]
get {
if (m_intializedLock == null) {
Object o = new Object();
Interlocked.CompareExchange<Object>(ref m_intializedLock, o, null);
}
return m_intializedLock;
}
}
...
private bool InitializeWithStatus() {
if (!initialized) {
lock (IntializedLock) {
if (initialized || initializing) {
return false;
}
....
所以我们在这里等待一个 private SourceSwitch
锁。我们知道 SourceSwitch
的地址(来自 !dso
命令的输出),所以我们可以转储它。
0:007> !DumpObj /d 01778978
Name: System.Diagnostics.SourceSwitch
MethodTable: 72ebe134
EEClass: 72c9d164
Size: 44(0x2c) bytes
File: C:\Windows\Microsoft.Net\assembly\GAC_MSIL\System\v4.0_4.0.0.0__b77a5c561934e089\System.dll
Fields:
MT Field Offset Type VT Attr Value Name
72ebf520 4000547 4 ...lementsCollection 0 instance 00000000 switchSettings
73b23e18 4000548 8 System.String 0 instance 01721228 description
73b23e18 4000549 c System.String 0 instance 017788ec displayName
...
73b241b8 4000550 1c System.Object 0 instance 0187258c m_intializedLock
...
0:007> !DumpObj /d 017788ec
Name: System.String
MethodTable: 73b23e18
EEClass: 737238f0
Size: 24(0x18) bytes
File: C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String: mysql
...
很明显,第 7 个线程正在尝试记录与 mysql 相关的内容。通过检查其调用堆栈,我们可以猜测 MySql 驱动程序试图记录连接超时异常的警告。我们需要找出线程 6 是何时获得给定锁的。我们知道被锁定的对象在线程 6 的堆栈中地址 0x050DEF4C 被引用,这与对 System.Diagnostics.Switch.InitializeWithStatus()
函数的调用(子 SP 在 0x050def40)匹配。
050def34 72e3c01a System.Diagnostics.Switch.InitializeConfigSettings()
050def40 72e3be34 System.Diagnostics.Switch.InitializeWithStatus()
050def84 72e3bd99 System.Diagnostics.Switch.get_SwitchSetting()
通过检查 SourceSwitch
实例地址,我们可以确定它是阻塞了第 7 个线程的同一个实例。此外,情况也是一样的——MySql 驱动程序试图记录连接超时。
现在是时候对线程 6 正在等待的对象进行类似的调查了。线程 6 堆栈中最顶层的方法,紧跟在 System.Threading.Monitor.ReliableEnter
之后,是 System.Diagnostics.DiagnosticsConfiguration.Initialize
,并且有一个容易识别的锁语句。
namespace System.Diagnostics {
...
internal static class DiagnosticsConfiguration {
...
internal static void Initialize() {
// Initialize() is also called by other components outside of Trace
// (such as PerformanceCounter)
// as a result using one lock for this critical section
// and another for Trace API critical sections
// (such as Trace.WriteLine) could potentially lead to deadlock between 2 threads that are
// executing these critical sections
// (and consequently obtaining the 2 locks) in the reverse order.
// Using the same lock for DiagnosticsConfiguration as well as TraceInternal avoids this issue.
// Sequential locks on TraceInternal.critSec by the same thread
// is a non issue for this critical section.
lock (TraceInternal.critSec) {
...
}
}
...
}
...
}
我们可以看到线程 6 正在等待一个全局锁,该锁用于初始化 System.Diagnostics
类中的跟踪。在第 7 个线程堆栈中,该对象在地址 0x0521F090 被引用,这指向 System.Diagnostics.TraceSource.TraceEvent
方法。
0521f044 04600efc NLog.NLogTraceListener.TraceEvent(System.Diagnostics.TraceEventCache, System.String, ...
0521f08c 73483d61 System.Diagnostics.TraceSource.TraceEvent(System.Diagnostics.TraceEventType, Int32 ...
0521f0e4 04604e45 ...SmsRouter.SmsProcessing.SmsReceiveJob.CollectSmsMessagesFromProvider(...
反编译后,我们可以轻松找到 lock
语句。
public void TraceEvent(TraceEventType eventType, int id, string format, params object[] args)
{
this.Initialize();
TraceEventCache eventCache = new TraceEventCache();
if (!this.internalSwitch.ShouldTrace(eventType) || this.listeners == null)
return;
if (TraceInternal.UseGlobalLock)
{
lock (TraceInternal.critSec)
...
总而言之,导致故障的假设场景可能如下:
- 在第 7 个线程上运行的代码尝试使用
TraceSource.TraceEvent
方法保存日志,并获取了全局跟踪日志。然后将调用转发给NLogTraceListener
,后者调用了DatabaseTarget.Write
。 - 在第 6 个线程上运行的代码在将数据保存到 MySql 数据库时失败,ADO.NET 驱动程序尝试将警告记录到
System.Diagnostics
跟踪中——为了检查 mysqlSwitchSource
值,需要初始化该开关(获取内部开关锁),这需要获得全局跟踪锁的所有权——**被第 7 个线程阻塞**。 - 在第 7 个线程上运行的代码在将日志保存到 MySql 数据库时失败,ADO.NET 驱动程序尝试将警告保存到
System.Diagnostics
跟踪中——为了检查 mysqlSwitchSource
值,需要初始化该开关——**被第 6 个线程阻塞并死锁**。
如果数据库响应正常,问题可能就不会发生,因为 MySql 驱动程序永远不会生成日志。解决此问题的一种方法是禁用跟踪侦听器的全局锁定——这可以通过应用程序配置文件来实现,例如。
<configuration>
<system.diagnostics>
<trace useGlobalLock="false" />
</system.diagnostics>
</configuration>
这是安全的,因为即使此设置关闭且跟踪侦听器不支持多线程(IsThreadSafe
属性返回 false
),全局锁仍将被使用。在我们的例子中,NLogTraceListener
支持多线程,因此不会获取锁。
我希望您从这篇帖子中找到一些有关调试 .NET 代码中线程问题的有用技巧。如果您有任何关于调试锁问题的有趣技巧,请在评论中分享。
Filed under: CodeProject, 诊断线程问题