透過 Rust 探索系統的本原:網絡篇

如今所有的應用程序幾乎都離不開網絡。從應用開發的角度,絕大多數應用以及其後端系統都工作在應用層:

一般來說,應用程序的網絡層除了發生在客戶端和服務器之間,還存在於整個後端。下圖是一個典型的應用程序:

客戶端和服務端之間的流量會走這些網絡協議:

  1. API 和 analytics 大多會走 HTTP(S)/1.1 或 HTTP(S)/2,以及在其之上的 websocket。

  2. trace/metrics 走 UDP

服務端內部:

  1. 服務和服務間如果走 gRPC 的話,是 HTTP/2。

  2. 服務跟數據庫之間走數據庫自己的網絡協議(一般直接建立在 TLS 之上)。

  3. 服務跟第三方的雲服務,一般走 HTTPS。

  4. 服務器和 Message queue 之間,也許會走 AMQP 協議。

大多數時候,這些網絡協議都被封裝好,或以 SDK 的形式,或以類庫的形式供你調用,所以開發者基本不用操心協議的細節是怎麼回事。頂多,把 HTTP/1.1 的狀態碼(200/301/302/304/400/404/429/500),方法(GET/POST/PUT/PATCH/DELETE/HEAD),常見的頭字段記一記,知道什麼情況下客戶端該請求什麼方法,返回什麼狀態碼,什麼時候數據應該放在頭部,什麼時候應該放在腰部(body),也就足夠應付 80% 的應用場景了。

不過,爲了更大的吞吐量,更小的延遲,更好的用戶體驗,我們還需要掌握更多的細節。本文就談談這些內容。

異步處理

提升網絡性能的第一大法寶是異步處理。網絡跟 I/O 相關,發送和接收數據都存在潛在的阻塞線程的風險,如果不妥善處理,會大大降低系統的吞吐量。傳統的處理方式是用 non-blocking I/O,自己處理 EWOULDBLOCK —— 一般來說,去調度其它能執行的任務,避免線程閒置。當然,現代編程語言都有不錯的異步支持,相當於幫助開發者完成這種原本需要手工完成的調度。Rust 提供了 Future trait,在語言核心支持了 asyncawait。相信大家對 asyncawait 並不陌生,下圖對比了 sync/async File I/O:

(圖片來自:Writing an OS in Rust - Async/Await [1])

在衆多不同語言的異步實現中,Rust 的實現是獨樹一幟的。它的異步庫(無論 Tokio/async-std)使用了 Reactor/Executor 模式 [2],一個 Future 只有被主動 poll(await)纔會得到執行。這一點和 javascript 有本質的不同 —— 在 js 裏,一個 promise 一旦生成,就會放入 event loop 裏等待執行。

在 Reactor/Executor 模式裏, executor 就是我們常說的調度器(scheduler)。它負責調度可執行的 Future 的執行。每次執行意味着一次 poll,要麼 poll 到整個 Future 結束,要麼 poll 到 Future 直到  Poll::Pending。當一個 Future 不能做更多事情時(Poll::Pending),executor 不會再管它,直到有人(Waker)通知 executor 這個 Future 又重新 ready 了。這個 Waker 就是我們所說的 reactor。它一般會跟操作系統的 nonblocking I/O(linux 下是 epoll,bsd 下是 kqueue,以及 windows 下是 IOCP)協作,來喚醒 Future。下圖概括了 Tokio 下 Reactor/Executor 是如何協作的:

(圖片來自 Explained: How does async work in Rust? [3])

如果你做過多核 CPU 下的(非 ASIC)網絡設備相關的開發,會發現這個過程似曾相識。我覺得未來 Rust 會在高性能網絡設備領域佔據一席之地,這得益於其高效強大的易步處理庫。

Rust 下主流的異步庫有 Tokio 和 async-std。下圖做了不錯的總結,大家可以就着原文的討論一起看:

(圖片來自 reddit 討論:Diagram of Async Architectures [4],有些舊,async-std 現在已經基於 smol 了,但整個討論值得一讀)

異步開發的好處是:儘管底層的處理相當複雜,各種實現但對開發者來說,接口非常簡單,只需要很少的代價就可以把一個同步的處理變成異步的處理。

但是,我們要小心其中的一些陷阱:

對於後者,我這兩天在做一個類似 Phoenix Channel[5] 的 Websocket 應用(以下簡稱 WS channel)時遇到了這個問題:我的 WebSocket 在 wss 連接時,每個連接要花大概 300-400ms,很奇怪。後來我用 jaeger 追蹤了一下 tracing,發現客戶端代碼在連接時時間都耗在了 new_with_rustls_cert 上,真正花在 TLS handshake 上的時間只有 1.5ms:

由於我客戶端做的操作是:

  1. TCP connect(異步)

  2. 準備 TLS connector(這裏會做 new_with_rustls_cert),這是同步操作,耗時 ~300ms

  3. TLS connect(異步)

這直接導致服務端 tls_accept 每個新建連接的延遲奇高(因爲客戶端 TCP 連上了遲遲不做 handshake):

解決辦法:客戶端準備 TLS connector 的步驟提前到第一步。之後,服務器的延遲正常了(~1ms):

這是個有趣的 bug。new_with_rustls_cert 看上去是個人畜無害的純內存操作,但因爲裏面有讀取操作系統的受信證書的操作,所以延時高一些。其實它應該做成異步操作。

隊列

在網絡開發中,最快能提升性能的工具就是隊列。雖然操作系統層面,已經使用了發送隊列和接收隊列來提升性能,在應用層面,我們最好也構建相應的隊列,來讓整個服務的處理變得更順滑,更健壯,更高效。除此之外,隊列還能幫助我們解耦,讓應用本身的邏輯和 I/O 分離。

我們還是以上文中提到的 WS channel 爲例。其產品邏輯是:客戶端可以連接 websocket,然後 join/leave 某個 channel,當成功 join 某個 channel 後,客戶端可以向 channel 裏廣播任意消息,其它連接到這個 channel 的客戶端可以接收到這條消息。

服務器端需要處理這樣的邏輯:

很簡單,是不是?

然而,如果把所有這些邏輯都塞在 accept socket 後的一個大的 async move { loop {...} } 裏,代碼晦澀難懂,到處耦合,不好單元測試;如果分成一個個子函數,又需要對 websocket 的 reader/writer 套上 Arc<RwLock<...>> 傳遞,囉嗦且性能不好,每個子函數還是不好單元測試(websocket reader/writer  不好構造)。

最好的方式是用隊列將邏輯和 I/O 分離開:event loop 只負責從 websocket 中接收數據,將其發送到接收隊列中,供後續的邏輯處理;以及從發送隊列中 poll 出數據,寫入 websocket。整體的示意圖如下:

我們換個視角,只看一個 client,大概是這個樣子:

服務器:

  1. accept socket,爲 ws socket 創建一個本地 own 的 peer 結構和一個不在本地 own 的 client 結構。peer own socket 的 writer/reader,peer 和 client 之間建立一個雙向隊列。然後 spawn tokio task,處理該 peer。

  2. peer 的 event loop 很簡單,只處理 socket 的收發 —— 收到的消息放入 recv 隊列;從 send 隊列拿到要發的消息,寫入 socket

  3. client 在創建後會啓動一個 tokio task,運行自己的 event loop:從 recv 隊列收消息,根據消息類型進行相應處理(比如說 join 會和 channel 間建立隊列)。如果消息需要 broadcast,將其放入 broadcast 的發送隊列,由 channel 結構處理。

  4. channel 從 broadcast 接收隊列裏收到消息後,遍歷自己的所有 subscribers(排除發送者),然後將消息發送到他們的 broadcast 發送隊列。

這是理論上最佳的運作方式。實操的時候,爲了節省內存,channel 可以不和 client 建立隊列,直接獲取 client send 隊列的 rx 的淺拷貝,直接發送,省去了一層傳遞。

使用隊列之後,我們可以很方便地測試 client / channel 處理的邏輯,而無需關心 I/O 部分(I/O 的構造是 unit test 中最困難的部分)。同時,整個結構的邏輯也更清晰,效率更高(使用隊列緩存減少了等待),且更平滑(隊列有效地緩解了 burst 請求)。

此外,我們還可以在 websocket 發送端,對 send 隊列的 rx 做批處理,就像 ReactiveX 裏的 Window 操作那樣,讓發送端在一段時間內等夠一些數據再統一發送(比如:period=200ms / count=16 messages):

減少內存分配和拷貝

網絡應用中,數據從內核態到用戶態,在用戶態的多個線程之間,以及最後經過內核態把新的數據發送出去,裏面免不了有很多內存的分配和拷貝。還是上面 WS Channel 的例子,我大概統計了一下在 channel  中廣播一條用 protobuf 序列化的消息,應用程序自己所需要的內存分配和內存拷貝:

  1. 首先 WebSocket 服務器收到消息後,需要把二進制的 protobuf 轉化成 struct 進行一些處理。如果 protobuf 消息中含有 repeated (在 Rust 裏對應的是 Vec)或者 map (在 Rust 裏對應 HashMap)或者 string (在 Rust 裏對應的是 String),那麼都涉及到堆上的內存分配。堆上的內存的分配代價很大,切記。

  2. 假設 channel 裏有 100 個用戶,那麼要做 broadcast 的話,這個 struct 需要被拷貝 100 次。

  3. 當要發送消息時,需要把 struct 再序列化成二進制,封裝成 Websocket 消息,發送。這裏面,序列化的過程涉及到承載二進制內容的 buf 的分配,如果不優化,也是在堆上進行。

這裏最多的內存分配和複製在 2。爲了優化這裏的拷貝,我們可以用 Arc 來做引用計數,避免拷貝數據本身。

對於 3,我們可以使用一個全局的可增長的 ring buffer,每次需要 buf 時,從 ring buffer 裏取;也可以用 slab,預先分配好相同大小的內存,然後使用之。

此外,還可以使用一些零拷貝的序列化 / 反序列化工具,如 rkyv[8]。

我在開始寫 Rust 項目時往往在做應用的時候過多使用拷貝,導致辛辛苦苦編譯通過的代碼效率低下,有時候做同樣一件事,Rust 代碼的性能還不如 go 和 java。所以說,合理使用引用,避免代碼中不必要的拷貝,是撰寫高性能應用的必經之路。

降低延時

在服務器和客戶端的交互中,往往數據傳輸本身佔據總延遲的大頭。一種降低延時的方式是將數據甚至數據和計算本身都挪到網絡的邊緣處理,這樣因爲儘可能貼近用戶,傳輸的距離大大減小,延遲就大爲改觀。目前很多 CDN 都支持了邊緣計算(如 aws for edge)

另外一種降低總延時的方式是壓縮。如果原本傳輸完成 1MB 的數據需要 1s,壓縮後只剩下 400k,那麼傳輸完成所需要的時間也相應降低到 400ms。一般 http 服務器和客戶端都支持的壓縮算法有:gzip,deflate,compress 等。隨着時間的推移,類似 zstd[9] 這樣高性能且高壓縮比的算法也會得到越來越多的使用。如果你的應用是自己的客戶端(不是瀏覽器)和服務器交互,那麼可以考慮使用 zstd —— 它有媲美 gzip 的性能,以及比 gzip 好不少的壓縮比。

流式處理 (streaming)

降低延時的另一個手段是流式處理:發送端不需準備好所有數據才發送,而接收端也無需接收到所有數據才處理。gRPC 是應用的最爲廣泛的支持流式處理的工具。在 Rust 裏,有 tonic [10] 這個庫支持高性能 gRPC 服務。

流式處理雖然能大大降低延時,並讓數據在系統中流動得更加自然(我們的時間是一個流式運轉的世界,但大部分系統在處理起數據來,只能做批處理),但它最大的問題是使用起來不想批處理那麼顯而易見,更要命的是,測試起來很多時候無從下手。

在 Rust 下,我們可以將 channel 和 tonic 的流式接口綁起來,使用起來比較方便。至於測試,我製作了一個 tonic-mock[11],可以很方便地通過 prost 生成的 struct 從一個數組生成流式接口供測試使用。

如果你想在 TCP 之上構建流式處理,又希望能夠避免應用層上的 head-of-line blocking[12],可以使用 yamux [13],它是 Hashicorp 提出的一種類似 HTTP/2 的流管理的 multiplexing spec。Rust 下的實現 有 Parity 的 yamux [14]。當然,HTTP/2 或者 yamux 只能解決應用層的 head-of-line blocking,無法解決 TCP 層的問題,如果對你而言,TCP 層是瓶頸所在,那麼,可以試試 HTTP/3 或者在 QUIC(目前在 draft 34)[15] 上構建你自己的應用層。Rust 下對 HTTP/3 和 QUIC 的支持有 quinn(支持到 draft 32)[16] 和 cloudflare 出品的 quiche[17]。

日誌 / 追蹤(logging/tracing)

複雜的網絡應用,在追蹤問題的時候,合理的 logging/tracing 能幫助我們快速定位問題。在我用過的諸多語言的各種各樣的庫中,Rust 裏的 tracing [18] 庫是體驗最佳的工具。它可以記錄日誌,生成 flamegraph,把數據以 opentelemetry[19] 的形式發送給第三方(比如 jaeger)。比如:

上文提到,通過使用它,我解決了一個非常令人困擾的 TLS 新建連接的延遲問題。

當我們構建應用的時候,最好從一開始就設計好你的 tracing infrastructure:

賢者時刻

下圖囊括了 Rust 下面主流的和網絡應用相關的庫,希望能夠幫助大家在合適的場合使用合適的協議和工具:

雖然這是一篇有關 Rust 的文章,和我司目前的技術棧並無關聯,但我忍不住要爲我司的人文關懷吹一波:

我們目前國內團隊有不少職位招募,很快我們還會啓用一個一整層的獨立辦公室(座標北京望京)。感興趣的同學可以戳「閱讀原文」瀏覽,如果感覺對某個職位感興趣,可以直接在原文鏈接裏投遞簡歷,或者把簡歷發到:jobs.china at tubi.tv。

參考資料

[1] Writing an OS in Rust: https://os.phil-opp.com/async-await/

[2] The Reactor-Executor Pattern: https://cfsamsonbooks.gitbook.io/epoll-kqueue-iocp-explained/appendix-1/reactor-executor-pattern

[3] Explained: How does async work in Rust: https://dev.to/gruberb/explained-how-does-async-work-in-rust-46f8

[4] Diagram of Async Architectures: https://www.reddit.com/r/rust/comments/jpcv2s/diagram_of_async_architectures/

[5] Phoenix channel: https://hexdocs.pm/phoenix/channels.html

[6] Futures batch: https://github.com/mre/futures-batch

[7] ReactiveX window operation: http://reactivex.io/documentation/operators/window.html

[8] rkyv: https://github.com/djkoloski/rkyv

[9] zstd: https://facebook.github.io/zstd/

[10] tonic: https://github.com/hyperium/tonic

[11] tonic-mock: https://github.com/tyrchen/tonic-mock

[12] Head-of-line blocking: https://en.wikipedia.org/wiki/Head-of-line_blocking

[13] Yamux spec: https://github.com/hashicorp/yamux/blob/master/spec.md

[14] Yamux rust: https://github.com/paritytech/yamux

[15] QUIC: https://tools.ietf.org/html/draft-ietf-quic-transport-34

[16] Quinn: https://github.com/quinn-rs/quinn

[17] Quiche: https://github.com/cloudflare/quiche

[18] tracing: https://github.com/tokio-rs/tracing

[19] Opentelemetry: https://opentelemetry.io/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bOxEEK7Hh_tsua8HBahsjg