# bench-of-chain **Repository Path**: elsejj/bench-of-chain ## Basic Information - **Project Name**: bench-of-chain - **Description**: go/rust 的队列及调度性能测评 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 5 - **Forks**: 3 - **Created**: 2022-07-26 - **Last Updated**: 2025-11-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 综述 现代的异步编程中有如下的几个概念 - 协程 coroutine : 用户态的线程,可在某些特定的操作(如 IO 读取)时被挂起,以让出 CPU 供其他协程使用。 - 队列 channel: 队列用于将多个协程连接起来 - 调度运行时 runtime: 调度运行时管理多个协程,为协程分配计算资源(CPU),挂起、恢复协程 由于协程是非常轻量的,所以可以在一个进程中大量的创建,`runtime` 会实际创建系统线程(一般为恰好的物理 CPU 数),并将协程映射到实际的物理线程上执行,这个有时候称为 `M:N模型`。好的 runtime 会使得系统整体的性能随着物理 CPU 的增加而线性增加。 [Golang](https://Golang.google.cn/) 是原生支持上述模型的语言,这也是 `Golang` 与众不同的主要特性,在 `Golang` 中,通过关键词 `go` 即可轻松开启一个协程,通过关键词 `chan` 则可以定义一个队列,`Golang` 内置了调度运行时来支撑异步编程。 [Rust](https://www.Rust-lang.org/) 在 2019 年的 `1.39` 版本中,加入 `async/.await` 关键词,为异步编程提供了基础支撑,之后,随着 `Rust` 生态中的主要异步运行时框架之一 [tokio 1](https://tokio.rs) 发布,`Rust` 编写异步系统也变得跟 `Golang` 一样方便。 [Kotlin](https://kotlinlang.org) 是一个基于 JVM 的语言,它语言层面原生支持协程,但由于 JVM 现在还不支持协程,所以它是在 JVM 之上提供了的调度运行时和队列。顺便,阿里巴巴的 Dragonwell JDK 在 OpenJDK 的基础上可以选择[开启 Wisp2 特性](https://github.com/alibaba/dragonwell8/wiki/Alibaba-Dragonwell8-User-Guide#wisp),来使得 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 的调度和队列性能。 # 场景设计 测评的逻辑如下 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 | 消息的类型 | -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 中字符串是不可变的,所以复制不对字符串内容做复制,仅重新生成一个轻量的包装,所以,在实现中,通过 strings.Clone 方法来进行全复制 - Rust 字符串的复制总是全复制 - Kotlin 中字符串是不可变的,复制仅生成一个轻量包装,通过 String.String(chars)来进行全复制 - 字符串指针消息的复制 - Golang 中的轻量字符串为指针,所以复制仅是指针复制 - Rust 轻量字符串为 &'static str, 复制为引用复制,由于 Rust 的强所有权,此处的实现是一个专项的实现,生产中不应采用这种方式,因为它有内存泄漏。 - Kotlin 中的轻量字符串是 String ,实际即是字符串指针 - Rust 中队列的选择 - Rust 生态中中有许多队列实现可选,经过测评,队列使用了 [futures::channel::mpsc](https://docs.rs/futures/latest/futures/channel/mpsc/index.html), 相比 tokio 自带的 [tokio::sync::mpsc](https://docs.rs/tokio/latest/tokio/sync/mpsc/index.html), 它在性能上,略有优势。 - 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.git cd bench-of-chain make ``` ### 标准环境 感谢 [腾讯云原生构建](https://cnb.cool/), 可以通过 `cnb` 来快速构建标准的运行环境, 而无需在本机上安装相关的工具 请在其页面中, Fork [https://cnb.cool/elsejj/bench-of-chan](https://cnb.cool/elsejj/bench-of-chan), 然后点击 "云原生开发", 即可打开云上的IDE, 来进行编译和运行 ### 运行 - 脚本 `run.sh` 以相同的参数,同时运行各语言实现的程序,得到如下的输出 ``` $ ./run.sh -w 5000 -e 10000 -q 256 -t 2 program,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 | ## 结果 ### 运行环境(2022-07-26) | | 值 | | ------ | ---------------------------------------- | | 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 | ### 运行环境(2024-11-06) | | 值 | | ------ | --------------------------- | | OS | Ubuntu 24.04 64bit | | CPU | Intel(R) Core(TM) i7-13700K | | Mem | 64G | | Go | 1.23.0 | | Rust | 1.82.0 | | JDK | OpenJDK 21.0.0 | | Kotlin | 2.0.10 | | CangJie | 0.5 | ### 运行环境(2025-07-02) | | 值 | | ------ | --------------------------- | | OS | Ubuntu 24.04 64bit | | CPU | Intel(R) Core(TM) i7-13700K | | Mem | 64G | | Go | 1.24.4 | | Rust | 1.88.0 | | JDK | OpenJDK 21.0.0 | | Kotlin | 2.0.10 | | CangJie | 1.0.0 | ### 运行环境(2025-11-11) | | 值 | | ------ | --------------------------- | | OS | Debian 13 | | CPU | AuthenticAMD * 8 | | Mem | 16G | | Go | 1.25 | | Rust | 1.91 | | JDK | OpenJDK 25 | | Kotlin | 2.2 | | CangJie | 1.0.4 | ### 结果 ``` ./run.sh -e 10000 ``` 每个测评项会执行 5 次,取其平均值 #### 2022-07-26 ![10Kmessage!](charts/result_20220726.png) #### 2024-11-06 ![10Kmessage!](charts/result_20241106.png) ### 2025-11-11 ![10Kmessage!](charts/result_20251111.png) # 结论和分析 从上述的运行结果来看 ## 调度运行时和队列 - 伸缩性:各语言的调度都很优秀,随着协程数目的增加,事件的处理能力并没有明显的降低。一般来说,随着协程数目的增加,调度的压力也会增加,调度 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](https://gitee.com/elsejj/bench-of-chain)