惡劣的網路環境下,Netty是如何處理寫事件的?

dashuai的部落格發表於2020-04-20

 

更多技術分享可關注我 

前言

前面,在Netty在接收完新連線後,預設為何要為其註冊讀事件,其處理I/O事件的優先順序是什麼?這篇文章,分析到了Netty處理I/O事件的優先順序——讀事件優先,寫事件僅僅是需要寫的時候才註冊,為什麼要這樣設計呢?下面丟擲兩個問題,可以帶著這兩個問題閱讀本篇文章:惡劣的網路環境下,Netty是如何處理寫事件的?

1、假設伺服器在成功接收到一個客戶端新連線後,就給它註冊了OP_WRITE事件,此時可能會發生什麼問題?

2、有人說,JDK不是已經提供了一個往Socket寫資料的方法麼,在客戶端直接呼叫它,給伺服器傳送資料不就OK了麼,還註冊什麼事件,費這個勁呢,對此你怎麼理解?

另外本文後續引起的幾篇文章可以參考:

Netty在接收完新連線後,預設為何要為其註冊讀事件,其處理I/O事件的優先順序是什麼?

Netty在接收完新連線後,預設為何要為其註冊讀事件,其處理I/O事件的優先順序是什麼?

NIO的I/O事件都有哪些,它們的本質是什麼,處理它們有哪些坑需要注意?

NIO的I/O事件都有哪些,它們的本質是什麼,處理它們有哪些坑需要注意?

​Netty為何在Channel裡設計Unsafe,且針對不同型別的Channel設計了兩大類實現?

Netty為何在Channel裡設計Unsafe,且針對不同型別的Channel設計了兩大類實現?

​NIO的connect方法有什麼坑,Netty是如何解決的?

NIO的connect方法有什麼坑,Netty是如何解決的?

​Reactor主從模型你理解對了麼?

Reactor主從模型你理解對了麼?

總結Netty的新連線接入和資料讀寫相關的面試題

總結Netty的新連線接入和資料讀寫相關的面試題

NIO處理寫事件的坑和正確做法

先丟擲結論:

1、JDK NIO的OP_WRITE事件處理不對,很容易發生“無限”迴圈的問題!

2、在網路不給力的情況下,往處於非阻塞模式下的連線上呼叫寫方法容易導致CPU被浪費,伺服器效能會陡然下降!

首先,知道JDK NIO的OP_WRITE事件何時會被觸發,前提是必須在註冊了Channel的I/O多路複用器上註冊了OP_WRITE事件,之後該連線上:

1、Socket的緩衝區有空閒位置

2、對端關閉了該連線

3、該連線自己內部出現了錯誤

發生以上三個場景,都可以觸發I/O多路複用器上註冊的寫事件。

 

帶著上述結論,回答開頭提到的第一個問題:

1、假設一個伺服器在成功接收到一個客戶端新連線後,就給它註冊了OP_WRITE事件,此時可能會發生什麼問題呢?

答案是可能導致“死”迴圈發生,最終結果就是CPU利用率達到100%,服務被拖垮!因為一個Channel上寫事件的就緒條件為TCP寫緩衝區有空閒位置,根據常識我們也知道TCP寫緩衝區在大多場景下,都是有空閒位置的,所以直接給新連線註冊寫事件,那麼這個寫事件在大多數時間下會一直被觸發,處理這個過程的I/O執行緒就會被長時間拖累,直到佔用整個CPU資源。這樣幹說可能不太好理解,看一個demo,這是早些年我寫的一個NIO框架裡面的一段I/O事件迴圈處理的程式碼,當然很挫,和Netty的run方法沒得比,但是大概思路是通的:

 

 

看最外層do-while迴圈裡的while子迴圈程式碼,紅線處有一個當前輪詢出的Channel是否可寫的判斷,如果上來就給該Channel註冊寫事件,那麼此時該判斷在大多數時間下都是ture,接著反覆執行doIOCoreOperation這個非阻塞的方法,此時並沒有資料要寫出,所以一直在做無用功,更根本的原因在於最開始的selector.select()大多數時間都不會阻塞,一直能讓do-while迴圈跑起來。。。

為此,一種合理的做法是:

1、JDK NIO的OP_WRITE事件只有在有資料需要寫出的場景,才註冊到對應Channel上

2、大前提是這個Channel必須活躍

3、在觸發OP_WRITE事件後,業務層應該及時處理這個事件,一般交給I/O執行緒處理,並且處理完立即取消OP_WRITE事件的註冊,然後做判斷:

  • 當前需要寫出的資料,一次傳送不完,那麼需要重新註冊OP_WRITE事件,即迴圈的註冊-寫-取消-判斷-。。。

  • 當前需要寫出的資料,已經傳送完,那麼就無需再次註冊寫事件

註冊、觸發寫事件和什麼時候寫出沒有直接關係

可能一些人初學NIO程式設計都會有這樣一個認識誤區:想當然的認為NIO的WRITE事件是呼叫了channel.write後發生的,因為呼叫Channel的write方法會執行把緩衝區裡的資料真正寫出去的操作。其實這是完全兩個不同的東西,沒有必然聯絡。

要知道,給元件註冊XXX事件,僅僅是事件驅動模型的一種程式設計思想,不代表xxx事件一定會發生。比如寫事件,寫事件被觸發,不代表有資料在此時此刻已經寫出,它僅僅是告訴I/O多路複用器,此時某些連線上的緩衝區有空閒位置可放(寫)資料。即這個註冊寫事件的過程是I/O多路複用器需要的,當某個Channel上註冊了相關的I/O事件,就可以通過Selector的select(xxx)方法輪詢出發生該事件的那些Channel,之後業務上做相應判斷和處理即可。

還有一個可能的疑問,也就是開頭提到的第二個問題:

有人說,JDK不是已經提供了一個往Socket連線寫資料的方法麼,在客戶端直接呼叫它,給伺服器傳送資料不就OK了麼,還註冊什麼寫事件,還得檢測,非同步處理,各種坑。。。費這個勁呢!對此你怎麼理解?

首先,理解為什麼需要註冊寫事件,其它I/O事件同理。以寫事件為例,給某個Channel註冊寫事件的目的是為了檢視當前Channel的緩衝區是否可以寫資料,這個觸發時機前面說了就是底層緩衝區有空閒位置。如果寫的資料非常非常少,那麼完全可以不搞註冊監聽這一套邏輯,直接呼叫write方法也行,也能正常通訊,但如果資料稍微多一些,那麼就需要使用者自己判斷好連線底層的可讀、可寫、以及是否關閉等狀態。即單純的通訊跟是否註冊I/O事件沒有直接關係。

其次,Channel的write方法並不可靠,即不一定真的會寫出資料,比如在非阻塞模式下,該方法不會阻塞。假設網路環境很差,業務層一直在發資料,TCP的傳送緩衝區很快會滿,這一般是由滑動視窗等流量控制機制決定的,緩衝區滿就會拒絕新資料寫入。此時呼叫Channel的write方法就會立即返回0,口說無憑,我們們看JDK的註釋:

 /**
     * Writes a sequence of bytes to this channel from the given buffer.
     *
     * <p> An attempt is made to write up to <i>r</i> bytes to the channel,
     * where <i>r</i> is the number of bytes remaining in the buffer, that is,
     * <tt>src.remaining()</tt>, at the moment this method is invoked.
     *
     * <p> Suppose that a byte sequence of length <i>n</i> is written, where
     * <tt>0</tt>&nbsp;<tt>&lt;=</tt>&nbsp;<i>n</i>&nbsp;<tt>&lt;=</tt>&nbsp;<i>r</i>.
     * This byte sequence will be transferred from the buffer starting at index
     * <i>p</i>, where <i>p</i> is the buffer's position at the moment this
     * method is invoked; the index of the last byte written will be
     * <i>p</i>&nbsp;<tt>+</tt>&nbsp;<i>n</i>&nbsp;<tt>-</tt>&nbsp;<tt>1</tt>.
     * Upon return the buffer's position will be equal to
     * <i>p</i>&nbsp;<tt>+</tt>&nbsp;<i>n</i>; its limit will not have changed.
     *
     * <p> Unless otherwise specified, a write operation will return only after
     * writing all of the <i>r</i> requested bytes.  Some types of channels,
     * depending upon their state, may write only some of the bytes or possibly
     * none at all.  A socket channel in non-blocking mode, for example, cannot
     * write any more bytes than are free in the socket's output buffer.
     *
     * <p> This method may be invoked at any time.  If another thread has
     * already initiated a write operation upon this channel, however, then an
     * invocation of this method will block until the first operation is
     * complete. </p>
     *
     * @param  src
     *         The buffer from which bytes are to be retrieved
     *
     * @return The number of bytes written, possibly zero
     *
     * @throws  NonWritableChannelException
     *          If this channel was not opened for writing
     *
     * @throws  ClosedChannelException
     *          If this channel is closed
     *
     * @throws  AsynchronousCloseException
     *          If another thread closes this channel
     *          while the write operation is in progress
     *
     * @throws  ClosedByInterruptException
     *          If another thread interrupts the current thread
     *          while the write operation is in progress, thereby
     *          closing the channel and setting the current thread's
     *          interrupt status
     *
     * @throws  IOException
     *          If some other I/O error occurs
     */
    public int write(ByteBuffer src) throws IOException;

大概意思是:嘗試向該Channel中寫入最多r個位元組,r是呼叫此方法時緩衝區中剩餘的位元組數,即src.remaining()返回值,假設寫入了長度為n的位元組序列,其中0<=n<=r。從緩衝區的索引p處開始傳輸該位元組,其中p是呼叫此方法時該緩衝區的位置;最後寫入的位元組索引是p+n-1。返回時該緩衝區的位置將等於p+n;限制不會更改。除非另行指定,否則僅在寫入所有請求的r個位元組後write操作才會返回。有些型別的Channel(取決於它們的狀態)可能僅寫入某些位元組或者可能根本不寫入。例如處於非阻塞模式的SocketChannel只能寫入該套接字輸出緩衝區中的位元組。可在任意時間呼叫此方法。但是如果另一個執行緒已經在此Channel上發起了一個寫操作,則在該操作完成前此方法的呼叫被阻塞。

 

註釋說的非常細緻了,需要正確理解這個過程。簡單說,在傳送緩衝區空間不夠時,write方法返回的位元組數可能只是需要寫出資料的一部分,比如寫緩衝區只剩100位元組空間,寫入200位元組,write返回100,如果緩衝區滿,那麼write返回0。在正常情況下不太可能發生上述問題,就怕網路不好的時候,此時資料包重傳率非常高,傳送資料的I/O執行緒會一直被拖累在這裡,這樣幹說可能不太形象,下面看一個demo,這是我之前自己寫的一個NIO框架裡,伺服器傳送訊息的方法,當初就沒有考慮這種情況:

 

 

假設此時網路較差,呼叫socketChannel.write方法可能會返回0,而且是在非阻塞模型下程式設計,故socketChannel.write會立刻返回,且while判斷條件會一直為true,在網路較差的這一段時間內,while迴圈快速轉動。。。消耗大量CPU,且什麼也沒做,導致伺服器效能會馬上下降。

 

這時候註冊OP_WRITE事件就有用了!NIO程式設計中比較常用的套路如下:

1、在socketChannel.write返回0時,給此Channel註冊OP_WRITE事件,然後馬上退出迴圈,讓I/O執行緒去做別的事情

2、當網路恢復正常後,該Channel的底層寫緩衝區會變為非滿,此時觸發Channel上的寫事件,通知Selector,業務上就可以讓I/O執行緒來處理寫資料的操作,這樣就能節約大量CPU資源,伺服器也能適應惡劣的網路環境,非常健壯了。

 

說了很多理論,看看Netty是怎麼做的。由此也感慨,有時候你覺得簡單,是因為你不知道你不懂的東西還很多,共勉。

Netty處理寫事件的過程分析

1、首先知道,Netty優先處理讀事件,不會主動註冊寫事件,參考:

Netty在接收完新連線後,預設為何要為其註冊讀事件,其處理I/O事件的優先順序是什麼?如下是Netty的事件迴圈機制裡,輪詢到寫事件後的處理邏輯,註釋寫到必須在處理完OP_WRITE事件後,在forceFlush方法裡取消(clear)註冊。

 

2、下面看Netty如何取消註冊的I/O事件:

跟進forceFlush方法,中間的過程省略,會在寫到Netty編解碼的時候詳細拆解,最終會呼叫到doWrite方法:

在for(;;)迴圈裡,判斷是否已經寫完全部的訊息,如果是,那麼就呼叫clearOpWrite方法,清理註冊的寫事件:

如果以後有自己寫NIO程式碼的時候,那麼學會這種用法——使用位與運算判斷並清理註冊的I/O事件。

 

3、在看一下Netty的發訊息的方法,還是隻看本文相關的程式碼,其餘過程省略,在寫到編解碼的時候在詳細拆解。

在使用Netty時,往對端發訊息,往往都是呼叫pipeline的writeAndFlush方法,如下:

最終呼叫到invokeFlush0方法是真正重新整理訊息到Channel裡:

重點看這個方法,它最終呼叫到該客戶端Channel的pipeline的頭結點的flush方法,前面也提到過flsuh,write等都屬於出站方法,而pipeline的頭結點本身就是出站處理器,如下:

最終呼叫到內部類——unsafe的flush方法,內部最終會呼叫到doWrite方法,前面說取消註冊的寫事件時,簡單提到過,看裡面一段核心程式碼:

首先呼叫客戶端Channel——ch的write方法,往Channel裡寫出資料,如果返回為0,說明可能遇到了網路較差的情況,此時Netty會立即break出迴圈寫資料的邏輯,設定標記位setOpWrite為true,後面會進入如下方法:

此時setOpWrite為true,故會進入if條件,執行setOpWrite方法,顧名思義就是給當前Channel註冊寫事件:

註冊完畢後,會退出整個writeAndFlush方法,等該NIO執行緒的事件迴圈處理器——run方法裡再次輪詢到寫事件時,說明網路OK了,NIO執行緒再回頭執行寫操作。

總結

1、NIO的寫事件不能隨便註冊,必須在寫資料時才註冊

2、寫完資料,需要及時取消寫事件的註冊

3、知道為什麼會有寫事件,以及它在何時使用

4,學習Netty是如何適用惡劣的網路環境的

歡迎關注

dashuai的部落格是終身學習踐行者,大廠程式設計師,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於網際網路行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!

 

相關文章