簡述
本文主要介紹一下jdk1.6版本中的NIO Selector空輪詢BUG,描述一下BUG的現象及原因,以及Netty中如何巧妙的規避了這個bug。
為什么要寫這篇文章,說來慚愧,很久以前面試官問我,知道jdk空輪詢問題嗎,為什么會有這個問題,如何解決這個問題?我沒答上來。。
Selector空輪詢BUG
重現場景步驟
- 服務端等待連接
- 客戶端發起連接,發送消息
- 服務端接受連接,並注冊監聽通道的OP_READ
- 服務端讀取消息,從感興趣事件集合中移除OP_READ
- 客戶端關閉連接
- 服務端給客戶端發送消息
- 服務端select方法不再阻塞,無限被喚醒並且返回值為0.
實驗結果
在window上,此步驟下,是正常的。但是在linux機器上,selector陷入了死循環(cpu100%)。
上面是官方JDK-6670302 : (se) NIO selector wakes up with 0 selected keys infinitely [lnx 2.4]給出的重現實驗步驟。
bug根源
官方在6670302-BUG頁面上好像並不認為是jdk的bug。也沒給出具體原因。而把原因歸結為Linux Kernel 2.4版本的bug(JDK-6481709)。官方認為linux 內核2.6版本解決這個bug並且也發行了4年了,更建議大家使用linux kernel2.6。
筆者愚鈍,看了JDK-6481709這個BUG后,並沒發現產生的原因。
后來終於在JDK-6403933 : (se) Selector doesn't block on Selector.select(timeout) (lnx)這個bug里找到了貌似是答案的答案。
問題產生於linux的epoll(顯然是被甩鍋了)。如果一個socket文件描述符,注冊的事件集合碼為0,然后連接突然被對端中斷,那么epoll會被POLLHUP或者有可能是POLLERR事件給喚醒,並返回到事件集中去。這意味着,Selector會被喚醒,即使對應的channel興趣事件集是0,並且返回的events事件集合也是0。
簡而言之就是,jdk認為linux的epoll告訴我事件來了,但是jdk沒有拿到任何事件(READ、WRITE、CONNECT、ACCPET)。但此時select()方法不再選擇阻塞了,而是選擇返回了0。
BUG現狀
官方頁面中顯示jdk6u4版本和jdk7b12版本都已解決。實際上在1.6,1.7,1.8都沒有解決。
也就是說linux內核為2.4的,使用jdk6u4以下的開發者,仍可能遭遇此bug。
其實官方也提供了解決的思路。
解決方案
JDK-6403933里面提到了幾種方案,我總結一下:
- 取消對應的key,馬上刷新Selector。就是在重現步驟中的第4步,立馬調用selector.selectNow刷新一次selector。
-
如果注冊到selector興趣事件集為0,則直接取消注冊。 如果注冊到selector興趣事件集不為0,則需要將linux epoll事件POLLHUP/POLLERR轉化為OP_READ 或者OP_WRITE。由誰決定轉化呢,筆者認為應該由jdk。這樣程序就有機會探測到IO異常。
- 丟棄舊的selector,重新構造一個。
三種方法,筆者認為1、2都可能沒有徹底解決問題。第一種,selectNow的調用,只是select的非阻塞版本,非常有可能在多線程中和selectionKey.cancel同時調用的。第二種方案,即使讀寫channel數據時拋出了IO異常,不是所有人都會記得關閉此Channel並deregister這個channel。
至於第三種方案,應該是可行的,因為重新構造了selector,需要重新注冊channnel到其上,並注冊感興趣事件,重新注冊的過程中有機會檢測channel的可用性。但是什么時候需要重新創建一個呢?這可能就需要一些檢測空輪詢的機制了
Netty3中如何解決
netty3采用的是第三種方案,檢測重點是select函數是否返回了0。代碼在AbstractNioSelector類中
if (timeBlocked < minSelectTimeout) { boolean notConnected = false; //循環遍歷所有selectionKey,剔除可能導致selector喚醒的被關閉的channel for (SelectionKey key : selector.keys()) { SelectableChannel ch = key.channel(); try { if (ch instanceof DatagramChannel && !ch.isOpen() || ch instanceof SocketChannel && !((SocketChannel) ch).isConnected()) { notConnected = true; //發現了關閉的通道趕緊取消以防萬一,不會再下次select的key集合中 key.cancel(); } } catch (CancelledKeyException e) { // ignore } } if (notConnected) { selectReturnsImmediately = 0; } else { //到這里,發生了一次selector在關閉的通道上被喚醒,所以記數+1 //防止引起jdk epoll的bug selectReturnsImmediately++; } } else { selectReturnsImmediately = 0; } if (selectReturnsImmediately == 1024) { //發生了1024次了,應該碰到著名的epollbug了, //重新構造一個selector rebuildSelector(); selector = this.selector; selectReturnsImmediately = 0; wakenupFromLoop = false; continue; }
這里,netty通過線程不斷循環檢測select是否返回0,若發生了1024次(次數不重要,若發生了epoll bug,肯定次數飆升),則開始重建selector。
看看重建的seletor代碼,rebuildSelector方法:
public void rebuildSelector() { final Selector oldSelector = selector; final Selector newSelector; if (oldSelector == null) { return; } try { newSelector = SelectorUtil.open(); } catch (Exception e) { logger.warn("Failed to create a new Selector.", e); return; } // 將老的channel重新注冊到新selector上 int nChannels = 0; for (; ; ) { try { for (SelectionKey key : oldSelector.keys()) { try { if (key.channel().keyFor(newSelector) != null) { continue; } int interestOps = key.interestOps(); key.cancel(); key.channel().register(newSelector, interestOps, key.attachment()); nChannels++; } catch (Exception e) { logger.warn("Failed to re-register a Channel to the new Selector,", e); close(key); } } } catch (ConcurrentModificationException e) { continue; } break; } selector = newSelector; try { //關閉老的selector oldSelector.close(); } catch (Throwable t) { if (logger.isWarnEnabled()) { logger.warn("Failed to close the old Selector.", t); } } }
- AbstractNioSelector會啟動一個線程,在當前selector會循環調用selector.select(timeout)方法,如果在timeout時間之內,selector返回了,則需要檢測喚醒它的SelectionKey里面,有沒有未關閉的連接channel存在。有則取消這個key。這能防止引起epoll bug。
- 什么時候可以認為發生了epoll bug呢,就是阻塞的select方法提前被喚醒了並且返回了0。有就增加計數器,計數器的值很快會到1024,然后就可以重建一個selector,拋棄那個已經在無限輪回的oldSelector。
- 將oldselector上的key都取消掉,重新注冊到新的selector上。關閉oldSelector。
總結
本文講述了jdk epoll bug的原因,及解決方法。原因是給關閉的通道發消息。解決的最好方法,是重建一個selector。
