1. 程式人生 > >(譯)Netty In Action第七章—事件迴圈和執行緒模型

(譯)Netty In Action第七章—事件迴圈和執行緒模型


請尊重勞動成果,未經本人允許,拒絕轉載,謝謝!


這章包涵以下內容
- 執行緒模型概覽
- 事件迴圈概念和實現
- 任務排程
- 實現細節

簡單地說,執行緒模型指定了OS、程式語言、框架或應用程式的上下文中的執行緒管理的關鍵方面。執行緒創造的方式和時間明顯對於應用程式程式碼的執行有著重大的影響,所以開發人員有必要去理解與不同模型相關的權衡。無論他們自己選擇模型還是通過採用框架語言隱式獲取模型,都是無可厚非的。

這一章我們將仔細研究Netty的執行緒模型。它是功能強大而易於使用的,正和Netty一樣,致力於簡化你的應用程式程式碼並使其效能和可維護性儘可能最大化。同時我們將探討引導我們選擇當前模型的經歷。

如果你對於Java併發API(java.util.concurrent)有著融會貫通的理解,你應該發現這一章的探討是簡單明瞭的。如果你對這些概念是陌生的或者你需要重新喚起你對它們的記憶,Brian Goetz等人所著的Java Concurrency in Practice(Addison-Wesley Professional,2006)是一個相當不錯的資源。

7.1 執行緒模型概覽

這一節我們將從整體上介紹執行緒模型,然後探討Netty過去和現在的執行緒模型,回顧它們的優點和缺陷。
正如我們在這章開頭提及的,執行緒模型指定程式碼如何被執行。因為我們必須一直監視併發執行可能產生的副作用,理解所應用模型的含義是非常重要的(單執行緒模型也一樣)。如果你忽略這些問題且僅僅希冀於最好的,無異於賭博——毫無疑問對你不利。

因為多核或多個CPUs的電腦隨處可見,大多數現代應用程式使用複雜的多執行緒技術來充分利用系統資源。相比而言,我們在Java早期的多執行緒方案不外乎按需建立和開啟新的執行緒來執行工作的併發單元,這是一個重負載下工作效率低下的原始方案。Java 5隨之引入了Executor API,此API中的執行緒池通過Thread快取和重用極大提高了效能。

基礎的執行緒池模式可以被描述為:
- 從執行緒池空閒列表選擇Thread然後把它分配去執行一個提交的任務(Runnable介面的實現)。
- 當任務完成,Thread返回執行緒池空閒列表且變成可重用的。
圖7.1闡明瞭這一模式。

圖 7.1 Executor執行邏輯

image

執行緒池和可重用執行緒相比於每個任務建立和銷燬執行緒是一個改進,但是它並沒有消除上下文切換的成本,當執行緒數量增加到處於極度重負載時,這種成本消耗愈加明顯。此外,在專案的生命週期過程中僅僅因為應用程式的整體複雜性或併發需求,其他執行緒相關的問題可能出現。

簡而言之, 多執行緒是複雜的。下一節我們將發現Netty如何幫助簡化多執行緒。

EventLoop 介面

執行任務來處理在連線的生命週期過程中發生的事件,這是任何網路框架的基本方法。相應的編碼結構通常被稱為事件迴圈,Netty從介面io.netty.channel.EventLoop採用的術語。

以下程式碼清單闡明瞭事件迴圈基本的想法,每一個任務是一個Runnable例項(如圖7.1所示)。

碼單 7.1 以事件迴圈方式執行任務

while (!terminated) {
    //blocks until there are events that are ready to run
    List<Runnable> readyEvents = blockUntilEventsReady();
    for (Runnable ev: readyEvents) {
       ev.run();
    }
}

Netty的EventLoop是協作設計的一部分,此設計採用兩個基礎的APIs:併發和網路。首先,包io.netty.util.concurrent建立在JDK包java.util.concurrent上提供執行緒執行器。其次,為了連線Channel事件,包io.netty.channel中的類繼承這些執行器。最終的類層次結構如圖7.2所示。

在這個模型中,一個EventLoop恰好被一個Thread持有,並且不會改變,任務(Runnable或Callable)可以直接提交到EventLoop實現以立即執行或排程執行。根據配置和可用核心,為了使資源利用最大化,多個EventLoops可能被建立,且單個EventLoop可能被分配去服務多個Channels。

注意Netty的這個EventLoop,當它繼承ScheduledExecutorService,僅僅定義一個方法parent()。這個顯示在以下程式碼塊中的方法,目的是返回當前EventLoop實現例項所屬的EventLoopGroup的引用。

public interface EventLoop extends EventExecutor, EventLoopGroup {
    @Override
    EventLoopGroup parent();
}

圖 7.2 EventLoop類層次結構

image

事件/任務執行順序事件和任務以FIFO(先進先出)順序執行。通過保證位元組內容以正確順序處理消除資料損壞的可能性。

7.2.1 Netty 4中I/O和事件處理

正如我們在第六章詳細描述的,I/O操作流通已經建立一個或多個ChannelHandlers的ChannelPipeline會觸發事件。傳播這些事件的方法呼叫可以被ChannelHandlers攔截並按需處理事件。

事件的性質通常決定它被處理的方式;它有可能從網路堆疊傳送資料到你的應用程式,相反,也可能做一些完全不同的事。但是事件處理的邏輯必須是通用的且足夠複雜以處理所有可能的用例。因此,在Netty 4所有I/o操作和事件被已經分配給EventLoop的執行緒處理。

這與Netty 3中使用的模型不同。下一節我們將討論更早的模型和它為什麼被替換。

7.2.2 Netty 3中的I/O操作

在先前版本使用的執行緒模型僅僅保證入站(先前叫做上游)事件在所謂的I/O執行緒(與Netty 4的EventLoop對應)中會被執行。所有出站(下游)事件被呼叫的執行緒處理,此執行緒可能是I/O執行緒或者任何其他執行緒。起初這看起來是一個好主意,但由於ChannelHandlers中出站事件的仔細同步的需要而被發現是有問題的。簡而言之,不可能保證多執行緒不會在同一時間嘗試去訪問入站事件。這是可能發生的,舉個例子,如果你通過在不同執行緒中呼叫Channel.write(),觸發同一個Channel的同時發生的下游事件。

當入站事件被觸發導致出站事件時,另外一個消極放方面的影響出現。當Channel.write()導致異常,你有必要去生成並觸發exceptionCaught事件。但在Netty 3模型中,因為這是入站事件,你最終在呼叫執行緒中執行程式碼,然後通過執行I/O執行緒處理事件,隨之而來的是多餘的上下文切換。

Netty 4中採用的執行緒模型,通過處理髮生在同一執行緒所給定的EventLoop的每件事,解決了這些問題。此模型提供了一個更簡便的執行架構且消除了ChannelHandlers中的同步需要(除了多個Channels之間可能共享的部分)。

既然你理解了EventLoop的角色,讓我們看看任務是如何排程執行的。

7.3 任務排程

有時候你有必要去排程一個任務更遲的(延期的)或定期的執行。舉個例子,你可能想去註冊一個任務,並在客戶端已經連線5分鐘後觸發它。一個普遍的用例是傳送心態資訊給遠端,以此檢查連線是否仍然活躍。如果沒有迴應,你就知道你可以關閉通道了。

在下一節中,我們將向你展示如何用核心的Java API和Netty的EventLoop排程任務。然後,我們將檢查Netty的內部實現並討論它的優缺點。

7.3.1 JDK排程API

在Java 5之前,任務排程以java.util.Timer為基礎,它使用一個後臺執行緒,且有和標準的執行緒相同的限制。隨後,JDK提供了包java.util.concurrent,它定義了介面ScheduledExecutorService。表7.1展示了java.util.concurrent.Executors的相關工廠方法。

表 7.1 java.util.concurrent.Executors工廠方法

方法 描述
newScheduledThreadPool(int corePoolSize) / newScheduledThreadPool(int corePoolSize,ThreadFactorythreadFactory) 建立一個可以排程命令在延遲後執行或定期執行的ScheduledThreadExecutorService。它使用引數corePoolSize來計算執行緒數量。
newSingleThreadScheduledExecutor() / newSingleThreadScheduledExecutor(ThreadFactorythreadFactory) 建立一個可以排程命令在延遲後執行或定期執行的ScheduledThreadExecutorService。它使用一個執行緒執行排程的任務。

雖然選擇不是很多,但對於大多數用例來說這些提供的選擇已經足夠了。以下程式碼清單展示瞭如何使用ScheduledExecutorService在60s延遲後執行任務。
碼單 7.2 用ScheduledExecutorService排程任務

ScheduledExecutorService executor =
Executors.newScheduledThreadPool(10);
ScheduledFuture<?> future = executor.schedule(
    new Runnable() {
        @Override
        public void run() {
        System.out.println("60 seconds later");
        }
}, 60, TimeUnit.SECONDS);
...
executor.shutdown();

雖然ScheduledExecutorService API簡單明瞭,但是重負載下它能帶來效能成本。下一節我們將看到Netty如何用更高的效率提供相同的功能。

7.3.2 使用EventLoop排程任務

ScheduledExecutorService實現有不足之處,比如額外的執行緒被建立為執行緒池管理的一部分。如果任務沒有被積極地呼叫,這可能成為一個性能瓶頸。Netty通過使用通道的EventLoop實現排程來解決這一問題,正如以下程式碼清單所示。

碼單 7.3 用EventLoop排程任務

Channel ch = ...
ScheduledFuture<?> future = ch.eventLoop().schedule(
    new Runnable() {
    @Override
    public void run() {
        System.out.println("60 seconds later");
    }
}, 60, TimeUnit.SECONDS);

60s過後,Runnable例項將被分配給通道的EventLoop執行。如果要排程一個任務使其每間隔60s執行,使用scheduleAtFixedRate(),如下所示。

碼單 7.4 用EventLoop排程迴圈任務

Channel ch = ...
ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(
    new Runnable() {
    @Override
    public void run() {
        System.out.println("Run every 60 seconds");
    }
}, 60, 60, TimeUnit.Seconds);

正如我們之前提到的,Netty的EventLoop繼承ScheduledExecutorService(看圖7.2),所以它提供了JDK實現的所有可用方法,包括上述例子中用到的schedule()和scheduleAtFixedRate()。所有操作的完整清單可以在關於ScheduledExecutorService的Javadocs中找到。

使用每一個非同步操作返回的ScheduledFuture來取消或者檢查執行狀態。以下程式碼清單佔了一個簡單的取消操作。

碼單 7.5 使用ScheduledFuture取消任務

ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(...);
// Some other code that runs...
boolean mayInterruptIfRunning = false;
//cancels the task, which prevents it from running again
future.cancel(mayInterruptIfRunning);

這些例子闡明效能可以通過充分利用Netty的排程能力進行獲取。反過來,這些依賴於底層執行緒模型,接下來我們將討論它。

7.4 實現細節

這一節進一步詳細討論Netty執行緒模型和排程實現的主要因素。我們還會提到需要注意的缺陷,以及持續發展的領域。

7.4.1 執行緒管理

Netty執行緒模型的優越效能取決於確定當前正在執行的Thread的標識;也就是說,不論它被分配給當前Channel和它的EventLoop。(回想一下,EventLoop有責任為Channel處理其生命週期期間所有的事件。)

如果呼叫的執行緒是EventLoop的,有問題的程式碼被執行。否則,EventLoop排程任務來延遲執行並把它放到一個內部的佇列中。當EventLoop接著執行它的事件,它將執行在佇列中的這些事件。這闡釋了在ChannelHandlers沒有取得同步的情況下,Thread如何直接與Channel相互作用的。

注意每一個EventLoop有它自己的任務佇列,並不依賴於任何其他EventLoop。圖7.3展示了EventLoop用來排程任務的執行邏輯。這是Netty執行緒模型的一個關鍵的元件。

我們之前講了不要阻塞當前I/O執行緒的重要性。我們用另一種方式再陳述一遍:“千萬不要將長期執行的執行緒放到執行佇列中,因為它會阻塞任何其他的任務在同一執行緒執行。”如果你必須阻塞呼叫或者執行長期執行的任務,我們建議使用專用的EventExecutor。(檢視側邊欄章節6.2.1中的“ChannelHandler執行和阻塞”)

圖 7.3 EventLoop執行邏輯

image

暫且不說這種極限情況,執行緒模型在使用中可以強烈影響排隊任務對整體系統效能的影響。正如所使用的傳輸事件處理實現。(和我們在第四章看到的一樣,Netty在不借助於修改你的程式碼庫的情況下,可以方便地切換傳輸。)

7.4.2 EventLoop/執行緒配置

服務Channels的I/O和事件的EventLoops被包含在一個EventLoopGroup中。EventLoops被建立和分配的方式根據傳輸實現變化。

非同步傳輸
非同步實現僅僅使用很少的EventLoops(和它們相關的執行緒),並在當前模型中這些可以在Channels之間共享。這允許很多Channels由儘可能少的Threads服務,而不是每一個Channel分配一個Thread。

圖7.4顯示了一個EventLoopGroup,其大小固定為三個EventLoopsge(每個由一個執行緒驅動)。當EventLoopGroup被建立時,EventLoops(和它們的執行緒)直接被分配,以此確保它們在被需要的時候是可用的。

EventLoopGroup負責分配EventLoop給每一個新建立的Channel。在目前的實現中,使用迴圈方法實現均衡分佈,且同樣的EventLoop會分配到多個Channels。(這一點在未來的版本中可能會改善。)

圖7.4 非阻塞傳輸的EventLoop分配(比如NIO和AIO)
image

一旦一個Channel已經被分配給一個EventLoop,它將使用這個EventLoop(和相關的執行緒)貫穿其生命週期。記住這一點,因為它使你不用擔心在你的ChannelHandler實現中的執行緒安全和同步。

同樣,請注意EventLoop分配對ThreadLocal使用的影響。因為一個EventLoop通常驅動不止一個Channel,ThreadLocal對於所有相關的Channels都是一樣的。這使得實現一個方法,比如狀態跟蹤,不是一個很好的選擇。然而,在一個無狀態的上下文中,對於共享它仍然可用於Channels之間共享繁重或昂貴的物件,甚至事件。

阻塞傳輸
其他傳輸的設計比如IOI(古老的阻塞I/O)是有一點不同的,如圖7.5所闡明的。

圖7.5 阻塞傳輸的EventLoop 分配(比如OIO)

image

這裡一個EventLoop(和它的執行緒)分配給一個Channel。如果你已經使用java.io包中的阻塞I/O來開發應用程式,你可能已經遇到這種模式。

但使用這種模式之前,你必須保證每個Channel的I/O事件僅僅被一個驅動此Channel的EventLoop的執行緒處理。這是Netty的一致性設計的另一例子,並且它對Netty的可靠性和易用性有很大貢獻。

7.5 總結

在這章中你學習了通常的執行緒模型和特別的Netty執行緒模型,我們詳細討論了後者的效能和一致性優點。

你明白瞭如何用EventLoop(I/O Thread)去執行你自己的任務,正如框架自身所做的。你學會了如何去排程延遲執行的任務,並且我們研究了重負載下的可擴充套件性問題。你也知道如何去驗證一個任何是否已經執行和如何去取消它。

這些由我們對框架實現細節的研究拓展的資訊,將幫助你使得你的應用程式效能最大化同時簡化它的程式碼庫。更多關於執行緒池和併發程式設計的資訊,我們推薦Brian Goetz所著的Java Concurrency in Practice(java併發實戰)。這本書將給你對最複雜多執行緒用例的深度理解。
我們已經到了一個激動人心的時刻——下一章我們將討論Bootstrapping,一個為你的應用程式帶來生命的配置和連線所有Netty元件的處理過程。