跳至內容
GitHub 儲存庫 論壇 RSS 新聞摘要

bdw-gc 協程支援

Brian J. Cardiff

Crystal 使用 bdw-gc 並支援協程。這裡稱協程為纖程(Fibers)。多年來,Crystal 一直是單執行緒搭配纖程。單執行緒仍然是預設的替代方案。不久前,我們加入了多執行緒支援,其中每個執行緒可以並行執行多個纖程。由於該函式庫中沒有內建的協程支援,為了實現這一點,需要對 bdw-gc 進行一些修補和最終的貢獻。

多執行緒協程的支援是透過允許使用者控制每個執行緒的堆疊底部而獲得的。更改堆疊底部和指令指標是有效賦予協程生命力的原因。也就是說,讓程式選擇接下來要執行的程式部分,而不需要告知作業系統。告知作業系統相當於使用執行緒,這會更耗費資源。

因此,Crystal 和其他程式語言可以從擁有多執行緒程式中受益,其中每個執行緒都可以並行執行多個協程。

在實作協程時,執行時期很可能會對仍然需要繼續執行的現有協程進行某種形式的簿記。這些記錄將包括它們的堆疊、指令指標和暫存器的持久性,以及其他特定於執行時期的資訊。

以下說明 Crystal 如何在單執行緒和多執行緒模式下使用 bdw-gc 來實現協程支援。我們不會著重於 Crystal 執行時期的詳細資訊及其簿記,這主要是針對與 bdw-gc 的介面。

在兩種情況下要涵蓋的主題方面是

  1. 當目前的協程需要切換到另一個協程時,應該發生什麼事?
  2. 如何設定 bdw-gc,使其知道所有的協程,即使是那些未執行,因此無法從目前的堆疊存取的協程。

單執行緒協程

當目前的協程 C_0 需要切換到另一個協程 C_1 時,

  1. 我們將全域變數 GC_stackbottom 設定為 stack_bottom(C_1)
  2. 我們在 C_0C_1 之間進行內容切換

當協程配置時,已知 stack_bottom(C_1) 的值。配置協程通常意味著保留一些將用作該協程堆疊的堆空間。因此,堆疊底部在那個時候是已知的。

特殊情況是第一個協程發生的情況,也就是屬於程式主執行緒的協程。嗯,透過在程式開始時使用全域變數 GC_stackbottom,我們可以獲得第一個纖程的堆疊底部。

由於我們處於單執行緒中,因此所有協程都是由執行時期建立,或是主執行緒被視為協程。

我們如何進行內容切換?C_0 將對一個保留所有敏感內容(這是特定於架構的)的常式進行常規函式呼叫,然後從 C_1 的已儲存內容中還原堆疊指標並進行返回。實際上,我們正在掛起 C_0 並恢復 C_1src/fiber/context.cr 中有關於此過程的一些進一步詳細資訊,但這不依賴於 GC。

為了處理 bdw-gc 的設定部分,我們使用 GC_set_push_other_roots 在 GC 嘗試收集之前進行掛鉤。在此程序中,我們推送所有非目前協程的堆疊,也就是說,那些沒有運作的協程。

目前協程的堆疊已經透過 GC_stackbottom 為 GC 所知,而所有其他協程則透過 GC_set_push_other_roots 為人所知。由於 GC 將暫停主執行緒以執行收集,因此我們可以清楚了解需要關心的所有記憶體。太棒了!

多執行緒協程

現在涵蓋了較簡單的單執行緒,我們可以討論多執行緒的協程。

到目前為止,我們不需要為單執行緒停用 GC,而且為了效能考量,最好保持這種方式。但是對於多執行緒環境,我們將需要在切換纖程常式周圍使用一些鎖定。我們使用全域的讀取/寫入鎖定

當目前的協程 C_0 需要切換到另一個協程 C_1 時,

  1. 標記 C_1 將在目前的執行緒中執行
  2. 將讀取器新增至全域鎖定
  3. 我們在 C_0C_1 之間進行內容切換
  4. 從全域鎖定中移除讀取器

值得注意的是,我們不像在單執行緒情況下那樣存取 GC_stackbottom。此外,內容切換與之前完全相同。

由於最後一步不是內容切換,這表示我們需要在纖程開始執行時從全域鎖定中移除讀取器。僅在執行時期建立的纖程中。

考慮這個問題的一種方式是,在內容切換之後,下一步是在 C_1 中執行,而不是 C_0 中。如果 C_1 先前已由協程切換移除,則程式碼已就位,但對於第一次執行,它需要在程式設計師指示的指令之前執行最後一步。

為了處理 bdw-gc 的設定部分,我們仍然使用 GC_set_push_other_roots 在 GC 嘗試收集之前進行掛鉤。在此程序中,我們推送所有未運作的協程的堆疊。我們還需要處理正在運作的協程,在這種情況下,每個應用程式執行緒都有一個協程(讓我們稱之為應用程式執行緒,因為也有可以省略的 GC 執行緒)。

因此,作為此程序的一部分,我們還會告知 GC 所有正在運作的纖程的堆疊底部。為此,我們反覆運算所有應用程式執行緒,並使用反覆運算執行緒的運作纖程的堆疊底部呼叫 GC_set_stackbottom

作為程序的最後一步,我們從全域鎖定中移除寫入器。

因此,為了回顧,在 GC_set_push_other_roots 中註冊的程序會執行

  1. 推送所有未運作的纖程的堆疊
  2. 透過 GC_set_stackbottom 通知每個正在運作的纖程的堆疊底部(每個應用程式執行緒一個)
  3. 從全域鎖定中移除寫入器

與單執行緒類似的做法是針對每個內容切換呼叫 GC_set_stackbottom,但是呼叫 GC_set_stackbottom 會取得 GC 鎖定,因此最好僅在必要時執行。也許單執行緒情況可以模仿這一點,但由於歷史原因,我們最終造成了這種差異。

我們遺漏了將寫入器新增至全域鎖定的位置。這是在 GC_set_start_callback 註冊的回呼中完成的。

全域鎖定的作用是允許同時進行內容切換,除非正在進行收集。

與單執行緒情況一樣,有兩種協程,a) 由執行時期手動建立的協程,它們的堆疊位於程式的堆中,以及 b) 對應於執行緒初始堆疊的協程。知道第一個協程的堆疊底部與之前一樣,記憶體是已知的。對於後者,當在執行時期註冊纖程時,我們使用 GC_get_my_stackbottom

因此,這就是使用 GC_get_my_stackbottomGC_set_stackbottom 在多執行緒環境中啟用協程的方式!有很多部分結合在一起才能使之成為可能,因此我們希望這可以闡明它們的使用方式。

原始碼

有些細節沒有涵蓋,但這些是關於 Crystal 執行時期的:如何維護纖程堆疊記憶體集區以便它們可以重複使用、執行纖程和執行緒的執行緒安全連結清單等等。如果您有興趣了解更多細節,相關檔案如下