其實這篇應該叫Netty實踐,但是為了與前一篇名字保持一致,所以還是用一下坑這個名字吧。
Netty是高性能Java NIO網絡框架,在很多開源系統里都有她的身影,而在絕大多數互聯網公司所實施的服務化,以及最近流行的MicroService中,她都作為基礎中的基礎出現。
Netty的出現讓我們可以簡單容易地就可以使用NIO帶來的高性能網絡編程的潛力。她用一種統一的流水線方式組織我們的業務代碼,將底層網絡繁雜的細節隱藏起來,讓我們只需要關注業務代碼即可。並且用這種機制將不同的業務划分到不同的handler里,比如將編碼,連接管理,業務邏輯處理進行分開。Netty也力所能及的屏蔽了一些NIO bug,比如著名的epoll cpu 100% bug。而且,還提供了很多優化支持,比如使用buffer來提高網絡吞吐量。
但是,和所有的框架一樣,框架為我們屏蔽了底層細節,讓我們可以很快上手。但是,並不表示我們不需要對框架所屏蔽的那一層進行了解。本文所涉及的幾個地方就是Netty與底層網絡結合的幾個地方,看看我們使用的時候應該怎么處理,以及為什么要這么處理。
autoread
在Netty 4里我覺得一個很有用的功能是autoread。autoread是一個開關,如果打開的時候Netty就會幫我們注冊讀事件(這個需要對NIO有些基本的了解)。當注冊了讀事件后,如果網絡可讀,則Netty就會從channel讀取數據,然后我們的pipeline就會開始流動起來。那如果autoread關掉后,則Netty會不注冊讀事件,這樣即使是對端發送數據過來了也不會觸發讀時間,從而也不會從channel讀取到數據。那么這樣一個功能到底有什么作用呢?
它的作用就是更精確的速率控制。那么這句話是什么意思呢?比如我們現在在使用Netty開發一個應用,這個應用從網絡上發送過來的數據量非常大,大到有時我們都有點處理不過來了。而我們使用Netty開發應用往往是這樣的安排方式:Netty的Worker線程處理網絡事件,比如讀取和寫入,然后將讀取后的數據交給pipeline處理,比如經過反序列化等最后到業務層。到業務層的時候如果業務層有阻塞操作,比如數據庫IO等,可能還要將收到的數據交給另外一個線程池處理。因為我們絕對不能阻塞Worker線程,一旦阻塞就會影響網絡處理效率,因為這些Worker是所有網絡處理共享的,如果這里阻塞了,可能影響很多channel的網絡處理。
但是,如果把接到的數據交給另外一個線程池處理就又涉及另外一個問題:速率匹配。
比如現在網絡實在太忙了,接收到很多數據交給線程池。然后就出現兩種情況:
1. 由於開發的時候沒有考慮到,這個線程池使用了某些無界資源。比如很多人對ThreadPoolExecutor的幾個參數不是特別熟悉,就有可能用錯,最后導致資源無節制使用,整個系統crash掉。
//比如開始的時候沒有考慮到會有這么大量 //這種方式線程數是無界的,那么有可能創建大量的線程對系統穩定性造成影響 Executor executor = Executors.newCachedTheadPool(); executor.execute(requestWorker); //或者使用這個 //這種queue是無界的,有可能會消耗太多內存,對系統穩定性造成影響 Executor executor = Executors.newFixedThreadPool(8); executor.execute(requestWorker);
2. 第二種情況就是限制了資源使用,所以只好把最老的或最新的數據丟棄。
//線程池滿后,將最老的數據丟棄 Executor executor = new ThreadPoolExecutor(8, 8, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(1000), namedFactory, new ThreadPoolExecutor.DiscardOldestPolicy());
其實上面兩種情況,不管哪一種都不是太合理。不過在Netty 4里我們就有了更好的解決辦法了。如果我們的線程池暫時處理不過來,那么我們可以將autoread關閉,這樣Netty就不再從channel上讀取數據了。那么這樣造成的影響是什么呢?這樣socket在內核那一層的read buffer就會滿了。因為TCP默認就是帶flow control的,read buffer變小之后,向對端發送ACK的時候,就會降低窗口大小,直至變成0,這樣對端就會自動的降低發送數據的速率了。等到我們又可以處理數據了,我們就可以將autoread又打開這樣數據又源源不斷的到來了。
這樣整個系統就通過TCP的這個負反饋機制,和諧的運行着。那么autoread涉及的網絡知識就是,發送端會根據對端ACK時候所攜帶的advertises window來調整自己發送的數據量。而ACK里的這個window的大小又跟接收端的read buffer有關系。而不注冊讀事件后,read buffer里的數據沒有被消費掉,就會達到控制發送端速度的目的。
不過設計關閉和打開autoread的策略也要注意,不要設計成我們不能處理任何數據了就立即關閉autoread,而我們開始能處理了就立即打開autoread。這個地方應該留一個緩沖地帶。也就是如果現在排隊的數據達到我們預設置的一個高水位線的時候我們關閉autoread,而低於一個低水位線的時候才打開autoread。不這么弄的話,有可能就會導致我們的autoread頻繁打開和關閉。autoread的每次調整都會涉及系統調用,對性能是有影響的。類似下面這樣一個代碼,在將任務提交到線程池之前,判斷一下現在的排隊量(注:本文的所有數字純為演示作用,所有線程池,隊列等大小數據要根據實際業務場景仔細設計和考量)。
int highReadWaterMarker = 900; int lowReadWaterMarker = 600; ThreadPoolExecutor executor = new ThreadPoolExecutor(8, 8, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(1000), namedFactory, new ThreadPoolExecutor.DiscardOldestPolicy()); int queued = executor.getQueue().size(); if(queued > highReadWaterMarker){ channel.config().setAutoRead(false); } if(queued < lowReadWaterMarker){ channel.config().setAutoRead(true); }
但是使用autoread也要注意一件事情。autoread如果關閉后,對端發送FIN的時候,接收端應用層也是感知不到的。這樣帶來一個后果就是對端發送了FIN,然后內核將這個socket的狀態變成CLOSE_WAIT。但是因為應用層感知不到,所以應用層一直沒有調用close。這樣的socket就會長期處於CLOSE_WAIT狀態。特別是一些使用連接池的應用,如果將連接歸還給連接池后,一定要記着autoread一定是打開的。不然就會有大量的連接處於CLOSE_WAIT狀態。
其實所有異步的場合都存在速率匹配的問題,而同步往往不存在這樣的問題,因為同步本身就是帶負反饋的。
isWritable
isWritable其實在上一篇文章已經介紹了一點,不過這里我想結合網絡層再啰嗦一下。上面我們講的autoread一般是接收端的事情,而發送端也有速率控制的問題。Netty為了提高網絡的吞吐量,在業務層與socket之間又增加了一個ChannelOutboundBuffer。在我們調用channel.write的時候,所有寫出的數據其實並沒有寫到socket,而是先寫到ChannelOutboundBuffer。當調用channel.flush的時候才真正的向socket寫出。因為這中間有一個buffer,就存在速率匹配了,而且這個buffer還是無界的。也就是你如果沒有控制channel.write的速度,會有大量的數據在這個buffer里堆積,而且如果碰到socket又『寫不出』數據的時候,很有可能的結果就是資源耗盡。而且這里讓這個事情更嚴重的是ChannelOutboundBuffer很多時候我們放到里面的是DirectByteBuffer,什么意思呢,意思是這些內存是放在GC Heap之外。如果我們僅僅是監控GC的話還監控不出來這個隱患。
那么說到這里,socket什么時候會寫不出數據呢?在上一節我們了解到接收端有一個read buffer,其實發送端也有一個send buffer。我們調用socket的write的時候其實是向這個send buffer寫數據,如果寫進去了就表示成功了(所以這里千萬不能將socket.write調用成功理解成數據已經到達接收端了),如果send buffer滿了,對於同步socket來講,write就會阻塞直到超時或者send buffer又有空間(這么一看,其實我們可以將同步的socket.write理解為半同步嘛)。對於異步來講這里是立即返回的。
那么進入send buffer的數據什么時候會減少呢?是發送到網絡的數據就會從send buffer里去掉么?也不是這個樣子的。還記得TCP有重傳機制么,如果發送到網絡的數據都從send buffer刪除了,那么這個數據沒有得到確認TCP怎么重傳呢?所以send buffer的數據是等到接收端回復ACK確認后才刪除。那么,如果接收端非常慢,比如CPU占用已經到100%了,而load也非常高的時候,很有可能來不及處理網絡事件,這個時候send buffer就有可能會堆滿。這就導致socket寫不出數據了。而發送端的應用層在發送數據的時候往往判斷socket是不是有效的(是否已經斷開),而忽略了是否可寫,這個時候有可能就還一個勁的寫數據,最后導致ChannelOutboundBuffer膨脹,造成系統不穩定。
所以,Netty已經為我們考慮了這點。channel有一個isWritable屬性,可以來控制ChannelOutboundBuffer,不讓其無限制膨脹。至於isWritable的實現機制可以參考前一篇。
序列化
所有講TCP的書都會有這么一個介紹:TCP provides a connection-oriented, reliable, byte stream service。前面兩個這里就不關心了,那么這個byte stream到底是什么意思呢?我們在發送端發送數據的時候,對於應用層來說我們發送的是一個個對象,然后序列化成一個個字節數組,但無論怎樣,我們發送的是一個個『包』。每個都是獨立的。那么接收端是不是也像發送端一樣,接收到一個個獨立的『包』呢?很遺憾,不是的。這就是byte stream的意思。接收端沒有『包』的概念了。
這對於應用層編碼的人員來說可能有點困惑。比如我使用Netty開發,我的handler的channelRead這次明明傳遞給我的是一個ByteBuf啊,是一個『獨立』的包啊,如果是byte stream的話難道不應該傳遞我一個Stream么。但是這個ByteBuf和發送端的ByteBuf一點關系都沒有。比如:
public class Decorder extends ChannelInboundHandlerAdapter{ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //這里的msg和發送端channel.write(msg)時候的msg沒有任何關系 } }
這個ByteBuf可能包含發送端多個ByteBuf,也可能只包含發送端半個ByteBuf。但是別擔心,TCP的可靠性會確保接收端的順序和發送端的順序是一致的。這樣的byte stream協議對我們的反序列化工作就帶來了一些挑戰。在反序列化的時候我們要時刻記着這一點。對於半個ByteBuf我們按照設計的協議如果解不出一個完整對象,我們要留着,和下次收到的ByteBuf拼湊在一起再次解析,而收到的多個ByteBuf我們要根據協議解析出多個完整對象,而很有可能最后一個也是不完整的。不過幸運的是,我們有了Netty。Netty為我們已經提供了很多種協議解析的方式,並且對於這種半包粘包也已經有考慮,我們可以參考ByteToMessageDecoder以及它的一連串子類來實現自己的反序列化機制。而在反序列化的時候我們可能經常要取ByteBuf中的一個片段,這個時候建議使用ByteBuf的readSlice方法而不是使用copy。
另外,Netty還提供了兩個ByteBuf的流封裝:ByteBufInputStream, ByteBufOutputStream。比如我們在使用一些序列化工具,比如Hessian之類的時候,我們往往需要傳遞一個InputStream(反序列化),OutputStream(序列化)到這些工具。而很多協議的實現都涉及大量的內存copy。比如對於反序列化,先將ByteBuf里的數據讀取到byte[],然后包裝成ByteArrayInputStream,而序列化的時候是先將對象序列化成ByteArrayOutputStream再copy到ByteBuf。而使用ByteBufInputStream和ByteBufOutputStream就不再有這樣的內存拷貝了,大大節約了內存開銷。
另外,因為socket.write和socket.read都需要一個direct byte buffer(即使你傳入的是一個heap byte buffer,socket內部也會將內容copy到direct byte buffer)。如果我們直接使用ByteBufInputStream和ByteBufOutputStream封裝的direct byte buffer再加上Netty 4的內存池,那么內存將更有效的使用。這里提一個問題:為什么socket.read和socket.write都需要direct byte buffer呢?heap byte buffer不行么?
總結起來,對於序列化和反序列化來講就是兩條:1 減少內存拷貝 2 處理好TCP的粘包和半包問題
后記
作為一個應用層程序員,往往是幸福的。因為我們有豐富的框架和工具為我們屏蔽下層的細節,這樣我們可以更容易的解決很多業務問題。但是目前程序設計並沒有發展到不需要了解所有下層的知識就可以寫出更有效率的程序,所以我們在使用一個框架的時候最好要對它所屏蔽和所依賴的知識進行一些了解,這樣在碰到一些問題的時候我們可以根據這些理論知識去分析原因。這就是理論和實踐的相結合。