概述
ChannelOption 是 Netty 中在構建引導類時可以填寫的構建 Channel 的選項
其可以分為兩部分,一部分為控制 Netty 自身底層運行的選項;另一部分則是操作系統創建 socket 時的選項 (如果熟悉 UNIX 網絡編程的話應該知道這玩意)
本文上半部主要解釋作用於 Netty 的常用選項,需要有 Netty 的基礎知識;后半部主要解釋作用於 socket 的選項,需要有 TCP/IP 的基礎知識。
Netty 部分
ALLOCATOR
設置需要使用的 ByteBufAllocator
(ByteBuf 的分配器)
ByteBuf 的分配器主要是 PooledByteBufAllocator
和 UnpooledByteBufAllocator
,區別就如同其類名稱介紹的一樣,前者的池化的,后者為非池化的。
ByteBuf 是 Netty 中數據存儲的容器,它可以從三個維度來分類
-
Pooled 和 Unpooled
池化的意思是使用對象池去管理一些對象,需要的時候從池子中取出,用完后放回去,在 Netty 中池化有兩類好處
一是分裝為局部對象池,通過 ThreadLocal 機制來消除多線程競爭所帶來的消耗
二是通過對象池來復用對象,可以顯而易見的減少創建對象所帶來的對象創建的消耗
-
Unsafe 和 非 Unsafe
Unsafe 和 非 Unsafe 指的是底層獲取數據的方式
Unsafe 的方式是指通過 Unsafe 包下的 api 來獲取底層的數據
非 Unsafe 則是直接通過 ByteBuffer 或 byte[] 對應的 api 獲取
-
Heap 和 Direct
這個分類是指 ByteBuf 所存儲的數據所在的位置。
Heap 是直接在 Java 的堆中存放對象;由於是在堆中,所以可能會受到 GC 時對象的地址的移動的影響(取決於使用的垃圾回收器對應的 GC 算法)。
Direct 是指直接 Java 中的直接內存(也叫堆外內存),這部分的內存不會被 GC 影響,操作的效率高於 Heap,但同時創建成本高於 Heap (因為需要涉及到系統調用,而 heap 中的內存是在啟動的時候就已經申請好的)。
由於以上兩個 ByteBuf 的區別,Channel 在將 Heap ByteBuf 寫出到網卡的發送緩沖區前,會先將其數據拷貝到一塊 Direct ByteBuf 中。所以在 Netty 中,如果我們打算發送大量的數據到對端,最好是直接申請一塊 Direct ByteBuf ,這樣可以免去從 Netty 的將其拷貝的消耗。
在 Netty 4.1 中,默認的 ALLOCATOR 使用的是 PooledByteBufAllocator
,大多數情況不需要更改
WRITE_BUFFER_WATER_MARK
控制 Netty 中 Write Buffer 的水位線
要理解水位線 (wrter mark) 的概念,還要從 Netty 的 channel.write(...)
講起。
首先先來根據下面這張圖來觀察 write
的大致流程
首先,我們對一個 Channel 寫入的時候,會先將需要 write
的對象封裝為任務放入 Queue
然后,同時 IO 線程會定時將任務從 Queue 取出,然后再經過 Pipeline 中各個處理器處理(圖中未畫出),再將處理結果寫入到 Netty Buffer,然后到達操作系統的底層的 TCP 的發送緩沖區。
最后,TCP 發送緩沖區中的數據會分包發送給對端,就是在這里的對面的 Client 的 TCP 接收緩沖區。
需要注意的是,如果只是調用 channel.write(..)
方法,該數據只會暫時存儲到 Netty Buffer。在 channel.flush()
被調用后,則會發送 flush 包(即上圖中標記為 "F" 的包),在 Netty Buffer 收到了 flush 控制包,才會將 Buffer 沖刷到 TCP Buffer。
其中,TCP 連接的數據發送一方中的 TCP Buffer (發送緩沖區) 的大小由 SO_SNDBUF
控制,而 Netty Buffer 是"無界"的,且它的位置在堆外內存(Direct Buffer)。
我們在一開始提到的水位線,則是標記當前 Netty Buffer 所使用的大小的一個值。當 Netty Buffer 的大小到達這個值后,調用 chanel.isWriteable
則會返回 false
,且會通過調用業務 handler 的 writabilityChanged
方法來通知上層應用。
同時水位線還分為高水位線和低水位線,到達高水位線后調用 chanel.isWriteable
則會返回 false
,直到下降到低水位線,調用時才會返回為 true
。
不過,水位線只是一個警示,並不是實際上限,到達水位線后 Netty Buffer 仍然可以被寫入,寫入后會在由 Netty 維護的內部緩沖區進行排隊。
順帶一提,在之前的 netty 版本中,高水位線通過
WRITE_BUFFER_HIGH_WATER_MARK
設置,低水位線通過WRITE_BUFFER_LOW_WATER_MARK
,但現在已經被標記為 Deprecated,取而代之則是上文介紹的WRITE_BUFFER_WATER_MARK
,通過下列樣式進行配置.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(10000, 20000))
上面提到的 Netty Buffer 的在 Netty 中的類名為 ChannelOutboundBuffer;TCP Buffer 也叫 socket 發送緩沖區
AUTO_READ
啟用自動讀取
准確來講,應該說是啟用自動向 Selector 注冊 OP_READ 事件的功能;啟用后,在有可讀的數據時,會自動的從 channel 讀取數據,並交給業務上層的 handler。
但是如果 handler 對於這些數據的處理過於慢又沒有相對的措施的話,那么很可能就會使 CPU 的負載過高或將 JVM 的 heap 占滿。
這取決於業務 handler 對於數據的處理方式,如果是放入到線程池的話將很快的將線程池中的線程消耗殆盡,若使用的等待隊列是無界隊列,那么最終會導致 JVM 的 OOM。
否則的話會根據拒絕策略來處理 (這應該算是相對較好的情況)
當然以上是速率不匹配且沒有做處理時的最壞情況,實際上我們可以通過背壓 (Back Pressure) 來做流量控制。
而關閉 AUTO_READ 選項,就是一種策略,在 netty 不再自動從 socket 接收緩沖區讀取數據時,TCP 自帶的流量控制就開始工作。
TCP 的流量控制的做法簡單來講,就是可以在接收端進行 ack 時,可以順便帶上剩余緩沖區的大小,發送端會根據這個大小來控制發送速率。
一旦我們不再從 socket 接收緩沖區讀取數據了,接收緩沖區的可用大小就只能減少,發送方就會調整發送的速率。所以在實際使用中,我們可以通過 channel.config().setAutoRead(..)
來設置是否自動讀取以做流量控制。
AUTO_CLOSE
啟用自動關閉
在一個 Channel 寫入失敗的時候立刻自動 close 這個 Channel,不需要手動去關閉
WRITE_SPIN_COUNT
控制一次 write 操作的最多次數
Netty 對於一個大文件的寫入,並不會直接調用底層的 socket.write()
來將整個文件寫入,因為這會導致該 socket 在一段時間,其他嘗試寫入的文件必須等待這個大文件寫入完成。
所以 Netty 為了減少這種多個其他文件的寫入被單個大文件阻塞的情況,會對這個大文件進行拆分,且分多次寫入,這個選項控制的就是最多允許拆分的寫入次數
SINGLE_EVENTEXECUTOR_PER_GROUP
開啟單線程執行 ChannelPipeline 中的 handler
在關閉時會為每一個 handler 都分配可能不同的 EventLoop ,在開啟這個選項后,會讓所有 handler 在同一個 EventLoop 來執行,這樣可以減少線程上下文切換的開銷。
UNIX socket 部分
SNDBUF 和 SO_RCVBUF
發送緩沖區大小 與 接收緩沖區大小
在 socket 中,發送緩沖區和接受緩沖區決定了流量控制中的發送窗口與接收窗口的大小,上文也不止一次提到了 socket 緩沖區
REUSEADDR 和 REUSEPORT
允許地址復用 和 允許端口復用
大多數情況下啟用該選項都是為了允許快速的重新啟動一個服務器應用
假如當舊的服務器應用崩潰了,我們需要立刻啟動,但是由於舊的 socket 還沒有完全關閉,所以立刻進行 bind
時可能會提示端口已經被占用了。
出現這種情況的原因是,舊的服務器在還沒有調用 close()
來關閉所有的 tcp 連接就直接關閉了,這會導致在該端口上還有一些 socket 處於 TIME_WAIT
狀態,所以會提示已經被占用,這樣的話就需要等待直到 TIME_WAIT
時間結束才能重新啟動這個服務器應用。
如果啟用了這個選項,可以立即重用這個端口(但是如果處於非 TIME_WAIT
狀態時仍然會報錯)
在大多數的服務器應用都會啟用該選項
當然這兩個選項還有其他的功能,詳細介紹見:
SO_BACKLOG
backlog 也是個老生常談的話題了(在面試中),其出現的背景是:
TCP 三次握手中,當處於 LISTEN
狀態的服務器收到來自客戶端的 SYN 包時,會將這個 SYN 包放起來,直到收到客戶端對於自己發送另一個的 SYN 包的 ACK 為止,而放入的位置則叫 backlog
在 Linux 中,backlog 分為了兩個隊列,分別是 SYN 隊列和 accept 隊列
-
SYN 隊列
這個隊列就是上面說的用來存放需要等待 ACK 的 socket 的隊列。
這個隊列的長度由系統控制,即如果修改,只能修改整個系統的 SYN 隊列的大小
同時,SYN 隊列隊滿后,會直接把新來的 ACK 包進行拋棄,客戶端發現超時未收到 ACK 時會重發。
-
accept 隊列
同時,當在 SYN 隊列中的 SYN 包收到對應的 ACK 包后,會放入 accept 隊列,等待應用程序
accept()
,通常這個過程會很快但如果應用程序沒有及時的通過
accept()
函數將 socket 取出,當這個隊列滿的時候,將不會把該 SYN 的 ACK 包交給到上層,而是會直接丟掉這個包,當作沒收到。而另一方處於
ESTABLISHED
的連接雖然已經開始發包,但由於 TCP 的慢啟動,所以發送端很快就會發現並進行重發,故並不會有太大的影響。同時當 accept 隊列滿的時候,還會對 SYN 隊列的接收速率加以控制
TCP_FASTOPEN | TCP_FASTOPEN_CONNECT
開啟 TCP Fast Open 機制
TCP Fast Open (簡稱 TFO) 是一個由 Google 工程師設計的算法,用於減少在 TCP 三次握手中建立連接所帶來的延時與消耗。
具體來講,這個算法分為兩個部分:
上半部為交換 cookie 的過程
- Client 發送一個 SYN 包請求生成 cookie
- Server 收到后使用對稱加密算法對 Client ip 進行加密,然后將加密結果和 ACK 包一起發回給 Client
下半部為使用 cookie 來快速建立 TCP 連接的過程
- Client 想要建立 TCP 時,發送 SYN + cookie + 想要立刻發送的數據,到 Server
- Server 收到后,若校驗 cookie 成功,會將收到的自定義數據交給上層應用,並發送對 Client SYN 包的 ACK 與另一個 SYN
- 然后,Server 可以不需要等待收到剛發送的 SYN 包的 ACK,就能立即開始對 Client 發送數據
圖片流程示例如下:
可以看出,由於不需要等待 TCP 連接建立好后就能發送數據,所以節省了不少時間。
但根據上述流程,我們不難發現幾個問題:
-
如果兩段中有一端不支持 TFO 怎么辦?
我們從 TFO 的握手階段來看:
假定 Client 不支持,那么 Server 只需要進行檢查是否具有 cookie 即可,如果沒有,則進入普通的 TCP 握手流程
假定 Server 不支持,那么在申請 cookie 時不返回 cookie 即可,這樣 Client 即可得知 Server 不支持 TFO,接下來進行普通的 TCP 握手
-
為什么要加上 cookie 生成的過程,直接走下半部分不是更簡單更節約嗎?
如果網絡中不存在攻擊的話,這是行得通的,但是在網絡中存在一類叫 源地址欺騙攻擊(source-address spoofing attack) 的攻擊,簡單來講,就是偽造 IP 包首部中的源 IP 字段
如果我們不經過驗證就直接的接受來自源 IP 的所有數據包,且在 ACK 前就進行工作與數據的發送;那如果有這么一台機器,不斷的發送具有不同的 ip 的偽造的 ip 數據包,那么只需要一台機器就能很快的讓服務器的 CPU 資源和網絡資源消耗殆盡。
那為什么普通的 TCP 可以避免這種攻擊呢?
因為 TCP 需要三次握手才能建立連接,如果對面的 ip 是偽造的,那么 Server 的 ACK 包只會發往被偽造的 ip,用來偽造的機器無法收到這個 ACK 包,所以就無法建立握手,就不能欺騙 Server 處理請求與發送大量數據包
這樣的話,第一步生成 cookie 的意圖就很好理解了,就是為了目標 ip 不是被偽造的 ip
-
使用了 cookie 后,這個協議就安全了嗎?
RFC 7413 的回答是:並不
例如常見的泛洪攻擊 (SYN flood),其嘗試用大量的 SYN 來請求 Server 建立連接,但不進行響應,從而耗盡服務器的資源。
而這個攻擊對於 TFO 造成的影響可能更大,還記得上半部嗎?我們在那里使用了對稱加密算法進行加密,通常上,我們會使用 AES128,且加密速度只需要幾百納秒,在正常使用中並不會造成影響,但如果受到了泛洪攻擊,則造成的影響是可能會耗盡服務器的 CPU 資源的。
而 TFO 的做法是:創建的 cookie 到達一定上限后,退化為普通的 TCP 進行三次握手
除此之外,還有 cookie 竊取等攻擊,TFO 同樣也做了不少對策
但只用 TFO 是並不會完全安全的,實際上使用時還會配合 SSL/TLS 進行使用
最后,在 Netty 中的這兩個選項,一個對應客戶端,一個對應服務器,兩端都需要打開才有效
Server:
ServerBootstrap sb = ...;
sb.option(ChannelOption.TCP_FASTOPEN, maxPendingFastOpen);
Client:
Bootstrap cb = new Bootstrap();
cb.option(ChannelOption.TCP_FASTOPEN_CONNECT, true);
// ...set handler, etc...
Channel channel = cb.register().sync().channel(); // Get unconnected channel.
ByteBuf fastOpenData = ...;
ByteBuf normalData = ...;
channel.write(fastOpenData); // Write TFO data.
channel.connect(remoteAddress).sync(); // Establish connection (flushes TFO data).
channel.write(normalData); // TCP connection works like normal now.
SO_LINGER
若有數據發送則延遲關閉。
這個設置有兩個用處:
第一個是設置為正數,這樣在調用 close()
時,會在發送 FIN 包后,等待設置的時間,然后才進行清理並返回;如果沒有設置這個時間則會直接進行清理並返回。
當然,我們並不能在這段時間內發送數據,這一段等待時間只是為了接收到,之前發送的數據包的 ACK 和最后的 FIN 包的 ACK;如果不設置這個選項,我們甚至不能確定對方是否收到了數據。
第二個作用則是設置為 0 ,這樣在 close()
時,就不會進入到 FIN_WAIT_1 狀態,而是直接刪除 socket 並清理掉發送緩沖區,然后發送一個 RST 包過去,我們都知道一旦一方收到 RST 包就會直接關閉 socket 。
所以使用這種方法可以直接關閉 TCP 連接而不經過 TIME_WAIT 狀態,所以通常被用來結束大量的 TIME_WAIT 狀態。
但這並不是一個好的做法,因為 TIME_WAIT 在設計上就是為了讓舊的 tcp 包在網絡中超時,來避免新的 TCP 連接獲取到錯誤的控制信息
SO_KEEPALIVE
周期測試連接是否存活。
給一個 TCP 的 socket 設置這個選項后,如果 2 小時內維持 socket 的兩端都沒有互相發送過包(包括發送 FIN 包和 RST 包),設置了該選項的一方的 socket 將會發送一個包。然后對端可能發生以下幾種情況:
-
回以對應的 ACK 包
該 socket 仍然存活
-
回以 RST 響應
對方已經的 socket 已經被關閉
-
對方沒有任何響應或響應錯誤
發生這種情況經常是對方的主機已經崩潰或發生了網絡故障,此時對面的路由器將會返回常見的 "主機不可達" 響應
當然 2 小時這個時間可以被縮短,但是只能只能調整內核,也就是說不能調整單獨的一個 socket。
所以在使用 Netty 時,我們經常選擇關閉這個選項,且使用 Netty 自帶的心跳控制器。IdleStateHandler
就是我們經常使用的控制器,這個 handler 可以設置 未讀超時時間、未寫超時時間、未讀未寫超時時間,當發生以上超時情況的時候,就會發送對應的事件,我們可以通過繼承這個類來捕獲這些事件,來做出對應的處理 (比如發送一個心跳包保持連接啥的)。
TCP_NODELAY
禁用 Nagle 算法
Nagle 算法主要用於在 TCP 減少分組的數量,當我們發送一個包的時候,如果大小較小(Nagle 算法覺得只要小於 MSS 就算小),且發現還有一些自己發送的包還沒被對面 ACK,就會稍微等待一下,到滿后,和其他的小的包一起發送。
MSS
最大報文端長度,TCP 連接建立時,雙方會確定一個最大緩沖區長度,各自發送的包都不會超過這個大小
ALLOW_HALF_CLOSURE
允許半關閉的 socket(默認不允許)
TCP 是雙向通道,所以 TCP 允許只關閉自己發往對端的數據通道,但對端仍然可以向自己發送數據,同時可以從接收的數據通道中讀取。
CONNECT_TIMEOUT_MILLIS
TCP 連接建立的超時時間
如果在指定的時間內還沒有建立起連接,將會拋出異常 ConnectTimeoutException
SO_TIMEOUT
同上,但是這個是 socket 的選項,即不僅包括 connect 的超時時間,還包括 accept 的等待時間
對於 accept,一般如果不進行指定,會被 accept 阻塞直到客戶端的連接建立的請求到來;設置這個時間后,如果在指定的時間內還沒有客戶端的連接到來,將會拋出異常
SO_BROADCAST
用來開啟或關閉廣播數據報發送的能力。
開啟這個選項后,UDP 才能發送廣播數據報;但是對於我們經常使用的 TCP 是無效的
參考資料