在追求極致系統效能與高吞吐量的現代軟體架構中,作業系統層級的執行緒管理常因其資源消耗與上下文切換成本而成為瓶頸。為此,開發者轉向應用程式層級尋求更輕量的併發解決方案。纖程(Fiber)與綠色執行緒(Green Thread)便是此背景下的重要產物,它們共同構成 M:N 執行緒模型的核心。此模型將併發任務的管理權責從作業系統核心轉移至應用程式運行時(Runtime),由一個專屬的排程器負責調度。這種設計不僅大幅降低了任務切換的開銷,更賦予應用程式對排程策略的完全控制權,使其能根據特定工作負載進行優化,特別適用於 I/O 密集型等需要處理大量併發連線的場景。
纖程與綠色執行緒
注意!
這是一個M:N執行緒的範例。許多任務可以在一個作業系統執行緒上併發運行。
纖程和綠色執行緒通常被稱為有堆疊協程(stackful coroutines)。
「綠色執行緒」這個名稱最初源於Java中使用的M:N執行緒模型的一個早期實現,此後與M:N執行緒的不同實現相關聯。你將會遇到這個術語的不同變體,例如「綠色行程」(在Erlang中使用),它們與我們在這裡討論的不同。你還會看到一些比我們在這裡更廣泛地定義綠色執行緒的定義。
此圖示將展示M:N執行緒模型中,多個使用者層級執行緒如何映射到少數作業系統執行緒上。
@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 "使用者執行緒 1 (纖程/綠色執行緒)" as UT1
component "使用者執行緒 2 (纖程/綠色執行緒)" as UT2
component "使用者執行緒 3 (纖程/綠色執行緒)" as UT3
component "運行時排程器" as RTScheduler
}
package "作業系統核心" {
component "作業系統執行緒 A" as OST_A
component "作業系統執行緒 B" as OST_B
}
UT1 -up-> RTScheduler
UT2 -up-> RTScheduler
UT3 -up-> RTScheduler
RTScheduler --> OST_A : 映射/排程
RTScheduler --> OST_B : 映射/排程
note "M:N 執行緒模型" as MN_Note
MN_Note .down. RTScheduler
@enduml
看圖說話:
此圖示清晰地展示了M:N執行緒模型的架構,其中多個使用者執行緒(M)被映射到少數作業系統執行緒(N)上。在應用程式運行時內部,使用者執行緒1、2、3(通常被稱為纖程或綠色執行緒)由運行時排程器進行管理和排程。這些使用者執行緒是輕量級的,它們的上下文切換開銷遠低於作業系統執行緒。運行時排程器負責將這些使用者執行緒分派到底層的作業系統執行緒A和B上執行。這種模型的好處是可以在應用程式層面實現高度的併發,同時避免了作業系統執行緒的創建和上下文切換所帶來的高昂開銷。這使得應用程式能夠更有效地利用系統資源,特別是在需要處理大量併發任務的場景下,如網路伺服器。
玄貓認為,纖程和綠色執行緒作為一種輕量級的併發機制,透過在應用程式層面管理執行緒,提供了比作業系統執行緒更高的靈活性和潛在效率。然而,它們在堆疊管理方面也帶來了獨特的挑戰,需要精心的設計和實作來克服。
纖程與綠色執行緒
本書中,我們將綠色執行緒定義為與纖程同義,因此這兩個術語在後續的討論中指代相同的概念。
纖程和綠色執行緒的實作意味著存在一個帶有排程器的運行時,該排程器負責排程哪個任務(M)在作業系統執行緒(N)上獲得運行時間。任務的數量遠多於作業系統執行緒的數量,而且這樣的系統只使用一個作業系統執行緒也能完美運行。後者通常被稱為M:1執行緒。
Go協程(Goroutines)是有堆疊協程的一個特定實作範例,但它帶有一些細微的差別。「協程」這個術語通常意味著它們本質上是協作式的,但Go協程可以被運行時搶佔(至少自版本1.14以來),這賦予了它們類似於作業系統執行緒的搶佔能力。這意味著運行時可以儲存CPU的狀態,為每個任務設置一個堆疊,並從一個任務(執行緒)跳轉到另一個任務,然後繼續運行不同的任務。
執行狀態儲存在每個堆疊中,因此在這樣的解決方案中,不需要async、await、Future或Pin。在許多方面,綠色執行緒模仿了作業系統促進併發的方式,實現它們是一個很棒的學習經驗。
使用纖程/綠色執行緒處理併發任務的運行時可以具有高度的靈活性。例如,任務可以在其執行的任何時間點被搶佔和上下文切換,因此一個長時間運行並佔用CPU的任務理論上可以被運行時搶佔,作為一種保護措施,防止因邊緣情況或程式設計師錯誤而導致任務阻塞整個系統。
這使得運行時排程器幾乎擁有與作業系統排程器相同的功能,這是使用纖程/綠色執行緒的系統的最大優勢之一。
典型的流程如下:
- 你運行一些非阻塞程式碼。
- 你對某些外部資源進行阻塞呼叫。
- CPU跳轉到主執行緒,主執行緒排程另一個執行緒運行並跳轉到該堆疊。
- 你在新執行緒上運行一些非阻塞程式碼,直到新的阻塞呼叫或任務完成。
- CPU跳轉回主執行緒,排程一個準備好推進的新執行緒,並跳轉到該執行緒。
此圖示展示了使用纖程/綠色執行緒的程式流程。
@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
participant "應用程式運行時" as Runtime
participant "作業系統執行緒 (OS Thread)" as OST
participant "纖程/綠色執行緒 A" as FiberA
participant "纖程/綠色執行緒 B" as FiberB
participant "外部資源 (e.g., 網路)" as External
OST -> Runtime : 1. OS執行緒啟動運行時
Runtime -> FiberA : 2. 運行時排程Fiber A執行
FiberA -> FiberA : 3. 執行非阻塞程式碼
FiberA -> External : 4. 發出阻塞呼叫 (e.g., 網路請求)
External --> FiberA : 5. 阻塞 (等待回應)
FiberA -> Runtime : 6. Fiber A讓出控制權給運行時
Runtime -> FiberB : 7. 運行時排程Fiber B執行
FiberB -> FiberB : 8. 執行非阻塞程式碼
FiberB -> FiberB : 9. 完成或再次阻塞
FiberB -> Runtime : 10. Fiber B讓出控制權給運行時
External --> FiberA : 11. 外部資源回應 (Fiber A解除阻塞)
Runtime -> FiberA : 12. 運行時重新排程Fiber A執行
FiberA -> FiberA : 13. 繼續執行非阻塞程式碼
FiberA -> Runtime : 14. Fiber A完成或再次讓出
@enduml
看圖說話:
此圖示詳細描繪了纖程/綠色執行緒在應用程式運行時中的執行流程。一個作業系統執行緒啟動運行時後,運行時排程器負責在多個纖程之間切換。當纖程A執行到一個阻塞呼叫(例如網路請求)時,它會將控制權讓出給運行時。此時,作業系統執行緒並不會阻塞,而是運行時會選擇另一個纖程B來執行非阻塞程式碼。當外部資源回應,纖程A解除阻塞後,運行時會重新排程纖程A繼續執行。這種機制使得單個作業系統執行緒能夠高效地處理多個併發任務,避免了作業系統執行緒上下文切換的高昂開銷,從而提高了系統的吞吐量和響應速度。
每個堆疊都有固定空間
由於纖程和綠色執行緒類似於作業系統執行緒,它們也具有一些相同的缺點。每個任務都設置有固定大小的堆疊,因此你仍然需要預留比實際使用更多的空間。然而,這些堆疊可以是可增長的,這意味著一旦堆疊滿了,運行時可以增長堆疊。雖然這聽起來很簡單,但這是一個相當複雜的問題。
我們不能簡單地像樹一樣增長堆疊。實際需要發生的是以下兩種情況之一:
- 你分配一塊新的連續記憶體,並處理你的堆疊分佈在兩個不連續記憶體段的事實。
- 你分配一個新的更大的堆疊(例如,是前一個堆疊的兩倍大小),將所有數據移到新堆疊,然後從那裡繼續。
第一種解決方案聽起來很簡單,因為你可以讓原始堆疊保持原樣,並且你可以在需要時基本上上下文切換到新堆疊並從那裡繼續。然而,如果現代CPU可以在連續的記憶體塊上工作,由於快取以及它們預測下一個指令將處理哪些數據的能力,它們可以非常快速地工作。將堆疊分佈在兩個不連續的記憶體塊上會阻礙性能。當你遇到一個恰好在堆疊邊界處的循環時,這一點尤其明顯,因此你最終在循環的每次迭代中進行多達兩次上下文切換。
第二種解決方案透過移動數據解決了第一種解決方案的問題,但它也帶來了一些問題。首先,你需要分配一個新堆疊並將所有數據移到新堆疊。但會發生什麼?
玄貓認為,纖程和綠色執行緒在提供輕量級併發的同時,也帶來了堆疊管理、上下文切換複雜性以及與外部函數介面(FFI)交互的挑戰。理解這些細節對於設計高效能的運行時至關重要。
纖程與綠色執行緒
當所有指標和引用指向堆疊上的某個位置時,當一切都移動到新位置時會發生什麼?你猜對了:每個指向堆疊上任何位置的指標和引用都需要更新,以便它們指向新位置。這既複雜又耗時,但如果你的運行時已經包含一個垃圾收集器,那麼你已經承擔了跟蹤所有指標和引用的開銷,所以這可能比對於非垃圾收集程式來說問題要小。然而,這確實需要垃圾收集器和運行時之間高度的整合,以便在堆疊每次增長時都這樣做,因此實現這種運行時會變得非常複雜。
其次,你必須考慮如果你有許多長時間運行的任務,這些任務只在短時間內需要大量堆疊空間(例如,如果它在任務開始時涉及大量遞歸),但在其餘時間主要受I/O限制時會發生什麼。你最終會多次增長堆疊,僅僅為了該任務的一個特定部分,你必須決定是接受任務佔用比它所需更多的空間,還是某個時候將其移回較小的堆疊。這對你的程式的影響當然會因你所做的工作類型而異,但這仍然是需要注意的事情。
上下文切換
儘管這些纖程/綠色執行緒與作業系統執行緒相比是輕量級的,但在每次上下文切換時你仍然必須儲存和恢復暫存器。這在大多數情況下可能不是問題,但與不需要上下文切換的替代方案相比,它的效率可能較低。
上下文切換也可能非常複雜才能正確實現,特別是如果你打算支援許多不同的平台。
排程
當一個纖程/綠色執行緒讓出給運行時排程器時,排程器可以簡單地在新任務準備好運行時恢復執行。這意味著你避免了每次讓出給排程器時都被放入系統中所有其他任務的相同運行佇列的問題。從作業系統的角度來看,你的執行緒一直在忙碌地工作,所以如果可能的話,它會盡量避免搶佔它們。
這的一個意想不到的缺點是,大多數作業系統排程器會確保所有執行緒都能獲得一些運行時間。M:N執行緒很可能只使用少數作業系統執行緒(在大多數系統上,每個CPU核心一個執行緒似乎是起點)。因此,根據系統上運行的其他內容,你的程式可能獲得的總時間片比使用許多作業系統執行緒時要少。然而,考慮到大多數現代CPU上可用的核心數量以及併發系統的典型工作負載,這方面的影響應該是最小的。
外部函數介面 (FFI)
由於你創建自己的堆疊,這些堆疊在某些條件下應該增長/縮小,並且可能有一個排程器假定它可以在任何時候搶佔正在運行的任務,因此在使用FFI時你將不得不採取額外措施。大多數FFI函數會假定一個正常的作業系統提供的C堆疊,因此從纖程/綠色執行緒呼叫FFI函數很可能會出現問題。你需要通知運行時排程器,上下文切換到不同的作業系統執行緒,並以某種方式通知排程器你已完成,並且纖程/綠色執行緒可以繼續。這自然會為運行時實現者和進行FFI呼叫的使用者帶來開銷和額外的複雜性。
優勢
- 對使用者來說,它使用簡單。程式碼看起來就像使用作業系統執行緒一樣。
- 上下文切換速度合理。
- 與作業系統執行緒相比,記憶體使用過多的問題較小。
- 你可以完全控制任務的排程方式,如果你願意,可以根據需要優先處理它們。
- 很容易整合搶佔,這可能是一個強大的功能。
此圖示展示了纖程/綠色執行緒在堆疊管理和FFI交互方面的挑戰。
@startuml
skinparam backgroundColor #FEFEFE
@startum
!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 "運行時排程器" as RTScheduler
component "纖程 A" as FiberA
component "纖程 B" as FiberB
}
package "纖程堆疊管理" {
component "纖程 A 堆疊" as StackA
component "纖程 B 堆疊" as StackB
component "堆疊增長機制" as StackGrowth
}
package "外部函數介面 (FFI)" {
component "FFI 呼叫" as FFI
component "標準 C 堆疊" as CStack
}
FiberA -up-> StackA : 使用堆疊
FiberB -up-> StackB : 使用堆疊
StackA --> StackGrowth : 堆疊滿時觸發
StackB --> StackGrowth : 堆疊滿時觸發
StackGrowth -[dashed]-> StackA : 擴展/移動堆疊
StackGrowth -[dashed]-> StackB : 擴展/移動堆疊
FiberA --> FFI : 呼叫外部函數
FFI --> CStack : 依賴標準C堆疊
FFI -[dashed]-> RTScheduler : FFI呼叫時通知排程器
RTScheduler -[dashed]-> OST : 切換到OS執行緒處理FFI
note right of StackGrowth : 複雜的指標更新與記憶體移動
note right of FFI : 需要運行時與OS執行緒協調
end note
end note
@enduml
看圖說話:
此圖示詳細闡述了纖程/綠色執行緒在堆疊管理和與外部函數介面(FFI)交互時所面臨的挑戰。每個纖程(例如纖程A和纖程B)都擁有自己的堆疊。當堆疊空間不足時,堆疊增長機制會被觸發,這涉及複雜的指標更新和記憶體移動操作,以擴展或重新分配堆疊。這是一個性能敏感且容易出錯的環節。此外,當纖程需要進行FFI呼叫時,由於FFI函數通常預期在標準C堆疊上運行,這要求運行時排程器與作業系統執行緒進行協調。纖程必須通知排程器,排程器隨後可能需要將上下文切換到一個專門的作業系統執行緒來處理FFI呼叫,這會增加額外的開銷和複雜性。這些挑戰突顯了實現高效能纖程運行時的難度。
雖然纖程和綠色執行緒在提供輕量級併發方面具有優勢,但其固有的複雜性,尤其是在堆疊管理和與外部系統交互時,是其主要限制。這促使我們探索其他更抽象、更易於管理的併發模型,例如基於回呼函數或期貨/承諾的設計。
結論
深入剖析纖程與綠色執行緒的實作細節後,我們得以洞見併發模型設計中,優雅抽象與底層複雜性之間的恆久張力。此M:N模型在使用者端提供了接近同步編程的直觀體驗,成功隱藏了非同步事件循環的複雜性。然而,這份簡潔的代價,是將堆疊動態增長的複雜性、指標更新的效能損耗,以及與外部函數介面(FFI)交互時的協調開銷,完全轉嫁給了運行時的設計者。
正是這些難以迴避的實作瓶頸,驅動了業界尋求其他併發路徑的創新。例如,無堆疊協程(stackless coroutines)搭配async/await語法糖的崛起,正是為了繞過複雜的堆疊管理,將狀態儲存的責任從運行時轉移到編譯器層面,尋求另一種平衡。
玄貓認為,纖程模型是理解高效能併發演進的關鍵節點,它深刻體現了「抽象洩漏」的挑戰。對高階管理者而言,理解這層技術權衡,不僅有助於評估技術選型,更能培養在面對任何複雜系統時,洞察其「表面簡潔」背後隱藏成本的策略思維。