檢視原始碼 Port 訊號
問題
Erlang port 在概念上與 Erlang 程序非常相似。Erlang 程序在虛擬機器中執行 Erlang 程式碼,而 Erlang port 則執行通常用於與外界通訊的原生程式碼。例如,當 Erlang 程序想要使用 TCP 透過網路通訊時,它會透過以原生程式碼實現 TCP socket 介面的 Erlang port 進行通訊。Erlang 程序和 Port 都使用非同步訊號進行通訊。Erlang port 執行的原生程式碼是一組回呼函式,稱為驅動程式。每個回呼函式或多或少實現了傳送至 port 或從 port 傳出的訊號程式碼。
儘管程序和 port 在概念上一直非常相似,但實現方式卻大相逕庭。最初,幾乎所有的 port 訊號都是在它們發生時同步處理的。在執行時期系統的 SMP 支援開發初期,我們就意識到這對 port 與外界之間的訊號(即來回於外界的 I/O 事件或 I/O 訊號)來說是一個巨大的問題。這是為了能夠並行執行 I/O 而必須重寫的第一批東西之一。解決方案是實作這些訊號的排程。與不同 port 對應的 I/O 訊號可以在不同的排程器執行緒上並行執行。從程序到 port 的訊號不像 I/O 訊號那樣是個大問題,因此這些訊號的實作保持不變。
每個 port 都受到其自身的鎖保護,以防止在多個執行緒中同時執行。先前,當在排程器執行緒上執行的程序向 port 發送訊號時,它會鎖定 port 鎖並同步執行與該訊號對應的程式碼。如果鎖處於忙碌狀態,排程器執行緒會被封鎖,等待直到它可以鎖定該鎖。如果多個程序在不同的排程器執行緒上同時執行,並向同一個 port 發送訊號,則排程器會遭受嚴重的鎖競爭。這種競爭也可能發生在一個排程器執行緒上執行的 port 的 I/O 訊號,與另一個排程器執行緒上執行的從程序到 port 的訊號之間。除了競爭問題之外,我們也失去了在不同排程器執行緒上並行執行工作的潛力。因為發送非同步訊號的程序會在實作訊號的程式碼同步執行時被封鎖。
解決方案
為了防止多個排程器試圖同時執行到/從同一 port 的訊號,我們需要能夠確保到/從 port 的所有訊號都在一個排程器上依序執行。或多或少,唯一的方法是排程所有類型的訊號。與 port 對應的訊號可以由單一排程器執行緒依序執行。如果只有一個執行緒試圖執行 port,則 port 鎖上不會出現競爭。除了消除競爭之外,發送訊號到 port 的程序也可以在另一個排程器上執行訊號程式碼的同時,繼續在其他排程器上執行自己的 Erlang 程式碼。
在實作此功能時,有一些重要的屬性是我們需要或想要保留的
訊號排序保證。從程序
X
到 portY
的訊號,必須以從X
發送的相同順序傳送到Y
。訊號延遲。由於先前的同步實作,從程序發送到 port 的訊號延遲通常非常低。在競爭期間,延遲當然會增加。使用者期望這些訊號的延遲很低,延遲的突然增加將不會受到使用者的歡迎。
相容的流量控制。port 長期以來都有可能在實作流量控制時使用忙碌 port 功能。人們可能會認為此功能與概念上完全非同步的訊號傳遞非常不相容,但是該功能已經存在很長時間了,並且預期會在那裡。當 port 將自身設定為忙碌狀態時,不應傳送
command
訊號,並且此類訊號的發送者應暫停,直到 port 將自身設定為非忙碌狀態。
Port 訊號的排程
執行佇列具有四個不同優先順序的程序佇列和一個 port 佇列。當佇列中同時存在程序和 port 時,與執行佇列相關聯的排程器執行緒會在程序和 port 的執行之間均勻切換。這並非完全正確,但對於此討論並不重要。執行佇列中的 port 也有一個要執行的任務佇列。每個任務對應於一個輸入或輸出訊號。當選擇要執行的 port 時,將依序執行每個任務。執行佇列鎖不僅保護了 port 的佇列,還保護了 port 任務的佇列。
由於我們從只有 I/O 訊號被排程的狀態轉變為可能排程所有與 port 相關的訊號的狀態,我們可能會大幅增加執行佇列鎖的負載。排程的 port 任務量很大程度取決於執行的 Erlang 應用程式,我們無法控制,而且我們不希望執行佇列鎖的競爭加劇。因此,我們需要另一種保護 port 任務佇列的方法。
任務佇列
我們選擇了一種「半鎖定」方法,其中包含一個公開的鎖定任務佇列和一個私有的、無鎖定的、類似佇列的任務資料結構。這種「半鎖定」方法類似於管理程序訊息框的方式。該鎖是 port 特有的,僅用於保護 port 任務,因此現在對於 port 來說,執行佇列鎖的需求與程序的需求大致相同。這確保了由於重新編寫 port 功能而不會看到執行佇列鎖上的競爭加劇。
當正在執行的 port 在私有任務資料結構中耗盡要執行的工作時,它會在持有鎖的同時將公開的任務佇列移動到私有任務資料結構中。一旦任務被移動到私有資料結構中,就沒有鎖保護它們。這樣,port 就可以繼續處理私有資料結構中的任務,而無需爭奪鎖。
但是,I/O 訊號可能會中止。可以通過讓 port 特有的排程鎖也保護私有任務資料結構來解決此問題,但是這樣 port 將不得不頻繁地與排隊新任務的其他程式競爭。為了在保持私有任務資料結構無鎖定的同時處理此問題,我們使用了與處理在執行佇列中被暫停的程序時類似的「非侵略性」方法。我們不刪除中止的 port 任務,而是使用原子記憶體操作將其標記為中止。當選擇要執行的任務時,我們會先驗證它是否未中止。如果中止,我們就直接丟棄該任務。
一個可以中止的任務是透過系統其他部分的另一個資料結構引用的,以便需要中止該任務的執行緒可以到達它。為了確保安全地釋放不再使用的任務,我們首先清除此引用,然後使用執行緒進度功能,以確保不存在對該任務的任何引用。不幸的是,未管理的執行緒也可能中止任務。這種情況很少見,但可能會發生。可以在每個 port 本地處理此問題,但是每個 port 結構都需要額外的信息,而這些信息很少使用。我們沒有在每個 port 中實現此功能,而是實現了通用功能,可以從未管理的執行緒中使用該功能來延遲執行緒進度。
如果不是為了忙碌 port 功能,私有「類似佇列」任務資料結構可能會是一個普通的佇列。當 port 將自身標記為忙碌時,不允許傳送 command
訊號,並且需要將其封鎖。從同一個發送者發出的其他訊號,如果跟隨已被封鎖的 command
訊號,也必須被封鎖;否則,我們將違反排序保證。同時,預期會傳送與被封鎖的 command
訊號沒有關聯的其他訊號。
以上要求使私有任務資料結構成為相當複雜的資料結構。它具有未處理任務的佇列和忙碌佇列。忙碌佇列包含與 command
訊號對應的被封鎖任務,以及與此類任務相關聯的任務。忙碌佇列附帶一個基於發送者的被封鎖任務表格,其中包含從特定發送者到忙碌佇列中最後一個任務的引用。這是因為我們需要在未處理任務的佇列中處理新任務時檢查關聯性。當處理需要被封鎖的新任務時,它不會排隊到忙碌佇列的末尾,而是直接排在具有相同發送者的最後一個任務之後。這是為了能夠輕鬆檢測到何時有不再與應該從忙碌佇列中移出的 command
訊號對應的任務存在任何關聯性。當 port 執行時,它會在根據其忙碌狀態處理忙碌佇列中的任務和直接處理未處理佇列中的任務之間切換。當然,當直接從未處理佇列處理時,它可能必須將任務移動到忙碌佇列中,而不是執行它。
忙碌 Port 佇列
由於 port 本身決定何時進入忙碌狀態,因此它需要執行才能進入忙碌狀態。由於 command
訊號被排程,我們可能會遇到這樣的情況:port 在甚至有機會將自身設定為忙碌狀態之前就被大量的 command
訊號淹沒。因為它尚未被排程執行。也就是說,在這種情況下,忙碌 port 功能將失去它旨在提供的流量控制特性。
為了解決這個問題,我們引入了一個新的忙碌功能,即「忙碌 port 佇列」。port 限制了允許在任務佇列中排隊的 command
資料量。當達到此限制時,port 將自動進入忙碌 port 佇列狀態。在此狀態下,command
訊號的發送者將被暫停,但是 command
訊號仍將傳送到 port,除非它也處於忙碌 port 狀態。此限制稱為上限。
還有一個下限。當佇列的 command
資料量降至此限制以下,並且 port 處於忙碌 port 佇列狀態時,忙碌 port 佇列狀態將自動停用。下限通常應顯著低於上限,以防止圍繞忙碌 port 佇列狀態頻繁振盪。
透過引入這個新的忙碌狀態,我們仍然可以提供流量控制。舊的驅動程式甚至不必更改。但是,port 可以配置甚至禁用這些限制。預設情況下,上限為 8 KB,下限為 4 KB。
準備發送訊號
先前,所有向埠口發送訊號的操作,都必須先取得埠口的鎖定,接著準備發送訊號,最後才發送訊號。準備工作通常包括檢查埠口的狀態,以及準備要隨著訊號傳遞的資料。資料的準備工作通常相當耗時,而且實際上並不依賴於埠口。也就是說,我們希望在不鎖定埠口鎖定的情況下完成這項工作。
為了改善這一點,我們重新組織了埠口結構中的狀態資訊,以便可以使用原子記憶體操作來存取它。這與新的埠口表實作結合,使我們能夠在取得埠口鎖定之前查找埠口並檢查其狀態,進而使我們能夠在取得埠口鎖定之前執行訊號資料的準備工作。
維持低延遲
如果我們忽略競爭的情況,那麼將訊號排程在稍後執行,相較於立即執行訊號,無可避免地會產生更高的延遲。為了維持低延遲,我們現在首先檢查是否為競爭情況。如果是,我們將訊號排程為稍後執行;否則,我們立即執行訊號。當埠口上已經排程了其他訊號,或者我們未能取得埠口鎖定時,即為競爭情況。也就是說,我們不會阻塞等待鎖定。
以這種方式進行,我們將維持低延遲,但代價是可能會失去訊號與程序中其他程式碼同時平行執行的機會。然而,這種預設行為可以針對每個埠口或系統範圍進行更改,強制將來自程序的所有訊號排程到不屬於同步通訊的埠口。也就是說,非同步訊號的無條件請求/回應對。在這種情況下,沒有並行執行的潛力,因此沒有必要強制排程請求訊號。
立即執行訊號也可能導致即將執行排程任務的排程器阻塞,等待埠口鎖定。然而,這或多或少是排程器需要等待埠口鎖定的唯一情況。它必須等待的最大時間是執行一個訊號所需的時間,因為我們總是在發生競爭時排程訊號。
訊號操作
除了實作能夠在沒有埠口鎖定的情況下排程和準備訊號資料等功能之外,每個向埠口發送訊號的操作都必須進行相當廣泛的重寫。這是為了將所有可以在沒有鎖定的情況下完成的子操作移到我們取得鎖定之前的位置,而且由於訊號現在有時會立即執行,有時會排程為稍後執行,這對要隨著訊號傳遞的資料提出了不同的要求。
一些基準測試結果
當執行一些簡單的基準測試時,如果競爭僅是由於 I/O 訊號與來自單一程序的訊號競爭而產生,我們獲得了 5-15% 的加速。當多個程序向單一埠口發送訊號時,改進可能會更大,但一個程序與 I/O 競爭的情況是最常見的。
這些基準測試是在一台相對較新的機器上執行的,該機器配備了具有超執行緒技術的 Intel i7 四核心處理器,使用 8 個排程器。