Tomcat8之後,針對Http協議預設使用org.apache.coyote.http11.Http11NioProtocol,也就是NIO模式。通過之前的部落格分析,我們知道Connector元件在初始化和start的時候會觸發它子元件(Http11NioProtocol、NIOEndpoint的初始化和start)。
NIO模式工作時序圖
還是像之前那樣,我們先整理出NIO模式啟動時的時序圖。
從上面的時序圖可以看出,整個流程的重點時在NioEndpoint這個類中。下面我們通過原始碼看下這幾個重點方法。
//NIO模式繫結埠
public void bind() throws Exception {
//初始化套接字服務,需要注意的是在NIO模式下,這個ServerSocketChannel還是阻塞模式的
initServerSocket();
//設定預設的acceptor執行緒數,預設是1個,這個引數暫時好像沒法修改(??)
//注意這個引數和acceptCount(接收請求連線的數量)之間的區別
if (acceptorThreadCount == 0) {
acceptorThreadCount = 1;
}
//設定pollerThreadCount,根據CPU的核數來,CPU大於2個設定為2,否則為1
if (pollerThreadCount <= 0)
pollerThreadCount = 1;
}
//設定CountDownLatch
setStopLatch(new CountDownLatch(pollerThreadCount));
initialiseSsl();
selectorPool.open();
}
這個程式碼主要做了些初始化工作,初始化套接字服務,初始化acceptorThreadCount和pollerThreadCount等。
再看看startInternal程式碼:
@Override
public void startInternal() throws Exception {
if (!running) {
running = true;
paused = false;
//建立3個快取
//頻繁建立SocketProcessor成本高
processorCache = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
socketProperties.getProcessorCache());
eventCache = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
socketProperties.getEventCache());
nioChannels = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
socketProperties.getBufferPool());
//一般情況下,我們自己不配置執行緒池,所以會進入這個方法,也可以自己在server.xml中配置這個執行緒池。
if ( getExecutor() == null ) {
//建立一個核心執行緒數是10,最大執行緒數是200,佇列長度是Integer.MaxValue的執行緒池
//注意下,這邊執行緒池的邏輯和JDK中執行緒池的邏輯不一樣,預設建立10個執行緒,當請求數
//超過10個的話會繼續建立,最大建立200個執行緒,超過200個後,任務就會進入阻塞佇列
//值得注意的是Tomcat的執行緒池繼承了JDK的ThreadPoolExecutor,但是重寫了執行緒池的預設
//機制。Tomcat的執行緒池會預設建立corePoolSize個執行緒,此時執行緒池中的執行緒都是空閒的。
//隨著不斷向執行緒池中新增任務,空閒執行緒逐漸減少,當執行緒池中的空閒執行緒耗盡之前,任務
//都會直接被提交到執行緒池的佇列中(這些任務會立即被空閒執行緒消費),當執行緒池中沒有空閒
//執行緒而且執行緒池中的執行緒總數沒達到MaximumPoolSize,會建立一個新的執行緒來執行新的任務;
//當執行緒池的大小達到MaximumPoolSize時,直接將任務放進佇列,等到有執行緒空閒下來後再處理
//這個任務。(參考TaskQueue的offer方法)
createExecutor();
}
initializeConnectionLatch();
// Start poller threads
//開啟poller執行緒,如果CPU是多核就開啟2個,否則開啟一個
pollers = new Poller[getPollerThreadCount()];
for (int i=0; i<pollers.length; i++) {
pollers[i] = new Poller();
Thread pollerThread = new Thread(pollers[i], getName() + "-ClientPoller-"+i);
pollerThread.setPriority(threadPriority);
pollerThread.setDaemon(true);
pollerThread.start();
}
//開啟acceptor執行緒,預設開啟一個acceptor執行緒
startAcceptorThreads();
}
}
Acceptor執行緒分析
acceptor執行緒的作用是接收客戶端請求,啟動之後一個loop執行緒一直在監聽使用者請求。值得注意的是,如果使用者一直沒法請求過來,這個執行緒也是會一直阻塞的,直到有請求過來。
//Acceptor這個類是NIOEndpoint的一個內部類
public void run() {
int errorDelay = 0;
// 一直會監聽,直到關閉tomcat
while (endpoint.isRunning()) {
// Loop if endpoint is paused
while (endpoint.isPaused() && endpoint.isRunning()) {
state = AcceptorState.PAUSED;
try {
Thread.sleep(50);
} catch (InterruptedException e) {
// Ignore
}
}
if (!endpoint.isRunning()) {
break;
}
state = AcceptorState.RUNNING;
try {
//如果已經接受的請求超過maxAcceptCount,那麼accept執行緒進入wait狀態
endpoint.countUpOrAwaitConnection();
if (endpoint.isPaused()) {
continue;
}
U socket = null;
try {
//接受socket,這個方法會阻塞,因為NIOEndpoint在初始化的時候
//將ServerSocketChannel設定成了阻塞模式
socket = endpoint.serverSocketAccept();
} catch (Exception ioe) {
endpoint.countDownConnection();
if (endpoint.isRunning()) {
// Introduce delay if necessary
errorDelay = handleExceptionWithDelay(errorDelay);
// re-throw
throw ioe;
} else {
break;
}
}
errorDelay = 0;
if (endpoint.isRunning() && !endpoint.isPaused()) {
//這邊委託給NioEndpoint的setSocketOptions方法處理
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
} else {
endpoint.destroySocket(socket);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
String msg = sm.getString("endpoint.accept.fail");
if (t instanceof Error) {
Error e = (Error) t;
if (e.getError() == 233) {
log.warn(msg, t);
} else {
log.error(msg, t);
}
} else {
log.error(msg, t);
}
}
}
state = AcceptorState.ENDED;
}
下面看下NioEndpoint的setSocketOptions(SocketChannel socket)方法:
protected boolean setSocketOptions(SocketChannel socket) {
// Process the connection
try {
//disable blocking, APR style, we are gonna be polling it
socket.configureBlocking(false);
Socket sock = socket.socket();
socketProperties.setProperties(sock);
//使用快取的NioChannel,沒有快取的則新建
NioChannel channel = nioChannels.pop();
if (channel == null) {
SocketBufferHandler bufhandler = new SocketBufferHandler(
socketProperties.getAppReadBufSize(),
socketProperties.getAppWriteBufSize(),
socketProperties.getDirectBuffer());
if (isSSLEnabled()) {
channel = new SecureNioChannel(socket, bufhandler, selectorPool, this);
} else {
channel = new NioChannel(socket, bufhandler);
}
} else {
channel.setIOChannel(socket);
//使用快取的channel,但是需要重新reset這個通道
channel.reset();
}
//將socket註冊到poller佇列中
getPoller0().register(channel);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
try {
log.error("",t);
} catch (Throwable tt) {
ExceptionUtils.handleThrowable(tt);
}
// Tell to close the socket
return false;
}
return true;
}
Tomcat以NIO模式啟動時NioEndpoint元件將啟動某個埠的監聽,一個連線到來後將被註冊到NioChannel佇列中,由Poller(輪詢器)負責檢測通道的讀寫事件,並在建立任務後扔進執行緒池中,執行緒池進行任務處理。處理過程中將通過協議解析器Http11NioProcessor元件對HTTP協議解析,同時通過介面卡(Adapter)匹配到指定的容器進行處理並響應客戶端。
LimitLatch元件負責對連線數的控制,Acceptor元件負責接收套接字連線並註冊到通道佇列裡面,Poller元件負責輪詢檢查事件列表,Poller池包含了若干Poller元件,SocketProcessor元件是任務定義器,Executor元件是負責處理套接字的執行緒池。下面將對每個元件的結構與作用進行解析。
連線數控制器LimitLatch
NIO模式中的LimitLatch元件和BIO模式中的LimitLatch元件功能一致,作用也是對最大連線數的限制。
與BIO中的控制器不同的是,控制閥門的大小不相同,BIO模式受本身模式的限制,它的連線數與執行緒數比例是1:1的關係,所以當連線數太多時將導致執行緒數也很多,JVM執行緒數過多將導致執行緒間切換成本很高。預設情況下,Tomcat處理連線池的執行緒數為200,所以BIO流量控制閥門大小也預設設定為200。但NIO模式能克服BIO連線數的不足,它能基於事件同時維護大量的連線,對於事件的遍歷只須交給同一個或少量的執行緒,再把具體的事件執行邏輯交給執行緒池。例如,Tomcat把套接字接收工作交給一個執行緒,而把套接字讀寫及處理工作交給N個執行緒,N一般為CPU核數。對於NIO模式,Tomcat預設把流量閥門大小設定為10 000,如果你想更改大小,可以通過server.xml中
Acceptor元件
Acceptor的主要職責也是監聽是否有客戶端連線進來並接收連線,這裡需要注意的是,accept操作是阻塞的。假如使用者一直沒有請求傳送過來,acceptor執行緒將一直阻塞。
Acceptor接收SocketChannel物件後要把它設定為非阻塞,這是因為後面對客戶端所有的連線都採取非阻塞模式處理。接著設定套接字的一些屬性,再封裝成非阻塞通道物件。非阻塞通道可能是NioChannel也可能是SecureNioChannel,這取決於使用HTTP通訊還是使用HTTPS通訊。最後將非阻塞通道物件註冊到通道佇列中並由Poller負責檢測事件。
任務定義器SocketProcessor
與JIoEndpoint元件相似,將任務放到執行緒池中處理前需要定義好任務的執行邏輯。根據執行緒池的約定,它必須擴充套件Runnable介面:
protected class SocketProcessor extends SocketProcessorBase<NioChannel> {
//NIO方式讀取套接字處理,並返回
//連線數減一
//關閉連線
}
因為NIO與BIO模式有很大不同,其中一個很大不同在於BIO每次返回都肯定能獲取若干位元組,而NIO無法保證每次讀取的位元組量,可多可少甚至可能沒有,所以對於NIO模式,只能“嘗試”處理請求報文。例如,第一次只讀取了請求頭部的一部分,不足以開始處理,但並不會阻塞,而是繼續往下執行,直到下次迴圈到來,此時可能請求頭部的另外一部分已經被讀取,則可以開始處理請求頭部。
連線輪詢器Poller
NIO模型需要同時對很多連線進行管理,管理的方式則是不斷遍歷事件列表,對相應連線的相應事件做出處理,而遍歷的工作正是交給Poller負責。Poller負責的工作可以用下圖簡單表示出來,在Java層面上看,它不斷輪詢事件列表,一旦發現相應的事件則封裝成任務定義器SocketProcessor,進而扔進執行緒池中執行任務。當然,由於NioEndpoint元件內有一個Poller池,因此如果不存線上程池,任務將由Poller直接執行。
Poller內部依賴JDK的Selector物件進行輪詢,Selector會選擇出待處理的事件,每輪詢一次就選出若干需要處理的通道,例如從通道中讀取位元組、將位元組寫入Channel等。在NIO模式下,因為每次讀取的資料是不確定的,對於HTTP協議來說,每次讀取的資料可能既包含了請求行也包含了請求頭部,也可能不包含請求頭部,所以每次只能嘗試去解析報文。若解析不成功則等待下次輪詢讀取更多的資料後再嘗試解析,若解析報文成功則做一些邏輯處理後對客戶端響應,而這些報文解析、邏輯處理、響應等都是在任務定義器中定義的。
Poller池子
在NIO模式下,對於客戶端連線的管理都是基於事件驅動的,上一節提到NioEndpoint元件包含了Poller元件,Poller負責的工作就是檢測事件並處理事件。但假如整個Tomcat的所有客戶端連線都交給一個執行緒來處理,那麼即使這個執行緒是不阻塞的,整體處理效能也可能無法達到最佳或較佳的狀態。為了提升處理效能,Tomcat設計成由多個Poller共同處理所有客戶端連線,所有連線均攤給每個Poller處理,而這些Poller便組成了Poller池。
整個結構如圖6.40所示,客戶端連線由Acceptor元件接收後按照一定的演算法放到通道佇列上。這裡使用的是輪詢排程演算法,從第1個佇列到第N個佇列迴圈分配,假如這裡有3個Poller,則第1個連線分配給第1個Poller對應的通道列表,第2個連線分配給第2個Poller對應的通道列表,以此類推,到第4個連線又分配到第1個Poller對應的通道列表上。這種演算法基本保證了每個Poller所對應處理的連線數均勻,每個Poller各自輪詢檢測自己對應的事件列表,一旦發現需要處理的連線則對其進行處理。這時如果NioEndpoint元件包含任務執行器(Executor)則會將任務處理交給它,但假如沒有Executor元件,Poller則自己處理任務。
Poller池的大小多少比較合適呢?Tomcat使用了一個經典的演算法Math.min(2, Runtime. getRuntime().availableProcessors()),即會根據Tomcat執行環境決定Poller元件的數量。所以在Tomcat中最少會有兩個Poller元件,而如果執行在更多處理器的機器上,則JVM可用處理器個數等於Poller元件的個數。
參考
http://server.51cto.com/sOS-595052.html
https://nod0620.iteye.com/blog/998215
https://www.jianshu.com/p/370af4895545