CLR 執行緒概覽

killmyday發表於2016-05-09

託管 vs. 原生執行緒

託管程式碼在“託管執行緒”上執行,(託管執行緒)與作業系統提供的原生執行緒不同。原生執行緒是在物理機器上執行的原生程式碼序列;而託管執行緒則是在CLR虛擬機器上執行的虛擬執行緒。

正如JIT直譯器將“虛擬的”中間(IL)指令對映到物理機器上的原聲指令,CLR執行緒基礎架構將“虛擬的”託管執行緒對映到作業系統的原生執行緒上。

在任意時刻,一個託管執行緒可能會也可能不會被分配到一個原生執行緒執行。例如,一個已經被建立(通過“new System.Threading.Thread”)但是未啟動(通過“System.Threading.Thread.Start”)的託管執行緒不會被指派到原生執行緒上執行。類似的,雖然CLR在實際上不會這樣做,但是一個託管執行緒在執行時可被切換到多個原生執行緒上執行。

託管程式碼裡公開的Thread介面就是用來隱藏其底層原生執行緒的細節的:

  • 託管執行緒無需繫結到一個原生執行緒上(甚至有可能根本不對映到原生執行緒上)。
  • 不同作業系統的原生執行緒不一樣。
  • 原則上,託管執行緒是“虛擬的”。

CLR提供並實現了託管執行緒的抽象。比如說,雖然其不暴露作業系統的執行緒本地儲存(TLS)機制,但是其提供了託管“執行緒靜態”變數。類似的,雖然其不提供原生執行緒的“執行緒ID”,但是其提供與作業系統無關的“託管執行緒ID”。不過為了便於診斷問題,底層原生執行緒的一些細節可以通過System.Diagnostics名稱空間裡的型別獲得。

託管執行緒還提供了原生執行緒通常不用的功能。第一,託管執行緒在堆疊上使用GC引用,這樣CLR必須在GC的時候可以列舉(甚至可能修改)這些GC引用。為了實現這個目的,CLR必須“暫停”每個託管執行緒(即停止執行以便可以發現所有的GC引用)。第二,當AppDomain解除安裝時,CLR必須保證沒有執行緒在執行這個AppDomain裡的程式碼。這要求CLR可以強制執行緒從AppDomain脫離,CLR通過線上程裡注入ThreadAbortException來實現這點。

資料結構

每個託管執行緒都跟一個Thread物件關聯,其在threads.h裡定義。這個物件跟蹤CLR關於託管物件所需要了解的所有東西。包括如執行緒的當前GC模式和堆疊幀鏈這些必需品,也包括為了效能因素建立的很多元素(如一些快速arena-style分配器)。

所有的Thread物件都儲存在ThreadStore中(也在threads.h中定義),其時一個所有已知執行緒的列表。要遍歷所有的託管執行緒,需要先獲取ThreadStoreLock,再使用ThreadStore::GetAllThreadList來列舉所有的執行緒物件。這個列表也包含沒有被指派原生執行緒的託管執行緒(如未啟動的執行緒,或原生執行緒已經存在了)。

原生執行緒可以通過一個原生執行緒本地儲存(TLS)槽來獲取繫結到該原生執行緒的託管執行緒。這允許原生執行緒上執行的程式碼可以通過GetThread()獲取對應的Thread物件。

另外,許多託管執行緒有一個與原生Thread物件相區別的 託管 Thread物件(System.Threading.Thread)。託管Thread物件提供了方法以便託管程式碼與執行緒互動,其大部分是原生Thread物件功能的封裝。通過Thread.CurrentThread可以(在託管程式碼中)獲取到當前的託管執行緒物件。

在偵錯程式裡,“!Threads”這個SOS擴充套件命令可以用來列舉ThreadStore裡的所有Thread物件。

執行緒的生命週期

一個託管執行緒在下列這些情形中建立:

  1. 託管程式碼通過System.Threading.Thread顯式要求CLR建立一個新執行緒。
  2. CLR自己建立的託管執行緒(參見“特殊執行緒”一節)。
  3. 原生程式碼在原生執行緒上呼叫託管程式碼,而這個託管程式碼沒有跟託管執行緒相關聯(通過“反向p/invoke”或者COM互互動)。
  4. 一個託管程式被啟動了(在程式的主執行緒上呼叫其Main函式)。

在#1和#2這些情形中,CLR負責建立支撐託管執行緒的原生執行緒。這個只會線上程實際上啟動了才會發生。在這些情形裡,CLR“負責”原生執行緒;CLR負責原生執行緒的生命週期,由於CLR建立了它,因此也就知道執行緒的存在。

在#3和#4這些情形裡,原生執行緒在託管執行緒之前就存在了,而且由CLR之外的程式碼負責。CLR不負責這種原生執行緒的生命週期。CLR只是在其第一次呼叫託管程式碼時意識到其存在。

當一個原生執行緒結束時,CLR通過其DllMain函式獲得通知。這在作業系統的“載入鎖”中發生,所以在處理這個通知的時候只能做很少(安全)的事情。與其銷燬與託管執行緒關聯的資料結構,這個執行緒只是被簡單地標識成“死亡”狀態,並啟動finalizer執行緒。finalizer執行緒會遍歷ThreadStore裡所有死亡託管程式碼不再使用的執行緒。

暫停

CLR必須可以找到託管物件的所有引用以便執行GC。託管程式碼一直在不停的訪問GC堆,操作堆疊和暫存器上的引用。CLR必須保證所有執行緒停在安全可靠的位置(這樣他們不會修改GC堆),以便找到所有的託管物件。它只會停在安全點,這個時候可以在暫存器和堆疊上檢查所有可用的引用。

另一個辦法就是GC堆、每個執行緒的堆疊和暫存器狀態都是所謂的“共享狀態”,可被多個執行緒訪問。正如大多數共享狀態一樣,需要一些“鎖”來保護它們。託管程式碼在訪問堆之前必須要獲取鎖,並且在安全的時候釋放鎖。

CLR將這種“鎖”稱作執行緒的“GC模式”。當執行緒獲取鎖的時候,處於“合作模式(cooperative mode)”;其必須與GC“合作”(通過釋放鎖)才能允許進行垃圾回收。而執行緒沒有獲取鎖的時候,處於“優先模式(preemptive mode)” - GC可以“優先”進行垃圾回收,因為其知道執行緒沒有訪問GC堆。

GC只有在所有執行緒都處於“優先”模式(即沒有獲取鎖)時才能進行垃圾回收。將所有執行緒移到優先模式的過程就稱為“GC懸停(GC suspension)”或“暫停執行引擎”。

一個不大成熟的實現“鎖”的方案是要求每個託管執行緒在訪問GC堆的時候實際獲取和釋放保護它的鎖。然後GC會向每個執行緒嘗試獲取鎖,一旦其獲取所有執行緒的鎖,就可以安全的進行垃圾回收了。

然而,上面的方案因為兩個原因而顯得不足。第一,這會要求託管程式碼耗費大量的時間在於獲取和釋放鎖(或至少是檢查GC是否在嘗試獲取鎖 - 也就是“GC輪詢 GC poll - 即不停的向GC輪詢”)。第二,它要求JIT直譯器生成大量的“GC資訊程式碼”,以描述每一行JIT生成的程式碼後的堆疊的佈局和暫存器狀態,這些資訊會耗費大量的記憶體。

我們針對上述辦法的改進方案是,將JIT後的託管程式碼區分成“部分可中斷”和“全部可中斷”的程式碼。在部分可中斷程式碼中,呼叫其他函式的地方是唯一的安全點,且JIT生成顯式的“GC輪詢”點以便檢查是否有等待的GC。(JIT)只需要在這些地方生成GC資訊。在全部可中斷程式碼裡,每個指令都是一個安全點,JIT為每個指令生成GC資訊 - 但是其不生成“GC”輪詢程式碼。全部可中斷程式碼而是通過劫持執行緒(該過程在後文講解)來進入“中斷”狀態。JIT基於程式碼質量,GC資訊的大小以及GC懸停的時間延遲這些因素來判定是產生全部或部分可中斷程式碼。

基於上述資訊,定義了三個基礎操作:進入合作模式,離開合作模式以及暫停執行引擎。

進入合作模式

一個執行緒通過呼叫Thread::DisablePreemptiveGC進入合作模式。其為當前執行緒獲取“鎖”:

  1. 如果有GC正在執行(GC擁有這個鎖),那麼等待GC完成。
  2. 標識這個執行緒將進入合作模式,在這個執行緒進入“優先模式”之前不能觸發GC。

兩個步驟實際上是原子操作。

進入優先模式

一個執行緒通過呼叫Thread::EnablePreemptiveGC來進入優先模式(釋放鎖)。其通過標識執行緒不再進入合作模式來完成,並通知GC執行緒可以啟動執行。

中斷執行引擎

當GC開始執行時,第一步就是中斷執行引擎。GCHeap::SuspendEE函式就是用來幹這個的:

  1. 設定一個全域性變數(g_fTrapReturningThreads)來標誌GC正在執行,任何想進入合作模式的執行緒都會被阻止,直到GC執行完畢。
  2. 找出所有處於合作模式的執行緒,針對每個這樣的執行緒,試圖劫持執行緒並強制其離開合作模式。
  3. 重複前面的步驟直到沒有執行緒處於合作模式。

劫持

為了GC懸停而進行的劫持操作是通過Thread::SysSuspendForGC函式完成的。這個函式通過強制所有執行在合作模式的託管執行緒在“安全點”離開合作模式。其通過列舉所有的託管執行緒(通過遍歷ThreadStore),針對每個執行在合作模式中的託管執行緒:

  1. 通過Win32的SuspendThread API來暫停底層的原生執行緒。這個API強制執行緒從執行狀態停止在任意位置(不一定是一個安全點)。
  2. 通過GetThreadContext獲取執行緒的上下文(CONTEXT)。這是一個作業系統的概念;上下文存放了執行緒的當前暫存器狀態。這就允許我們來監視其指令暫存器,並獲知正在執行的指令型別。
  3. 再次檢查執行緒是否在合作模式,因為其可能在被暫停之前已經離開合作模式了。如果是這樣的話,那麼執行緒處於危險地段:執行緒可能在執行任意的原生程式碼,必須立即恢復執行以規避死鎖。
  4. 檢查執行緒是否在執行託管程式碼。其有可能在合作模式下執行虛擬機器(VM)自身的原生程式碼(參看下面的同步章節),其也需要跟上一步一樣立即恢復執行。
  5. 那麼執行緒目前是暫停在託管程式碼上。取決於程式碼是全部還是部分可中斷,採取下面的措施之一:
    • 如果是全部可中斷,那麼在任意位置GC都是安全的,因為執行緒按照全部可中斷的定義就是在安全點。理論上可以讓執行緒停在這個位置(因為是安全的),但是幾個歷史性的作業系統Bug妨礙了這點,因為前面獲取的執行緒上下文也許已經損壞了)。於是(CLR)改寫執行緒的指令暫存器,引導執行緒跳轉到一個程式碼塊以便獲取更完整的上下文,離開合作模式,等待GC執行完畢,重新進入合作模式,並且還原執行緒的暫存器。
    • 如果是部分可中斷,那麼執行緒按照定義不在一個安全點。但是,其呼叫者是處於安全點的(函式間切換)。基於這個知識,CLR在堆疊幀上“劫持”起返回地址(即修改堆疊),引導執行緒跳轉到跟“全部可中斷”類似的程式碼塊。當函式返回時,其不是返回原來的呼叫函式那裡,而是這個程式碼塊(這個函式可能也會執行JIT在之前注入的GC輪詢,導致執行緒離開合作模式並撤銷劫持操作)。

ThreadAbort / AppDomain-Unload

為了解除安裝一個應用程式域(AppDomain),CLR需要保證沒有執行緒執行在這個應用程式域中。為了實現這點,所有託管執行緒都被列舉,而任何堆疊上有屬於被解除安裝應用程式域的幀的執行緒都被“中斷”。一個ThreadAbortException異常被注入正在執行的執行緒,並導致執行緒向上展開(一直執行拆除程式碼)直到沒有執行在這個應用程式域當中的堆疊幀,而ThreadAbortException也被轉換成一個AppDomainUnloaded異常。

ThreadAbortException是一個很特別的異常。其也許會被使用者程式碼捕捉到,但是CLR確保其在使用者的異常處理程式碼之後再次被丟擲。因此ThreadAbortException有時被稱作“無法被捕捉”的,儘管嚴格來說不是這樣的。

ThreadAbortException通常通過在託管執行緒上設定一個標誌位標誌其“正在終止”來丟擲的。CLR很多地方都會檢查這個標誌位(特別要注意的,每次從p/invoke返回),並且經常有設定這個標誌位的目的就是為了讓執行緒及時終止的情形。

然而,比如說,執行緒正在執行一個長時間的託管迴圈,那麼它可能根本不會檢查這個標誌位。為了讓這樣的執行緒快速終止,執行緒就被“劫持”並強制丟擲ThreadAbortException異常。劫持過程跟GC懸停很類似,只是執行緒跳轉過去的程式碼塊丟擲ThreadAbortException,而不是等待GC執行完畢。

這種劫持意味著ThreadAbortException可能在任意位置發生。這樣使得託管程式碼很難正確處理ThreadAbortException異常。因此除了在解除安裝應用程式域的時候使用這種機制以外 - 保證由ThreadAbort損壞的狀態都跟應用程式域一起被清理,在其他地方使用它都不是很明智的選擇。

同步: 託管程式碼

託管程式碼可以訪問很多在System.Threading裡定義的同步原語。包括作業系統原語的簡單封裝如:互斥(Mutex),事件(Event)和旗標(Semaphore)物件,也包括類似的柵欄(Barrier)和自旋鎖(SpinLock)等抽象。但託管程式碼用的最多的同步機制是System.Threading.Monitor,其提供了針對 任意託管物件 的高效能同步鎖機制,還提供了被其保護的狀態發生變化時的通知機制的“條件變數”語義。

Monitor是通過一個“混合鎖”來實現的,其有自旋鎖和類似互斥(Mutex)這些基於作業系統核心鎖的功能。這個思路源自於大部分鎖都是短暫獲取的,因此自旋等待鎖被釋放的所耗費的時間比呼叫核心API從而阻塞執行緒更少。當然將CPU的時鐘週期浪費在自旋上也是很嚴重的,因此如果鎖在一段時間內沒有被釋放的話,那麼CLR則會退回到呼叫核心API的實現上。

因為任意一個物件都是潛在的鎖/條件變數,每個物件都需要有一個地方用來儲存鎖資訊。這個就是在“物件頭(object headers)”和“同步塊(sync blocks)”裡完成的。

物件頭是一個在每個託管物件前面機器字長大小的欄位。它在很多地方會用到,例如儲存物件的雜湊值。其中一個目的就是儲存物件的鎖狀態。如果物件頭需要儲存更多的資訊,我們通過建立一個“同步塊”的方式擴充物件。

同步塊儲存在同步塊表(Sync Block Table)裡,通過同步塊索引來定址。物件的同步塊索引儲存在物件頭裡。

關於物件頭和同步塊的細節在syncblk.h/.cpp裡定義。

如果物件頭裡還有空間,Monitor將鎖住物件的執行緒的託管執行緒ID(如果沒有執行緒鎖住物件則是0)儲存在其中。在這種情形下,獲取鎖的過程其實就是自旋等待物件頭的執行緒ID為0,然後原子操作設定其值為當前執行緒的託管執行緒ID。

如果自旋一些次數後還不能獲取鎖,或物件頭已經用作其它目的,那麼就會為這個物件建立同步塊。它包含一些額外資料,包括用來阻塞當前執行緒的事件物件,這樣執行我們停止自旋並等待鎖被釋放。

一個用來作為條件變數的物件(通過Monitor.Wait 和 Monitor.Pulse)總是會被擴充的,因為同步塊裡已經沒有足夠的空間來儲存必要的狀態。

同步: 原生情況

CLR的原生部分也必須要有執行緒意識,因為其可能在多個執行緒上呼叫託管程式碼。這樣要求原生的同步機制,例如鎖,事件等等。

ITaskHost API 允許一個CLR宿主修改託管執行緒的很多方面,包括執行緒的建立、銷燬和同步。這種允許宿主修改原生同步機制要求虛擬機器的程式碼不能直接使用原生的同步原語(即臨界區,互斥鎖,事件等),而是需要使用虛擬機器在其上的封裝)。

除了上述細節之外,GC懸停是一個特殊的“鎖”,而且幾乎影響CLR的方方面面。如果必須處理GC堆上的物件,虛擬機器的原生程式碼可能要進入“合作”模式,這樣“GC懸停鎖”就變成原生虛擬機器程式碼裡最重要的同步機制,在託管世界裡也一樣。

原生虛擬機器程式碼裡主要用到的同步機制是GC模式和Crst。

GC 模式

如上所述,所有託管執行緒都在合作模式中執行,因為其可能操作GC堆。一般來講,原生程式碼不會碰託管物件,因此執行在優先模式。但有些虛擬機器裡的原生程式碼需要訪問GC堆,需要執行在合作模式。

原生程式碼通常不會直接操作GC模式,而是通過兩個巨集:GCX_COOP and GCX_PREEMP 來進入期望的模式,並建立“支援物”以便執行緒在退出範圍的時候返回到之前的模式。

需要注意的是GCX_COOP從GC堆上獲取一個鎖。線上程處於合作模式時,不能執行GC。而且原生執行緒也不能像託管執行緒那樣被“劫持”,因此執行緒在切換回優先模式時都是處於合作模式。

因此在原生程式碼裡進入合作模式是不被鼓勵的。如果必須要進入合作模式,那麼時間越短越好。執行緒在此模式時不能被阻塞,而且實際上不能安全的獲取鎖。

類似的,GCX_PREEMP 釋放 執行緒擁有的鎖。在進入優先模式之前必須要萬分小心來確保所有GC引用都被妥善保護。

程式碼規範 文件描述了安全進行GC模式切換的必要原則。

Crst

正如Monitor物件是託管程式碼裡推薦的鎖機制,Crst是虛擬機器程式碼裡的推薦機制。與Monitor類似,Crst是一個知道宿主和GC模式的混合鎖。Crst通過“層級鎖”機制來規避死鎖,該實現可參考 BotR的層級鎖章節.

雖然有一些必須這麼做的異常情況,在合作模式下獲取一個Crst鎖通常是不合適的。

特殊執行緒

除了託管程式碼建立的託管執行緒,CLR自身還建立了一些“特殊”執行緒。

終結者(Finalizer)執行緒

每個程式都建立了這個執行緒用來執行託管程式碼。當GC決定一個可終結(finalizable)的物件不再被引用,其將該物件置於終結佇列。當GC結束後,終結者執行緒會被喚醒並處理佇列裡的所有終結物件。物件一個一個出列,其終結(finalizer)函式被依次呼叫。

該執行緒還用來處理一些CLR內部的清理工作,並等待一些外部事件通知(如低記憶體情形下,GC會被告知儘量凶悍的回收垃圾)。詳情請參見GCHeap::FinalizerThreadStart。

GC 執行緒

當執行在“並行”或“伺服器”模式時,GC建立一個或多個後臺執行緒來並行執行垃圾回收的不同階段。這些執行緒完成由GC管理,而且永遠不會執行託管程式碼。

偵錯程式執行緒

CLR為每個託管程式維護了一個原生執行緒,其用來在附加到託管偵錯程式時執行多個除錯操作。

應用程式域解除安裝執行緒

這個執行緒負責解除安裝應用程式域。其通過一個單獨的CLR內部執行緒,而不是在請求解除安裝應用程式域的執行緒裡完成。因為 a) 為解除安裝過程提供受保證的堆疊空間,b) 在必要時允許請求解除安裝的執行緒從應用程式域裡向上展開。

執行緒池執行緒

CLR執行緒池維護一個託管執行緒集合用來執行使用者的“工作”。這些託管執行緒都繫結到執行緒池管理的原生執行緒。執行緒池還維護一小部分的原生執行緒來處理類似“執行緒注入”,定時器以及“已註冊的等待”等等功能。

相關文章