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


    综述

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

    • 协程 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 中队列的选择

      • 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 的机器上

    git clone https://gitee.com/elsejj/bench-of-chain.gitcd bench-of-chainmake

    运行

    • 脚本 run.sh 以相同的参数,同时运行各语言实现的程序,得到如下的输出

    1. $ ./run.sh -w 5000 -e 10000 -q 256 -t 2program,etype,worker,event,time,speed
    2. golang,str_ptr,5000,10000,0.477,104845454
    3. rust,str_ptr,5000,10000,0.652,76636797
    4. 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

    结果

    运行环境


    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次,取其平均值

    ef9f44f5c751dae1294ec697760fd8e8.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

  • 相关阅读:
    专利申请怎样做快速预审?
    (LeetCode C++)盛最多水的容器
    京东数据分析(京东销量):2023年9月京东投影机行业品牌销售排行榜
    为什么数据集中的mask是彩色的?
    总监让我当小组长我拒绝了
    web中操作sqlite数据库
    找不到类org.springframework.cloud.client.loadbalancer.LoadBalanced
    【ARMv9 DSU-120 系列 10 -- PMU 详细介绍】
    HSRP协议(思科私有)/VRRP协议(公有)
    【云原生】Docker小工具:runlike与whaler(打印容器的启动命令与导出镜像的dockerfile)
  • 原文地址:https://blog.csdn.net/u012067469/article/details/126277122