Tomcat對keep-alive的實現邏輯
Tomcat的connector實現邏輯蠻複雜的,有很多種狀態總記不住,每次遇到網路相關的問題都要翻一遍程式碼,這次結合一個案例看看tomcat的三種connector的實現方式。
這個案例在畢玄的blog裡也提到了,背景是某應用上游有個用c寫的模組與server端tomcat進行http通訊,這個應用tomcat配置的connector是apr模式。之前一直執行的很穩定,但一次前端擴容後,導致後端的tomcat全部阻塞在下面的堆疊上:
"http-apr-7001-exec-2" #28 daemon prio=5 os_prio=31 tid=0x00007fe1e43db800 nid=0x660b runnable [0x0000000124629000] java.lang.Thread.State: RUNNABLE at org.apache.tomcat.jni.Socket.recvbb(Native Method) at org.apache.coyote.http11.InternalAprInputBuffer.fill(InternalAprInputBuffer.java:575) at org.apache.coyote.http11.InternalAprInputBuffer.parseHeader(InternalAprInputBuffer.java:464) at org.apache.coyote.http11.InternalAprInputBuffer.parseHeaders(InternalAprInputBuffer.java:312) at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:969) at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:607) at org.apache.tomcat.util.net.AprEndpoint$SocketProcessor.doRun(AprEndpoint.java:2442) at org.apache.tomcat.util.net.AprEndpoint$SocketProcessor.run(AprEndpoint.java:2431) - locked <0x000000079581e018> (a org.apache.tomcat.util.net.AprEndpoint$AprSocketWrapper) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) at java.lang.Thread.run(Thread.java:745)
在我們最近這一年內推出的ali-tomcat版本里已經不推薦apr模式了,因為它帶來的效能上的提升與運維和維護的成本比不值。一方面在使用時tcnative這個本地庫的版本要與tomcat的版本匹配,否則不同的版本可能不工作(曾經出現過這樣的運維故障);二是我們曾遇到過apr導致jvm crash的情況;還有一個問題還是這個模組曾經被某個大牛修改過,繼續維護的話團隊裡需要一個C/C++的人才行。
當時的情況有些緊急,看到堆疊阻塞在apr的本地呼叫上,通過pstack
檢視libapr的呼叫底層阻塞在poll
或epoll_wait
上,一下子沒有思路,只好先讓應用方升級到新版本的ali-tomcat上,採用BIO或NIO模式看看。
應用方切換到了新的版本後預設配置了BIO,執行緒池設定的是250,過不了一會兒大部分又阻塞在了下面的堆疊上:
"http-bio-7001-exec-977" daemon prio=10 tid=0x00007f3e0bb96800 nid=0x6ff5 runnable [0x00007f3e054d3000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.read(SocketInputStream.java:129) at org.apache.coyote.http11.InternalInputBuffer.fill(InternalInputBuffer.java:516) at org.apache.coyote.http11.InternalInputBuffer.fill(InternalInputBuffer.java:501) at org.apache.coyote.http11.Http11Processor.setRequestLineReadTimeout(Http11Processor.java:167) at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:948) at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:607) at org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor.run(JIoEndpoint.java:314) - locked <0x00000006ed322ed8> (a org.apache.tomcat.util.net.SocketWrapper) at java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:886) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:908) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) at java.lang.Thread.run(Thread.java:662)
從這個堆疊來看直覺上以為在讀資料,問了以下應用方,上游主要通過POST
方式呼叫tomcat,每次資料大約在幾K到幾十K,當時沒有細問,誤以為真的是傳送過來的資料量達,都阻塞在資料讀取上。採取了增加執行緒數大小的做法,先調到1000,發覺穩定在700-800之間,後應用負責人有些不放心,又調到了1500.
週末之後,應用雖然穩定,但BIO模式執行緒數開銷的仍較大,通過ali-tomcat內建的監控功能可以看到執行緒池的狀態:
$ curl http://localhost:8006/connector/threadpool
"http-bio-7001"
-----------------------------------------------------------------
| thread_count | thread_busy | min_pool_size | max_pool_size |
-----------------------------------------------------------------
| 1121 | 1091 | 10 | 1500 |
-----------------------------------------------------------------
BIO模式下在使用的執行緒有1091個,應用方嘗試採用NIO模式,觀察了一段時間,同等壓力下,執行緒數有大幅度下降:
$ curl http://localhost:8006/connector/threadpool
"http-nio-7001"
-----------------------------------------------------------------
| thread_count | thread_busy | min_pool_size | max_pool_size |
-----------------------------------------------------------------
| 483 | 44 | 10 | 1500 |
-----------------------------------------------------------------
對於這麼明顯的下降,一方面懷疑BIO模式的瓶頸在哪兒,另一方面也覺得與業務的場景有關,正巧這個場景適用NIO模式。瞭解到他們使用了keep-alive
,之前對於keep-alive
的實現僅在NIO模式下有跟蹤過,對於BIO和APR模式下如何實現的keep-alive
沒有很深入的瞭解,正好借這次問題排查詳細的跟蹤了一下另外兩種模式下對keep-alive的實現。
在說keep-alive
的實現之前,先貼張之前分享ali-tomcat的ppt的一張圖:
這張表格引用自apache-tomcat官方網站,對於connector的三種模式有很好的對比,上次分享時著重講NIO模式的實現,所以對NIO也不是完全非阻塞(讀body和寫response是模擬阻塞行為)的地方用紅色突出了一下。這次我們先著重關注一下表格里的 “Wait for next Request” 這一項。它表示的是當開啟keep-alive的情況下三種模式對等待下一次請求是否阻塞。
1) BIO模式下的keep-alive實現:
首先在BIO的專門負責socket建立的Acceptor
執行緒的邏輯裡,將socket封裝成一個task(對應的是JIoEndpoint.SocketProcessor
這個類)提交給執行緒池處理。而這個task(即SocketProcessor
)的run方法邏輯大致是:
try{
...
state = handler.process(...); // 這裡是具體的處理邏輯
...
if (state == SocketState.OPEN){
socket.setKeptAlive(true);
socket.access();
launch = true;
}
...
}finally{
if(launch) {
executor.execute(new SocketProcessor(...)); // 再次封裝為同類型的task,並再次提交給執行緒池
}
}
注意上面的邏輯,如果請求是開啟keep-alive
的話,socket在請求結束後仍處於OPEN狀態,下一次請求仍可以複用當前socket而不必重新建立,在 finally 塊裡會判斷連線狀況如果是keep-alive
會再次封裝為同樣的任務提交給執行緒池,重複這段邏輯,相當於一個迴圈,只不過每次執行的執行緒不一定相同。如果socket上已經沒有請求了,最終socket會因超時或客戶端close造成的EOFException
而關閉。
有一個簡單的方法來判斷keep-alive
是否被有效利用,如果socket被複用得當的話,socket(對應的是SocketWrapper
這個類)的例項數應該是大大小於請求task(對應的是SocketProcessor
這個類)例項數。比如我們先模擬不復用scoket的情況:
$ curl http://localhost:7001/main
$ curl http://localhost:7001/main
$ jmap -histo `pidof java` | sed -n -e '1,3p' -e '/SocketWrapper/p' -e '/SocketProcessor/p'
num #instances #bytes class name
----------------------------------------------
516: 2 128 org.apache.tomcat.util.net.SocketWrapper
587: 4 96 org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor
上面執行了2次curl,建立了2次連線,因為http1.1預設就開啟了keep-alive
,所以根據前面try-finally
裡邏輯,一次連線的過程被建立的SocketProcessor
例項數會比它實際的請求數多1個。所以這2次curl命令(每次的請求為1),沒有複用socket,共建立了2個SocketWrapper
例項和4個SocketProcessor
例項。正好是2倍關係。
如果複用socket,則SocketProcessor
例項數應該比SocketWrapper
的例項數多不止一倍,比如下面用zsh模擬10次請求:
n=0;
while (( n < 10 ));do
n=$((n+1));
echo -ne "GET /main HTTP/1.1\nhost: localhost:7001\n\n";
sleep 1;
done | telnet localhost 7001
這10次請求是複用的同一個socket,在每次請求中間間隔了1秒,結束後再檢視SocketProcessor
和SocketWrapper
的例項數:
$ jmap -histo `pidof java` | sed -n -e '1,3p' -e '/SocketWrapper/p' -e '/SocketProcessor/p'
num #instances #bytes class name
----------------------------------------------
348: 11 264 org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor
669: 1 64 org.apache.tomcat.util.net.SocketWrapper
這次就一個socket的例項,task的例項數則是請求數+1,即11個。現實情況種這兩個例項數差出1~2個數量級也常見(說明socket被複用的比較多)。
BIO模式下keep-alive為什麼執行緒利用率不高?
再回到這次應用的例子中,為什麼切換到BIO模式的情況下,被使用的執行緒數有1091個左右,而NIO的則只有44個,差距這麼大的原因是什麼?
其實上面給出的官方對比的表格裡已經有答案了,BIO在處理下一次請求的時候是阻塞的,而NIO是非阻塞的。所謂阻塞是執行緒會一直掛在這個連線上等待新的資料到來。
正好這個應用的情況是開啟keep-alive保持長連線,然後每隔1秒鐘向tomcat傳送一次資料。
如果要模擬他們的情況,可以用下面的指令碼:
while :; do
echo -ne "POST /main HTTP/1.1\nhost: localhost:7001\nContent-length:4\n\nData\n";
sleep 1;
done | telnet localhost 7001
按說幾K到幾十K的資料最多不超過幾十毫秒,也就是說socket在90%以上的時間是空閒狀態,而BIO卻一直有一個執行緒會阻塞在上面,白白浪費。
這裡有個細節,其實根據前邊的JIoEndpoint.SocketProcessor
的try-finally
程式碼段,它不一定是阻塞在同一個執行緒上,取決於執行緒池的實現,但總會佔用一個執行緒資源。現在看一下在等待下一次請求時的執行緒是怎麼阻塞的:
$ { echo -e "GET /main HTTP/1.1\nhost: localhost:7001\n"; sleep 10 } | telnet localhost 7001
上面模擬了一次連線,請求結束後先不釋放,保持10秒鐘,以便我們執行jstack來看此時的執行緒情況:
$ jstack `pidof java` | grep "socketRead0" -B2 -A10
"http-bio-7001-exec-4" #28 daemon prio=5 os_prio=31 tid=0x00007f8a742c4000 nid=0x7d03 runnable [0x0000000128ceb000]
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.read(SocketInputStream.java:150)
at java.net.SocketInputStream.read(SocketInputStream.java:121)
at org.apache.coyote.http11.InternalInputBuffer.fill(InternalInputBuffer.java:516)
at org.apache.coyote.http11.InternalInputBuffer.fill(InternalInputBuffer.java:501)
at org.apache.coyote.http11.Http11Processor.setRequestLineReadTimeout(Http11Processor.java:167)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:946)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:607)
at org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor.run(JIoEndpoint.java:316)
- locked <0x00000007973b0298> (a org.apache.tomcat.util.net.SocketWrapper)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
看到這個堆疊資訊和之前應用切換到BIO之後的情況一模一樣,之前以為客戶端發來的資料很多,這裡是正在讀取資料中,實際這裡是沒有請求資料過來,執行緒阻塞在這裡等待資料。
正是因為BIO的實現方式讓執行緒一直阻塞在長連線上,而這應用的長連線在絕大部分時間內又是沒有資料的,造成了執行緒的浪費,而APR和NIO則不會讓執行緒一直阻塞在長連線上,提高了執行緒的利用率。
2) APR模式下的keep-alive實現:
APR有點類似NIO,也有Poller執行緒的角色。在處理下一次請求的時候,不會像BIO那樣阻塞。下面看一下在處理socket時的大致邏輯,摘自AbstractHttp11Processor.process
方法做了簡化,這個類是BIO/NIO/APR三種模式處理socket邏輯時的基類,在開啟keep-alive的情況下會一直迴圈:
while ( keepAlive && !error && otherConditions ) {
// Parsing the request header
try {
setRequestLineReadTimeout();
if (!getInputBuffer().parseRequestLine(keptAlive)) {
if (handleIncompleteRequestLineRead()) {
break; //第一個break
}
}
...
} catch (IOException e) {
error = true;
}
...
prepareRequest();
adapter.service(request, response); // 提交到後邊的servlet容器
endRequest();
...
if (breakKeepAliveLoop(socketWrapper)) {
break; //第二個break
}
}
APR模式在處理完一次請求後,再次進入迴圈時會在第一個break
點跳出(得不到下次請求),把執行緒讓出來,後續socket再有請求時poller執行緒會再封裝一個任務(對應SocketProcessor
類),不過APR模式下acceptor在收到socket之後會先封裝成一個SocketWithOptionsProcessor
的task,它的作用只是把socket跟poller關聯起來,真正處理請求時是靠poller。
下面模擬3次請求:
$ n=0;
$ while (( n < 3 ));do
n=$((n+1));
echo -ne "GET /main HTTP/1.1\nhost: localhost:7001\n\n";
sleep 1;
done | telnet localhost 7001
觀察相關幾個類的例項數:
$ jmap -histo `pidof java` | sed -n -e '1,3p' -e '/SocketWrapper/p' -e '/Endpoint.*Processor/p'
num #instances #bytes class name
----------------------------------------------
619: 1 72 org.apache.tomcat.util.net.AprEndpoint$AprSocketWrapper
620: 3 72 org.apache.tomcat.util.net.AprEndpoint$SocketProcessor
975: 1 24 org.apache.tomcat.util.net.AprEndpoint$SocketWithOptionsProcessor
socket所對應AprSocketWrapper
例項為1,說明只有一個連線;SocketWithOptionsProcessor
例項也為1,poller真正處理請求邏輯時還是用SocketProcessor
封裝的邏輯,這裡3次請求對應3個例項數。注意有時候可能因為young-gc干擾你看到的例項數,可以把heap設定大一些避免。
既然APR模式對下一次請求並不是阻塞,執行緒會釋放出來,為何應用方還是出現了阻塞呢?因為當時的環境已經不能復現了,無法準確判斷當時的網路情況,但APR模式在處理header和body的時候都是阻塞的,所以一種很大的可能是當時client傳送資料時,沒有傳送完全,造成connector阻塞在jni.Socket.recvbb
方法上。可以模擬一下這個情況:
$ { echo -ne "POST /main HTTP/1.1\nhost: localhost:7001"; sleep 15 } | telnet localhost 7001
上面模擬的POST
請求沒有傳送完整,header部分還沒有結束,這時通過jstack來看執行緒的情況:
$ jstack `pidof java` | grep "recvbb" -B2 -A7
"http-apr-7001-exec-6" #33 daemon prio=5 os_prio=31 tid=0x00007fc8b2044000 nid=0x7e07 runnable [0x0000000120a20000]
java.lang.Thread.State: RUNNABLE
at org.apache.tomcat.jni.Socket.recvbb(Native Method)
at org.apache.coyote.http11.InternalAprInputBuffer.fill(InternalAprInputBuffer.java:575)
at org.apache.coyote.http11.InternalAprInputBuffer.parseHeader(InternalAprInputBuffer.java:464)
at org.apache.coyote.http11.InternalAprInputBuffer.parseHeaders(InternalAprInputBuffer.java:312)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:969)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:607)
at org.apache.tomcat.util.net.AprEndpoint$SocketProcessor.doRun(AprEndpoint.java:2442)
at org.apache.tomcat.util.net.AprEndpoint$SocketProcessor.run(AprEndpoint.java:2431)
跟應用當時的情況是吻合的,當然如果client傳送過程中如果body部分資料沒有傳送完整也會讓tomcat阻塞在recvbb這個方法上。
3) NIO模式下的keep-alive實現:
NIO的大致結構也可以參考之前分享ali-tomcat的ppt裡的圖
對於keep-alive情況下處理下一次請求,NIO跟APR類似,執行緒不會一直阻塞在socket上。對於header的處理,NIO也同樣不會阻塞,只有在body的讀取時,NIO採取模擬阻塞的方式。可以模擬一下,在一個servlet裡對post過來的資料回寫過去:
public void doPost(HttpServletRequest request, HttpServletResponse resp) throws IOException {
PrintWriter wr = resp.getWriter();
BufferedReader br = new BufferedReader(new InputStreamReader(request.getInputStream()));
String line = null;
while ((line = br.readLine()) != null) {
wr.write(line);
}
wr.write("done");
}
模擬請求:
$ {
echo -ne "POST /main HTTP/1.1\nhost: localhost:7001\nContent-length:5\n\na";
sleep 15
} | telnet localhost 7001
請求裡描述的資料長度是5,但只給出了一個字元,出於資料未傳送完的狀態,這時來看伺服器端執行緒狀況:
"http-nio-7001-exec-1" #26 daemon prio=5 os_prio=31 tid=0x00007f8693c52800 nid=0x7a07 waiting on condition [0x00000001268f6000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x0000000795ca3b50> (a java.util.concurrent.CountDownLatch$Sync)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedNanos(AbstractQueuedSynchronizer.java:1037)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.tryAcquireSharedNanos(AbstractQueuedSynchronizer.java:1328)
at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:277)
at org.apache.tomcat.util.net.NioEndpoint$KeyAttachment.awaitLatch(NioEndpoint.java:1566)
at org.apache.tomcat.util.net.NioEndpoint$KeyAttachment.awaitReadLatch(NioEndpoint.java:1568)
at org.apache.tomcat.util.net.NioBlockingSelector.read(NioBlockingSelector.java:185)
at org.apache.tomcat.util.net.NioSelectorPool.read(NioSelectorPool.java:246)
at org.apache.tomcat.util.net.NioSelectorPool.read(NioSelectorPool.java:227)
at org.apache.coyote.http11.InternalNioInputBuffer.readSocket(InternalNioInputBuffer.java:422)
at org.apache.coyote.http11.InternalNioInputBuffer.fill(InternalNioInputBuffer.java:794)
at org.apache.coyote.http11.InternalNioInputBuffer$SocketInputBuffer.doRead(InternalNioInputBuffer.java:819)
at org.apache.coyote.http11.filters.IdentityInputFilter.doRead(IdentityInputFilter.java:124)
at org.apache.coyote.http11.AbstractInputBuffer.doRead(AbstractInputBuffer.java:346)
at org.apache.coyote.Request.doRead(Request.java:422)
at org.apache.catalina.connector.InputBuffer.realReadBytes(InputBuffer.java:290)
at org.apache.tomcat.util.buf.ByteChunk.substract(ByteChunk.java:449)
at org.apache.catalina.connector.InputBuffer.read(InputBuffer.java:315)
at org.apache.catalina.connector.CoyoteInputStream.read(CoyoteInputStream.java:200)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
- locked <0x0000000795c96f28> (a java.io.InputStreamReader)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.readLine(BufferedReader.java:324)
- locked <0x0000000795c96f28> (a java.io.InputStreamReader)
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at org.r113.servlet3.MainServlet.doPost(MainServlet.java:37)
執行緒並不是阻塞在原生的IO方法上,而是NioBlockingSelector.read
方法上,這個方法從名字就可以看出它用NIO實現的阻塞式selector(裡面的read和write方法註釋也有明確說明);相當於通過鎖的方式來模擬阻塞方式,正如之前表格裡紅色字型突出的。
為什麼NIO在讀取body時要模擬阻塞?
tomcat的NIO完全可以以非阻塞方式處理IO,為什麼在讀取body部分時要模擬阻塞呢?這是因為servlet規範裡定義了ServletInputStream
在讀資料時是阻塞模式,這裡相關的爭論可以google。
在servlet3.0裡引入了非同步,但僅針對傳統IO,對應用來說仍有很多限制,所以servlet3.1又引入了非阻塞IO,但這要tomcat8才提供了。