综述

现代的异步编程中有如下的几个概念

  • 协程 coroutine : 用户态的线程,可在某些特定的操作(如IO读取)时被挂起,以让出CPU供其他协程使用。

  • 队列 channel: 队列用于将多个协程连接起来

  • 调度运行时 runtime: 调度运行时管理多个协程,为协程分配计算资源(CPU),挂起、恢复协程

runtimeM:N模型
GolangGolanggochanGolang
1.39async/.awaitRustRustGolang

Kotlin 是一个基于 JVM 的语言,它语言层面原生支持协程,但由于 JVM 现在还不支持协程,所以它是在 JVM 之上提供了的调度运行时和队列。顺便,阿里巴巴的 Dragonwell JDK 在 OpenJDK 的基础上可以选择开启 Wisp2 特性,来使得 JVM 中的 Thread 不再是系统线程,而是一个协程。JDK 19 开始增加了预览版的轻量级线程(协程),也许在下一个 JDK LTS 会有正式版。

下表对比了使用这两种语言对异步编程的特性支持


GolangRustKotlin
协程语言内置由异步运行时框架提供语言内置
队列语言内置由异步运行时框架提供语言内置
调度运行时语言内置,不可更改多个实现, tokio/async_std/...语言内置
异步函数无需区分需显式的定义需显式定义
队列类型无需特指,只有一种 mpmc可特指,不同的场景提供不同实现无需特指
垃圾回收通过GC算法进行垃圾回收无GC,资源超出作用域即释放通过GC算法进行垃圾回收
  • oneshot: 代表一个发送者,一个接收者的队列

  • mpsc: 代表多个发送者,一个接收者的队列

  • spmc/broadcast: 代表一个发送者,多个接收者的队列

  • mpmc/channel: 代表多个发送者,多个接收者的队列

GolangKotlin

测评的逻辑如下

  1. 创建 N 个接收协程,每个协程拥有一个队列,在接收协程中,从队列读取 M 个消息

  2. 创建 N 个发送协程,于接收协程一一对应,向其所属的队列,发送 M 个消息

  3. 消息分为三种类型

  • 整数(0:int):这种类型的消息,几乎不涉及内存分配

  • 字符串(1:str):这种类型的消息,是各语言默认的字符串复制,Rust 会有一次内存分配,Go/Kotlin 则是共享字符内容,生成包装对象

  • 字符串指针(2:str_ptr):传递字符串的指针,几乎不涉及内存分配

  • 字符串复制(3:str_clone): 传递时总是进行字符串内容的复制

这个场景类似服务器的实现,当客户端连接到服务器时,创建一个协程,接收客户端的请求,然后将请求投递给处理协程。

在这样的逻辑下,有如下的几个参数来控制测评的规模


含义命令行参数说明
workers协程的数目-w
events消息数目-e
queue队列可堆积的消息的数目-q队列满了之后协程会阻塞
etype消息的类型-t0 整数 1 字符串 2 字符串指针 3 字符串复制
esize消息的大小-s对于字符串类似,越大的消息内存分配压力越大

测评完成后,会输出如下的几个数据


含义说明
total_events总共产生和接收的消息数目即 workers * events
time完成测试使用的需要的时间越小越好
speed每秒处理的消息数目total_events/time 越大越好

实现

源码

  • boc-go 目录中是 go 对场景的实现

  • boc-rs 目录中是 rust 对场景的实现,使用 tokio 作为异步框架

  • boc-kt 目录中是 kotlin 对场景的实现

以下是各语言实现时的一些额外说明

EventEvent--cpuprofile 文件名boc-go/target/boc-go -w 10000 -e 10000 -q 256 --cpuprofile boc-go.pprofgo tool pprof -http=:8081 boc-go.pprofboc-rs/target/release/boc-rs -c -w 10000 -e 10000 -q 256 --cpuprofile boc-rs.svgboc-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.shbench.sh
$ ./run.sh -e 10000
programetypeworkereventtimespeed
golangint100100000.01098969725
rustint100100000.01280789148
kotlinint100100000.1456917313
golangstr100100000.04521989041
ruststr100100000.01953630230
kotlinstr100100000.1596304093
golangstr_ptr100100000.01188775257
ruststr_ptr100100000.01281436541
kotlinstr_ptr100100000.1367340791
...




kotlinstr_ptr500001000012.43440212992
golangint50000100005.59489376773
rustint50000100009.13154760465
kotlinint50000100009.62951927597
golangstr500001000017.79428099233
ruststr500001000012.43740203692
kotlinstr500001000016.77429807544
golangstr_ptr50000100004.911101819179
ruststr_ptr50000100008.79556850205
kotlinstr_ptr500001000011.6624287558

结果

运行环境


OSUbuntu 22.04  WSL on windows 11 64bit
CPUIntel(R) Core(TM) i7-9750H CPU @ 2.60GHz
Mem32G
Go1.18.1
Rust1.62.0
JDKOpenJDK 17.0.3
Kotlin1.7.10

结果

./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)要快很多,尽管它们都没有实际的进行字符串内容的分配。

其他

bench.sh