前言

我們就從底層的網路 I/O 模型優化出發,再到記憶體拷貝優化和執行緒模型優化,深入分析下 Tomcat、Netty 等通訊框架是如何通過優化 I/O 來提高系統性能的。

網路 I/O 模型優化

網路通訊中,最底層的就是核心中的網路 I/O 模型了。 隨著技術的發展,作業系統核心的網路模型衍生出了五種 I/O 模型,《UNIX 網路程式設計》一書將這五種 I/O 模型分為阻塞式 I/O、非阻塞式 I/O、I/O 複用、訊號驅動式 I/O 和非同步 I/O。每一種 I/O 模型的出現,都是基於前一種 I/O 模型的優化升級。

最開始的阻塞式 I/O,它在每一個連線建立時,都需要一個使用者執行緒來處理, 並且在 I/O 操作沒有就緒或結束時,執行緒會被掛起,進入阻塞等待狀態,阻塞式 I/O 就成為了導致效能瓶頸的根本原因。

那阻塞到底發生在套接字(socket)通訊的哪些環節呢?

在《Unix 網路程式設計》中,套接字通訊可以分為流式套接字(TCP)和資料報套接字(UDP)。其中 TCP 連線是我們最常用的,一起來了解下 TCP 服務端的工作流程 (由於 TCP 資料傳輸比較複雜,存在拆包和裝包的可能,這裡我只假設一次最簡單的 TCP 資料傳輸):

  • 首先, 應用程式通過系統呼叫 socket 建立一個套接字,它是系統分配給應用程式的一個檔案描述符;
  • 其次, 應用程式會通過系統呼叫 bind,繫結地址和埠號,給套接字命名一個名稱;
  • 然後, 系統會呼叫 listen 建立一個佇列用於存放客戶端進來的連線;
  • 最後, 應用服務會通過系統呼叫 accept 來監聽客戶端的連線請求。

當有一個客戶端連線到服務端之後,服務端就會呼叫 fork 建立一個子程序,通過系統呼叫 read 監聽客戶端發來的訊息,再通過 write 向客戶端返回資訊。

1. 阻塞式 I/O

在整個 socket 通訊工作流程中,socket 的預設狀態是阻塞的。 也就是說,當發出一個不能立即完成的套接字呼叫時,其程序將被阻塞,被系統掛起,進入睡眠狀態,一直等待相應的操作響應。從上圖中,我們可以發現,可能存在的阻塞主要包括以下三種。

connect 阻塞:當客戶端發起 TCP 連線請求,通過系統呼叫 connect 函式,TCP 連線的建立需要完成三次握手過程,客戶端需要等待服務端傳送回來的 ACK 以及 SYN 訊號,同樣服務端也需要阻塞等待客戶端確認連線的 ACK 訊號,這就意味著 TCP 的每個 connect 都會阻塞等待,直到確認連線。

accept 阻塞:一個阻塞的 socket 通訊的服務端接收外來連線,會呼叫 accept 函式,如果沒有新的連線到達,呼叫程序將被掛起,進入阻塞狀態。

read、write 阻塞:當一個 socket 連線建立成功之後,服務端用 fork 函式建立一個子程序, 呼叫 read 函式等待客戶端的資料寫入,如果沒有資料寫入,呼叫子程序將被掛起,進入阻塞狀態。

2. 非阻塞式 I/O

使用 fcntl 可以把以上三種操作都設定為非阻塞操作。 如果沒有資料返回,就會直接返回一個 EWOULDBLOCK 或 EAGAIN 錯誤,此時程序就不會一直被阻塞。

當我們把以上操作設定為了非阻塞狀態,我們需要設定一個執行緒對該操作進行輪詢檢查,這也是最傳統的非阻塞 I/O 模型。

3. I/O 複用

如果使用使用者執行緒輪詢檢視一個 I/O 操作的狀態,在大量請求的情況下,這對於 CPU 的使用率無疑是種災難。 那麼除了這種方式,還有其它方式可以實現非阻塞 I/O 套接字嗎?

Linux 提供了 I/O 複用函式 select/poll/epoll,程序將一個或多個讀操作通過系統呼叫函式,阻塞在函式操作上。這樣,系統核心就可以幫我們偵測多個讀操作是否處於就緒狀態。

select() 函式:它的用途是,在超時時間內,監聽使用者感興趣的檔案描述符上的可讀可寫和異常事件的發生。Linux 作業系統的核心將所有外部裝置都看做一個檔案來操作,對一個檔案的讀寫操作會呼叫核心提供的系統命令,返回一個檔案描述符(fd)。

 int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

檢視以上程式碼,select() 函式監視的檔案描述符分 3 類,分別是 writefds(寫檔案描述符)、readfds(讀檔案描述符)以及 exceptfds(異常事件檔案描述符)。

呼叫後 select() 函式會阻塞,直到有描述符就緒或者超時,函式返回。 當 select 函式返回後,可以通過函式 FD_ISSET 遍歷 fdset,來找到就緒的描述符。fd_set 可以理解為一個集合,這個集合中存放的是檔案描述符,可通過以下四個巨集進行設定:

poll() 函式:在每次呼叫 select() 函式之前,系統需要把一個 fd 從使用者態拷貝到核心態,這樣就給系統帶來了一定的效能開銷。再有單個程序監視的 fd 數量預設是 1024,我們可以通過修改巨集定義甚至重新編譯核心的方式打破這一限制。但由於 fd_set 是基於陣列實現的,在新增和刪除 fd 時,數量過大會導致效率降低。

poll() 的機制與 select() 類似,二者在本質上差別不大。poll() 管理多個描述符也是通過輪詢,根據描述符的狀態進行處理,但 poll() 沒有最大檔案描述符數量的限制。

poll() 和 select() 存在一個相同的缺點,那就是包含大量檔案描述符的陣列被整體複製到使用者態和核心的地址空間之間,而無論這些檔案描述符是否就緒,他們的開銷都會隨著檔案描述符數量的增加而線性增大。

epoll() 函式:select/poll 是順序掃描 fd 是否就緒,而且支援的 fd 數量不宜過大,因此它的使用受到了一些制約。

Linux 在 2.6 核心版本中提供了一個 epoll 呼叫,epoll 使用事件驅動的方式代替輪詢掃描 fd。 epoll 事先通過 epoll_ctl() 來註冊一個檔案描述符,將檔案描述符存放到核心的一個事件表中,這個事件表是基於紅黑樹實現的,所以在大量 I/O 請求的場景下,插入和刪除的效能比 select/poll 的陣列 fd_set 要好,因此 epoll 的效能更勝一籌,而且不會受到 fd 數量的限制。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)

通過以上程式碼,我們可以看到: epoll_ctl() 函式中的 epfd 是由 epoll_create() 函式生成的一個 epoll 專用檔案描述符。op 代表操作事件型別,fd 表示關聯檔案描述符,event 表示指定監聽的事件型別。

一旦某個檔案描述符就緒時,核心會採用類似 callback 的回撥機制,迅速啟用這個檔案描述符,當程序呼叫 epoll_wait() 時便得到通知,之後程序將完成相關 I/O 操作。

int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)

4. 訊號驅動式 I/O

訊號驅動式 I/O 類似觀察者模式,核心就是一個觀察者,訊號回撥則是通知。使用者程序發起一個 I/O 請求操作,會通過系統呼叫 sigaction 函式,給對應的套接字註冊一個訊號回撥,此時不阻塞使用者程序,程序會繼續工作。當核心資料就緒時,核心就為該程序生成一個 SIGIO 訊號,通過訊號回撥通知程序進行相關 I/O 操作。

訊號驅動式 I/O 相比於前三種 I/O 模式, 實現了在等待資料就緒時,程序不被阻塞,主迴圈可以繼續工作,所以效能更佳。

而由於 TCP 來說,訊號驅動式 I/O 幾乎沒有被使用,這是因為 SIGIO 訊號是一種 Unix 訊號,訊號沒有附加資訊,如果一個訊號源有多種產生訊號的原因,訊號接收者就無法確定究竟發生了什麼。而 TCP socket 生產的訊號事件有七種之多,這樣應用程式收到 SIGIO,根本無從區分處理。

但訊號驅動式 I/O 現在被用在了 UDP 通訊上, UDP 只有一個數據請求事件,這也就意味著在正常情況下 UDP 程序只要捕獲 SIGIO 訊號,就呼叫 recvfrom 讀取到達的資料報。如果出現異常,就返回一個異常錯誤。比如,NTP 伺服器就應用了這種模型。

5. 非同步 I/O

訊號驅動式 I/O 雖然在等待資料就緒時,沒有阻塞程序,但在被通知後進行的 I/O 操作還是阻塞的,程序會等待資料從核心空間複製到使用者空間中。而非同步 I/O 則是實現了真正的非阻塞 I/O。

當用戶程序發起一個 I/O 請求操作,系統會告知核心啟動某個操作,並讓核心在整個操作完成後通知程序。這個操作包括等待資料就緒和將資料從核心複製到使用者空間。由於程式的程式碼複雜度高,除錯難度大,且支援非同步 I/O 作業系統比較少見(目前 Linux 暫不支援,而 Windows 已經實現了非同步 I/O),所以在實際生產環境中很少用到非同步 I/O 模型。

NIO 使用 I/O 複用器 Selector 實現非阻塞 I/O,Selector 就是使用了這五種型別中的一種 I/O 複用模型。 Java 中的 Selector 其實就是 select/poll/epoll 的外包類。

我們在上面的 TCP 通訊流程中講到,Socket 通訊中的 conect、accept、read 以及 write 為阻塞操作,在 Selector 中分別對應 SelectionKey 的四個監聽事件 OP_ACCEPT、OP_CONNECT、OP_READ 以及 OP_WRITE。

在 NIO 服務端通訊程式設計中, 首先會建立一個 Channel,用於監聽客戶端連線;接著,建立多路複用器 Selector,並將 Channel 註冊到 Selector,程式會通過 Selector 來輪詢註冊在其上的 Channel,當發現一個或多個 Channel 處於就緒狀態時, 返回就緒的監聽事件,最後程式匹配到監聽事件,進行相關的 I/O 操作。

在建立 Selector 時,程式會根據作業系統版本選擇使用哪種 I/O 複用函式。在 JDK1.5 版本中, 如果程式執行在 Linux 作業系統,且核心版本在 2.6 以上, NIO 中會選擇 epoll 來替代傳統的 select/poll,這也極大地提升了 NIO 通訊的效能。

由於訊號驅動式 I/O 對 TCP 通訊的不支援, 以及非同步 I/O 在 Linux 作業系統核心中的應用還不太成熟,大部分框架都還是基於 I/O 複用模型實現的網路通訊。

零拷貝

在 I/O 複用模型中,執行讀寫 I/O 操作依然是阻塞的,在執行讀寫 I/O 操作時,存在著多次記憶體拷貝和上下文切換,給系統增加了效能開銷。

零拷貝是一種避免多次記憶體複製的技術,用來優化讀寫 I/O 操作。

在網路程式設計中,通常由 read、write 來完成一次 I/O 讀寫操作。每一次 I/O 讀寫操作都需要完成四次記憶體拷貝,路徑是 I/O 裝置 -> 核心空間 -> 使用者空間 -> 核心空間 -> 其它 I/O 裝置。

Linux 核心中的 mmap 函式可以代替 read、write 的 I/O 讀寫操作, 實現使用者空間和核心空間共享一個快取資料。mmap 將使用者空間的一塊地址和核心空間的一塊地址同時對映到相同的一塊實體記憶體地址,不管是使用者空間還是核心空間都是虛擬地址,最終要通過地址對映對映到實體記憶體地址。這種方式避免了核心空間與使用者空間的資料交換。I/O 複用中的 epoll 函式中就是使用了 mmap 減少了記憶體拷貝。

在 Java 的 NIO 程式設計中,則是使用到了 Direct Buffer 來實現記憶體的零拷貝。Java 直接在 JVM 記憶體空間之外開闢了一個實體記憶體空間,這樣核心和使用者程序都能共享一份快取資料。

執行緒模型優化

除了核心對網路 I/O 模型的優化,NIO 在使用者層也做了優化升級。NIO 是基於事件驅動模型來實現的 I/O 操作。 Reactor 模型是同步 I/O 事件處理的一種常見模型,其核心思想是將 I/O 事件註冊到多路複用器上,一旦有 I/O 事件觸發,多路複用器就會將事件分發到事件處理器中,執行就緒的 I/O 事件操作。該模型有以下三個主要元件:

  • 事件接收器 Acceptor: 主要負責接收請求連線;
  • 事件分離器 Reactor: 接收請求後,會將建立的連線註冊到分離器中,依賴於迴圈監聽多路複用器 Selector,一旦監聽到事件,就會將事件 dispatch 到事件處理器;
  • 事件處理器 Handlers: 事件處理器主要是完成相關的事件處理,比如讀寫 I/O 操作。

1. 單執行緒 Reactor 執行緒模型

最開始 NIO 是基於單執行緒實現的,所有的 I/O 操作都是在一個 NIO 執行緒上完成。由於 NIO 是非阻塞 I/O,理論上一個執行緒可以完成所有的 I/O 操作。

但 NIO 其實還不算真正地實現了非阻塞 I/O 操作,因為讀寫 I/O 操作時使用者程序還是處於阻塞狀態,這種方式在高負載、高併發的場景下會存在效能瓶頸,一個 NIO 執行緒如果同時處理上萬連線的 I/O 操作,系統是無法支撐這種量級的請求的。

2. 多執行緒 Reactor 執行緒模型

為了解決這種單執行緒的 NIO 在高負載、高併發場景下的效能瓶頸,後來使用了執行緒池。

在 Tomcat 和 Netty 中都使用了一個 Acceptor 執行緒來監聽連線請求事件, 當連線成功之後,會將建立的連線註冊到多路複用器中,一旦監聽到事件,將交給 Worker 執行緒池來負責處理。大多數情況下,這種執行緒模型可以滿足效能要求,但如果連線的客戶端再上一個量級,一個 Acceptor 執行緒可能會存在效能瓶頸。

3. 主從 Reactor 執行緒模型

現在主流通訊框架中的 NIO 通訊框架都是基於主從 Reactor 執行緒模型來實現的。在這個模型中,Acceptor 不再是一個單獨的 NIO 執行緒,而是一個執行緒池。Acceptor 接收到客戶端的 TCP 連線請求,建立連線之後,後續的 I/O 操作將交給 Worker I/O 執行緒。

基於執行緒模型的 Tomcat 引數調優

Tomcat 中,BIO、NIO 是基於主從 Reactor 執行緒模型實現的。

在 BIO 中, Tomcat 中的 Acceptor 只負責監聽新的連線,一旦連線建立監聽到 I/O 操作,將會交給 Worker 執行緒中,Worker 執行緒專門負責 I/O 讀寫操作。

在 NIO 中, Tomcat 新增了一個 Poller 執行緒池,Acceptor 監聽到連線後,不是直接使用 Worker 中的執行緒處理請求,而是先將請求傳送給了 Poller 緩衝佇列。在 Poller 中,維護了一個 Selector 物件,當 Poller 從佇列中取出連線後,註冊到該 Selector 中;然後通過遍歷 Selector,找出其中就緒的 I/O 操作,並使用 Worker 中的執行緒處理相應的請求。

你可以通過以下幾個引數來設定 Acceptor 執行緒池和 Worker 執行緒池的配置項。

acceptorThreadCount: 該引數代表 Acceptor 的執行緒數量,在請求客戶端的資料量非常巨大的情況下,可以適當地調大該執行緒數量來提高處理請求連線的能力,預設值為 1。

maxThreads: 專門處理 I/O 操作的 Worker 執行緒數量,預設是 200,可以根據實際的環境來調整該引數,但不一定越大越好。

acceptCount: Tomcat 的 Acceptor 執行緒是負責從 accept 佇列中取出該 connection,然後交給工作執行緒去執行相關操作,這裡的 acceptCount 指的是 accept 佇列的大小。

當 Http 關閉 keep alive,在併發量比較大時,可以適當地調大這個值。而在 Http 開啟 keep alive 時,因為 Worker 執行緒數量有限,Worker 執行緒就可能因長時間被佔用,而連線在 accept 佇列中等待超時。如果 accept 佇列過大,就容易浪費連線。

maxConnections: 表示有多少個 socket 連線到 Tomcat 上。在 BIO 模式中,一個執行緒只能處理一個連線,一般 maxConnections 與 maxThreads 的值大小相同;在 NIO 模式中,一個執行緒同時處理多個連線,maxConnections 應該設定得比 maxThreads 要大得多,預設是 10000。

看完三件事️



如果你覺得這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:

  1. 點贊,轉發,有你們的 『點贊和評論』,才是我創造的動力。
  2. 關注公眾號 『 阿風的架構筆記 』,不定期分享原創知識。
  3. 同時可以期待後續文章ing
  4. 關注後回覆【666】掃碼即可獲取架構進階學習資料包