logo
0
0
WeChat Login
add 20251111 result

综述

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

  • 协程 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 会有正式版。

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

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

根据场景的不同,选择不同的队列,不同的运行时,可以得到更好的性能,但 GolangKotlin 简化了这些选择,一般来说,简化会带来性能的损失,本文测评 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消息的类型-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 对场景的实现

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

  • 消息的定义
    • 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 中队列的选择
  • 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

标准环境

感谢 腾讯云原生构建, 可以通过 cnb 来快速构建标准的运行环境, 而无需在本机上安装相关的工具

请在其页面中, Fork 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
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

结果

运行环境(2022-07-26)

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

运行环境(2024-11-06)

OSUbuntu 24.04 64bit
CPUIntel(R) Core(TM) i7-13700K
Mem64G
Go1.23.0
Rust1.82.0
JDKOpenJDK 21.0.0
Kotlin2.0.10
CangJie0.5

运行环境(2025-07-02)

OSUbuntu 24.04 64bit
CPUIntel(R) Core(TM) i7-13700K
Mem64G
Go1.24.4
Rust1.88.0
JDKOpenJDK 21.0.0
Kotlin2.0.10
CangJie1.0.0

运行环境(2025-11-11)

OSDebian 13
CPUAuthenticAMD * 8
Mem16G
Go1.25
Rust1.91
JDKOpenJDK 25
Kotlin2.2
CangJie1.0.4

结果

./run.sh -e 10000

每个测评项会执行 5 次,取其平均值

2022-07-26

10Kmessage!

2024-11-06

10Kmessage!

2025-11-11

10Kmessage!

结论和分析

从上述的运行结果来看

调度运行时和队列

  • 伸缩性:各语言的调度都很优秀,随着协程数目的增加,事件的处理能力并没有明显的降低。一般来说,随着协程数目的增加,调度的压力也会增加,调度 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