综述
现代的异步编程中有如下的几个概念
- 协程 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 会有正式版。
下表对比了使用这两种语言对异步编程的特性支持
Golang | Rust | Kotlin | |
---|---|---|---|
协程 | 语言内置 | 由异步运行时框架提供 | 语言内置 |
队列 | 语言内置 | 由异步运行时框架提供 | 语言内置 |
调度运行时 | 语言内置,不可更改 | 多个实现, tokio/async_std/... | 语言内置 |
异步函数 | 无需区分 | 需显式的定义 | 需显式定义 |
队列类型 | 无需特指,只有一种 mpmc | 可特指,不同的场景提供不同实现 | 无需特指 |
垃圾回收 | 通过GC算法进行垃圾回收 | 无GC,资源超出作用域即释放 | 通过GC算法进行垃圾回收 |
- 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): 传递时总是进行字符串内容的复制
这个场景类似服务器的实现,当客户端连接到服务器时,创建一个协程,接收客户端的请求,然后将请求投递给处理协程。
在这样的逻辑下,有如下的几个参数来控制测评的规模
含义 | 命令行参数 | 说明 | |
---|---|---|---|
workers | 协程的数目 | -w | |
events | 消息数目 | -e | |
queue | 队列可堆积的消息的数目 | -q | 队列满了之后协程会阻塞 |
etype | 消息的类型 | -t | 0 整数 1 字符串 2 字符串指针 3 字符串复制 |
esize | 消息的大小 | -s | 对于字符串类似,越大的消息内存分配压力越大 |
测评完成后,会输出如下的几个数据
含义 | 说明 | |
---|---|---|
total_events | 总共产生和接收的消息数目 | 即 workers * events |
time | 完成测试使用的需要的时间 | 越小越好 |
speed | 每秒处理的消息数目 | total_events/time 越大越好 |
实现
源码
- 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 中的消息,是实现了
- 消息的处理
- 在接收协程收到消息后,会进行一个简单的判断,这主要是为了避免编译器将空实现优化掉
- 这个判断,对于各实现语言都是极其轻量的,基本不会对主要测评产生影响
- 字符串复制消息的实现
- 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
来查看
- Golang 和 Rust 的实现中可以附加
编译
在安装了 go、rust、JDK/maven 的机器上
代码语言:javascript复制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
program | etype | worker | event | time | speed |
---|---|---|---|---|---|
golang | int | 100 | 10000 | 0.010 | 98969725 |
rust | int | 100 | 10000 | 0.012 | 80789148 |
kotlin | int | 100 | 10000 | 0.145 | 6917313 |
golang | str | 100 | 10000 | 0.045 | 21989041 |
rust | str | 100 | 10000 | 0.019 | 53630230 |
kotlin | str | 100 | 10000 | 0.159 | 6304093 |
golang | str_ptr | 100 | 10000 | 0.011 | 88775257 |
rust | str_ptr | 100 | 10000 | 0.012 | 81436541 |
kotlin | str_ptr | 100 | 10000 | 0.136 | 7340791 |
... | |||||
kotlin | str_ptr | 50000 | 10000 | 12.434 | 40212992 |
golang | int | 50000 | 10000 | 5.594 | 89376773 |
rust | int | 50000 | 10000 | 9.131 | 54760465 |
kotlin | int | 50000 | 10000 | 9.629 | 51927597 |
golang | str | 50000 | 10000 | 17.794 | 28099233 |
rust | str | 50000 | 10000 | 12.437 | 40203692 |
kotlin | str | 50000 | 10000 | 16.774 | 29807544 |
golang | str_ptr | 50000 | 10000 | 4.911 | 101819179 |
rust | str_ptr | 50000 | 10000 | 8.795 | 56850205 |
kotlin | str_ptr | 50000 | 10000 | 11.662 | 4287558 |
结果
运行环境
值 | |
---|---|
OS | Ubuntu 22.04 WSL on windows 11 64bit |
CPU | Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz |
Mem | 32G |
Go | 1.18.1 |
Rust | 1.62.0 |
JDK | OpenJDK 17.0.3 |
Kotlin | 1.7.10 |
结果
代码语言:javascript复制./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