[原创] Go/Rust/Kotlin 的协程和队列性能评测

2022-11-28 11:50:54 浏览数 (1)

综述

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

  • 协程 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: 代表多个发送者,多个接收者的队列

根据场景的不同,选择不同的队列,不同的运行时,可以得到更好的性能,但 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

消息的类型

-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, 相比 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 的机器上

代码语言:javascript复制
git clone https://gitee.com/elsejj/bench-of-chain.gitcd bench-of-chainmake

运行

  • 脚本 run.sh 以相同的参数,同时运行各语言实现的程序,得到如下的输出
代码语言:javascript复制
$ ./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 在不同的机器上,可能会运行数分钟, 其结果如
代码语言:javascript复制
$ ./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

0 人点赞