Java NIO如何處理慢速的連接
對企業(yè)級的服務器軟件,高性能和可擴展性是基本的要求。除此之外,還應該有應對各種不同環(huán)境的能力。例如,一個好的服務器軟件不應該假設所有的客戶端都有很快的處理能力和很好的網(wǎng)絡環(huán)境。如果一個客戶端的運行速度很慢,或者網(wǎng)絡速度很慢,這就意味著整個請求的時間變長。而對于服務器來說,這就意味著這個客戶端的請求將占用更長的時間。這個時間的延遲不是由服務器造成的,因此CPU的占用不會增加什么,但是網(wǎng)絡連接的時間會增加,處理線程的占用時間也會增加。這就造成了當前處理線程和其他資源得不到很快的釋放,無法被其他客戶端的請求來重用。例如Tomcat,當存在大量慢速連接的客戶端時,線程資源被這些慢速的連接消耗掉,使得服務器不能響應其他的請求了。
前面介紹過,NIO的異步非阻塞的形式,使得很少的線程就能服務于大量的請求。通過Selector的注冊功能,可以有選擇性地返回已經(jīng)準備好的頻道,這樣就不需要為每一個請求分配單獨的線程來服務。
在一些流行的NIO的框架中,都能看到對OP_ACCEPT和OP_READ的處理。很少有對OP_WRITE的處理。我們經(jīng)??吹降拇a就是在請求處理完成后,直接通過下面的代碼將結(jié)果返回給客戶端:
不對OP_WRITE進行處理的樣例:
- while (bb.hasRemaining()) {
- int len = socketChannel.write(bb);
- if (len < 0) {
- throw new EOFException();
- }
- }
這樣寫在大多數(shù)的情況下都沒有什么問題。但是在客戶端的網(wǎng)絡環(huán)境很糟糕的情況下,服務器會遭到很沉重的打擊。
因為如果客戶端的網(wǎng)絡或者是中間交換機的問題,使得網(wǎng)絡傳輸?shù)男屎艿停@時候會出現(xiàn)服務器已經(jīng)準備好的返回結(jié)果無法通過TCP/IP層傳輸?shù)娇蛻舳?。這時候在執(zhí)行上面這段程序的時候就會出現(xiàn)以下情況。
(1) bb.hasRemaining()一直為“true”,因為服務器的返回結(jié)果已經(jīng)準備好了。
(2) socketChannel.write(bb)的結(jié)果一直為0,因為由于網(wǎng)絡原因數(shù)據(jù)一直傳不過去。
(3) 因為是異步非阻塞的方式,socketChannel.write(bb)不會被阻塞,立刻被返回。
(4) 在一段時間內(nèi),這段代碼會被無休止地快速執(zhí)行著,消耗著大量的CPU的資源。事實上什么具體的任務也沒有做,一直到網(wǎng)絡允許當前的數(shù)據(jù)傳送出去為止。
這樣的結(jié)果顯然不是我們想要的。因此,我們對OP_WRITE也應該加以處理。在NIO中最常用的方法如下。
一般NIO框架中對OP_WRITE的處理:
- while (bb.hasRemaining()) {
- int len = socketChannel.write(bb);
- if (len < 0){
- throw new EOFException();
- }
- if (len == 0) {
- selectionKey.interestOps(
- selectionKey.interestOps() | SelectionKey.OP_WRITE);
- mainSelector.wakeup();
- break;
- }
- }
上面的程序在網(wǎng)絡不好的時候,將此頻道的OP_WRITE操作注冊到Selector上,這樣,當網(wǎng)絡恢復,頻道可以繼續(xù)將結(jié)果數(shù)據(jù)返回客戶端的時候,Selector會通過SelectionKey來通知應用程序,再去執(zhí)行寫的操作。這樣就能節(jié)約大量的CPU資源,使得服務器能適應各種惡劣的網(wǎng)絡環(huán)境。
可是,Grizzly中對OP_WRITE的處理并不是這樣的。我們先看看Grizzly的源碼吧。在Grizzly中,對請求結(jié)果的返回是在ProcessTask中處理的,經(jīng)過SocketChannelOutputBuffer的類,最終通過OutputWriter類來完成返回結(jié)果的動作。在OutputWriter中處理OP_WRITE的代碼如下:
Grizzly中對OP_WRITE的處理:
- public static long flushChannel(SocketChannel socketChannel,
- ByteBuffer bb, long writeTimeout) throws IOException
- {
- SelectionKey key = null;
- Selector writeSelector = null;
- int attempts = 0;
- int bytesProduced = 0;
- try {
- while (bb.hasRemaining()) {
- int len = socketChannel.write(bb);
- attempts++;
- if (len < 0){
- throw new EOFException();
- }
- bytesProduced += len;
- if (len == 0) {
- if (writeSelector == null){
- writeSelector = SelectorFactory.getSelector();
- if (writeSelector == null){
- // Continue using the main one
- continue;
- }
- }
- key = socketChannel.register(writeSelector, key.OP_WRITE);
- if (writeSelector.select(writeTimeout) == 0) {
- if (attempts > 2)
- throw new IOException("Client disconnected");
- } else {
- attempts--;
- }
- } else {
- attempts = 0;
- }
- }
- } finally {
- if (key != null) {
- key.cancel();
- key = null;
- }
- if (writeSelector != null) {
- // Cancel the key.
- writeSelector.selectNow();
- SelectorFactory.returnSelector(writeSelector);
- }
- }
- return bytesProduced;
- }
上面的程序例17.9與例17.8的區(qū)別之處在于:當發(fā)現(xiàn)由于網(wǎng)絡情況而導致的發(fā)送數(shù)據(jù)受阻(len==0)時,例17.8的處理是將當前的頻道注冊到當前的Selector中;而在例17.9中,程序從SelectorFactory中獲得了一個臨時的Selector。在獲得這個臨時的Selector之后,程序做了一個阻塞的操作:writeSelector.select(writeTimeout)。這個阻塞操作會在一定時間內(nèi)(writeTimeout)等待這個頻道的發(fā)送狀態(tài)。如果等待時間過長,便認為當前的客戶端的連接異常中斷了。
這種實現(xiàn)方式頗受爭議。有很多開發(fā)者置疑Grizzly的作者為什么不使用例17.8的模式。另外在實際處理中,Grizzly的處理方式事實上放棄了NIO中的非阻塞的優(yōu)勢,使用writeSelector.select(writeTimeout)做了個阻塞操作。雖然CPU的資源沒有浪費,可是線程資源在阻塞的時間內(nèi),被這個請求所占有,不能釋放給其他請求來使用。
Grizzly的作者對此的回應如下。
(1) 使用臨時的Selector的目的是減少線程間的切換。當前的Selector一般用來處理OP_ACCEPT,和OP_READ的操作。使用臨時的Selector可減輕主Selector的負擔;而在注冊的時候則需要進行線程切換,會引起不必要的系統(tǒng)調(diào)用。這種方式避免了線程之間的頻繁切換,有利于系統(tǒng)的性能提高。
(2) 雖然writeSelector.select(writeTimeout)做了阻塞操作,但是這種情況只是少數(shù)極端的環(huán)境下才會發(fā)生。大多數(shù)的客戶端是不會頻繁出現(xiàn)這種現(xiàn)象的,因此在同一時刻被阻塞的線程不會很多。
(3) 利用這個阻塞操作來判斷異常中斷的客戶連接。
(4) 經(jīng)過壓力實驗證明這種實現(xiàn)的性能是非常好的。
原文鏈接:http://qingfengjushi1.iteye.com/blog/1185070
【編輯推薦】