聊一聊 C# 前臺線程如何阻塞程序退出
一、背景
1. 講故事
這篇文章起源于我的 C#內功修煉訓練營里的一位朋友提的問題:后臺線程的內部是如何運轉的 ? ,猶記得C# Via CLR這本書中 Jeffery 就聊到了他曾經給別人解決一個程序無法退出的bug,最后發(fā)現(xiàn)是有一個 Backgrond=false 的線程導致的。恰巧在我分析的350+dump中,也還真遇到了。有了這些鋪墊,我覺得有必要簡單的聊一聊。
二、后臺線程的底層邏輯
1. 測試代碼
為了方便講解,先上一段代碼,參考如下:
static void Main(string[] args)
{
var thread = new Thread(() =>
{
while (true)
{
Console.WriteLine(DateTime.Now);
}
});
thread.IsBackground = false;
thread.Start();
}
圖片
按照我們樸素的想法,主線程退出,程序自然就terminal,但這個程序并沒有退出?原因就在于設置了 thread.IsBackground = false; 導致的,當然要想程序正常退出改為 ``thread.IsBackground = true;` 即可,接下來我們洞察下 IsBackground 有何魔力導致程序無法退出。
2. 程序為什么無法退出
要想知道這個答案,可以用 windbg 附加一下看看主線程此時正在做什么? 參考如下:
0:000> k
# Child-SP RetAddr Call Site
00 0000003f`7d59e498 00007ffd`cd8d0590 ntdll!NtWaitForMultipleObjects+0x14
01 0000003f`7d59e4a0 00007ffd`8f842dd4 KERNELBASE!WaitForMultipleObjectsEx+0xf0
02 (Inline Function) --------`-------- coreclr!Thread::DoAppropriateAptStateWait+0x4a [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 3333]
03 0000003f`7d59e790 00007ffd`8f842c25 coreclr!Thread::DoAppropriateWaitWorker+0x170 [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 3467]
04 0000003f`7d59e850 00007ffd`8f99498e coreclr!Thread::DoAppropriateWait+0x85 [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 3182]
05 (Inline Function) --------`-------- coreclr!CLREventBase::WaitEx+0x26 [D:\a\_work\1\s\src\coreclr\vm\synch.cpp @ 459]
06 (Inline Function) --------`-------- coreclr!CLREventBase::Wait+0x26 [D:\a\_work\1\s\src\coreclr\vm\synch.cpp @ 412]
07 0000003f`7d59e8d0 00007ffd`8f94c185 coreclr!CLREventWaitWithTry+0x9a [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 5676]
08 0000003f`7d59e980 00007ffd`8f8a062b coreclr!ThreadStore::WaitForOtherThreads+0xabafd [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 5715]
09 0000003f`7d59e9b0 00007ffd`8f83eaad coreclr!RunMainPost+0x5f [D:\a\_work\1\s\src\coreclr\vm\assembly.cpp @ 1407]
0a 0000003f`7d59e9f0 00007ffd`8f83e0e7 coreclr!Assembly::ExecuteMainMethod+0x1f5 [D:\a\_work\1\s\src\coreclr\vm\assembly.cpp @ 1524]
0b 0000003f`7d59ecc0 00007ffd`8f889778 coreclr!CorHost2::ExecuteAssembly+0x267 [D:\a\_work\1\s\src\coreclr\vm\corhost.cpp @ 349]
...
從卦中數(shù)據(jù)可以看到,主線程正在調用 ThreadStore::WaitForOtherThreads 方法,貌似是在等待其他線程完成,那具體做了什么呢?這個需要在 coreclr 上尋找答案,刪減后的代碼如下:
void ThreadStore::WaitForOtherThreads()
{
if (!OtherThreadsComplete())
{
TSLockHolder.Release();
pCurThread->SetThreadState(Thread::TS_ReportDead);
DWORD ret = WAIT_OBJECT_0;
while (CLREventWaitWithTry(&m_TerminationEvent, INFINITE, TRUE, &ret))
{
}
}
}
BOOL OtherThreadsComplete()
{
return (m_ThreadCount - m_UnstartedThreadCount - m_DeadThreadCount
- Thread::m_ActiveDetachCount + m_PendingThreadCount
== m_BackgroundThreadCount);
}
從卦中看邏輯還是非常簡單的,就是因為 m_ThreadCount - m_UnstartedThreadCount - m_DeadThreadCount- Thread::m_ActiveDetachCount + m_PendingThreadCount 減完之后和 m_BackgroundThreadCount 對不上,最后在 m_TerminationEvent 事件上等待喚醒。
這里稍微提一下,這幾個值可以通過 !t 顯示出來,參考如下:
圖片
還有一個 Thread::m_ActiveDetachCount 計數(shù)值,這個值統(tǒng)計的是那種被coreclr從 ThreadStore 中移除尚未被 delete 的線程對象。結合 !t 的輸出,很顯然 OtherThreadsComplete() 為 3=2 顯然返回 false。因為有 1 個 background 的存在。
3. IsBackground=true 能破局嗎
癥結我們也找到了,只要m_TerminationEvent事件能夠被喚醒,鏈路就會被再次打通,讓程序安全退出。接下來我們研究下 IsBackground=true 在底層會做什么?簡化后的C++代碼如下:
void Thread::SetBackground(BOOL isBack)
{
if (isBack)
{
if (!IsBackground())
{
SetThreadState(TS_Background);
if (!IsUnstarted())
ThreadStore::s_pThreadStore->m_BackgroundThreadCount++;
ThreadStore::CheckForEEShutdown();
}
}
}
void ThreadStore::CheckForEEShutdown()
{
if (g_fWeControlLifetime &&
s_pThreadStore->OtherThreadsComplete())
{
BOOL bRet;
bRet = s_pThreadStore->m_TerminationEvent.Set();
_ASSERTE(bRet);
}
}
哈哈,卦中的化煞方法真的妙不可言,做了如下兩個步驟:
- 做了 m_BackgroundThreadCount++,這樣 OtherThreadsComplete() 的值就對上了。
- 使用 m_TerminationEvent.Set 做了事件喚醒,這樣主線程就可以從 WaitForOtherThreads() 方法中逃出生天。
如果有些朋友沒搞明白,我再畫一張簡圖吧:
圖片
4. 判斷線程的前后狀態(tài)
這是最后一個要聊的話題,要想知道線程的前后狀態(tài),這個需要在 coreclr 源碼中尋找答案,參考代碼如下:
void SetThreadState(ThreadState ts)
{
InterlockedOr((LONG*)&m_State, ts);
}
enum ThreadState
{
TS_Background = 0x00000200, // Thread is a background thread
}
從代碼中可以看到,只要判斷 ThreadState 中有沒有 0x200 的標記即可,接下來用 !t 觀察線程狀態(tài)。
0:000> !t
ThreadCount: 4
UnstartedThread: 0
BackgroundThread: 3
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
Lock
DBG ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 918 000001FA530317B0 203a220 Preemptive 000001FA574096F8:000001FA5740A5C8 000001fa530273e0 -00001 MTA
6 2 37c8 000001FA53009B70 21220 Preemptive 0000000000000000:0000000000000000 000001fa530273e0 -00001 Ukn (Finalizer)
7 3 2c7c 000001FA5307F700 2b220 Preemptive 0000000000000000:0000000000000000 000001fa530273e0 -00001 MTA
8 4 3bd4 0000023AE951DFD0 2b020 Preemptive 000001FA57563A08:000001FA57565010 000001fa530273e0 -00001 MTA
從卦中可以輕松的看到 DBG=8 的線程狀態(tài)是 2b020,自然就是前臺線程咯。
三、總結
現(xiàn)在我們知道了前后臺線程本質上是 coreclr 弄出來的概念,并非系統(tǒng)線程素有之物。還是那句話,知識不重要,重要的是會使用合適的工具和保有的探索心,這也是在訓練營里重度強調的。