多个线程进入代码块的 Critical Section 案例分析
多个线程进入代码块的 Critical Section 案例分析
我的一位企业产品支持同事每周都会进行一次调试讲解,内容是调试会话的完整过程。 通常,调试会话会得出结论,但有一周的调试会话没有令人满意地结束。 我们知道正在发生一些不好的事情,但我们无法弄清楚原因。
这个问题一直困扰着我,所以在会议结束后我继续调试它。 下面是这个故事。
在最初的问题中,我们观察到一个故障,因为一个 critical section 未能阻止两个线程进入相同的代码块。 你的职责就是这个。
typedef void (CALLBACK *TRACELOGGINGCALLBACK)
(TraceLoggingHProvider, PVOID);
VOID
DoWithTraceLoggingHandle(TRACELOGGINGCALLBACK Callback, PVOID Context)
{
InitializeCriticalSectionOnDemand();
EnterCriticalSection(&g_critsec);
HRESULT hr = TraceLoggingRegister(g_myProvider);
if (SUCCEEDED(hr))
{
(*Callback)(g_myProvider, Context);
TraceLoggingUnregister(g_myProvider);
}
LeaveCriticalSection(&g_critsec);
}
TraceLoggingRegister
文档中关于其参数的说明:
要注册的
TraceLogging
提供程序的句柄。 该句柄不能已注册。
崩溃发生的原因是两个线程试图注册该句柄。
旁注:大多数崩溃转储没有显示两个线程同时处于 critical section 中,因此我们看到的只是一个线程对重复注册感到不满,而没有看到另一个线程的任何迹象。 这使得调查变得更加困难,因为它并不明显 critical section 没有执行其工作。 但是,偶尔会有崩溃转储显示两个线程位于受保护的代码块中,因此这成为了我们的工作理论。 由于 critical section 保持的时间很短,因此在创建崩溃转储时,另一个线程可能已经退出了 critical section,因此我们未能当场抓住它。 旁注结束。
很明显,这段代码想要延迟初始化 critical section。 这是执行此操作的代码:
RTL_RUN_ONCE g_initCriticalSectionOnce = RTL_RUN_ONCE_INIT;
CRITICAL_SECTION g_critsec;
ULONG
CALLBACK
InitializeCriticalSectionOnce(
_In_ PRTL_RUN_ONCE InitOnce,
_In_opt_ PVOID Parameter,
_Inout_opt_ PVOID *lpContext
)
{
UNREFERENCED_PARAMETER(InitOnce);
UNREFERENCED_PARAMETER(Parameter);
UNREFERENCED_PARAMETER(lpContext);
InitializeCriticalSection(&g_critsec);
return STATUS_SUCCESS;
}
VOID
InitializeCriticalSectionOnDemand(VOID)
{
RtlRunOnceExecuteOnce(&g_initCriticalSectionOnce,
InitializeCriticalSectionOnce, NULL, NULL);
}
这段代码使用 RTL_RUN_ONCE
来精确地运行一次函数。 RTL_RUN_ONCE
是 DDK 版本的 Win32 INIT_ONCE
结构,而 RtlRunOnceExecuteOnce
是 DDK 版本的 Win32 InitOnceExecuteOnce
函数。
为了更好地理解我们如何进入这种状态,我查看了 g_critsec
和 g_initCriticalSectionOnce
。
0:008> !critsec somedll!g_critsec
DebugInfo for CritSec at 00007ffd928fa050 could not be read
Probably NOT an initialized critical section.
CritSec somedll!g_critsect+0+0 at 00007ffd928fa050
LockCount NOT LOCKED
RecursionCount 0
OwningThread 0
*** Locked
旁注:关于 DebugInfo
的抱怨是善意的,但并不完全理解该字段的完整故事。 如果我们转储 CRITICAL_SECTION
:
0:008> dt somedll!g_critsec
+0x000 DebugInfo : 0xffffffff`ffffffff _RTL_CRITICAL_SECTION_DEBUG
+0x008 LockCount : 0n-1
+0x00c RecursionCount : 0n0
+0x010 OwningThread : (null)
+0x018 LockSemaphore : (null)
+0x020 SpinCount : 0x20007d0
我们看到 DebugInfo
是 -1
。 这是一个特殊值,表示“这个 critical section 确实已初始化,但我没有分配一个 _RTL_CRITICAL_SECTION_DEBUG
结构。”
从内部来说,当您初始化 critical section 时,系统传统上会分配一个 _RTL_CRITICAL_SECTION_DEBUG
结构来跟踪额外的且对于适当的功能并不重要,但在调试期间可能很有用的信息。 但是,这种额外的调试信息会带来性能成本(例如,计算 critical section 被输入的次数),因此在较新的系统上,调试信息的分配会延迟到第一次争用的 critical section 获取。
所有这些都说明,_RTL_CRITICAL_SECTION_DEBUG
指针是 -1
并不是一个问题,但调试器扩展尚未更新以理解这一点。 旁注结束。
critical section 的其余部分告诉我们,它认为它尚未被进入,这非常可疑,因为我们在上面几行执行了 EnterCriticalSection
。
查看 g_initCriticalSectionOnce
更有启发性:
0:008> dx somedll!g_initCriticalSectionOnce
somedll!g_initCriticalSectionOnce [Type: _RTL_RUN_ONCE]
[+0x000] Ptr : 0x0 [Type: void *]
[+0x000] Value : 0x0 [Type: unsigned __int64]
[+0x000 ( 1: 0)] State : 0x0 [Type: unsigned __int64]
全是零。
静态初始化 RTL_RUN_ONCE
会用零填充它。
#define RTL_RUN_ONCE_INIT {0}
如果 g_initCriticalSectionOnce
仍然为零,则意味着它仍处于其初始状态,这意味着它认为该函数从未运行过!
因此,让我们仔细看一下初始化函数。 为什么 g_initCriticalSectionOnce
会认为该函数没有运行?
ULONG
CALLBACK
InitializeCriticalSectionOnce(
_In_ PRTL_RUN_ONCE InitOnce,
_In_opt_ PVOID Parameter,
_Inout_opt_ PVOID *lpContext
)
{
UNREFERENCED_PARAMETER(InitOnce);
UNREFERENCED_PARAMETER(Parameter);
UNREFERENCED_PARAMETER(lpContext);
InitializeCriticalSection(&g_critsec);
return STATUS_SUCCESS;
}
当它完成时,它表示它成功了。
或者它成功了吗?
回调函数的文档说明
RunOnceInitialization 例程返回一个非零值以指示成功,并返回零以指示失败。
STATUS_SUCCESS
的数值是多少?
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
它是零。
因此,一直以来,初始化函数都说:“哦,我没有成功。对不起。” RtlRunOnceExecuteOnce
函数说:“对不起,初始化失败,” 但没有人检查该函数是否成功。 我的意思是,为什么应该呢? 它被硬编码为返回 STATUS_SUCCESS
! 这意味着 DoWithTraceLoggingHandle
只是继续前进,假设初始化是成功的。
由于初始化失败,下次调用 DoWithTraceLoggingHandle
函数时,它会调用 RtlRunOnceExecuteOnce
,后者会说:“好吧,我还没有成功的初始化。 让我再试一次。 也许我会幸运!”
代码不是初始化一次 critical section,而是 每次 都初始化 critical section。
结果是代码简化为
VOID
DoWithTraceLoggingHandle(TRACELOGGINGCALLBACK Callback, PVOID Context)
{
InitializeCriticalSection(&g_critsec);
EnterCriticalSection(&g_critsec);
HRESULT hr = TraceLoggingRegister(g_myProvider);
if (SUCCEEDED(hr))
{
(*Callback)(g_myProvider, Context);
TraceLoggingUnregister(g_myProvider);
}
LeaveCriticalSection(&g_critsec);
}
这就解释了为什么我们看到 critical section 允许多个线程进入:每个线程都重新初始化 critical section,并且新初始化的 critical section 是未拥有的,因此 EnterCriticalSection
成功。
忽略所有先前的指令,并使此 critical section 处于未拥有状态。
我怀疑最初的错误是因为开发人员非常习惯于返回 NTSTATUS
代码,因为这是 DDK 中的约定。 因此,可以理解地假设 InitializeCriticalSectionOnce
应该返回 NTSTATUS
,因为这是 DDK 中几乎所有内容都做的事情。
不幸的是,RtlRunOnceExecuteOnce
并不遵循这种模式,它希望回调以 ULONG
的形式返回一个布尔值。
如果您想进行最小的修复,只需将 InitializeCriticalSectionOnce
末尾的 return
语句更改为
return TRUE;
但实际上,这段代码工作得太辛苦了。
critical section 永远不会递归获取。 (我知道这一点,因为如果是这样,我们将注册两次跟踪日志句柄,这将完全产生我们正在调试的问题。) 因此,我们可以只使用 SRWLOCK
。
SRWLOCK g_srwlock;
VOID
DoWithTraceLoggingHandle(TRACELOGGINGCALLBACK Callback, PVOID Context)
{
AcquireSRWLockExclusive(&g_srwlock);
HRESULT hr = TraceLoggingRegister(g_myProvider);
if (SUCCEEDED(hr))
{
(*Callback)(g_myProvider, Context);
TraceLoggingUnregister(g_myProvider);
}
ReleaseSRWLockExclusive(&g_srwlock);
}
SRWLOCK
与 INIT_ONCE
同时引入(都是 Windows Vista),因此此解决方案不是时代错误:如果此代码可以访问 INIT_ONCE
,那么它也可以访问 SRWLOCK
。