這是一份關於 SBCL 用戶端輕量級協同式執行緒的草稿文件,目前仍在積極開發中,細節可能會有所變動。這是一份活文件,您可以查看其修訂歷史。您可以在 GitHub 上的 fibers-v2 分支上進行嘗試:github.com/atgreen/sbcl。

附錄 A:將 Hunchentoot 與 Fibers 搭配使用

許多伺服器工作負載是併發的,但非並行的。一個處理 10,000 個連線的 Web 伺服器幾乎所有時間都花在等待網路 I/O 上;每個請求的實際計算量微乎其微。自然的程式設計模型是每個連線一個控制執行緒——讀取請求、計算回應、寫回——但作業系統執行緒的開銷對於大規模使用來說太高了。

SBCL 中的每個作業系統執行緒都帶有一個完整大小的控制堆疊(通常為 8 MB)、一個繫結堆疊、信號處理基礎結構和一個核心 task_struct。建立執行緒需要 mmap、clone 和 TLS 設定;銷毀一個則需要反向操作。執行緒之間的上下文切換需要核心轉換、TLB 管理和排程器記錄。對於 10,000 個併發連線,僅堆疊就需要 80 GB 的虛擬位址空間,而核心排程器——設計用於處理數十到數百個可執行任務——開始出現效能下降。

傳統的替代方案是事件驅動程式設計:單一執行緒使用 epoll 或 kqueue 多工處理連線,並在 I/O 就緒時分派回呼。這可以很好地擴展,但會反轉控制流程。順序邏輯必須分解為狀態機或延續鏈。錯誤處理、資源清理和除錯都變得更加困難。堆疊追蹤顯示的是事件迴圈,而不是正在服務的請求的邏輯呼叫堆疊。

Fibers 提供第三種選擇。Fiber 是一種用戶端執行緒,擁有自己的控制堆疊和繫結堆疊,由函式庫層級的排程器協同調度,而非核心。Fibers 保留了順序式程式設計模型——程式碼從上到下讀取,如同正常執行緒一樣——同時實現了事件驅動 I/O 的資源效率。Fiber 的控制堆疊預設為 256 KB(而非 8 MB),上下文切換在用戶端儲存和恢復六個暫存器(而非完整核心轉換),並且數千個 Fibers 可以共用一小池作業系統載體執行緒。

Common Lisp 使情況更加複雜,因為該語言比大多數語言攜帶更多的隱式每執行緒狀態:特殊變數繫結存在於單獨的繫結堆疊中,非局部退出透過 catch 標籤和 unwind-protect 鏈進行傳遞,所有這些都必須在掛起點正確儲存和恢復。僅切換控制堆疊的 Fiber 實作將損壞此狀態。SBCL 的 Fiber 實作處理了所有這些:在 Fiber 中進行的動態繫結僅限於該 Fiber,unwind-protect 清理表單在 Fiber 終止時執行,而 handler-case / handler-bind 的行為符合預期。

實作受到幾個優先順序的指導,大致順序如下:

垃圾回收 (GC) 正確性。SBCL 使用世代、壓縮、停止世界的垃圾回收器。GC 必須能夠找到所有活躍的 Lisp 物件,包括那些在掛起的 Fiber 堆疊和 Fiber 繫結堆疊中的物件。若此處出錯,將導致靜默的堆積損壞。Fiber 運行時的每個設計決策都受到 GC 可能在幾乎任何點觸發(唯一例外是 without-gcing 區域)且必須看到所有 Fiber 狀態一致檢視的要求的約束。

透明整合。現有的 SBCL 程式碼應能在 Fiber 中無需修改即可運作。grab-mutex、condition-wait、wait-until-fd-usable、sleep 和 wait-for 會偵測到它們正在 Fiber 中執行,並協同讓出而非阻塞載體執行緒。非 Fiber 感知的程式碼無需更改。無法安全讓出的程式碼(例如,在多步驟外部函式庫互動的中間)可以釘住 Fiber,使阻塞原語能夠直接呼叫其作業系統實作。

每次切換的低開銷。上下文切換的熱路徑——yield-to-scheduler-to-resume——必須快速,因為 Fiber 的切換頻率遠高於作業系統執行緒。實作目標是透過使用手寫的組合語言例程來實現亞微秒切換,該例程僅儲存呼叫者儲存的暫存器,在切換路徑上進行零堆積分配,並從每次切換的程式碼中消除所有互斥鎖獲取。

擴展到大量 Fibers。系統應能處理數萬個併發 Fiber 而不降低效能。這需要每個 Fiber 的記憶體佔用空間小(256 KB + 16 KB 堆疊,用於回收),O(1) 或 O(log N) 的調度操作,以及避免每個 Fiber 輪詢的高效 I/O 多工處理。

多核心利用。當有足夠的工作時,載體執行緒池應能讓所有核心保持忙碌。閒置的載體不應浪費 CPU。工作應在程式設計師干預的情況下在載體之間遷移。實作使用無鎖工作竊取雙端佇列(Chase-Lev),因此忙碌的載體從不爭用共享佇列,而閒置的載體可以在沒有鎖的情況下從忙碌的載體中獲取工作。

Fiber:一種輕量級的協同式執行緒,擁有自己的控制堆疊和繫結堆疊,在用戶端空間進行調度。

載體執行緒(或載體):執行 Fiber 的作業系統執行緒。每個載體都有自己的排程器。一個 Fiber 在任何時間點僅在一個載體上執行,但可以透過工作竊取在載體之間遷移。

排程器:每個載體的結構,管理執行佇列(工作竊取雙端佇列)、等待列表、截止日期堆疊和 I/O 多工處理器狀態。每個載體執行緒有一個排程器。

排程器群組:一組排程器(每個載體一個),透過工作竊取進行協同。Fiber 生命週期管理的單位:Fiber 被提交到群組,結果從中收集。

Yield:Fiber 自願掛起自身,將控制權交還給載體執行緒的排程器。然後排程器可以在同一載體上執行另一個 Fiber。

Resume:排程器恢復掛起的 Fiber 的上下文並將控制權轉移給它。

Pin:暫時阻止 Fiber 讓出。在釘住期間,阻塞原語會直接呼叫其作業系統實作,阻塞載體執行緒。

喚醒條件:與掛起的 Fiber 相關聯的述詞函式。排程器會定期呼叫它;當它返回 true 時,Fiber 變為可執行。

Deque(雙端佇列):每個載體的執行佇列,一個 Chase-Lev 工作竊取雙端佇列。擁有者從底部(LIFO)推入和彈出;竊取者透過 CAS 從頂部(FIFO)竊取。

工作竊取:當載體的本地執行佇列為空時,它會嘗試從另一個載體的佇列中獲取一個 Fiber。這會在沒有集中調度的情況下平衡跨核心的負載。

所有公開符號都從 SB-THREAD 套件匯出。當使用 :sb-fiber 功能建置 SBCL 時(透過將 :sb-fiber 添加到 local-target-features.lisp-expr 來啟用),Fiber API 可用。

Fiber 使用 make-fiber 建立,並提交到排程器群組執行。它在排程器選取它之前不會開始執行。

function — 一個無參數的函式。這是 Fiber 的進入點。當它正常返回時,所有返回值都作為一個列表捕獲在 Fiber 的結果槽中(可透過 fiber-result 檢索,或透過 fiber-join 作為多個值)。如果它發出未處理的錯誤,則條件物件將作為結果。

name — 一個可選的字串,用於除錯。出現在 print-object 輸出和堆疊追蹤註釋中。

stack-size — Fiber 控制堆疊的大小(以位元組為單位)。預設為 256 KB。這是可用區域;額外的保護頁(通常為 4 KB)分配在其下方,用於堆疊溢位偵測。堆疊從每個大小的池中提取並回收,使用 madvise(MADV_DONTNEED),因此建立的成本通常是池命中而非新的 mmap。

binding-stack-size — Fiber 繫結堆疊的大小(以位元組為單位)。預設為 16 KB。每個 (let ((*var* val)) ...) 或 progv 表單都會推入一個雙字條目(舊值 + TLS 索引),因此 16 KB 可以容納 1024 個巢狀特殊變數繫結。在可用區域上方分配了一個保護頁;溢位會觸發一個段錯誤,並帶有診斷訊息,就像控制堆疊溢位一樣。

initial-bindings — 一個 (symbol . value) 對的 alist。這些在呼叫 Fiber 的函式之前被建立為動態繫結(透過 progv),類似於 sb-thread:make-thread 的 :initial-bindings 引數。alist 中的值直接使用(不評估);呼叫者負責在傳遞 alist 之前計算它們。這是為每個 Fiber 提供其專有特殊變數副本的主要機制:

建立後,Fiber 處於 :created 狀態。必須將其提交給排程器才能執行(參見 2.7 節)。

fiber-yield 暫停當前 Fiber 並將控制權交還給載體執行緒的排程器。然後排程器可以在同一載體上執行另一個 Fiber。當 Fiber 最終恢復時,fiber-yield 正常返回,並從掛起點繼續執行。

如果提供了 wake-condition,它必須是一個無參數的函式。排程器會定期呼叫它;當它返回 true 值時,Fiber 會從等待列表移到執行佇列。如果 wake-condition 為 nil,Fiber 會立即再次變得可執行(它會被推回排程器的 deque,並在後續的迭代中被選取)。

如果在 Fiber 上下文之外呼叫 fiber-yield,或者 Fiber 被釘住(pin-count > 0),則會發出錯誤。

Yield 路徑的設計是快速的。它儲存 Fiber 的繫結堆疊指標、catch 區塊和 unwind-protect 鏈;可選地儲存 TLS overlay 值(當繫結堆疊為空時跳過);恢復載體執行緒的繫結狀態;然後執行一個暫存器級別的上下文切換回排程器。整個操作涉及零堆積分配和零互斥鎖獲取。

將當前 Fiber 暫停至少 seconds 秒(可以是小數)。透過在 fiber struct 中設定截止日期並讓出(沒有喚醒條件)來實現。排程器的截止日期堆疊在 get-internal-real-time 經過目標時間後過期 Fiber。這避免了為喚醒條件分配閉包——排程器直接檢查截止日期欄位。

fiber-sleep 與標準的 cl:sleep 函式整合。當在 Fiber 中呼叫 sleep 時,SBCL 會自動分派到 fiber-sleep。如果 Fiber 被釘住,sleep 會直接呼叫作業系統實作(阻塞載體執行緒)。

fiber-park 是通用掛起原語。它結合了喚醒述詞和可選的超時:

predicate — 一個無參數的函式。排程器在維護傳遞中呼叫它;當它返回 true 時,Fiber 會被喚醒。

timeout — 秒數(可以是小數),之後 Fiber 會被喚醒,無論述詞如何。

如果述詞得到滿足,則返回 t,如果超時先到期,則返回 nil。

內部來說,fiber-park 將超時儲存為 Fiber 的 deadline slot 中的截止日期,並將述詞傳遞給 fiber-yield。排程器在其維護迴圈中處理截止日期過期和述詞檢查,因此 fiber-park 本身除了呼叫者傳入的內容外,不分配任何東西。這種雙通道喚醒設計(述詞或截止日期)是所有更高級別等待操作的建構塊:互斥鎖獲取、條件變數、I/O 等待和 fiber-join。

對於純粹的定時等待且沒有喚醒條件,請使用 fiber-sleep(2.3 節)而不是帶有虛擬述詞的 fiber-park。fiber-park 用於需要等待直到條件為真的情況。

等待直到 Fiber 目標完成,並返回 Fiber 函式的返回值(透過 values-list),類似於 join-thread。如果 Fiber 因未處理的錯誤而終止,單一返回值是條件物件;使用 fiber-error-p 來區分它與正常返回。

如果超時先到期,則返回 nil(單一值)。Fiber 不能加入自身。

從 Fiber:使用檢查 (eq (fiber-state target) :dead) 的述詞來掛起呼叫的 Fiber。這是協同的——呼叫的 Fiber 讓出,其他 Fiber 在同一載體上繼續執行。

從作業系統執行緒:以 1 毫秒的間隔進行檢查。這是用於收集 Fiber 群組結果的執行緒的適當行為。

一個正在執行的 Fiber 可以使用相同的 make-fiber 和 submit-fiber API 來建立和提交新的 Fiber。新的 Fiber 會被提交到當前排程器的群組(如果存在),使其對工作竊取基礎結構可見:

沒有隱含的父子關係。產生 Fiber 在子 Fiber 執行時不會被掛起;兩者都是獨立可調度的。父項必須明確加入才能獲取子項的結果。

當 submit-fiber 目標為排程器群組時,Fiber 會被推送到隨機選擇的載體佇列(一個執行緒安全的原子列表)中,並喚醒一個已掛起的載體(如果存在)。如果所有載體都忙碌,Fiber 會保留在待處理佇列中,直到下一次維護傳遞將其清空。可以從任何執行緒安全地呼叫此函式,包括非載體執行緒和其他載體上的 Fiber。

提供了兩種 API 風格:用於批次式工作負載的簡單阻塞介面,以及用於長期伺服器應用程式的動態介面。

建立一個具有 carrier-count 個載體執行緒(預設為 Linux 上的可用 CPU 數量,支援 cgroup)的排程器群組,將 Fiber 輪流分配到載體上,執行它們直到完成,並按輸入順序返回它們的結果列表。這是 start-fibers + finish-fibers 的便利包裝器。

對於隨時間提交工作的應用程式(例如,Web 伺服器為每個接受的連線提交一個新 Fiber):

啟動 carrier-count 個載體執行緒,並輪流分配 initial-fibers。立即返回一個 fiber-scheduler-group 控制柄。所有載體都在背景執行緒上執行。

將一個新的 Fiber 提交到群組。原子地增加群組的活動計數(以便載體知道不要退出),將 Fiber 推送到隨機選擇的載體待處理佇列,並喚醒一個已掛起的載體(如果存在)。可以從任何執行緒安全地呼叫此函式。

finish-fibers 阻塞直到群組中的所有 Fiber 都完成,然後返回它們的結果。fiber-group-done-p 是一個非阻塞檢查,當群組的活動計數達到零時返回 t。

動態 API 是伺服器整合的預期介面。伺服器接受迴圈建立 Fiber 並提交它們;載體執行緒執行它們;Fiber 在 I/O 上讓出並在資料可用時恢復。載體計數控制並行性,工作竊取使所有載體保持忙碌,無論 Fiber 被提交到哪個載體。

現有的 SBCL 阻塞原語會偵測到 Fiber 上下文並協同讓出,而不是阻塞載體執行緒。這是透明整合的關鍵:呼叫 grab-mutex 或 sleep 的函式庫程式碼在 Fiber 中無需修改即可正常運作。

每個原語都遵循相同的模式:檢查是否在 Fiber 中執行以及 Fiber 是否可以讓出(未釘住);如果是,則掛起 Fiber 並帶有適當的喚醒條件;如果釘住,則呼叫配置的 *pinned-blocking-action* 並直接呼叫作業系統實作。

當 Fiber 呼叫 sb-thread:grab-mutex 且互斥鎖被爭用時,Fiber 會掛起並帶有一個檢查互斥鎖狀態是否為 free 的述詞。喚醒後,它會嘗試 CAS 來獲取互斥鎖。如果 CAS 失敗(另一個 Fiber 先取得),則 Fiber 會再次掛起。此重試迴圈是必要的,因為喚醒條件是定期檢查的,而不是與狀態變更原子地進行。

在 Fiber 上下文中 sb-thread:condition-wait:釋放互斥鎖,用一個世代計數器述詞(當 condition-notify 增加 waitqueue 的世代時喚醒)掛起 Fiber,在喚醒時重新獲取互斥鎖。世代計數器避免了錯過的喚醒——如果 condition-notify 在互斥鎖釋放和讓出之間觸發,當述詞首次檢查時,世代已經改變。

Semaphore 操作(wait-on-semaphore、signal-semaphore)遵循與互斥鎖相同的模式:掛起直到計數為正,然後 CAS 減一。

在 Fiber 上下文中,這會將檔案描述符註冊到排程器的事件多工處理器(Linux 上的 epoll,BSD 上的 kqueue)並掛起 Fiber。在 Linux 上使用 epoll 可用時,註冊使用邊緣觸發模式和單次拍攝(EPOLLET | EPOLLONESHOT):核心在 fd 準備好時傳遞單一事件,然後禁用註冊。這消除了虛假喚醒,並避免了當多個 Fiber 等待相關 fd 時的雷鳴牛問題。

排程器的空閒掛鉤在沒有可執行 Fiber 時呼叫 epoll_wait(Linux)或批次 poll()(其他地方),喚醒 fd 已準備好的 Fiber。Fiber 將其等待的 fd 和方向儲存在 struct 欄位中,以便排程器可以 O(1) 分派到 fd 到 Fiber 的表中。

當 epoll 不可用時(或在非 Linux 平台上),備用方案是針對所有等待的 fd 進行批次 poll() 呼叫。

cl:sleep 在 Fiber 上下文中呼叫時會分派到 fiber-sleep(2.3 節)。

sb-ext:wait-for 分派到 fiber-park,將測試函式作為述詞,並將超時轉換為截止日期。

Pinning 會增加每個 Fiber 的計數器;unpinning 會減少它。Pin 計數非零的 Fiber 無法讓出。任何嘗試在釘住時呼叫 fiber-yield 的行為都會發出錯誤。

釘住的目的是保護不能容忍掛起的程式碼區域。主要情況是執行緒親和的外部狀態:許多 C 函式庫在內部使用執行緒區域儲存(OpenSSL 上下文、資料庫控制柄、GPU 上下文)。如果 Fiber 在互動中間讓出,並稍後透過工作竊取在不同的載體上恢復,C 函式庫的執行緒區域狀態屬於舊載體——Fiber 現在位於錯誤的作業系統執行緒上。這在實務中應該很少見,但釘住提供了一個簡單的防護措施。

當一個釘住的 Fiber 呼叫阻塞原語(grab-mutex、sleep、wait-until-fd-usable 等)時,原語會偵測到釘住並直接呼叫作業系統實作,阻塞載體執行緒。變數 *pinned-blocking-action* 控制如何報告此情況:

此策略存在的原因是阻塞載體執行緒會降低該載體上所有 Fiber 的吞吐量。警告有助於開發人員識別應重構以避免在釘住時阻塞的程式碼,或者確實需要釘住的程式碼(這種情況下,策略可以設定為 nil)。

with-fiber-pinned 是推薦的介面。它在進入時釘住,執行主體,並在 unwind-protect 清理表單中取消釘住,確保即使主體發出錯誤,Pin 計數也始終恢復。

返回全域 Fiber 列表的副本。包括所有已建立且尚未銷毀的 Fiber(即所有狀態::created、:runnable、:running、:suspended、:dead)。列表在互斥鎖下捕獲,以提供一致的快照。

返回 Fiber 的當前狀態::created(已建立但尚未提交)、:runnable(在執行佇列上)、:running(在載體上積極執行)、:suspended(已讓出並帶有喚醒條件或截止日期),或 :dead(函式返回或發出錯誤)。

fiber-result 在 Fiber 死亡後返回 Fiber 的結果值作為列表(或錯誤時包含條件物件的單元素列表)。使用 fiber-join 透過 values-list 接收值。fiber-alive-p 是 (not (eq (fiber-state fiber) :dead)) 的簡寫。

為掛起的 Fiber 列印完整的符號堆疊追蹤,使用 SBCL 的標準除錯器基礎結構。產生與 print-backtrace 相同的人類可讀輸出——函式名稱、引數、來源位置和局部變數。內部它將除錯堆疊邊界綁定到 Fiber 的控制堆疊,並從 fiber-top-frame 開始遍歷框架。僅適用於 :suspended 或 :created 的 Fiber;正在執行的 Fiber 的堆疊位於載體上並正在積極修改。

空閒掛鉤是一個接受一個參數(排程器)的函式,當排程器沒有可執行 Fiber 但有掛起的 Fiber 在等待條件時被呼叫。掛鉤的任務是有效地阻塞載體執行緒,直到有事情發生——通常是透過執行 I/O 多工處理。

預設的空閒掛鉤 (fiber-io-idle-hook) 會呼叫 epoll_wait(Linux)或批次 poll()(其他地方),超時時間來自排程器截止日期堆疊中最近的截止日期。當 fd 事件到達時,對應的 Fiber 會被移到執行佇列。

自訂空閒掛鉤對於將 Fiber 與外部事件來源整合很有用,這些來源不是檔案描述符。例如,訊息佇列消費者可能會在其空閒掛鉤中檢查新訊息。掛鉤應阻塞有限的時間(預設使用最近的截止日期作為其超時),以確保基於截止日期的 Fiber 不會被餓死。

Fiber 運行時圍繞兩級層次結構組織:載體執行緒和排程器。

每個載體執行緒都是一個正常的 SBCL 作業系統執行緒 (sb-thread:thread),它執行一個排程器迴圈。排程器迴圈從本地工作竊取雙端佇列中提取 Fiber,執行它們直到它們讓出或死亡,處理切換後的記錄,然後重複。當雙端佇列為空時,排程器執行維護(清空待處理佇列、檢查喚醒條件、過期截止日期、輪詢 I/O)並嘗試從兄弟排程器竊取工作。

排程器群組 (fiber-scheduler-group) 將多個排程器綁定在一起。每個群組都有一個排程器結構向量(每個載體一個)、一個用於生命週期追蹤的共享活動計數、用於載體掛起的互斥鎖和條件變數,以及用於結果收集的已提交 Fiber 列表。

排程器和群組之間的區分是故意的。單個載體和單個排程器是最簡單的配置:沒有工作竊取,沒有跨執行緒協調。群組中的多個載體透過工作竊取增加並行性。群組的活動計數是協調機制:當它達到零時,載體會關閉;submit-fiber 會原子地增加它並喚醒一個掛起的載體。

:created — make-fiber 返回處於此狀態的 Fiber。它已分配堆疊和 GC 資訊記錄,但尚未提交給任何排程器。

:runnable — submit-fiber 或喚醒事件將 Fiber 轉換為此狀態。Fiber 位於某個排程器的 deque 或待處理佇列上,等待被選取。

:running — 排程器已從 deque 中取出 Fiber 並正在載體上執行它。每個載體最多只能有一個 Fiber處於此狀態。

:suspended — fiber-yield 轉換到此狀態。Fiber 具有儲存的堆疊框架,並可選地具有喚醒條件、截止日期或 I/O 等待註冊。

:dead — Fiber 的函式返回(或發出未處理的錯誤)。結果已捕獲,資源已清理,Fiber 已從全域列表中移除。

一個完整的 yield-and-resume 週期涉及以下步驟:

Fiber 上下文切換僅儲存平台 ABI 定義的呼叫者儲存的暫存器。這是 C 或 Lisp 函式可以在呼叫之間假設被保留的最小集合。所有其他暫存器要麼是呼叫者儲存的(編譯器已假設它們被破壞),要麼在正常函式返回時恢復。

x86-64 SysV ABI(Linux、macOS):6 個暫存器(48 位元組)

x86-64 Win64 ABI(Windows):8 個 GPR(64 位元組)+ 10 個 XMM(160 位元組)= 224 位元組

ARM64(AAPCS64):20 個暫存器(160 位元組)

選擇僅儲存呼叫者儲存的暫存器(而非完整的暫存器檔案)是 Fiber 切換快速的主要原因。核心上下文切換必須儲存和恢復所有暫存器以及段暫存器、FPU 狀態和信號遮罩。Fiber 切換在 x86-64 SysV 上儲存 6 個暫存器(48 位元組),儲存一個指標,載入一個指標,恢復 6 個暫存器,然後返回。

fiber_switch 是上下文切換的核心。它被實作 Trapezoid 組合語言例程(透過 define-assembly-routine),而不是手寫的 .S 檔案,這意味著它由 SBCL 的組合器組合,並與編譯的 Lisp 函式一起儲存在程式碼堆積中。

該例程在 ABI 暫存器中接受三個引數:

ret 是關鍵指令。當從正在執行的 Fiber 切換到排程器時,ret 會彈出排程器最初呼叫 fiber_switch 時推送的返回位址,將執行返回到排程器迴圈。當從排程器切換到掛起的 Fiber 時,ret 會從 Fiber 的堆疊中彈出返回位址,返回到 Fiber 的 fiber_switch 呼叫之後的位置。

一個全新的 Fiber 從未被切換過,因此其堆疊沒有儲存的暫存器。initialize-fiber-stack 建構一個合成的堆疊框架,使 fiber_switch 的暫存器恢復序列「返回」到進入的跳板。

在 x86-64 SysV 上,初始堆疊佈局(從頂部開始,向下增長):

當 fiber_switch 載入此 RSP 並彈出暫存器時,rbp 會接收到 fiber 的 lispobj(指向 Fiber 結構的標記 Lisp 指標)。ret 指令然後跳轉到 fiber_entry_trampoline。

fiber_entry_trampoline 是首次執行 Fiber 的著陸點。它在 rbp 中接收 Fiber lispobj(由合成堆疊框架放置),並:

fiber_run_and_finish 反過來呼叫一個 Lisp 跳板函式 (fiber-trampoline),該函式在排程器啟動時註冊。此 Lisp 函式使用 handler-case 錯誤處理來執行 Fiber 的使用者函式,捕獲結果,標記 Fiber 為死,清理繫結,然後切換回排程器。鏈是:

Lisp 跳板永不返回;它以一個 fiber_switch 回到排程器結束。C 呼叫後的 hlt 是一個安全網。

%fiber-switch VOP 將引數作為原始機器字元傳遞,而不是 Lisp 物件。saved-rsp 欄位位址是使用算術(加上欄位偏移並減去低標籤)從 Fiber 結構的標記指標計算出來的,產生永不成為堆積分配的 SAP 的無符號整數。

這很重要,因為 SAP 分配可能會觸發 GC,這會移動 Fiber 結構(使剛計算的位址失效)並需要在不方便的時間點停止世界。

透過將所有內容保留為原始字元,切換路徑產生零 GC 壓力。

TLS scratch hash table 和 overlay 陣列預先分配在排程器和 Fiber 結構中,而不是在切換路徑上。喚醒條件閉包由呼叫者在讓出之前分配。

SBCL 保留一個機器暫存器作為指向當前執行緒結構的指標:x86-64 上的 r13(當未使用 gs-segment TLS 時),ARM64 上的 x21。此暫存器在執行緒啟動時設定一次,在正常執行期間永不改變。

當 Fiber 透過工作竊取在載體之間遷移時,它在載體 A 上掛起,暫存器中儲存著 A 的執行緒指標。如果它在載體 B 上恢復,恢復的執行緒暫存器將指向 A 的執行緒結構——錯誤的執行緒。

resume-fiber-internal 函式透過將當前載體的執行緒 SAP 作為第三個引數傳遞給 fiber_switch 來處理此問題。組合語言例程檢查此值是否非零,如果是,則用正確的值覆蓋剛恢復的執行緒暫存器。這發生在所有其他暫存器恢復之後,但在 ret 之前,因此 Fiber 在其新載體上恢復時具有有效的執行緒指標。

對於同載體恢復(常見情況),第三個引數仍然傳遞(當前執行緒指標),但修補是無害的——它將暫存器設定為其已正確的值。

每個 Fiber 的控制堆疊是透過 os_allocate(Unix 上的 mmap,Windows 上的 VirtualAlloc)獲得的連續區域。佈局,從低位址到高位址:

底部的保護頁可捕獲堆疊溢位。當 Fiber 的堆疊指標下降到保護頁時,硬體會產生段錯誤。SBCL 的信號處理器呼叫 check_fiber_guard_page,它會遍歷活動 Fiber 上下文和掛起的 Fiber GC 資訊列表,以將故障位址與 Fiber 堆疊範圍進行匹配。如果匹配,它會呼叫 lose() 並帶有診斷訊息。

可用的堆疊區域是 Fiber 的程式碼看到的。RSP 從 control-stack-end(頂部)開始,並向下朝保護頁增長。

每個 Fiber 都有一個單獨的繫結堆疊用於動態變數繫結。這是一個雙字條目(舊值、TLS 索引)的平面陣列,透過 alloc_fiber_binding_stack 分配。預設大小為 16 KB,可容納 1024 個巢狀繫結。

繫結堆疊向上增長(binding-stack-pointer 隨每個繫結增加)。一個保護頁(PROT_NONE)分配在可用區域上方,在分配的頂部。如果 Fiber 用盡了其繫結堆疊,下一個繫結會寫入保護頁並觸發段錯誤。check_fiber_guard_page 會在 all_fiber_gc_info 中匹配故障位址與繫結堆疊保護區域,並透過 lose() 報告診斷訊息。

16 KB 的預設值對於幾乎所有工作負載來說都足夠了;使用深度巢狀動態繫結的程式可以透過 make-fiber 的 :binding-stack-size 關鍵字來增加它。

繫結堆疊是一個單獨的分配,因為它的存取模式與控制堆疊不同。控制堆疊被隨機存取(函式呼叫、局部變數);繫結堆疊被線性存取(繫結時推入,取消繫結時彈出)。將它們分開也簡化了 GC 掃描:繫結堆疊僅包含 TLS 索引和 Lisp 物件引用,精確掃描,而控制堆疊則保守掃描。

建立 Fiber 需要兩次 mmap 呼叫(控制堆疊 + 繫結堆疊),銷毀一個需要兩次 munmap 呼叫。在高 Fiber 建立率下(例如,每個 HTTP 請求一個),這些系統呼叫會成為瓶頸。

堆疊池消除了此成本。兩個全域池(一個用於控制堆疊,一個用於繫結堆疊)最多緩存 4096 個堆疊。當 Fiber 死亡並返回其堆疊時,池首先對可用區域呼叫 madvise(MADV_DONTNEED),告訴核心釋放實體頁面但保留虛擬映射。下次存取這些頁面時,核心的頁面錯誤處理器會返回零填充的記憶體——實際上是一個新的堆疊,無需 mmap/munmap 來回呼叫。

池查找是 O(1) 的:每個池是一個由互斥鎖保護的單向連結空閒列表。僅當堆疊大小與池的快取大小(通常為預設的 256 KB 或 16 KB)匹配時,堆疊才會被放入池中;大小不匹配的堆疊會繞過池並立即釋放。這避免了複雜性,同時有效地處理了常見情況。

保護頁的 PROT_NONE 保護不受 madvise 的影響,因此回收的控制堆疊保留了其溢位保護,而無需重新設定頁面權限。

SBCL 對 SIGSEGV(或某些平台上的 SIGBUS)的信號處理器呼叫 check_fiber_guard_page 以確定故障位址是否在 Fiber 的保護頁中。此函式遍歷兩個資料結構:

all_active_fiber_contexts — 當前在載體上執行的 Fiber。它們的 fiber_stack_start 標記了保護頁的頂部。

all_fiber_gc_info — 掛起的 Fiber。它們的 control_stack_base 標記了保護頁的頂部。

如果故障位址落在堆疊開始下方一個保護頁寬度的範圍內,則 Fiber 已溢位。處理器呼叫 lose() 並帶有包含故障位址和堆疊邊界的診斷訊息。

預設的 256 KB 控制堆疊大小是一個折衷。較小的堆疊允許更多的併發 Fiber(10,000 個 Fiber 在 256 KB = 2.5 GB 虛擬空間,而 8 MB 的作業系統執行緒需要 80 GB),但太小的堆疊會在深度遞迴或大型堆疊分配陣列的程式碼中導致溢位。

16 KB 的繫結堆疊預設值對於大多數工作負載來說是保守的。每個 let 過一個特殊變數或 progv 表單都會推入一個 16 位元組條目,因此 16 KB 支持 1024 個巢狀層級。伺服器請求處理器通常使用少量特殊變數。

這兩個大小都可以透過 make-fiber 的關鍵字為每個 Fiber 配置。

不支援動態堆疊增長(如 Erlang/BEAM 進程所示)。Fiber 堆疊是固定大小的 mmap 分配;增長它們需要重新定位堆疊(使所有內部指標、返回位址和 GC 保守引用失效)或使用不連續段(需要編譯器支援每次函式呼叫時的段交叉檢查)。在 SBCL 的原生程式碼編譯模型中,這兩種方法都不可行。實際的緩解措施是在 make-fiber 時選擇合適的堆疊大小,並為大型資料結構使用堆積分配。

SBCL 將特殊變數繫結實作為一個執行緒區域儲存(TLS)陣列,由每個符號的 TLS 索引索引。當程式碼繫結特殊變數時,運行時會將一個雙字條目(舊值在偏移量 0,TLS 索引在偏移量 +word)推送到繫結堆疊,並將新值寫入執行緒的 TLS 陣列。當繫結被展開時,unbind_to_here 會彈出條目,恢復舊值並將繫結堆疊條目歸零。

這對 Fiber 造成了問題。當 Fiber 讓出時,它必須儲存其 TLS 狀態並恢復載體執行緒的 TLS 狀態。但是繫結堆疊條目是追蹤需要恢復內容的機制——而 unbind_to_here 會破壞它們。如果在讓出時展開 Fiber 的繫結(呼叫 unbind_to_here),繫結堆疊將被歸零,我們將沒有記錄哪些 TLS 索引被繫結或 Fiber 的值是什麼。

因此,Fiber 實作在讓出或恢復期間從不呼叫 unbind_to_here。相反,它使用 TLS overlay 方法。

每個 Fiber 有兩個並行的陣列:tls-indices 和 tls-values。在讓出時,save-fiber-tls-and-restore-carrier 執行繫結堆疊的兩階段遍歷:

第一階段:使用臨時雜湊表(在排程器結構中預先分配以避免 consing)計算唯一 TLS 索引的數量。

第二階段:對於每個唯一的 TLS 索引,儲存當前的 TLS 值(Fiber 的值)到 Fiber 的 tls-values 陣列中,然後透過將繫結堆疊中最外層的 old_value 寫入 TLS 陣列來恢復載體的 TLS 值。

在恢復時,restore-fiber-tls-overlay 將 Fiber 的儲存值重播回 TLS 陣列:

這是一個簡單的索引儲存迴圈,沒有雜湊查找,沒有繫結堆疊操作,也沒有呼叫運行時。繫結堆疊本身從未被修改——它保留了 unwind-protect 和錯誤處理所需的完整繫結歷史。

當 Fiber 透過工作竊取在載體之間遷移時,其繫結堆疊的 old-value 條目仍然包含來自先前載體 TLS 陣列的值。如果 Fiber 稍後讓出或死亡,恢復路徑會將舊載體的值寫入新載體的 TLS——損壞新載體的狀態。

update-binding-stack-carrier-values 解決了這個問題。在重播 TLS overlay 之前,它會遍歷繫結堆疊,並用當前載體對該索引的 TLS 值覆蓋每個最外層的 old-value。這確保了當 Fiber 讓出時,無論 Fiber 最初在哪個載體上,載體的 TLS 都能正確恢復。

對於同載體恢復(常見情況),此操作會被跳過,由一個從 (not (eq (fiber-carrier fiber) *current-thread*)) 計算出的遷移標誌控制。

除了繫結堆疊之外,SBCL 還維護指向當前 catch 區塊(*current-catch-block*)和當前 unwind-protect 區塊(*current-unwind-protect-block*)的每執行緒指標。這些是透過控制堆疊串聯起來的單向連結列表,用於 throw、handler-case 和 unwind-protect。

在讓出時,Fiber 將這些指標從執行緒結構儲存到 fiber-saved-catch-block 和 fiber-saved-unwind-protect-block 中,然後恢復載體的指標(在 Fiber 掛載時儲存)。

在恢復時,會儲存載體的指標並恢復 Fiber 的指標。對於新的 Fiber,這些為零(尚未建立任何 catch 或 unwind-protect 區塊)。

許多 Fiber(尤其是基準測試和計算密集型 Fiber)從不繫結任何特殊變數。它們的繫結堆疊指標等於其繫結堆疊的起始位置。

Yield 和 resume 路徑會檢查此條件:

當繫結堆疊為空時,整個 TLS 儲存/恢復序列都會被跳過:沒有雜湊表操作,沒有陣列分配,沒有繫結堆疊遍歷。此快速路徑減少了不使用動態繫結的 Fiber 的上下文切換成本。

update-binding-stack-carrier-values 成本很高:它會遍歷整個繫結堆疊並使用雜湊表進行去重。但只有當 Fiber 在載體之間遷移時才需要它,因為如果 Fiber 沒有移動,old-value 條目已經與當前載體的 TLS 匹配。

排程器迴圈計算遷移標誌:

此標誌會傳播到 resume-fiber-internal,它會保護載體值更新:

在單載體配置或工作竊取不頻繁的情況下,這可以完全消除繫結堆疊在 resume 路徑上的遍歷。

GC 需要找到所有活躍的 Lisp 物件,包括 Fiber 堆疊上的物件。兩個資料結構用於此目的:

all_fiber_gc_info — 一個雙向連結列表,包含 fiber_gc_info 結構,每個已建立且尚未銷毀的 Fiber 一個。每個條目儲存 Fiber 的控制堆疊基礎/指標/結束和繫結堆疊起始/指標。用於掃描掛起 Fiber 的堆疊。

all_active_fiber_contexts — 一個雙向連結列表,包含 active_fiber_context 結構,每個當前正在執行排程器的載體執行緒一個。每個條目包含正在執行的 Fiber 的堆疊邊界和一個嵌套的 carrier_gc_info,代表載體執行緒的掛起堆疊。

在停止世界 GC 期間,所有變異執行緒都會停止。收集器在沒有鎖的情況下遍歷兩個列表(所有寫入者都已停止)。對於每個掛起的 Fiber,它會保守地掃描控制堆疊並精確地掃描繫結堆疊。對於每個活動上下文,它會掃描正在執行的 Fiber 的堆疊以及載體的掛起堆疊。

每個 Fiber 的 fiber_gc_info 包含:

在 GC 期間,收集器從 control_stack_pointer 到 control_stack_end 掃描字元:

掃描是保守的:每個看起來像有效堆積指標的字元都被視為潛在引用,固定了指向的物件。自我引用排除(exclude_from / exclude_to)可防止堆疊本身的位址範圍被視為堆積指標。

當 Fiber 在載體上積極執行時,RSP 指向 Fiber 的控制堆疊,而不是載體的。gencgc.c 中的標準執行緒掃描程式碼會錯過載體的掛起框架(在呼叫 fiber_switch 的點之上)。

active_fiber_context 透過一個嵌套的 carrier_gc_info 來解決此問題,該結構記錄了載體的堆疊邊界。載體的 control_stack_pointer 被保守地設定為 __builtin_frame_address(0) - 16384,確保在 update_fiber_gc_context 呼叫和實際掛起點(在 fiber_switch 內部)之間的所有框架都包含在掃描中。

GC 在兩個地方檢查活動 Fiber 上下文:

SP 驗證:如果執行緒的中斷上下文 SP 不在載體的控制堆疊範圍內,GC 會檢查它是否在 Fiber 的堆疊範圍內,方法是呼叫 find_active_fiber_context。

堆疊掃描:如果 Fiber 是活動的,GC 會掃描 Fiber 的堆疊(從 SP 到 fiber_stack_end),然後單獨掃描載體的堆疊(從 carrier_start + 3*page_size 到 carrier_stack_end),跳過保護頁。

與控制堆疊不同,繫結堆疊是精確掃描的。每個雙字條目都有一個已知的格式(舊值 + TLS 索引),因此收集器可以使用 scav_binding_stack 來處理它們,而不是保守地逐字掃描。

對於活動 Fiber,BSP 指向 Fiber 的繫結堆疊:

對於掛起的 Fiber,收集器遍歷 all_fiber_gc_info:

原始實作在每次恢復/讓出時呼叫 enter_fiber_gc_context 和 leave_fiber_gc_context,每個上下文切換獲取四個互斥鎖(兩個用於 fiber_gc_lock,兩個用於 active_fiber_context_lock)。在負載下,這些全域鎖成為瓶頸。

優化實作使用持久的載體上下文:

init_carrier_fiber_context() — 在載體啟動其排程器時呼叫一次。分配 active_fiber_context,使用空範圍註冊 carrier_gc_info(指標 = 結束,因此 GC 掃描迴圈執行零次迭代),連結到 all_active_fiber_contexts,並將指標儲存在執行緒結構的 fiber-context 槽中。總共兩次互斥鎖獲取。

update_fiber_gc_context() — 每次恢復時呼叫。從 thread->fiber_context 讀取上下文(無 TLS 查找)。更新 Fiber 堆疊邊界和載體堆疊掃描範圍。零互斥鎖。

clear_fiber_gc_context() — 每次 yield-return 時呼叫。將 Fiber 欄位設定為 NULL,並將載體範圍設定為空。零互斥鎖。

destroy_carrier_fiber_context() — 在載體退出時呼叫一次。從兩個列表中取消註冊。總共兩次互斥鎖獲取。

這將每個切換的互斥鎖開銷從 4 降低到 0。

GC 在停止世界期間對每個執行緒呼叫 find_active_fiber_context。原始實作遍歷 all_active_fiber_contexts 連結列表以查找與給定執行緒匹配的條目——對於載體數量來說是 O(n)。

優化實作將 active_fiber_context 指標直接儲存在執行緒結構中:

這需要在 objdef.lisp 中的 thread 結構定義中添加一個 fiber-context 槽:

該槽由 init_carrier_fiber_context 設定,由 destroy_carrier_fiber_context 清除。對於非載體執行緒,它是 NULL,find_active_fiber_context 返回 NULL(沒有 Fiber 上下文需要處理)。

兩個 SBCL 機制控制 GC 計時:

without-interrupts — 推遲非同步信號(包括 stop-for-GC 信號)的傳遞,但不會阻止在區域主體內觸發的 GC。

without-gcing — 完全阻止 GC(設定一個標誌,導致分配阻塞直到區域退出)。

Yield 和 resume 路徑在大部分工作中都使用 without-interrupts。這可以防止 stop-for-GC 信號在中途到達,這可能會將部分寫入的 Fiber 狀態暴露給收集器。

特定的 without-gcing 區域保護狹窄的關鍵部分,其中 GC 不得觸發:

register_fiber_for_gc / unregister_fiber_for_gc — 這些修改 all_fiber_gc_info 連結列表。如果在列表拼接期間觸發 GC,收集器可能會跟隨一個懸空指標。

GC 中的繫結堆疊指標更新 — 在讓出時,Fiber 的 gc_info 繫結堆疊指標必須在恢復載體的 BSP 之前更新。在這兩個操作之間,without-interrupts 本身並不能阻止 GC(分配可能會觸發它),因此 gc_info 寫入被包裝在 without-gcing 中。

持久的載體上下文設計似乎存在競爭條件:update_fiber_gc_context 呼叫在不持有任何鎖的情況下寫入多個欄位(Fiber 堆疊開始、結束、繫結堆疊開始、載體堆疊指標)。GC 能否看到一個半更新的上下文?

不能,因為 SBCL 的停止世界 GC 協定。在收集器讀取任何這些欄位之前,它會向所有變異執行緒發送停止信號,並等待所有執行緒到達安全點。一旦停止,沒有執行緒正在執行 update_fiber_gc_context 或 clear_fiber_gc_context。因此,當 GC 讀取它們時,欄位始終處於一致狀態。

唯一的邊界情況是空狀態:clear_fiber_gc_context 將 Fiber 欄位設定為 NULL,並將載體範圍設定為空。如果 GC 讀取此狀態,它會看到沒有 Fiber 堆疊可掃描,也沒有空的載體範圍(沒有額外的掃描)。載體的標準執行緒堆疊仍然透過標準的執行緒掃描路徑進行掃描,因此沒有物件會丟失。

排程器迴圈 (run-fiber-scheduler) 是每個載體執行緒的核心。其結構:

迴圈運行,直到群組中的所有 Fiber 都死亡(活動計數達到零),或者在單載體模式下,本地 deque 為空且沒有 Fiber 在等待。

忙碌排程器中的常見情況是具有非空 deque。快速路徑在每次迭代的頂部嘗試 wsd-pop。如果成功,則 Fiber 會立即執行,無需任何維護。