RPC 設計應該使用哪種網絡 IO 模型?
網絡通信在 RPC 調用中起到什麼作用呢?RPC 是解決進程間通信的一種方式。一次 RPC 調用,本質就是服務消費者與服務提供者間的一次網絡信息交換的過程。服務調用者通過網絡 IO 發送一條請求消息,服務提供者接收並解析,處理完相關的業務邏輯之後,再發送一條響應消息給服務調用者,服務調用者接收並解析響應消息,處理完相關的響應邏輯,一次 RPC 調用便結束了。可以說,網絡通信是整個 RPC 調用流程的基礎。
1 常見網絡 I/O 模型
兩臺 PC 機之間網絡通信,就是兩臺 PC 機對網絡 IO 的操作。
同步阻塞 IO、同步非阻塞 IO(NIO)、IO 多路複用和異步非阻塞 IO(AIO)。只有 AIO 爲異步 IO,其他都是同步 IO。
1.1 同步阻塞 I/O(BIO)
Linux 默認所有 socket 都是 blocking。
應用進程發起 IO 系統調用後,應用進程被阻塞,轉到內核空間處理。之後,內核開始等待數據,等待到數據後,再將內核中的數據拷貝到用戶內存中,整個 IO 處理完畢後返回進程。最後應用的進程解除阻塞狀態,運行業務邏輯。
系統內核處理 IO 操作分爲兩階段:
-
• 等待數據系統內核在等待網卡接收到數據後,把數據寫到內核中
-
• 拷貝數據系統內核在獲取到數據後,將數據拷貝到用戶進程的空間
在這兩個階段,應用進程中 IO 操作的線程會一直都處於阻塞狀態,若基於 Java 多線程開發,每個 IO 操作都要佔用線程,直至 IO 操作結束。
用戶線程發起 read 調用後就阻塞了,讓出 CPU。內核等待網卡數據到來,把數據從網卡拷貝到內核空間,接着把數據拷貝到用戶空間,再把用戶線程叫醒。
1.2 IO 多路複用(IO multiplexing)
高併發場景中使用最爲廣泛的一種 IO 模型,如 Java 的 NIO、Redis、Nginx 的底層實現就是此類 IO 模型的應用:
-
• 多路,即多個通道,即多個網絡連接的 IO
-
• 複用,多個通道複用在一個複用器
多個網絡連接的 IO 可註冊到一個複用器(select),當用戶進程調用 select,整個進程會被阻塞。同時,內核會 “監視” 所有 select 負責的 socket,當任一 socket 中的數據準備好了,select 就會返回。這個時候用戶進程再調用 read 操作,將數據從內核中拷貝到用戶進程。
當用戶進程發起 select 調用,進程會被阻塞,當發現該 select 負責的 socket 有準備好的數據時才返回,之後才發起一次 read,整個流程比阻塞 IO 要複雜,似乎更浪費性能。但最大優勢在於,用戶可在一個線程內同時處理多個 socket 的 IO 請求。用戶可註冊多個 socket,然後不斷調用 select 讀取被激活的 socket,即可達到在同一個線程內同時處理多個 IO 請求的目的。而在同步阻塞模型中,必須通過多線程實現。
好比我們去餐廳喫飯,這次我們是幾個人一起去的,我們專門留了一個人在餐廳排號等位,其他人就去逛街了,等排號的朋友通知我們可以喫飯了,我們就直接去享用。
本質上多路複用還是同步阻塞。
1.3 爲何阻塞 IO,IO 多路複用最常用?
網絡 IO 的應用上,需要的是系統內核的支持及編程語言的支持。
大多系統內核都支持阻塞 IO、非阻塞 IO 和 IO 多路複用,但像信號驅動 IO、異步 IO,只有高版本 Linux 系統內核支持。
無論 C++ 還是 Java,在高性能的網絡編程框架都是基於 Reactor 模式,如 Netty,Reactor 模式基於 IO 多路複用。非高併發場景,同步阻塞 IO 最常見。
應用最多的、系統內核與編程語言支持最爲完善的,便是阻塞 IO 和 IO 多路複用,滿足絕大多數網絡 IO 應用場景。
1.4 RPC 框架選擇哪種網絡 IO 模型?
IO 多路複用適合高併發,用較少進程(線程)處理較多 socket 的 IO 請求,但使用難度較高。
阻塞 IO 每處理一個 socket 的 IO 請求都會阻塞進程(線程),但使用難度較低。在併發量較低、業務邏輯只需要同步進行 IO 操作的場景下,阻塞 IO 已滿足需求,並且不需要發起 select 調用,開銷比 IO 多路複用低。
RPC 調用大多數是高併發調用,綜合考慮,RPC 選擇 IO 多路複用。最優框架選擇即基於 Reactor 模式實現的框架 Netty。Linux 下,也要開啓 epoll 提升系統性能。
2 零拷貝(Zero-copy)
2.1 網絡 IO 讀寫流程
應用進程的每次寫操作,都把數據寫到用戶空間的緩衝區,CPU 再將數據拷貝到系統內核緩衝區,再由 DMA 將這份數據拷貝到網卡,由網卡發出去。一次寫操作數據要拷貝兩次才能通過網卡發送出去,而用戶進程讀操作則是反過來,數據同樣會拷貝兩次才能讓應用程序讀到數據。
應用進程一次完整讀寫操作,都要在用戶空間與內核空間中來回拷貝,每次拷貝,都要 CPU 進行一次上下文切換(由用戶進程切換到系統內核,或由系統內核切換到用戶進程),這樣是不是很浪費 CPU 和性能呢?那有沒有什麼方式,可以減少進程間的數據拷貝,提高數據傳輸的效率呢?
這就要零拷貝:取消用戶空間與內核空間之間的數據拷貝操作,應用進程每一次的讀寫操作,都讓應用進程向用戶空間寫入或讀取數據,就如同直接向內核空間寫或讀數據一樣,再通過 DMA 將內核中的數據拷貝到網卡,或將網卡中的數據 copy 到內核。
2.2 實現
是不是用戶空間與內核空間都將數據寫到一個地方,就不需要拷貝了?想到虛擬內存嗎?
零拷貝有兩種實現:
mmap+write
通過虛擬內存來解決。
sendfile
Nginx sendfile
3 Netty 零拷貝
RPC 框架在網絡通信框架的選型基於 Reactor 模式實現的框架,如 Java 首選 Netty。那 Netty 有零拷貝機制嗎?Netty 框架中的零拷貝和我之前講的零拷貝又有什麼不同呢?
上節的零拷貝是 os 層的零拷貝,爲避免用戶空間與內核空間之間的數據拷貝操作,可提升 CPU 利用率。
而 Netty 零拷貝不大一樣,他完全站在用戶空間,即 JVM 上,偏向於數據操作的優化。
Netty 這麼做的意義
傳輸過程中,RPC 不會把請求參數的所有二進制數據整體一下子發送到對端機器,中間可能拆分成好幾個數據包,也可能合併其他請求的數據包,所以消息要有邊界。一端的機器收到消息後,就要對數據包處理,根據邊界對數據包進行分割和合並,最終獲得一條完整消息。
那收到消息後,對數據包的分割和合並,是在用戶空間完成,還是在內核空間完成的呢?
當然是在用戶空間,因爲對數據包的處理工作都是由應用程序來處理的,那麼這裏有沒有可能存在數據的拷貝操作?可能會存在,當然不是在用戶空間與內核空間之間的拷貝,是用戶空間內部內存中的拷貝處理操作。Netty 的零拷貝就是爲了解決這個問題,在用戶空間對數據操作進行優化。
那麼 Netty 是怎麼對數據操作進行優化的呢?
-
• Netty 提供了 CompositeByteBuf 類,它可以將多個 ByteBuf 合併爲一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。
-
• ByteBuf 支持 slice 操作,因此可以將 ByteBuf 分解爲多個共享同一個存儲區域的 ByteBuf,避免了內存的拷貝。
-
• 通過 wrap 操作,我們可以將 byte[] 數組、ByteBuf、ByteBuffer 等包裝成一個 Netty ByteBuf 對象, 進而避免拷貝操作。
Netty 框架中很多內部的 ChannelHandler 實現類,都是通過 CompositeByteBuf、slice、wrap 操作來處理 TCP 傳輸中的拆包與粘包問題的。
Netty 解決用戶空間與內核空間之間的數據拷貝
Netty 的 ByteBuffer 採用 Direct Buffers,使用堆外直接內存進行 Socket 的讀寫操作,最終的效果與我剛纔講解的虛擬內存所實現的效果一樣。
Netty 還提供 FileRegion 中包裝 NIO 的 FileChannel.transferTo() 方法實現了零拷貝,這與 Linux 中的 sendfile 方式在原理一樣。
4 總結
零拷貝帶來的好處就是避免沒必要的 CPU 拷貝,讓 CPU 解脫出來去做其他的事,同時也減少了 CPU 在用戶空間與內核空間之間的上下文切換,從而提升了網絡通信效率與應用程序的整體性能。
Netty 零拷貝與 os 的零拷貝有別,Netty 零拷貝偏向於用戶空間中對數據操作的優化,這對處理 TCP 傳輸中的拆包粘包問題有重要意義,對應用程序處理請求數據與返回數據也有重要意義。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/pdVSMLVa0O22XNc4t-Pp1g