在現代程式設計中,處理併發是提升效能的關鍵。本文從執行緒的基礎概念切入,釐清作業系統執行緒與使用者層級執行緒的本質差異。我們將聚焦於由作業系統直接管理的 1:1 執行緒模型,探討其運作機制與資源管理方式,以及決定任務推進的核心元件——排程器。透過理解此底層模型,我們才能評估其在處理大量異步 I/O 操作時的適用性與潛在瓶頸,並探討更具彈性的抽象化設計。
程式語言如何模型化異步程式流程
大多數現代作業系統上的作業系統執行緒有很多相似之處。其中一個例子是,大多數CPU都假設存在一個可以執行操作的堆疊,並且它有一個用於堆疊指標的暫存器以及用於堆疊操作的指令。
使用者層級執行緒,從最廣泛的意義上講,可以指任何建立和排程任務的系統(運行時)實現,你不能像對作業系統執行緒那樣做出相同的假設。
它們可以非常接近作業系統執行緒,正如我們在第五章討論纖程/綠色執行緒範例時將看到的,或者它們的本質可能截然不同,正如我們在本書第三部分稍後探討Rust如何模型化併發操作時將看到的。
無論定義如何,一組任務都需要一個管理者來決定誰獲得什麼資源才能推進。電腦系統上所有任務都需要推進的最明顯資源是CPU時間。我們將決定誰獲得CPU時間以推進的「某物」稱為排程器。
當有人在沒有額外上下文的情況下提到「執行緒」時,他們很可能指的是作業系統執行緒/核心執行緒,所以我們將繼續這樣稱呼。
玄貓也將繼續將執行緒簡單地稱為任務。玄貓發現,當我們盡可能限制使用那些根據上下文具有不同假設的術語時,異步程式設計的主題更容易理解。
既然已經解決了這個問題,讓我們來探討作業系統執行緒的一些定義特性,同時也強調它們的局限性。
重要提示!
定義會因你閱讀的書籍或文章而異。例如,如果你閱讀有關特定作業系統如何工作的內容,你可能會看到行程或執行緒是代表「任務」的抽象,這似乎與我們在這裡使用的定義相矛盾。正如玄貓之前提到的,參考框架的選擇很重要,這就是為什麼我們如此小心地在本書中遇到它們時徹底定義我們使用的術語。
執行緒的定義也可能因系統而異,儘管大多數流行系統今天共享相似的定義。最值得注意的是,Solaris(Solaris 9之前,於2002年發布)曾經有一個兩層執行緒系統,區分了應用程式執行緒、輕量級行程和核心執行緒。這是我們所稱的M:N執行緒的實現,我們將在本書後面了解更多。請注意,如果你閱讀較舊的材料,此類系統中執行緒的定義可能與當今常用的定義顯著不同。
既然我們已經完成了本章最重要的定義,現在是時候討論程式設計中最流行的併發處理方式了。
作業系統提供的執行緒
我們稱之為1:1執行緒。每個任務都被分配一個作業系統執行緒。
由於本書不會特別關注作業系統執行緒作為處理併發的方式,我們在這裡更徹底地討論它們。
讓我們從顯而易見的開始。要使用作業系統提供的執行緒,你需要一個作業系統。在我們討論使用執行緒作為處理併發的手段之前,我們需要明確我們正在談論哪種類型的作業系統,因為它們有不同的類型。
嵌入式系統現在比以往任何時候都更為普遍。這類硬體可能沒有作業系統的資源,如果有的話,你可能會使用一種完全不同的、為你的需求量身定制的作業系統,因為這些系統往往通用性較低,專業性較強。
它們對執行緒的支援,以及它們如何排程執行緒的特性,可能與你在Windows或Linux等作業系統中習慣的不同。
由於涵蓋所有不同的設計本身就是一本書,我們將範圍限制在討論在流行桌面和伺服器CPU上運行的Windows和基於Linux的系統中使用的執行緒。
此圖示將展示作業系統執行緒與使用者層級執行緒的關係,以及它們與排程器的互動。
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
package "應用程式" {
component "使用者層級執行緒 (User-level Threads)" as ULT
component "運行時排程器 (Runtime Scheduler)" as RT_Scheduler
}
package "作業系統" {
component "作業系統執行緒 (OS Threads)" as OST
component "核心排程器 (Kernel Scheduler)" as K_Scheduler
}
ULT --> RT_Scheduler : 創建與排程
RT_Scheduler --> OST : 映射到OS執行緒 (M:N 或 1:1)
OST --> K_Scheduler : 由核心排程器管理
K_Scheduler --> CPU : 分配CPU時間
note bottom of ULT : 輕量級,由應用程式管理\n上下文切換開銷低
note bottom of OST : 重量級,由OS核心管理\n上下文切換開銷高
end note
end note
@enduml看圖說話:
此圖示展示了應用程式中的使用者層級執行緒(ULT)與作業系統中的作業系統執行緒(OST)之間的關係,以及它們如何透過各自的排程器與CPU互動。使用者層級執行緒由運行時排程器在應用程式內部創建和管理,它們通常比作業系統執行緒更輕量,上下文切換開銷較低。這些ULT會被映射到作業系統執行緒上,這種映射可以是M:N(多個ULT對應多個OST)或1:1(一個ULT對應一個OST)。作業系統執行緒則由核心排程器進行管理,核心排程器負責將CPU時間分配給這些OST。這個分層的排程機制允許應用程式在使用者空間實現高效的併發,同時依賴作業系統來提供底層的資源管理和保護。理解這兩種執行緒的特性及其排程方式,對於設計高效能的併發程式至關重要。
玄貓認為,作業系統執行緒雖然提供了一種直接且相對簡單的併發模型,但其內在的開銷和複雜性,特別是在資源管理和上下文切換方面,是程式設計師在追求高效能和可擴展性時必須深入理解和權衡的。
作業系統提供的執行緒:優勢與挑戰
作業系統執行緒的實現和使用相對簡單,因為我們只需讓作業系統為我們處理一切。透過這種方式,我們「免費」獲得了平行性。然而,直接管理平行性和共享資源也帶來了一些缺點和複雜性。
創建新執行緒需要時間
創建一個新的作業系統執行緒涉及一些簿記和初始化開銷,因此,雖然在同一行程中兩個現有執行緒之間的切換非常快,但創建新執行緒和丟棄不再使用的執行緒涉及需要時間的工作。如果系統需要創建和丟棄大量執行緒,所有這些額外的工作將限制吞吐量。如果你有大量需要併發處理的小任務,這可能是一個問題,這在處理大量I/O時經常發生。
每個執行緒都有自己的堆疊
我們將在本書後面詳細介紹堆疊,但目前,只需知道它們佔用固定大小的記憶體即可。每個作業系統執行緒都帶有自己的堆疊,儘管許多系統允許配置此大小,但它們的大小仍然是固定的,不能增長或縮小。畢竟,它們是堆疊溢出的原因,如果你將它們配置得太小而無法執行你正在運行的任務,這將是一個問題。
如果我們有許多只需要少量堆疊空間的小任務,但我們預留了比我們所需多得多的空間,我們將佔用大量記憶體,並可能耗盡記憶體。
上下文切換
如你現在所知,執行緒和排程器緊密相連。上下文切換發生在CPU停止執行一個執行緒並繼續執行另一個執行緒時。儘管這個過程經過高度優化,但它仍然涉及儲存和恢復暫存器狀態,這需要時間。每次你讓出給作業系統排程器時,它都可以選擇在該CPU上排程來自不同行程的執行緒。
你看,由作業系統創建的執行緒,當它啟動一個行程時,該行程會創建至少一個初始執行緒,並在其中執行你編寫的程式。每個行程可以產生多個執行緒,這些執行緒共享相同的位址空間。
這意味著同一行程中的執行緒可以存取共享記憶體,並且可以存取相同的資源,例如檔案和檔案句柄。這樣做的一個結果是,當作業系統透過上下文切換來切換執行緒時,它不必儲存和恢復與該行程相關的所有狀態,只需儲存和恢復特定於該執行緒的狀態。
另一方面,當作業系統從與一個行程相關的執行緒切換到與另一個行程相關的執行緒時,新行程將使用不同的位址空間,作業系統需要採取措施確保行程「A」不會存取屬於行程「B」的數據或資源。如果沒有這樣做,系統就不會安全。
結果是快取可能需要被清除,並且可能需要儲存和恢復更多的狀態。在高併發系統在負載下,這些上下文切換可能會花費額外的時間,從而影響性能。每次你讓出給作業系統時,你都會被放入系統中所有其他執行緒和行程的相同佇列中。
此外,由於無法保證執行緒將在與其離開時相同的CPU核心上恢復執行,或者兩個任務不會平行運行並嘗試存取相同的數據,因此你需要同步數據存取以防止數據競爭和多核心程式設計相關的其他陷阱。
Rust作為一種語言將幫助你防止許多這些陷阱,但同步數據存取將需要額外的工作並增加此類程式的複雜性。我們常說使用作業系統執行緒來處理併發是「免費」獲得平行性,但它在增加複雜性和需要適當數據存取同步方面並非「免費」。
此圖示將展示作業系統執行緒的生命週期及其與排程器、堆疊和上下文切換的關係。
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
state "已創建 (Created)" as Created
state "就緒 (Ready)" as Ready
state "執行中 (Running)" as Running
state "阻塞 (Blocked)" as Blocked
state "終止 (Terminated)" as Terminated
Created --> Ready : 初始化完成
Ready --> Running : 排程器調度
Running --> Ready : 時間片用盡 / 自願讓出
Running --> Blocked : 等待I/O / 鎖定資源
Blocked --> Ready : I/O完成 / 資源釋放
Running --> Terminated : 任務完成
Ready --> Terminated : 被取消
note right of Running : 每個執行緒有獨立堆疊\n共享行程位址空間
note "上下文切換開銷" as ContextSwitchNote
Running -[hidden]right-> Ready
Ready -[hidden]right-> Running
ContextSwitchNote .up. Running
end note
@enduml看圖說話:
此圖示描繪了作業系統執行緒的典型生命週期,從創建到終止,並強調了其在不同狀態之間的轉換。一個執行緒在初始化完成後進入就緒狀態,等待排程器的調度。當排程器選擇一個執行緒執行時,它進入執行中狀態。在執行過程中,執行緒可能因為時間片用盡或自願讓出而回到就緒狀態,等待下一次調度。如果執行緒需要等待I/O操作或鎖定資源,它會進入阻塞狀態,直到I/O完成或資源釋放後才回到就緒狀態。最終,當任務完成或被取消時,執行緒進入終止狀態。圖中也特別指出,每個執行緒都擁有獨立的堆疊,但共享行程的位址空間,這使得它們可以存取共享記憶體。此外,從一個執行緒切換到另一個執行緒的上下文切換會產生一定的開銷,這是影響系統性能的重要因素。
將異步操作與作業系統解耦的優勢
將異步操作從作業系統執行緒的直接管理中解耦,可以帶來顯著的優勢。
玄貓認為,將異步操作從作業系統執行緒的直接管理中解耦,是現代高效能併發程式設計的必然趨勢。這種抽象層次的引入,賦予了運行時更大的彈性,以適應不同的執行環境和優化策略。
將異步操作與作業系統執行緒解耦的優勢
將異步操作從執行緒的概念中解耦具有許多好處。
首先,使用作業系統執行緒作為處理併發的手段,要求我們使用本質上是作業系統抽象來表示我們的任務。
擁有一個單獨的抽象層來表示併發任務,使我們能夠自由選擇如何處理併發操作。如果我們在併發操作之上創建一個抽象,例如Rust中的期貨(future)、JavaScript中的承諾(promise)或Go中的Go協程(goroutine),那麼如何處理這些併發任務就由運行時的實現者來決定。
一個運行時可以簡單地將每個併發操作映射到一個作業系統執行緒,它們可以使用**纖程(fibers)/綠色執行緒(green threads)或狀態機(state machines)**來表示任務。如果底層實現發生變化,編寫異步程式碼的程式設計師不一定需要更改其程式碼。理論上,如果存在一個運行時,相同的異步程式碼可以在沒有作業系統的微控制器上用於處理併發操作。
總結來說,使用作業系統提供的執行緒:
- 易於理解
- 易於使用
- 任務之間的切換速度合理
- 免費獲得平行性
然而,它們也有一些缺點:
- 作業系統層級的執行緒帶有相當大的堆疊。 如果你有許多任務同時等待(就像在高負載下的網路伺服器中那樣),你很快就會耗盡記憶體。
- 上下文切換可能成本高昂,並且你可能會獲得不可預測的性能,因為你讓作業系統執行所有排程。
- 作業系統有許多事情需要處理。 它可能不會像你希望的那樣快地切換回你的執行緒。
- 它與作業系統抽象緊密耦合。 這在某些系統上可能不是一個選項。
範例
由於我們在本書中不會花更多時間討論作業系統執行緒,我們將透過一個簡短的範例來展示它們的使用方式:
// ch02/aa-os-threads
use std::thread::{self, sleep};
use std::time::Duration;
fn main() {
println!("程式從這裡開始執行!");
let t1 = thread::spawn(move || {
sleep(Duration::from_millis(200));
println!("長時間運行的任務最後完成!");
});
let t2 = thread::spawn(move || {
sleep(Duration::from_millis(100));
println!("我們可以串聯回呼函數...");
let t3 = thread::spawn(move || {
sleep(Duration::from_millis(50));
println!("...就像這樣!");
});
t3.join().unwrap();
});
println!("這些任務是併發執行的!");
t1.join().unwrap();
t2.join().unwrap();
}
在這個範例中,我們只是產生了幾個作業系統執行緒並讓它們休眠。休眠本質上與讓出給作業系統排程器並請求在一定時間過後重新排程運行是相同的。為了確保我們的主執行緒在子執行緒有時間運行之前不會完成並退出(這將導致行程退出),我們在主函數的末尾等待它們完成。
如果我們運行這個範例,我們將看到操作以不同的順序發生,這取決於我們讓每個執行緒讓出給排程器的時間長度:
程式從這裡開始執行!
這些任務是併發執行的!
我們可以串聯回呼函數...
...就像這樣!
長時間運行的任務最後完成!
因此,雖然使用作業系統執行緒對於許多任務來說非常棒,但我們也列舉了一些充分的理由來尋找替代方案。我們將要探討的第一個替代方案是我們所稱的纖程(Fibers)和綠色執行緒(Green Threads)。
檢視傳統1:1執行緒模型在高併發情境下的實踐瓶頸,我們清晰地看到,其設計初衷與現代高效能應用的需求已出現顯著落差。作業系統執行緒雖然提供了直接的平行處理能力,但其創建開銷、固定的高昂堆疊記憶體,以及不可預測的核心級上下文切換成本,共同構成了效能天花板。這種將應用層「任務」與底層「執行緒」僵化綁定的模式,限制了資源調度的彈性與粒度。
將異步操作抽象化為期貨(Futures)或類似概念,正是為了解決此一核心矛盾。它將「任務定義」與「任務執行」分離,賦予運行時(Runtime)更精細的排程權力,從而能以更低的記憶體佔用和更可控的切換成本,管理數以萬計的併發任務。此一思維轉變預示著,未來高效能程式設計的焦點,將從直接操作作業系統資源,轉向精通特定語言的異步運行時生態。開發者需從「管理執行緒」的思維,進化到「描述任務依賴關係」,讓智慧化的排程器在使用者層級實現最佳化。
綜合評估後,玄貓認為,將併發任務與作業系統執行緒解耦的抽象化路徑,不僅是一種技術突破,更是應對未來大規模併發挑戰的根本性策略,值得所有追求極致效能的開發者深入掌握。