01 從零開始

大部分的程式碼都是以循序的方式執行,或者說,以同步(synchronous)的方式執行。一方面是因為我們的腦袋比較容易理解循序的執行程式碼,而較難想像同時併發、交錯執行的非同步程式碼實際上究竟是怎麼回事。另一方面,則是因為「交錯執行」所衍生的一些問題(例如鎖死 [deadlock])需要學習額外的技巧來解決,使得非同步程式設計更加困難。

本章將從最基礎的概念開始談起,介紹處理序(process)、執行緒(thread),以及其他相關的名詞與基礎觀念,包括 context switch、捱餓(starvation)、執行緒的優先順序,以及併發(concurrency)平行處理(parallel processing)、**非同步處理(asynchronous processing)**等等。

話說從頭:處理序與執行緒

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

循序執行 (後來又稱為同步執行)的方式有兩個問題。

首先,對於需要跟使用者互動的應用程式來說,如果有某項工作要花很長的時間才能跑完,使用者就只能在螢幕前發呆——這是很糟糕的使用者體驗。這就像披薩店內只有一位負責燒烤披薩的師傅和一個烤箱,故每當披薩送進烤箱後,師傅就只能坐等這一批的披薩烤好出爐,才能繼續烤下一批。

循序執行的第二個問題是,某個應用程式進入無窮迴圈將導致其他應用程式暫停,整個作業系統看起來就像當掉似的,使用者最終只好使出殺手鐧:強迫結束應用程式或重新開機——這當然也是很差的使用者體驗。

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

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


那麼,什麼是執行緒呢?

執行緒是用來切割 CPU 執行時間的基本單位,讓一顆 CPU 好像有分身似的,「感覺起來」可同時執行多項工作。從實際運行的角度來看,一個執行緒就是一條獨立執行的程式碼序列(code sequence)。因此,我們常說「一個執行緒」,或「一條執行緒」。

一個處理序裡面可以同時跑多條執行緒,於是原本只能單線循序執行的作業,現在變成可以像多頭馬車那樣分頭進行,如下圖所示。

如果以披薩店來比喻,就是當店裡唯一的燒烤師傅把批薩送進烤箱之後,店內還有其他烤箱,於是他不用閒著,可以立刻接著烤第二批。當電腦只有一顆 CPU,就像披薩店只有一位燒烤師傅,會經常需要在多個任務之間來回切換,相當忙碌。

執行緒帶來的負擔

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

執行緒所衍生的效能負擔主要是所謂的 context switch,於下一節說明。

Context Switch

Context Switch(環境切換、工作內容切換)其實是很貼近日常生活的概念。

註:由於中文翻譯容易誤解,往後皆使用英文 context switch。

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

接下來要說明 Windows 作業系統的 context switch 程序時,會提到作業系統與計算機結構的幾個專有名詞,例如暫存器(register)、虛擬位址空間等等。即使不了解這些名詞,對你撰寫 .NET 非同步應用程式也不會有太大的影響。

同樣的,對於只有一顆 CPU 的電腦而言,實際上每次只能執行一件工作。故當作業系統同時載入執行多個應用程式時,就必須適當切割並分配 CPU 的運算時間給這些應用程式的各個執行緒。於是,在某個瞬間會輪到某個執行緒擁有 CPU 資源一段短暫的時間;等這短暫的時間一到,作業系統便會把 CPU 資源分配給另一個執行緒。像這樣從某個執行緒切換至另一個執行緒的過程就是 context switch,而每一次 context switch 都需要先保存當前執行緒的一些內部資料,然後載入下一個選中的執行緒之內部資料,並由 CPU 執行那個選中的執行緒,直到分配給它的時間已過,接著又再一次切換執行緒。透過這種每隔一小段時間即切換執行緒的機制,就算某應用程式進入無窮迴圈,CPU 也不會被鎖在迴圈裡,而能夠繼續服務其他應用程式。

Note

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

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

就拿我的電腦來說吧,下圖是 Windows 工作管理員呈現的系統效能數據。值得注意的是,處理序的數量為 384,執行緒數量為 5840,而 CPU 整體負載只有 8%。這表示雖然目前的作業環境已建立了許多執行緒,但是大多在背景閒置。

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

從圖中可以看出,系統核心所建立的執行緒數量為 307 個,Dropbox 有 159 個,連檔案總管都有 78 條執行緒!挺驚人的,不是嗎?

目前市場上,多顆 CPU 或多核心(multi-core)CPU 的硬體架構已經非常普遍。在這些擁有多顆 CPU 或多核心的機器上跑多執行緒的應用程式時,前面提到的分時多工、輪流服務的情況可獲得大幅改善。這是因為作業系統會為每一個 CPU core 配給不同的執行緒,讓這些執行緒能夠真正地同時執行。當然了,在執行緒數量大於 CPU 數量的時候(幾乎都是這樣),每顆 CPU 內部還是會發生切換執行緒的情形。以披薩店來比喻,多個 CPU 核心就像店內有多名燒烤師傅,而烤箱(執行緒)的數量通常會比燒烤師傅多出很多。

爭先恐後-關於優先順序

既然切換執行緒在所難免,那麼對個別執行緒來說,能夠優先分配到更多 CPU 資源的,執行效能自然比較好。

如果你需要讓你的應用程式獲得更高的優先權,本節的內容會有一些幫助。若不需要,則可暫且略過不讀。

Windows 作業系統把執行緒的優先順序分成 32 個等級,編號從最低的 0 至最高的 31,優先權愈高,愈能分到更多 CPU 時間。進一步說,當 Windows 要決定把 CPU 分給誰的時候,會先看看目前有沒有優先等級 31 的執行緒正在等候安排 CPU,若有,就會把 CPU 分給它一段時間。等它跑完配給的時間後,系統會把 CPU 分給其他同樣是優先等級 31 的執行緒。如果目前已經沒有優先等級 31 的執行緒在等待分配資源,才會輪到優先等級 30 的執行緒,然後是 29、28……,依此類推。

零頁執行緒

Windows 作業系統啟動時會建立一條特殊的執行緒,叫做「零頁執行緒」(zero page thread)。這條執行緒的的優先等級是 0,而且整個系統當中也就只有它的優先等級是 0。換言之,應用程式的執行緒優先等級不可能為 0。

想像一群嗷嗷待哺的雛鳥,個個伸長了脖子張大了口等鳥媽媽餵食,但鳥媽媽卻偏愛其中一隻,只管餵牠。這樣下去,除非那隻受到特別關愛的雛鳥吃飽了,否則其他兄弟姊妹就只有捱餓的份。在 Windows 系統中,低優先等級的執行緒也可能會遭遇**捱餓(starvation)**的狀況。多 CPU(多核心)的機器能夠減少執行緒捱餓的機會,因為不同優先等級的執行緒可以同時分配給不同的 CPU。

還有一種狀況:有個優先等級 15 的執行緒幸運分配到一段 CPU 時間,可是才執行到一半,就出現另一個更高優先等級的執行緒;此時系統會立刻暫停較低優先的執行緒,並將 CPU 分配給較高優先的執行緒。用剛才的「鳥比喻」來說,就是:有隻雛鳥幸運分到食物,才剛咬幾口還沒吞下,就被鳥媽硬生生奪回,拿去餵另一隻更重要的雛鳥了。

之所以說「幸運分配到」,是因為我們無法精確指定或得知某執行緒究竟何時分配到 CPU,以及分配到多久的時間——這些都是由作業系統控制。我們能控制的,是藉由調整執行緒的優先等級來提高(或降低)執行緒獲得 CPU 資源的機會。

可是,優先等級共 32 級(若零頁執行緒專用的等級 0 不算則為 31 級),該如何決定哪些執行緒要用等級 2、5、12、還是 31 呢?為了簡化此問題,微軟用兩個條件的組合來決定執行緒的優先等級:處理序的優先順序類別(priority class),以及執行緒的優先順序。

處理序的優先順序

處理序的優先順序分成以下六種:

  • 即時(RealTime)
  • 高(High)
  • 高於標準(Above Normal)
  • 標準(Normal)
  • 低於標準(Below Normal)
  • 閒置(Idle)

預設的處理序優先順序是「標準」。應用程式應該只在真有必要時才用「高」優先類別,例如非關 I/O、執行時間短的處理序。至於「即時」優先類別則更應盡量避免,因為它的優先權極高,高到會影響作業系統的正常運作,例如干擾磁碟讀寫或網路傳輸,以及延遲鍵盤與滑鼠輸入的反應(使用者可能會以為系統當掉了)。總之,若無正當理由,最好別輕易調高應用程式的優先順序。

.NET Framework 的 System.Diagnostics.ProcessPriorityClass 列舉型別定義了處理序的優先順序。以下程式片段示範如何將目前處理序的優先順序類別設定為「高」:

var p = System.Diagnostics.Process.GetCurrentProcess();
p.PriorityClass = System.Diagnostics.ProcessPriorityClass.High;

此外,我們也可以利用 Windows 工作管理員來手動調整特定處理序的優先順序,如下圖所示:

了解處理序的優先等級之後,接著來看執行緒的優先等級。

執行緒的優先順序

Windows 提供七種執行緒優先順序:閒置(Idle)、最低(Lowest)、低於正常(Below Normal)、正常(Normal)、高於正常(Above Normal)、最高(Highest)、時間緊迫(Time-Critical)。

六種處理序優先順序類別搭配七種執行緒優先順序,便能決定執行緒最終的優先等級。參考下圖:

舉例來說,若某處理序的優先順序類別為 Normal,而該處理序中的某個執行緒的優先順序為 Above Normal,則該執行緒的實際優先等級為 9。處理序優先順序類別若為 Realtime,則其中的執行緒優先等級最起碼為 16。

在  .NET Framework 中,ThreadPriority 列舉型別定義了執行緒的優先順序。以下程式片段即示範了如何設定執行緒的優先順序:

var t = new Thread(() => { Console.WriteLine("in worker thread"); });
t.Priority = ThreadPriority.Highest; // 設定成最高優先的執行緒。

其中的 new Thread() 語法會建立一條新的執行緒,其相關細節會在下一章進一步說明。

值得一提的是,.NET Framework 以及 .NET Core 的 ThreadPriority 列舉型別僅定義了五種優先順序,缺了兩個:Idle 和 Time-Critical。為什麼  .NET 不提供這兩種執行緒優先順序呢?Jeffrey Richter 在他的《CLR via C#》中解釋:「如同 Windows 保留優先等級 0 和即時(real-time)等級給自己,.NET CLR 也保留了 Idle 和 Time-Critical 這兩種優先權給自己使用。」

併發、平行、非同步

結束本章前,讓我們來看一下幾個與非同步程式設計有關、而且容易彼此混淆的名詞。

  • 併發(concurrency):在同一段時間內處理多件工作。例如:使用者一邊輸入文字,應用程式在背後一邊執行拼字檢查。又如:應用程式一邊忙著寫入資料庫,一邊還能同時回應使用者的鍵盤輸入或滑鼠操作,或者一邊處理目前的 HTTP 請求,同時又能接受另一個 HTTP 請求。併發能夠讓應用程式看起來像是同時執行多項工作,但背後其實只是快速且頻繁地輪流切換工作而已。
  • 多執行緒(multithreading):特別指以多執行緒的方式來實現併發(concurrency)。換言之,多執行緒是實現 concurrency 的一種方式(但不是唯一方式)。
  • 平行處理(parallel processing):把工作切分成多個小單位,利用硬體的多重運算資源(多核心 CPU)來同時處理多項工作。
  • 非同步處理(asynchronous processing):也是 concurrency 的一種形式,但不必然(甚至會避免)使用執行緒。比如說,比較早期的 .NET 非同步 API 是透過回呼(callback)函式或事件(event)來達到併發的效果。

經由以上的說明,我們知道 concurrency 是一個比較廣泛的概念,而多執行緒以及非同步處理都是 concurrency 的一種形式;parallel processing 則通常會使用多條執行緒來同執行不同的任務。

此外,parallel processing 更常用於計算密集型工作,非同步處理則更適用於 I/O 密集型工作(像是磁碟讀寫、網路傳輸等等)。

重點回顧

讀完本章,您已經知道:

  • 非同步程式設計的目的有二:一是提升應用程式的回應速度(尤其是對 UI 操作的回應),讓使用者在操作應用程式時不會老是覺得卡住。二是提升應用程式的整體執行效能;這也意味著多執行緒應用程式往往有較佳的使用者體驗,而且更能善用 CPU 的強大運算能力。
  • 處理序(process)與執行緒(thread)的關係。
  • 與執行緒有關的幾個基本概念,包括:執行緒捱餓(starvation)、context switch。
  • 「處理序優先順序類別」和「執行緒優先順序」只是用來簡化執行緒優先等級的設定,實際上 Windows 只會依執行緒的優先等級作出相應處置,而不會有「調整處理序優先順序」的動作。換言之,作用的對象是執行緒,不是處理序。
  • 建立和摧毀執行緒都需要一些額外成本,包括記憶體空間的配置以及 context switch 的效能損耗。
  • 併發(concurrency)是一個比較廣泛的概念,而多執行緒以及非同步處理都是 concurrency 的一種形式;平行處理(parallel processing)則通常會使用多條執行緒來同執行不同的任務。

直接操控執行緒只是非同步程式設計的其中一種作法,屬於比較低階、而且通常不是最有效率的作法。你可以根據應用程式的需要,混和運用多種作法來實現 concurrency。本書後續章節將會陸續介紹幾種非同步程式設計的寫法。


先這樣,也許有空時會再更新。   我的其他站點:      

Last modified: 2024-11-19