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 可註冊到一個複用器(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 框架中很多內部的 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