深度解讀 Tomcat 中的 NIO 模型
TOMCA簡介
整個tomcat是一個比較完善的框架體系,各個元件之間都是基於介面的實現,所以比較方便擴充套件和替換。像這裡的“org.apache.coyote.http11.Http11NioProtocol”和BIO的“org.apache.coyote.http11.Http11Protocol”都是統一的實現org.apache.coyote.ProtocolHandler介面,所以從整體結構上來說,NIO還是與BIO的實現保持大體一致。
首先來看一下NIO connector的內部結構,箭頭方向還是訊息流;
還是可以看見connector中三大塊
-
Http11NioProtocol
-
Mapper
-
CoyoteAdapter
重點看看Http11NioProtocol.
和JIoEndpoint一樣,NioEndpoint是Http11NioProtocol中負責接收處理socket的主要模組。但是在結構上比JIoEndpoint要複雜一些,畢竟是非阻塞的。但是需要注意的是,tomcat的NIO connector並非完全是非阻塞的,有的部分,例如接收socket,從socket中讀寫資料等,還是阻塞模式實現的,在後面會逐一介紹。
如圖所示,NioEndpoint的主要流程;
圖中Acceptor及Worker分別是以執行緒池形式存在,Poller是一個單執行緒。注意,與BIO的實現一樣,預設狀態下,在server.xml中沒有配置<Executor>,則以Worker執行緒池執行,如果配置了<Executor>,則以基於java concurrent 系列的java.util.concurrent.ThreadPoolExecutor執行緒池執行。
Acceptor
接收socket執行緒,這裡雖然是基於NIO的connector,但是在接收socket方面還是傳統的serverSocket.accept()方式,獲得SocketChannel物件,然後封裝在一個tomcat的實現類org.apache.tomcat.util.net.NioChannel物件中。然後將NioChannel物件封裝在一個PollerEvent物件中,並將PollerEvent物件壓入events
queue裡。這裡是個典型的生產者-消費者模式,Acceptor與Poller執行緒之間通過queue通訊,Acceptor是events queue的生產者,Poller是events queue的消費者。
Poller
Poller執行緒中維護了一個Selector物件,NIO就是基於Selector來完成邏輯的。在connector中並不止一個Selector,在socket的讀寫資料時,為了控制timeout也有一個Selector,在後面的BlockSelector中介紹。可以先把Poller執行緒中維護的這個Selector標為主Selector。
Poller是NIO實現的主要執行緒。首先作為events queue的消費者,從queue中取出PollerEvent物件,然後將此物件中的channel以OP_READ事件註冊到主Selector中,然後主Selector執行select操作,遍歷出可以讀資料的socket,並從Worker執行緒池中拿到可用的Worker執行緒,然後將socket傳遞給Worker。整個過程是典型的NIO實現。
Worker
Worker執行緒拿到Poller傳過來的socket後,將socket封裝在SocketProcessor物件中。然後從Http11ConnectionHandler中取出Http11NioProcessor物件,從Http11NioProcessor中呼叫CoyoteAdapter的邏輯,跟BIO實現一樣。在Worker執行緒中,會完成從socket中讀取http request,解析成HttpServletRequest物件,分派到相應的servlet並完成邏輯,然後將response通過socket發回client。在從socket中讀資料和往socket中寫資料的過程,並沒有像典型的非阻塞的NIO的那樣,註冊OP_READ或OP_WRITE事件到主Selector,而是直接通過socket完成讀寫,這時是阻塞完成的,但是在timeout控制上,使用了NIO的Selector機制,但是這個Selector並不是Poller執行緒維護的主Selector,而是BlockPoller執行緒中維護的Selector,稱之為輔Selector。
NioSelectorPool
NioEndpoint物件中維護了一個NioSelecPool物件,這個NioSelectorPool中又維護了一個BlockPoller執行緒,這個執行緒就是基於輔Selector進行NIO的邏輯。以執行servlet後,得到response,往socket中寫資料為例,最終寫的過程呼叫NioBlockingSelector的write方法。
NIO模型
I/O複用模型,是同步非阻塞,這裡的非阻塞是指I/O讀寫,對應的是recvfrom操作,因為資料報文已經準備好,無需阻塞。
I/O複用模型解讀
Tomcat的NIO是基於I/O複用來實現的。對這點一定要清楚,不然我們的討論就不在一個邏輯線上。下面這張圖學習過I/O模型知識的一般都見過,出自《UNIX網路程式設計》,I/O模型一共有阻塞式I/O,非阻塞式I/O,I/O複用(select/poll/epoll),訊號驅動式I/O和非同步I/O。這篇文章講的是I/O複用。
IO複用
這裡先來說下使用者態和核心態,直白來講,如果執行緒執行的是使用者程式碼,當前執行緒處在使用者態,如果執行緒執行的是核心裡面的程式碼,當前執行緒處在核心態。更深層來講,作業系統為程式碼所處的特權級別分了4個級別。
不過現代作業系統只用到了0和3兩個級別。0和3的切換就是使用者態和核心態的切換。更詳細的可參照《深入理解計算機作業系統》。I/O複用模型,是同步非阻塞,這裡的非阻塞是指I/O讀寫,對應的是recvfrom操作,因為資料報文已經準備好,無需阻塞。
說它是同步,是因為,這個執行是在一個執行緒裡面執行的。有時候,還會說它又是阻塞的,實際上是指阻塞在select上面,必須等到讀就緒、寫就緒等網路事件。有時候我們又說I/O複用是多路複用,這裡的多路是指N個連線,每一個連線對應一個channel,或者說多路就是多個channel。
複用,是指多個連線複用了一個執行緒或者少量執行緒(在Tomcat中是Math.min(2,Runtime.getRuntime().availableProcessors()))。
上面提到的網路事件有連線就緒,接收就緒,讀就緒,寫就緒四個網路事件。I/O複用主要是通過Selector複用器來實現的,可以結合下面這個圖理解上面的敘述。
Selector圖解.png
TOMCAT對IO模型的支援
tomcat支援IO型別圖
tomcat從6以後開始支援NIO模型,實現是基於JDK的java.nio包。這裡可以看到對read body 和response body是Blocking的。關於這點在第6.3節原始碼閱讀有重點介紹。
TOMCAT中NIO的配置與使用
在Connector節點配置protocol="org.apache.coyote.http11.Http11NioProtocol",Http11NioProtocol協議下預設最大連線數是10000,也可以重新修改maxConnections的值,同時我們可以設定最大執行緒數maxThreads,這裡設定的最大執行緒數就是Excutor的執行緒池的大小。
在BIO模式下實際上是沒有maxConnections,即使配置也不會生效,BIO模式下的maxConnections是保持跟maxThreads大小一致,因為它是一請求一執行緒模式。
NioEndpoint元件關係圖解讀
tomcatnio組成
我們要理解tomcat的nio最主要就是對NioEndpoint的理解。它一共包含LimitLatch、Acceptor、Poller、SocketProcessor、Excutor5個部分。
LimitLatch是連線控制器,它負責維護連線數的計算,nio模式下預設是10000,達到這個閾值後,就會拒絕連線請求。Acceptor負責接收連線,預設是1個執行緒來執行,將請求的事件註冊到事件列表。
有Poller來負責輪詢,Poller執行緒數量是cpu的核數Math.min(2,Runtime.getRuntime().availableProcessors())。由Poller將就緒的事件生成SocketProcessor同時交給Excutor去執行。Excutor執行緒池的大小就是我們在Connector節點配置的maxThreads的值。
在Excutor的執行緒中,會完成從socket中讀取http request,解析成HttpServletRequest物件,分派到相應的servlet並完成邏輯,然後將response通過socket發回client。
在從socket中讀資料和往socket中寫資料的過程,並沒有像典型的非阻塞的NIO的那樣,註冊OP_READ或OP_WRITE事件到主Selector,而是直接通過socket完成讀寫,這時是阻塞完成的,但是在timeout控制上,使用了NIO的Selector機制,但是這個Selector並不是Poller執行緒維護的主Selector,而是BlockPoller執行緒中維護的Selector,稱之為輔Selector。詳細原始碼可以參照 第6.3節。
NioEndpoint執行序列圖
tomcatnio序列圖.png
在下一小節NioEndpoint原始碼解讀中我們將對步驟1-步驟11依次找到對應的程式碼來說明。
NioEndpoint原始碼解讀
初始化
無論是BIO還是NIO,開始都會初始化連線限制,不可能無限增大,NIO模式下預設是10000。
步驟解讀
下面我們著重敘述跟NIO相關的流程,共分為11個步驟,分別對應上面序列圖中的步驟。
步驟1:繫結IP地址及埠,將ServerSocketChannel設定為阻塞。
這裡為什麼要設定成阻塞呢,我們一直都在說非阻塞。Tomcat的設計初衷主要是為了操作方便。這樣這裡就跟BIO模式下一樣了。只不過在BIO下這裡返回的是
Socket,NIO下這裡返回的是SocketChannel。
步驟2:啟動接收執行緒
步驟3:ServerSocketChannel.accept()接收新連線
步驟4:將接收到的連結通道設定為非阻塞
步驟5:構造NioChannel物件
步驟6:register註冊到輪詢執行緒
步驟7:構造PollerEvent,並新增到事件佇列
步驟8:啟動輪詢執行緒
步驟9:取出佇列中新增的PollerEvent並註冊到Selector
步驟10:Selector.select()
步驟11:根據選擇的SelectionKey構造SocketProcessor提交到請求處理執行緒
NioBlockingSelector和BlockPoller介紹
上面的序列圖有個地方我沒有描述,就是NioSelectorPool這個內部類,是因為在整體理解tomcat的nio上面在序列圖裡面不包括它更好理解。
在有了上面的基礎後,我們在來說下NioSelectorPool這個類,對更深層瞭解Tomcat的NIO一定要知道它的作用。NioEndpoint物件中維護了一個NioSelecPool物件,這個NioSelectorPool中又維護了一個BlockPoller執行緒,這個執行緒就是基於輔Selector進行NIO的邏輯。
以執行servlet後,得到response,往socket中寫資料為例,最終寫的過程呼叫NioBlockingSelector的write方法。程式碼如下:
也就是說當socket.write()返回0時,說明網路狀態不穩定,這時將socket註冊OP_WRITE事件到輔Selector,由BlockPoller執行緒不斷輪詢這個輔Selector,直到發現這個socket的寫狀態恢復了,通過那個倒數計數器,通知Worker執行緒繼續寫socket動作。看一下BlockSelector執行緒的程式碼邏輯:
使用這個輔Selector主要是減少執行緒間的切換,同時還可減輕主Selector的負擔。
關於效能
下面這份報告是我們壓測的一個結果,跟想象的是不是不太一樣?幾乎沒有差別,實際上NIO優化的是I/O的讀寫,如果瓶頸不在這裡的話,比如傳輸位元組數很小的情況下,BIO和NIO實際上是沒有差別的。
NIO的優勢更在於用少量的執行緒hold住大量的連線。還有一點,我們在壓測的過程中,遇到在NIO模式下剛開始的一小段時間內容,會有錯誤,這是因為一般的壓測工具是基於一種長連線,也就是說比如模擬1000併發,那麼同時建立1000個連線,下一時刻再發送請求就是基於先前的這1000個連線來發送,還有TOMCAT的NIO處理是有POLLER執行緒來接管的,它的執行緒數一般等於CPU的核數,如果一瞬間有大量併發過來,POLLER也會頓時處理不過來。
壓測1.jpeg
壓測2
總結
NIO只是優化了網路IO的讀寫,如果系統的瓶頸不在這裡,比如每次讀取的位元組說都是500b,那麼BIO和NIO在效能上沒有區別。NIO模式是最大化壓榨CPU,把時間片都更好利用起來。
對於作業系統來說,執行緒之間上下文切換的開銷很大,而且每個執行緒都要佔用系統的一些資源如記憶體,有關執行緒資源可參照這篇文章《一臺java伺服器可以跑多少個執行緒》。
因此,使用的執行緒越少越好。而I/O複用模型正是利用少量的執行緒來管理大量的連線。在對於維護大量長連線的應用裡面更適合用基於I/O複用模型NIO,比如web qq這樣的應用。所以我們要清楚系統的瓶頸是I/O還是CPU的計算。
看完本文有收穫?請轉發分享給更多人
歡迎關注“暢聊架構”,我們分享最有價值的網際網路技術乾貨文章,助力您成為有思想的全棧架構師,我們只聊網際網路、只聊架構!打造最有價值的架構師圈子和社群。
長按下方的二維碼可以快速關注我們