如何編寫一個 SendFile 服務器
前言
之前討論零拷貝的時候,我們知道,兩臺機器之間傳輸文件,最快的方式就是 send file,眾所周知,在 Java 中,該技術對應的則是 FileChannel 類的 transferTo 和 transferFrom 方法。
在平時使用服務器的時候,比如 nginx ,tomcat ,都有 send file 的選項,利用此技術,可大大提高文件傳輸效能。
另外,可能也有人談論 send file 的缺點,例如不能利用 gzip 壓縮,不能加密。這里本文不做探討。
紙上得來終覺淺,絕知此事要躬行。
那么,如何使用這兩個 api 實現(xiàn)一個 send file 服務器和客戶端呢?
想象一下,你寫的 send file 服務器利用 send file 技術,利用萬兆網(wǎng)卡,從各個 client 端 copy 海量文件,瞬間打爆你那 1TB 的磁盤和 48核的 CPU。并且,注意:只需很小的 JVM 內存就可以實現(xiàn)這樣一臺強悍的服務器。為什么?如果你知道 send file 的原理,就會知道,使用 send file 技術時, 在用戶態(tài)中,是不需要多少內存的,數(shù)據(jù)都在內核態(tài)。
是不是很有成就感?什么?沒有?那打擾了 ??。
另外,關于 send file,我們都知道,由于是直接從內核緩沖區(qū)進入到網(wǎng)卡驅動,我們幾乎可以稱之為 “零拷貝”,他的性能十分強勁。
但是。
除了這個,還有其他的嗎?答案是有的,send file 利用 DMA 的方式 copy 數(shù)據(jù),而不是利用 CPU。注意,不利用 CPU 意味著什么?意味著數(shù)據(jù)不會進入“緩存行”,進一步,不會進入緩存行,代表著緩存行不會因為這個被污染,再進一步,就是不需要維護緩存一致性。
還記得我們因為這個特性搞的那些關于 “偽共享” 的各種黑科技嗎?是不是又學到了一點呢???
理念
作為一個純粹的,高尚的,有趣的 sendFile 服務器或者客戶端,使用場景是嵌入到某個服務中,或者某個中間件中,不需要搞成夸張的容器。我們可以借鑒一下,客戶端可以做成 Jedis 那樣的,如果你想搞個連接池也不是不可以,但 client 自身實例,還是單連接的。服務端可以做成 sun 的 httpServer 那種輕量的,隨時啟動,隨時關閉。
同時, 支持 oneway 的高性能發(fā)送,因為,只要機器不宕機,發(fā)送到網(wǎng)卡就意味著發(fā)送成功,這樣能大幅提高發(fā)送速度,減少客戶端阻塞時間。
另外,也支持帶有 ack 的穩(wěn)定發(fā)送,即只有返回 ack 了,才能確認數(shù)據(jù)已經(jīng)寫到目標服務器磁盤了。
server 端支持海量連接,必須得是 reactor 網(wǎng)絡模型,但我們不想在這么小的組件里用 netty,太重了,還容易和使用方有 jar 沖突。所以,我們可以利用 Java 的 selector + nio 自己實現(xiàn) Reactor 模型。
設計
IO 模型設計
設計圖:

如上圖,Server 端支持海量客戶端連接。
server 端含有 多個處理器,其中包括 accept 處理器,read 處理器 group, write 處理器 group。
accept 處理器將 serverSocketChannel 作為 key 注冊到一個單獨的 selector 上。專門用于監(jiān)聽 accept 事件。類似 netty 的 boss 線程。
當 accept 處理器成功連接了一個 socket 時,會隨機將其交給一個 readProcessor(netty worker 線程?) 處理器,readProcessor 又會將其注冊到 readSelector 上,當發(fā)生 read 事件時,readProcessor 將接受數(shù)據(jù)。
可以看到,readProcessor 可以認為是一個多路復用的線程,利用 selector 的能力,他高效的管理著多個 socket。
readProcessor 在讀到數(shù)據(jù)后,會將其寫入到磁盤中(DMA 的方式,性能炸裂)。
然后,如果 client 在 RPC 協(xié)議中聲明“需要回復(id 不為 -1)” 時,那就將結果發(fā)送到 Reply Queue 中,反之不必。
當結果發(fā)送到 Reply Queue 后,writer 組中的 寫線程,則會從 Queue 中拉取回復包,然后將結果按照 RPC 協(xié)議,寫回到 client socket 中。
client socket 也會監(jiān)聽著 read 事件,注意:client 是不需要 select 的,因為沒必要,selector 只是性能優(yōu)化的一種方式——即一個線程管理海量連接,如果沒有 select, 應用層無法用較低的成本處理海量連接,注意,不是不能處理,只是不能高效處理。
回過來,當 client socket 得到 server 的數(shù)據(jù)包,會進行解碼反序列化,并喚醒阻塞在客戶端的線程。從而完成一次調用。
線程模型
設計圖:

如上圖所示。
在 client 端:
每個 Client 實例,維護一個 TCP 連接。該 Client 的寫入方法是線程安全的。
當用戶并發(fā)寫入時,可并發(fā)寫的同時并發(fā)回復,因為寫和回復是異步的(此時可能會出現(xiàn),線程 A 先 send ,線程 B 后 send,但由于網(wǎng)絡延遲,B 先返回)。
在 server 端:
server 端維護著一個 ServerSocketChannel 實例,該實例的作用就是接收 accep 事件,且由一個線程維護這個 accept selector 。
當有新的 client 連接事件時,accept selector 就將這個連接“交給“ read 線程(默認 server 有 4 個 read 線程)。
什么是“交給”?
注意:每個 read 線程都維護著一個單獨的 selector。 4 個 read 線程,就維護了 4 個 selector。
當 accept 得到新的客戶端連接時,先從 4 個read 線程組里 get 一個線程,然后將這個 客戶端連接 作為 key 注冊到這個線程所對應的 read selector 上。從而將這個 Socket “交給” read 線程。
而這個 read 線程則使用這個 selector 輪詢事件,如果 socket 可讀,那么就進行讀,讀完之后,利用 DMA 寫進磁盤。
RPC 協(xié)議
Server RPC 回復包協(xié)議
| 字段名稱 | 字段長度(byte) | 字段作用 |
|---|---|---|
| magic_num | 4 | 魔數(shù)校驗,fast fail |
| version | 1 | rpc 協(xié)議版本 |
| id | 8 | Request id, TCP 多路復用 id |
| length | 8 | rpc 實際消息內容的長度 |
| Content | length | rpc 實際消息內容(JSON 序列化協(xié)議) |
Client RPC 發(fā)送包協(xié)議
| 字段名稱 | 字段長度(byte) | 字段作用 |
|---|---|---|
| magic_num | 4 | 魔數(shù)校驗,fast fail |
| id | 8 | Request id, TCP 多路復用 id, 默認 -1,表示不回復 |
| nameContent | 2 | Request id, TCP 多路復用 id |
| bodyLength | 8 | rpc 實際消息內容的長度 |
| nameContent | bodyLength | 文件名 UTF-8 數(shù)組 |
為什么 發(fā)送包和返回包協(xié)議不同?為了高效。
總結
注意:這是一個能用的,性能不錯的,輕量的 SendFile 服務器實現(xiàn),本地測試時, IO寫盤達到 824MB/S,4c 4.2g inter i7 CPU 滿載。

代碼地址:https://github.com/stateIs0/send_file
同時,歡迎大家 star, pr,issue。我來改進。
EOF