檢視原始碼 非阻塞程式碼載入

簡介

在 OTP R16 之前,當 Erlang 程式碼模組被載入時,虛擬機器中的所有其他執行都會暫停,而載入操作則在單執行緒模式下進行。對於虛擬機器啟動期間的模組初始載入來說,這可能不是個大問題,但當在具有執行中負載的虛擬機器上升級模組或新增程式碼時,這可能會對可用性造成嚴重問題。這個問題會隨著核心數量的增加而加劇,因為等待所有排程器停止的時間會增加,而且暫停的進行中工作量也會增加。

在 OTP R16 中,模組的載入不會阻塞虛擬機器。Erlang 處理程序可以在整個載入操作期間繼續不受干擾地並行執行。程式碼載入由一個普通的 Erlang 處理程序執行,該處理程序的排程方式與所有其他處理程序相同。載入操作完成的方式是透過單一原子指令,使載入的程式碼以一致的方式對所有處理程序可見。當在執行中的 SMP 系統上載入/升級模組時,非阻塞程式碼載入將改善即時特性。

載入階段

模組的載入分為兩個階段:準備階段完成階段。準備階段包含讀取 BEAM 檔案格式以及載入程式碼的所有準備工作,這些準備工作可以在不干擾執行中程式碼的情況下輕鬆完成。完成階段將使載入(和準備好)的程式碼可從執行中的程式碼存取。舊的模組版本(已替換或刪除)也將在完成階段中變得不可存取。

準備階段的設計允許多個「載入器」處理程序並行準備不同的模組,而完成階段一次只能由一個載入器處理程序完成。第二個嘗試進入完成階段的載入器處理程序將會被暫停,直到第一個載入器完成。這只會阻塞處理程序,排程器可以自由地排程其他工作,而第二個載入器則在等待。(請參閱 erts_try_seize_code_load_permissionerts_release_code_load_permission)。

目前尚未使用並行準備多個模組的功能,因為幾乎所有程式碼載入都是由 code_server 處理程序序列化的。但是,BIF 介面已為此做好準備。

  erlang:prepare_loading(Module, Code) -> LoaderState
  erlang:finish_loading([LoaderState])

其想法是,可以為不同的模組並行呼叫 prepare_loading,並傳回包含每個已準備好模組的內部狀態的「魔法二進制」。函式 finish_loading 可以接收此類狀態的清單,並一次完成所有狀態的完成。

目前,我們使用舊的 BIF erlang:load_module,現在在 Erlang 中是透過依序呼叫上述兩個函式來實作的。函式 finish_loading 僅限於接受一個模組狀態的清單,因為我們尚未支援多模組載入功能。

完成序列

在虛擬機器執行期間,程式碼是透過多個資料結構存取的。這些程式碼存取結構包括:

  • 匯出表。每個匯出的函式都有一個條目。
  • 模組表。每個載入的模組都有一個條目。
  • 「beam_catches」。識別 catch 指令的跳躍目標。
  • 「beam_ranges」。將程式碼位址對應至原始程式碼檔案中的函式和行。

這些結構中最常使用的是匯出表,它會在執行階段存取,以取得每次執行的外部函式呼叫之被呼叫者的位址。為了效能考量,我們希望存取所有這些結構時,不會有執行緒同步的額外負荷。早期,這是透過緊急中斷來解決的。停止整個虛擬機器以變更這些程式碼存取結構,否則將它們視為唯讀。

R16 中的解決方案是複製程式碼存取結構。我們有一組執行中程式碼讀取的活動結構。當載入新的程式碼時,會複製活動結構,然後更新複本以包含新載入的模組,然後進行切換,使更新的複本成為新的活動集合。活動集合由單一全域原子變數 the_active_code_index 識別。因此,可以透過單一原子寫入操作來進行切換。執行中的程式碼在使用活動存取結構時必須讀取這個原子變數,這表示每次外部函式呼叫都需要一次原子讀取操作。然而,這個額外原子讀取造成的效能損失非常小,因為它可以在完全沒有記憶體屏障的情況下完成(如下所述)。有了這個解決方案,我們也保留了載入操作的交易功能。執行中的程式碼永遠不會看到半載入模組的中間結果。

完成階段由 BIF erlang:finish_loading 以以下順序執行:

  1. 取得程式碼載入的獨佔權限(如果需要,暫停處理程序直到取得權限)。

  2. 建立所有活動存取結構的完整複本。這個複本稱為暫存區,並由全域原子變數 the_staging_code_index 識別。

  3. 更新暫存區中的所有存取結構,以包含新準備好的模組。

  4. 排程執行緒進度事件。這是未來的一個時間點,屆時所有排程器都已讓步並執行了完整的記憶體屏障。

  5. 暫停載入器處理程序。

  6. 執行緒進度後,透過將 the_staging_code_index 指派給 the_active_code_index 來提交暫存區。

  7. 釋放程式碼載入權限,允許其他處理程序暫存新的程式碼。

  8. 恢復載入器處理程序,允許它從 erlang:finish_loading 傳回。

執行緒進度

為了使處理程序在正常執行期間讀取 the_active_code_index 原子,而沒有任何昂貴的記憶體屏障,4-6 中等待執行緒進度是必要的。當我們在步驟 6 中將新值寫入 the_active_code_index 時,我們知道所有排程器在透過 the_active_code_index 可存取時,都會看到所有新的活動存取結構的更新且一致的檢視。

然而,在讀取 the_active_code_index 時完全沒有記憶體屏障,會產生一個有趣的後果。不同的處理程序可能會在不同的時間點看到新程式碼,具體取決於不同核心何時重新整理其硬體快取。這聽起來可能不安全,但實際上沒關係。我們必須保證的唯一屬性是,看到新程式碼的能力必須透過處理程序通訊來傳播。收到由新程式碼觸發的訊息後,必須保證接收者也能看到新程式碼。由於所有類型的處理程序通訊都涉及記憶體屏障,以便接收者確定讀取了發送者寫入的內容,因此可以保證這一點。然後,這個隱含的記憶體屏障也會確保接收者讀取 the_active_code_index 的新值,從而也看到新程式碼。這適用於所有類型的跨處理程序通訊(TCP、ETS、處理程序名稱註冊、追蹤、驅動程式、NIF 等),而不僅僅是 Erlang 訊息。

程式碼索引重用

為了最佳化步驟 2 中的複製操作,會重複使用程式碼存取結構。在目前的解決方案中,我們有三組程式碼存取結構,由 0、1 和 2 的程式碼索引識別。這些索引以循環方式使用。我們只需要更新自上次兩個程式碼載入操作以來發生的變更,而不是為每個載入操作初始化所有存取結構的全新複本。我們可以使用只有兩個程式碼索引(0 和 1)來完成,但這需要在 finish_loading 序列中的步驟 2 之前,再次等待執行緒進度。在我們知道沒有仍在將其用作活動程式碼索引的延遲排程器執行緒之前,我們無法開始將程式碼索引重複用作暫存區。使用三代程式碼索引,步驟 4-6 中的等待執行緒進度會為我們提供此保證。執行緒進度將等待所有執行中的排程器至少重新排程一次。在第二輪執行緒進度之後,無法存在從 the_active_code_index 的舊值中存取程式碼存取結構的任何正在進行的執行。

兩個或三個世代的程式碼存取結構之間的設計選擇,是在記憶體消耗和程式碼載入延遲之間的權衡。

一致的程式碼檢視

某些原生 BIF 可能需要取得活動程式碼的一致快照檢視。為此,重要的是僅讀取一次 the_active_code_index,然後將該索引值用於 BIF 期間的所有程式碼存取。如果在平行執行載入操作,則第二次讀取 the_active_code_index 可能會產生不同的值,從而產生不同的程式碼檢視。