最近在用Java NIO, 回顧自己寫的程序發現裏面居然沒有OP_WRITE,每次都是在OP_READ的處理邏輯中直接使用SocketChannel.write(ByteBuffer)就能夠達到寫數據的需求,那爲什麼還要OP_WIRTE呢?網上找到相關的文章,原來自己的程序是有很多細節沒有考慮的,慢慢學習進步吧:
17.3.1 如何處理慢速的連接
對企業級的服務器軟件,高性能和可擴展性是基本的要求。除此之外,還應該有應對各種不同環境的能力。例如,一個好的服務器軟件不應該假設所有的客戶端都有很快的處理能力和很好的網絡環境。如果一個客戶端的運行速度很慢,或者網絡速度很慢,這就意味着整個請求的時間變長。而對於服務器來說,這就意味着這個客戶端的請求將佔用更長的時間。這個時間的延遲不是由服務器造成的,因此CPU的佔用不會增加什麼,但是網絡連接的時間會增加,處理線程的佔用時間也會增加。這就造成了當前處理線程和其他資源得不到很快的釋放,無法被其他客戶端的請求來重用。例如Tomcat,當存在大量慢速連接的客戶端時,線程資源被這些慢速的連接消耗掉,使得服務器不能響應其他的請求了。
NIO的異步非阻塞的形式,使得很少的線程就能服務於大量的請求。通過Selector的註冊功能,可以有選擇性地返回已經準備好的頻道,這樣就不需要爲每一個請求分配單獨的線程來服務。
在一些流行的NIO的框架中,都能看到對OP_ACCEPT和OP_READ的處理。很少有對OP_WRITE的處理。我們經常看到的代碼就是在請求處理完成後,直接通過下面的代碼將結果返回給客戶端:
【例17.7】不對OP_WRITE進行處理的樣例:
while (bb.hasRemaining()) {
int len = socketChannel.write(bb);
if (len < 0) {
throw new EOFException();
}
}
這樣寫在大多數的情況下都沒有什麼問題。但是在客戶端的網絡環境很糟糕的情況下,服務器會遭到很沉重的打擊。
因爲如果客戶端的網絡或者是中間交換機的問題,使得網絡傳輸的效率很低,這時候會出現服務器已經準備好的返回結果無法通過TCP/IP層傳輸到客戶端。這時候在執行上面這段程序的時候就會出現以下情況。
(1) bb.hasRemaining()一直爲“true”,因爲服務器的返回結果已經準備好了。
(2) socketChannel.write(bb)的結果一直爲0,因爲由於網絡原因數據一直傳不過去。
(3) 因爲是異步非阻塞的方式,socketChannel.write(bb)不會被阻塞,立刻被返回。
(4) 在一段時間內,這段代碼會被無休止地快速執行着,消耗着大量的CPU的資源。事實上什麼具體的任務也沒有做,一直到網絡允許當前的數據傳送出去爲止。
這樣的結果顯然不是我們想要的。因此,我們對OP_WRITE也應該加以處理。在NIO中最常用的方法如下。
【例17.8】一般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;
}
}
上面的程序在網絡不好的時候,將此頻道的OP_WRITE操作註冊到Selector上,這樣,當網絡恢復,頻道可以繼續將結果數據返回客戶端的時候,Selector會通過SelectionKey來通知應用程序,再去執行寫的操作。這樣就能節約大量的CPU資源,使得服務器能適應各種惡劣的網絡環境。
可是,Grizzly中對OP_WRITE的處理並不是這樣的。我們先看看Grizzly的源碼吧。在Grizzly中,對請求結果的返回是在ProcessTask中處理的,經過SocketChannelOutputBuffer的類,最終通過OutputWriter類來完成返回結果的動作。在OutputWriter中處理OP_WRITE的代碼如下:
【例17.9】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的區別之處在於:當發現由於網絡情況而導致的發送數據受阻(len==0)時,例17.8的處理是將當前的頻道註冊到當前的Selector中;而在例17.9中,程序從SelectorFactory中獲得了一個臨時的Selector。在獲得這個臨時的Selector之後,程序做了一個阻塞的操作:writeSelector.select(writeTimeout)。這個阻塞操作會在一定時間內(writeTimeout)等待這個頻道的發送狀態。如果等待時間過長,便認爲當前的客戶端的連接異常中斷了。
這種實現方式頗受爭議。有很多開發者置疑Grizzly的作者爲什麼不使用例17.8的模式。另外在實際處理中,Grizzly的處理方式事實上放棄了NIO中的非阻塞的優勢,使用writeSelector.select(writeTimeout)做了個阻塞操作。雖然CPU的資源沒有浪費,可是線程資源在阻塞的時間內,被這個請求所佔有,不能釋放給其他請求來使用。
Grizzly的作者對此的迴應如下。
(1) 使用臨時的Selector的目的是減少線程間的切換。當前的Selector一般用來處理OP_ACCEPT,和OP_READ的操作。使用臨時的Selector可減輕主Selector的負擔;而在註冊的時候則需要進行線程切換,會引起不必要的系統調用。這種方式避免了線程之間的頻繁切換,有利於系統的性能提高。
(2) 雖然writeSelector.select(writeTimeout)做了阻塞操作,但是這種情況只是少數極端的環境下才會發生。大多數的客戶端是不會頻繁出現這種現象的,因此在同一時刻被阻塞的線程不會很多。
(3) 利用這個阻塞操作來判斷異常中斷的客戶連接。
(4) 經過壓力實驗證明這種實現的性能是非常好的。