Go-Rust-Kotlin 的協程和隊列性能評測
綜述
現代的異步編程中有如下的幾個概念
-
協程 coroutine : 用戶態的線程,可在某些特定的操作(如 IO 讀取)時被掛起,以讓出 CPU 供其他協程使用。
-
隊列 channel: 隊列用於將多個協程連接起來
-
調度運行時 runtime: 調度運行時管理多個協程,爲協程分配計算資源(CPU),掛起、恢復協程
由於協程是非常輕量的,所以可以在一個進程中大量的創建,runtime
會實際創建系統線程(一般爲恰好的物理 CPU 數),並將協程映射到實際的物理線程上執行,這個有時候稱爲 M:N模型
。好的 runtime 會使得系統整體的性能隨着物理 CPU 的增加而線性增加。
Golang 是原生支持上述模型的語言,這也是 Golang
與衆不同的主要特性,在 Golang
中,通過關鍵詞 go
即可輕鬆開啓一個協程,通過關鍵詞 chan
則可以定義一個隊列,Golang
內置了調度運行時來支撐異步編程。
Rust 在 2019 年的 1.39
版本中,加入 async/.await
關鍵詞,爲異步編程提供了基礎支撐,之後,隨着 Rust
生態中的主要異步運行時框架之一 tokio 1 發佈,Rust
編寫異步系統也變得跟 Golang
一樣方便。
Kotlin 是一個基於 JVM 的語言,它語言層面原生支持協程,但由於 JVM 現在還不支持協程,所以它是在 JVM 之上提供了的調度運行時和隊列。順便,阿里巴巴的 Dragonwell JDK 在 OpenJDK 的基礎上可以選擇開啓 Wisp2 特性,來使得 JVM 中的 Thread 不再是系統線程,而是一個協程。JDK 19 開始增加了預覽版的輕量級線程(協程),也許在下一個 JDK LTS 會有正式版。
下表對比了使用這兩種語言對異步編程的特性支持
-
oneshot: 代表一個發送者,一個接收者的隊列
-
mpsc: 代表多個發送者,一個接收者的隊列
-
spmc/broadcast: 代表一個發送者,多個接收者的隊列
-
mpmc/channel: 代表多個發送者,多個接收者的隊列
根據場景的不同,選擇不同的隊列,不同的運行時,可以得到更好的性能,但 Golang
和 Kotlin
簡化了這些選擇,一般來說,簡化會帶來性能的損失,本文測評 Go/Rust(tokio)/Kotlin 的調度和隊列性能。
場景設計
測評的邏輯如下
-
創建 N 個接收協程,每個協程擁有一個隊列,在接收協程中,從隊列讀取 M 個消息
-
創建 N 個發送協程,於接收協程一一對應,向其所屬的隊列,發送 M 個消息
-
消息分爲三種類型
-
整數 (0:int):這種類型的消息,幾乎不涉及內存分配
-
字符串 (1:str):這種類型的消息,是各語言默認的字符串複製,Rust 會有一次內存分配,Go/Kotlin 則是共享字符內容,生成包裝對象
-
字符串指針 (2:str_ptr):傳遞字符串的指針,幾乎不涉及內存分配
-
字符串複製 (3:str_clone): 傳遞時總是進行字符串內容的複製
這個場景類似服務器的實現,當客戶端連接到服務器時,創建一個協程,接收客戶端的請求,然後將請求投遞給處理協程。
在這樣的邏輯下,有如下的幾個參數來控制測評的規模
測評完成後,會輸出如下的幾個數據
實現
源碼
-
boc-go 目錄中是 go 對場景的實現
-
boc-rs 目錄中是 rust 對場景的實現,使用 tokio 作爲異步框架
-
boc-kt 目錄中是 kotlin 對場景的實現
以下是各語言實現時的一些額外說明
-
消息的定義
-
Golang 中的消息,是實現了
Event
接口的不同 struct, 如 IntEvent, StrEvent, CheapStrEvent 等 -
Kotlin 中的消息,是實現了
Event
接口的不同 struct, 如 IntEvent, StrEvent, CheapStrEvent 等 -
Rust 中的消息,是由 enum 包裝的若干消息
-
這樣的定義方式,基於各語言的最佳實踐模式
-
消息的處理
-
在接收協程收到消息後,會進行一個簡單的判斷,這主要是爲了避免編譯器將空實現優化掉
-
這個判斷,對於各實現語言都是極其輕量的,基本不會對主要測評產生影響
-
字符串複製消息的實現
-
Golang 中字符串是不可變的,所以複製不對字符串內容做複製,僅重新生成一個輕量的包裝,所以,在實現中,通過 strings.Clone 方法來進行全複製
-
Rust 字符串的複製總是全複製
-
Kotlin 中字符串是不可變的,複製僅生成一個輕量包裝,通過 String.String(chars) 來進行全複製
-
字符串指針消息的複製
-
Golang 中的輕量字符串爲指針,所以複製僅是指針複製
-
Rust 輕量字符串爲 &'static str, 複製爲引用複製,由於 Rust 的強所有權,此處的實現是一個專項的實現,生產中不應採用這種方式,因爲它有內存泄漏。
-
Kotlin 中的輕量字符串是 String ,實際即是字符串指針
-
Rust 中隊列的選擇
-
Rust 生態中中有許多隊列實現可選,經過測評,隊列使用了 futures::channel::mpsc, 相比 tokio 自帶的 tokio::sync::mpsc, 它在性能上,略有優勢。
-
Kotlin 預熱
-
JVM 語言通常需要預熱來使得 JIT 生效,所以在 Kotlin 的實現中,會先以一個固定的參數,運行測評進行預熱,然後再按照給定的參數執行測評。
-
Golang 和 Rust 都不進行預熱,因爲它們都已經編譯到機器碼
-
性能分析數據
-
Golang 和 Rust 的實現中可以附加
--cpuprofile 文件名
參數來生成程序運行的性能分析數據 -
Golang 生成 .pprof 文件,如
boc-go/target/boc-go -w 10000 -e 10000 -q 256 --cpuprofile boc-go.pprof
然後可以通過go tool pprof -http=:8081 boc-go.pprof
來查看 -
Rust 則直接生成火焰圖,如
boc-rs/target/release/boc-rs -c -w 10000 -e 10000 -q 256 --cpuprofile boc-rs.svg
, 然後使用瀏覽器打開boc-rs.svg
來查看
編譯
在安裝了 go、rust、JDK/maven 的機器上
git clone https://gitee.com/elsejj/bench-of-chain.gitcd bench-of-chainmake
運行
- 腳本
run.sh
以相同的參數,同時運行各語言實現的程序,得到如下的輸出
$ ./run.sh -w 5000 -e 10000 -q 256 -t 2program,etype,worker,event,time,speed
golang,str_ptr,5000,10000,0.477,104845454
rust,str_ptr,5000,10000,0.652,76636797
kotlin,str_ptr,5000,10000,1.638,30526077
- 腳本
bench.sh
以不同的 worker 、etype 運行多次,輸出結果列表,bench.sh
在不同的機器上,可能會運行數分鐘, 其結果如
$ ./run.sh -e 10000
結果
運行環境
結果
./run.sh -e 10000
每個測評項會執行 5 次,取其平均值
結論和分析
從上述的運行結果來看
調度運行時和隊列
-
伸縮性:各語言的調度都很優秀,隨着協程數目的增加,事件的處理能力並沒有明顯的降低。一般來說,隨着協程數目的增加,調度的壓力也會增加,調度 100 個協程和調度 10000 個協程,肯定會有額外的消耗增加,但實際上,這種增加比較可控,甚至不是主要的影響因素。甚至,對於 kotlin 還出現了隨着協程增加,性能提升的情況,這可能是 kotlin 的調度更適應大量協程,可以分散到更多的 CPU 來執行的情況。
-
性能:
-
Golang 原生支持的協程和隊列,性能非常優異,這一點並不奇怪,雖然 Golang 是帶有 GC 的語言,但其沒有虛擬機,會直接生成優化過的機器碼,協程和隊列是其語言的核心能力,在忽略了 GC 影響後,所以整體的性能最好。
-
Golang 對於 str_ptr 場景,基本沒有內存分配,所以性能最好,也是直接反映了其調度和隊列的性能,對於 int 的場景,當數字小於 256 ,其性能類似 str_ptr 的場景,沒有內存分配,否則也會有一次內存分配,導致性能下降。
-
Rust 具有良好性能,但與 Golang 這種高度優化的仍有差距。
-
Kotlin 在協程數目少時,無法發揮所有 CPU 的能力,但在協程數增加後,也能夠近乎達到 Rust/tokio 的性能,但與 Golang 仍有較大差距
-
GC 的影響
-
對於非簡單類型,有內存分配後,兩種 GC 語言相對於無 GC 語言,性能有更大幅度的降低。特別是對於大量內存分配的場景 (str_clone),其性能的降幅更大,而對於無 GC 的 Rust,表現則相對穩定。
-
在某些場景 (str),這種場景一個實際的例子是廣播消息,如聊天羣裏將一個發言分發給所有羣成員。三種實現具有接近的性能,但有 GC 的語言,由於實際不會有大量的內存分配,表現略好於有 GC 的語言。
-
在必須重新分配內存的場景 (str_clone),無 GC 的 Rust 有更好的性能,相比 JVM,Golang 的 GC 介入會更加積極,運行過程中,Kotlin 使用了 4 倍於 Golang 的內存 (40 倍於 Rust 的內存),但 GC 的介入也會降低業務性能。在實際的場景中,這種大量創建,短期內就會失效的很常見,此時,無 GC 的 Rust 會更具優勢。
-
Golang 中有很多技巧來避免內存分配,例如,使用字符串指針 (str_ptr) 就比使用字符串對象 (str) 要快很多,儘管它們都沒有實際的進行字符串內容的分配。
其他
-
本測評目標並不是選出一個最快、最好的實現,從測評的結果來看,三種語言的實現,都達到了一個較高的水平,在 10 萬規模協程規模,每秒通過隊列投遞超過 1000 萬消息,而且會隨着 CPU 資源的增加性能還會有提升,這種性能指標,對於大部分場景已經是足夠了。
-
Rust 的實現,在各個場景,都有穩定的表現,而帶有 GC 的語言,Golang 和 Kotlin 在隨着 GC 的介入表現變化較大。
-
測評並未包含,不同隊列長度,不同消息大小的影響,可以通過調整
bench.sh
來進行相關的測試。 -
歡迎 PR 其他的語言的實現,如有發現 BUG,也請不吝 PR,代碼的倉庫在 https://gitee.com/elsejj/bench-of-chain
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wb6qgKufTc1sXc1U2aPIRA