作者:vivo 網際網路伺服器團隊- Wang Ke
本文介紹了 Jetty 中 ManagedSelector 和 ExecutionStrategy 的設計實現,通過與原生 select 呼叫的對比揭示了 Jetty 的執行緒優化思路。Jetty 設計了一個自適應的執行緒執行策略(EatWhatYouKill),在不出現執行緒飢餓的情況下儘量用同一個執行緒偵測 I/O 事件和處理 I/O 事件,充分利用了 CPU 快取並減少了執行緒切換的開銷。這種優化思路對於有大量 I/O 操作場景下的效能優化具有一定的借鑑意義。
Jetty 跟 Tomcat 一樣是一種 Web 容器,它的總體架構設計如下:
Jetty 總體上由一系列 Connector、一系列 Handler 和一個 ThreadPool組成。
Connector 也就是 Jetty 的聯結器元件,相比較 Tomcat 的聯結器,Jetty 的聯結器在設計上有自己的特點。
Jetty 的 Connector 支援 NIO 通訊模型,NIO 模型中的主角是 Selector,Jetty 在 Java 原生 Selector 的基礎上封裝了自己的 Selector:ManagedSelector。
常規的 NIO 程式設計思路是將 I/O 事件的偵測和請求的處理分別用不同的執行緒處理。
具體過程是:
這個過程有兩個執行緒在幹活:一個是 I/O 事件檢測執行緒、一個是 I/O 事件處理執行緒。
這兩個執行緒是"生產者"和"消費者"的關係。
這樣設計的好處:
將兩個工作用不同的執行緒處理,好處是它們互不干擾和阻塞對方。
這樣設計的缺陷:
當 Selector 檢測讀就緒事件時,資料已經被拷貝到核心中的快取了,同時 CPU 的快取中也有這些資料了。
這時當應用程式去讀這些資料時,如果用另一個執行緒去讀,很有可能這個讀執行緒使用另一個 CPU 核,而不是之前那個檢測資料就緒的 CPU 核。
這樣 CPU 快取中的資料就用不上了,並且執行緒切換也需要開銷。
Jetty 的 Connector 將 I/O 事件的生產和消費放到同一個執行緒處理。
如果執行過程中執行緒不阻塞,作業系統會用同一個 CPU 核來執行這兩個任務,這樣既能充分利用 CPU 快取,又可以減少執行緒上下文切換的開銷。
ManagedSelector 本質上是一個 Selector,負責 I/O 事件的檢測和分發。
為了方便使用,Jetty 在 Java 原生 Selector 的基礎上做了一些擴充套件,它的成員變數如下:
public class ManagedSelector extends ContainerLifeCycle implements Dumpable
{
// 原子變數,表明當前的ManagedSelector是否已經啟動
private final AtomicBoolean _started = new AtomicBoolean(false);
// 表明是否阻塞在select呼叫上
private boolean _selecting = false;
// 管理器的參照,SelectorManager管理若干ManagedSelector的生命週期
private final SelectorManager _selectorManager;
// ManagedSelector的id
private final int _id;
// 關鍵的執行策略,生產者和消費者是否在同一個執行緒處理由它決定
private final ExecutionStrategy _strategy;
// Java原生的Selector
private Selector _selector;
// "Selector更新任務"佇列
private Deque<SelectorUpdate> _updates = new ArrayDeque<>();
private Deque<SelectorUpdate> _updateable = new ArrayDeque<>();
...
}
為什麼需要一個"Selector更新任務"佇列呢?
對於 Selector 的使用者來說,我們對 Selector 的操作無非是將 Channel 註冊到 Selector 或者告訴 Selector 我對什麼 I/O 事件感興趣。
這些操作其實就是對 Selector 狀態的更新,Jetty 把這些操作抽象成 SelectorUpdate 介面。
/**
* A selector update to be done when the selector has been woken.
*/
public interface SelectorUpdate
{
void update(Selector selector);
}
這意味著不能直接操作 ManagedSelector 中的 Selector,而是需要向 ManagedSelector 提交一個任務類。
這個類需要實現 SelectorUpdate 介面的 update 方法,在 update 方法中定義要對 ManagedSelector 做的操作。
比如 Connector 中的 Endpoint 元件對讀就緒事件感興趣。
它就向 ManagedSelector 提交了一個內部任務類 ManagedSelector.SelectorUpdate:
_selector.submit(_updateKeyAction);
這個 _updateKeyAction 就是一個 SelectorUpdate 範例,它的 update 方法實現如下:
private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate()
{
@Override
public void update(Selector selector)
{
// 這裡的updateKey其實就是呼叫了SelectionKey.interestOps(OP_READ);
updateKey();
}
};
在 update 方法裡,呼叫了 SelectionKey 類的 interestOps 方法,傳入的引數是 OP_READ,意思是我對這個 Channel 上的讀就緒事件感興趣。
上面有了 update 方法,那誰來執行這些 update 呢,答案是 ManagedSelector 自己。
它在一個死迴圈里拉取這些 SelectorUpdate 任務逐個執行。
I/O 事件到達時,ManagedSelector 通過一個任務類介面(Selectable 介面)來確定由哪個函數處理這個事件。
public interface Selectable
{
// 當某一個Channel的I/O事件就緒後,ManagedSelector會呼叫的回撥函數
Runnable onSelected();
// 當所有事件處理完了之後ManagedSelector會調的回撥函數
void updateKey();
}
Selectable 介面的 onSelected() 方法返回一個 Runnable,這個 Runnable 就是 I/O 事件就緒時相應的處理邏輯。
ManagedSelector 在檢測到某個 Channel 上的 I/O 事件就緒時,ManagedSelector 呼叫這個 Channel 所繫結的類的 onSelected 方法來拿到一個 Runnable。
然後把 Runnable 扔給執行緒池去執行。
前面介紹了 ManagedSelector 的使用互動:
如何註冊 Channel 以及 I/O 事件
提供什麼樣的處理類來處理 I/O 事件
那麼 ManagedSelector 如何統一管理和維護使用者註冊的 Channel 集合呢,答案是 ExecutionStrategy 介面。
這個介面將具體任務的生產委託給內部介面 Producer,而在自己的 produce 方法裡實現具體執行邏輯。
這個 Runnable 的任務可以由當前執行緒執行,也可以放到新執行緒中執行。
public interface ExecutionStrategy
{
// 只在HTTP2中用到的一個方法,暫時忽略
public void dispatch();
// 實現具體執行策略,任務生產出來後可能由當前執行緒執行,也可能由新執行緒來執行
public void produce();
// 任務的生產委託給Producer內部介面
public interface Producer
{
// 生產一個Runnable(任務)
Runnable produce();
}
}
實現 Produce 介面生產任務,一旦任務生產出來,ExecutionStrategy 會負責執行這個任務。
private class SelectorProducer implements ExecutionStrategy.Producer
{
private Set<SelectionKey> _keys = Collections.emptySet();
private Iterator<SelectionKey> _cursor = Collections.emptyIterator();
@Override
public Runnable produce()
{
while (true)
{
// 如果Channel集合中有I/O事件就緒,呼叫前面提到的Selectable介面獲取Runnable,直接返回給ExecutionStrategy去處理
Runnable task = processSelected();
if (task != null)
return task;
// 如果沒有I/O事件就緒,就乾點雜活,看看有沒有客戶提交了更新Selector的任務,就是上面提到的SelectorUpdate任務類。
processUpdates();
updateKeys();
// 繼續執行select方法,偵測I/O就緒事件
if (!select())
return null;
}
}
}
SelectorProducer 是 ManagedSelector 的內部類。
SelectorProducer 實現了 ExecutionStrategy 中的 Producer 介面中的 produce 方法,需要向 ExecutionStrategy 返回一個 Runnable。
在 produce 方法中 SelectorProducer 主要乾了三件事:
如果 Channel 集合中有 I/O 事件就緒,呼叫前面提到的 Selectable 介面獲取 Runnable,直接返回給 ExecutionStrategy 處理。
如果沒有 I/O 事件就緒,就乾點雜活,看看有沒有客戶提交了更新 Selector 上事件註冊的任務,也就是上面提到的 SelectorUpdate 任務類。
幹完雜活繼續執行 select 方法,偵測 I/O 就緒事件。
任務生產者自己依次生產和執行任務,對應到 NIO 通訊模型就是用一個執行緒來偵測和處理一個 ManagedSelector 上的所有的 I/O 事件。
後面的 I/O 事件要等待前面的 I/O 事件處理完,效率明顯不高。
圖中,綠色代表生產一個任務,藍色代表執行這個任務,下同。
任務生產者開啟新執行緒來執行任務,這是典型的 I/O 事件偵測和處理用不同的執行緒來處理。
缺點是不能利用 CPU 快取,並且執行緒切換成本高。
圖中,棕色代表執行緒切換,下同。
任務生產者自己執行任務,這種方式可能會新建一個新的執行緒來繼續生產和執行任務。
它的優點是能利用 CPU 快取,但是潛在的問題是如果處理 I/O 事件的業務程式碼執行時間過長,會導致執行緒大量阻塞和執行緒飢餓。
這是 Jetty 對 ExecuteProduceConsume 策略的改良,線上程池執行緒充足的情況下等同於 ExecuteProduceConsume;
當系統比較忙執行緒不夠時,切換成 ProduceExecuteConsume 策略。
這麼做的原因是:
ExecuteProduceConsume 是在同一執行緒執行 I/O 事件的生產和消費,它使用的執行緒來自 Jetty 全域性的執行緒池,這些執行緒有可能被業務程式碼阻塞,如果阻塞的多了,全域性執行緒池中執行緒自然就不夠用了,最壞的情況是連 I/O 事件的偵測都沒有執行緒可用了,會導致 Connector 拒絕瀏覽器請求。
於是 Jetty 做了一個優化:
在低執行緒情況下,就執行 ProduceExecuteConsume 策略,I/O 偵測用專門的執行緒處理, I/O 事件的處理扔給執行緒池處理,其實就是放到執行緒池的佇列裡慢慢處理。
本文基於 Jetty-9 介紹了 ManagedSelector 和 ExecutionStrategy 的設計實現,介紹了 PC、PEC、EPC 三種執行緒執行策略的差異,從 Jetty 對執行緒執行策略的改良操作中可以看出,Jetty 的執行緒執行策略會優先使用 EPC 使得生產和消費任務能夠在同一個執行緒上執行,這樣做可以充分利用熱快取,避免排程延遲。
這給我們做效能優化也提供了一些思路:
在保證不發生執行緒飢餓的情況下,儘量使用同一個執行緒生產和消費可以充分利用 CPU 快取,並減少執行緒切換的開銷。
根據實際場景選擇最適合的執行策略,通過組合多個子策略也可以揚長避短達到1+1>2的效果。
參考檔案: