C# 學習筆記:多執行緒 (1) - 從零開始

摘要:C# 非同步程式設計的學習筆記,先從基礎的執行緒觀念說起(意思是這篇筆記沒有一行程式碼)。提到的名詞包括:process、thread、context switch、前景執行緒、背景執行緒等等。

更新:本文新版內容已整理至電子書《.NET 本事-非同步程式設計》。點我查看出版訊息

前言

這篇筆記(以及續集,如果有的話)最初都是以比較接近翻譯或摘譯的方式整理,後來陸續改動了一些部分,或增或減,我沒有再去比對原書,很難說還有多少原汁原味。既是參考現有書籍再加以筆記整理,也談不上原創。總之,先把我參考的書籍列出來:

若這筆記有什麼優點,大多也應歸功於這些好書。我只是整理罷了。

多執行緒與非同步的細節挺多,疏漏難免。如有錯誤,還請多多指正(請鞭小力一點)。

2013-05-08 更新:Context Switch 保留英文,不再譯成中文,因為目前仍沒有找到恰當的中文術語。考慮過:脈絡切換、環境切換、上下文切換、工作內容切換等等,目前心中仍無定論。

從零開始:處理序、執行緒

在很早很早以前,在還沒有執行緒(thread)這個概念的時候,作業系統本身與應用程式都是以類似接力賽跑的方式執行。也就是說,一件工作做完才接著做下一件;呼叫某個函式時,必須等該函式執行完畢,返回之後,呼叫端才能繼續下去。若將每一件工作用線段連接起來,結果就會像一條線,看起來像這樣:


此運作方式有兩個問題。首先,對於需要跟使用者互動的應用程式來說,如果有某項工作要花很長的時間才能跑完,使用者就會在螢幕前發呆──這是很糟糕的使用者體驗。第二個問題,某個應用程式進入無窮迴圈將導致其他應用程式暫停,整個作業系統看起來就像當掉似的,使用者最終只好使出殺手鐧:強迫結束應用程式或重新開機──這當然也是很差的使用者體驗。

然後,Windows 作業系統有了處理序(process;又譯作「處理程序」或「行程」)的概念,作為隔離應用程式的基本單位。當使用者開啟某應用程式,作業系統會將它載入記憶體並開始執行,這個載入記憶體中運行的應用程式實體(instance),便稱為處理序。一個處理序會在系統中佔據一個記憶區塊,此區塊是個獨立的虛擬位址空間,其中包含該應用程式的程式碼以及相關資源,而且此空間只有該應用程式實體能夠存取,與別人互不相干。如此一來,運行中的各個處理序就不至於互相干擾,不會因為某個應用程式進入無窮迴圈而導致其他應用程式掛掉;同時,由於這些應用程式的處理序也被隔離於作業系統的核心程式碼之外,作業系統本身也更加穩固。

雖然應用程式與作業系統之間已經透過處理序來達到隔離和保護的效果,可是它們仍然有共用的資源:CPU。如果機器只有一顆 CPU,那麼當某個應用程式進入無窮迴圈,那顆唯一的 CPU 就會忙著跑無窮迴圈而無暇照顧其他應用程式,形同鎖住。於是,使用者會發現每個應用程式都無法回應了,無論滑鼠點在哪裡都不起作用。為了解決這個 CPU 無法分身的問題,執行緒(thread)便應運而生。

那麼,什麼是執行緒呢?

Jeffrey Richter 在《CLR via C# 第四版》中說:「執行緒是 Windows 作業系統用來虛擬化 CPU 的概念。」同時進一步解釋,「Windows 會給每一個處理序配發一個專屬的執行緒(其功能近似於 CPU),而當某應用程式進入無窮迴圈,其所屬之處理序形同凍結,但其他處理序(擁有各自的執行緒)並未凍結;它們都還能夠繼續運行!」若要再解釋得更淺白些:執行緒就是用來切割 CPU 執行時間的基本單位,讓 CPU 好像有分身似的,可「同時」執行多項工作。或者更簡短:執行緒就是一條獨立的程式碼序列(code sequence)。所以有時我會說「一個執行緒」,或「一條執行緒」。

執行緒帶來的負擔

執行緒解決了多個應用程式共用同一顆 CPU 所產生的問題,但也必須付出一點代價,包括空間(記憶體損耗)與時間(執行效能)。Windows 每建立一條執行緒,須為它配置大約 1MB 左右的記憶體,其中包含執行緒核心物件、環境區塊(Thread Environment Block)、使用者模式堆疊、核心模式堆疊等等。這是記憶體空間的額外負擔。

執行效能的負擔則主要來自於兩個地方:與 unmanaged DLL 的互動,以及 context switch。接著分別進一步說明之,其中提到的 unmanaged DLL,你可以在心中轉譯為傳統 Win32 DLL,也就是編譯成機器碼的 DLL;同樣地,碰到 managed DLL 則可視為 .NET 組件。

每當 Windows 在某個處理序當中建立一條執行緒時,所有已經載入至該處理序的 unmanaged DLL 的 DllMain 函式都會被呼叫一遍,且呼叫時會傳入 DLL_THREAD_ATTACH 旗號。相對的,每當處理序中的執行緒釋放前,Windows 也會自動呼叫該處理序中所有 unmanaged DLL 的 DllMain 函式,並傳入 DLL_THREAD_DETACH 旗號。Windows 之所以有這個機制,主要是考慮到某些 unmanaged DLL 可能會需要在執行緒建立或摧毀時收到通知,以便進行初始化或資源清理的動作。有些應用程式運行時會載入很多 unmanaged DLL,以至於建立和摧毀執行緒的時候得多花一些時間。根據  Jeffrey Richter 在《CLR via C# 第四版》中所說,他的機器上所安裝的 Visual Studio 運行時所載入的 DLL 數量高達 470 個!剛才所提到的現象並不會發生在 C# 或 Visual Basic 程式所編譯成的 managed DLL,因為它們並沒有 DllMain。

Context Switch

Context switch(環境切換、工作內容切換)其實是很貼近日常生活的概念。比如說,當我們需要同時處理多項工作的時候,由於手邊的工作進行到一半,必須先把目前進度、待辦事項等相關資訊先記在某處,然後--有時可能還需要調整一下心情--再把另一件工作當時保存的相關資訊拿出來,讓記憶恢復一下,再繼續處理後續未完的事項。在開發軟體專案時,相信大家都有過類似的 context switch 經驗吧!

同樣的,對於只有一顆 CPU 的電腦而言,其實每次只能執行一件工作。故當作業系統同時載入執行多個應用程式時,Windows 就必須適當切割並分配 CPU 的運算時間給這些應用程式的各個執行緒。於是,在某個瞬間會輪到某個執行緒擁有 CPU 資源一段時間;等時間一到,Windows 就會把 CPU 資源分配給另一個執行緒。像這樣從某個執行緒切換至另一個執行緒的程序就是 context switch,而每一次 context switch 都包含以下幾個動作:
  1. 把 CPU 各個暫存器的值保存至目前執行緒的內部資料結構。
  2. 挑選下一個幸運的執行緒。若該執行緒屬於某個處理序,則在切換之前, Windows 還必須切換虛擬位址空間,這樣 CPU 才能存取到正確的程式碼和資料。
  3. 從選中的執行緒之內部資料結構載入 CPU 暫存器的值。

上述 context switch 動作完成後,CPU 便開始執行下一個選中的執行緒,直到分配給它的時間已過,接著又再一次切換執行緒。透過這種每隔一小段時間就切換執行緒的機制,就算某應用程式進入無窮迴圈,CPU 也不會被鎖在迴圈裡,而能夠繼續服務其他應用程式。

切換執行緒的動作會影響系統的執行效能,且影響程度可能超乎想像。原因在於,CPU 本身有內建快取(cache),目的是提升運算速度,但由於切換執行緒的緣故(Windows 大約每 30ms 切換一次),才剛剛載入快取的資料不一會兒就因為切換至另一個執行緒而又得載入新的資料,使得 CPU 本身的快取形同虛設,徒勞無功。因此,在設計程式時,最好能夠盡量避免執行緒切換的動作,以提高應用程式的效能。(開發人員不也一樣嗎?過多的 context switch 可是會令工作績效大打折扣的!)

此外,每當 CLR 進行資源回收時,它必須先暫停所有的執行緒,等到回收動作完成之後才恢復。這表示如果我們在設計程式時能夠盡量減少執行緒的數量,就能改善 CLR 資源回收的效率。同樣的情形也發生在除錯時:每當除錯器碰到你設定的中斷點,Windows 就會暫停該應用程式的所有執行緒,直到你再做一次單步除錯或繼續執行,那些執行緒才又「活過來」。

經過上述討論,我們知道建立、摧毀、和管理執行緒都得額外消耗不少記憶體空間,而執行緒切換也需要多花一些時間。故可得出結論:在單一 CPU 的機器上,若無必要,應用程式最好少用執行緒。

好消息是,目前的市場上,多 CPU、超執行緒(hyperthreaded)CPU、或多核心(multi-core)CPU 的硬體架構已經非常普遍了。在這些擁有多顆 CPU 或多核心的機器上執行多執行緒的應用程式時,前面提到的分時多工、輪流服務的情況可獲得大幅改善,因為 Windows 會為每一個 CPU 核心配給不同的執行緒,讓這些執行緒能夠真正地同時執行。當然了,在執行緒數量大於 CPU 數量的時候,每顆 CPU 內部還是會發生執行緒切換的情形。

就拿我的電腦來說吧!下圖是我電腦上的工作管理員呈現的系統效能數據。值得注意的是,處理序的數量為 99,執行緒數量為 1340,可是 CPU 整體負載卻只有 16%。這表示雖然系統目前載入了許多執行緒,但是大部分都在背景閒置。


若切換至「詳細資料」頁籤,加入「執行緒」欄位,便能看到每個處理序的執行緒數量,如下圖所示。



從圖中可以看出,系統核心所建立的執行緒共有 243 個,SQL Server 有 81 個,連檔案總管都有 43 個執行緒!挺嚇人的,不是嗎?我們不禁要懷疑這些應用程式會不會過度使用執行緒了? 那些閒置的執行緒不僅會產生多餘的執行緒切換動作,而且更重要的是,它們都會占用一些記憶體空間,形成浪費。

儘管執行緒會帶來的一些額外負擔,但為了兼顧良好的回應速度與整體執行效能,它仍是必要的手段。後續章節將介紹如何運用 Windows 和 CLR 提供的一些機制來盡量避免建立執行緒,同時又能讓應用程式迅速回應、維持良好的效能。

還有一些與執行緒有關的東西也值得了解一下,條列整理如下:
  • .NET 的 CLR 執行緒等同於 Windows 執行緒。雖然在某些 .NET 類別的實作裡面似乎暗示 CLR 執行緒與 Windows 執行緒不是一對一的對應關係,但它們其實是同樣的東西。這是因為早期 CLR 開發團隊曾經打算讓 .NET 執行環境擁有屬於自己的邏輯執行緒,而在某些 .NET 類別中留下了痕跡。
  • Windows 市集應用程式無法使用某些 .NET 既有的執行緒相關類別或 API,例如 System.Threading.Thread。這是因為微軟認為這些類別容易導致開發人員寫出差勁的多執行緒應用程式,於是決定在 Windows 市集應用程式中移除這些 API(桌上型應用程式還是可以繼續使用)。
  • 依行為來區分,執行緒可分為兩種:前景執行緒和背景執行緒。兩者的主要區別是:當所有的前景執行緒停止時,應用程式就會結束,並且停止所有背景執行緒。若只是停止背景執行緒,則不會造成應用程式結束。此外,雖然結束應用程式時,.NET 會通知所有的背景執行緒停止,但比較保險的做法還是自行結束背景執行緒。
  • 應用程式所要處理的工作亦可分成兩類:一是涉及運算的(compute-bound)工作,一是涉及輸入/輸出的(I/O-bound)工作。 

本文新版內容已整理至電子書《.NET 本事-非同步程式設計》)

👉下一集:多執行緒 (2) - 分道揚鑣
技術提供:Blogger.
回頂端⬆️