RPC設計應該使用哪一種網路IO模型?

2023.11.25

RPC設計應該使用哪一種網路IO模型?

零拷貝帶來的好處就是避免沒必要的CPU拷貝,讓CPU解脫出來去做其他的事,同時也減少了CPU在用戶空間與核心空間之間的上下文切換,從而提升了網路通訊效率與應用程式的整體性能。

網路通訊在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傳輸中的拆包黏包問題有重要意義,對應用程式處理請求資料與回傳資料也有重要意義。