淺析 Jetty 中的執行緒優化思路

2023-06-26 15:00:50

作者:vivo 網際網路伺服器團隊- Wang Ke

本文介紹了 Jetty 中 ManagedSelector 和 ExecutionStrategy 的設計實現,通過與原生 select 呼叫的對比揭示了 Jetty 的執行緒優化思路。Jetty 設計了一個自適應的執行緒執行策略(EatWhatYouKill),在不出現執行緒飢餓的情況下儘量用同一個執行緒偵測 I/O 事件和處理 I/O 事件,充分利用了 CPU 快取並減少了執行緒切換的開銷。這種優化思路對於有大量 I/O 操作場景下的效能優化具有一定的借鑑意義。

一、什麼是 Jetty

Jetty 跟 Tomcat 一樣是一種 Web 容器,它的總體架構設計如下:

Jetty 總體上由一系列 Connector、一系列 Handler 和一個 ThreadPool組成。

圖片

Connector 也就是 Jetty 的聯結器元件,相比較 Tomcat 的聯結器,Jetty 的聯結器在設計上有自己的特點。

Jetty 的 Connector 支援 NIO 通訊模型,NIO 模型中的主角是 Selector,Jetty 在 Java 原生 Selector 的基礎上封裝了自己的 Selector:ManagedSelector。

二、Jetty 中的 Selector 互動

2.1 傳統的 Selector 實現

常規的 NIO 程式設計思路是將 I/O 事件的偵測和請求的處理分別用不同的執行緒處理。

具體過程是:

  1. 啟動一個執行緒;
  2. 在一個死迴圈裡不斷地呼叫 select 方法,檢測 Channel 的 I/O 狀態;
  3. 一旦 I/O 事件到達,就把該 I/O 事件以及一些封包裝成一個 Runnable;
  4. 將 Runnable 放到新執行緒中去處理。

這個過程有兩個執行緒在幹活:一個是 I/O 事件檢測執行緒、一個是 I/O 事件處理執行緒。

這兩個執行緒是"生產者"和"消費者"的關係。

這樣設計的好處:

將兩個工作用不同的執行緒處理,好處是它們互不干擾和阻塞對方。

這樣設計的缺陷:

當 Selector 檢測讀就緒事件時,資料已經被拷貝到核心中的快取了,同時 CPU 的快取中也有這些資料了。

這時當應用程式去讀這些資料時,如果用另一個執行緒去讀,很有可能這個讀執行緒使用另一個 CPU 核,而不是之前那個檢測資料就緒的 CPU 核。

這樣 CPU 快取中的資料就用不上了,並且執行緒切換也需要開銷。

2.2 Jetty 中的 ManagedSelector 實現

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<>();
     
    ...
}

2.2.1 SelectorUpdate 介面

為什麼需要一個"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 上的讀就緒事件感興趣。

2.2.2 Selectable 介面

上面有了 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 扔給執行緒池去執行。

三、Jetty 的執行緒優化思路

3.1 Jetty 中的 ExecutionStrategy 實現

前面介紹了 ManagedSelector 的使用互動:

  1. 如何註冊 Channel 以及 I/O 事件

  2. 提供什麼樣的處理類來處理 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 主要乾了三件事:

  1. 如果 Channel 集合中有 I/O 事件就緒,呼叫前面提到的 Selectable 介面獲取 Runnable,直接返回給 ExecutionStrategy 處理。

  2. 如果沒有 I/O 事件就緒,就乾點雜活,看看有沒有客戶提交了更新 Selector 上事件註冊的任務,也就是上面提到的 SelectorUpdate 任務類。

  3. 幹完雜活繼續執行 select 方法,偵測 I/O 就緒事件。

3.2 Jetty 的執行緒執行策略

3.2.1 ProduceConsume(PC) 執行緒執行策略

任務生產者自己依次生產和執行任務,對應到 NIO 通訊模型就是用一個執行緒來偵測和處理一個 ManagedSelector 上的所有的 I/O 事件。

後面的 I/O 事件要等待前面的 I/O 事件處理完,效率明顯不高。

圖片

圖中,綠色代表生產一個任務,藍色代表執行這個任務,下同。

3.2.2 ProduceExecuteConsume(PEC) 執行緒執行策略

任務生產者開啟新執行緒來執行任務,這是典型的 I/O 事件偵測和處理用不同的執行緒來處理。

缺點是不能利用 CPU 快取,並且執行緒切換成本高。

圖片

圖中,棕色代表執行緒切換,下同。

3.2.3 ExecuteProduceConsume(EPC) 執行緒執行策略

任務生產者自己執行任務,這種方式可能會新建一個新的執行緒來繼續生產和執行任務。

它的優點是能利用 CPU 快取,但是潛在的問題是如果處理 I/O 事件的業務程式碼執行時間過長,會導致執行緒大量阻塞和執行緒飢餓。

圖片

3.2.4 EatWhatYouKill(EWYK) 改良執行緒執行策略

這是 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 使得生產和消費任務能夠在同一個執行緒上執行,這樣做可以充分利用熱快取,避免排程延遲。

這給我們做效能優化也提供了一些思路:

  1. 在保證不發生執行緒飢餓的情況下,儘量使用同一個執行緒生產和消費可以充分利用 CPU 快取,並減少執行緒切換的開銷。

  2. 根據實際場景選擇最適合的執行策略,通過組合多個子策略也可以揚長避短達到1+1>2的效果。

參考檔案:

  1. Class EatWhatYouKill

  2. Eat What You Kill

  3. Thread Starvation with Eat What You Kill