本次参赛选手是…

  • 来自 Golang 的 Gin
  • 来自 Rust 的 Axum

本次将通过基本的路由和中间件,着重评测二位的性能和上手体验。测试设计很草率,并不代表普遍情况!

选择理由

有读者老爷可能会问了,明明 xxx 性能更强/更成熟/更优雅,为什么不选它?

Golang 方面, Gin 毋庸置疑是最为成熟稳定、生态最丰富的 Web Server Framework. 其 func(*gin.Context) 吃遍天的良好设计极其优雅高效。其他的 Framework 相比它 并没有决定性的性能优势,反倒是接口千奇百怪,难以理解。

Rust 方面,本来我是要选 Actix 的,然而它在本机上的性能表现比 Axum 差 20% 以上,而且 它的接口个人不太喜欢,尤其是其奇怪的 Response 构造和 path macro。Axum 的接口是最 对我胃口的,且性能在第一梯队。

当然,有测试表明,Actix 最大的优势是系统性能友好——它相比 Axum 能省 30% 以上的 CPU、 50% 以上的内存,而性能差距不到 10%。(虽然和我自己测出来的不一样)

Golang 代码

直接依赖: github.com/gin-gonic/gin

代码:

main.go

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.New()
    r.Use(Counter())
    r.GET("/count", Count)
    r.NoRoute(NotFound)
    r.Run("localhost:8081")
}

func NotFound(c *gin.Context) {
    c.JSON(200, gin.H{
        "code":  404,
        "message": "not found",
    })
}

func Count(c *gin.Context) {
    msg, _ := c.Get("example")
    c.JSON(200, gin.H{
        "code":  200,
        "message": msg,
    })
}

func Counter() gin.HandlerFunc {
    n := 0
    return func(c *gin.Context) {
        c.Set("example", n)
        n += 1
        c.Next()
    }
}

超级优雅的 Go 风格代码!仅需 35 行!

Rust 代码

Cargo.toml

tokio = {version = "1", features = ["full"]}
axum = "0.7"
serde_json = "1"

main.rs

use axum::{
    extract::{Request, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json,
};
use ctx::Ctx;
use serde_json::json;

#[tokio::main]
async fn main() {
    let ctx = Ctx::new();
    let app = axum::Router::new()
        .route("/count", get(count))
        .fallback(fallback)
        .layer(from_fn_with_state(ctx.clone(), counter))
        .with_state(ctx);

    let listener = tokio::net::TcpListener::bind("localhost:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn fallback(_: Request) -> impl IntoResponse {
    (StatusCode::OK, Json(json!({ 
        "code": 404, 
        "message": "not found" 
    })))
}

async fn count(State(state): State<Ctx>) -> impl IntoResponse {
    (StatusCode::OK, Json(json!({ 
        "code": 200, 
        "message": state.get() 
    })))
}

use axum::middleware::{from_fn_with_state, Next};
async fn counter(State(mut state): State<Ctx>, request: Request, next: Next) -> Response {
    let response = next.run(request).await;
    state.inc();
    response
}

mod ctx {
    use std::{cell::Cell, sync::Arc};

    #[derive(Clone)]
    pub struct Ctx(Arc<Cell<i32>>);
    unsafe impl Sync for Ctx {}
    unsafe impl Send for Ctx {}

    impl Ctx {
        pub fn new() -> Self {
            Self(Arc::new(Cell::new(0)))
        }
        pub fn get(&self) -> i32 {
            self.0.get()
        }
        pub fn inc(&mut self) {
            self.0.set(self.0.get() + 1)
        }
    }
}

其实核心代码和 Golang 差不多,但是总共却要 64 行代码。主要繁琐在

  • 需解析的参数要手动填写,而非从一个 gin.Context 中全部获得
  • State 类型需手动实现
  • Sync Trait 的约束使我们要手动绕过它(当然这是我的问题不是 Rust 的问题)
  • crate - module 组织结构太丑陋,不得不手动 use

性能测试

到了最激动人心的环节了!

我们采用 wrk 在 Ryzen 6800H 的 Linux(5.10.16.3-microsoft-standard-WSL2) 上,分别测试 1, 10, 100, 1000 连接的 loopback 内 close 和 keep-alive 连接 QPS。数据采用 五次测试取中位数的方式。

命令: wrk -d3s -t8 -c$CONNS

Connection: Close QPS:

Backend 单连接 10 连接 100 连接 1000 连接
Axum 5713.87 37406.69 67543.18 64061.99
Gin 5374.02 30646.14 86524.36 80722.98

Connection: Keep-Alive QPS:

Backend 单连接 10 连接 100 连接 1000 连接
Axum 15211.09 85662.56 206834.91 567978.31
Gin 9830.07 64874.18 214375.67 332513.46

可见,Gin 对短连接、高并发情形有蜜汁优化。

值得一提的是,在任何场景中,axum 的 CPU 占用都在 gin 的 75% 以下,在 Keep-Alive 场景中 甚至可以达到 50%。可见 axum 仍未充分利用 CPU,有提升空间;而 Golang 存在一些 Overhead 妥协, 在 CPU 性能受限时表现较差。

当然我还顺带测了测 nginx:差距太大啦!低并发下 QPS 差快一倍啦!果然还是不能跟专业的比啊(笑)

总结

Gin 的表现令人惊喜。在测试之前我以为 Rust 既然编译那么久,各种传参 数据流都做了充分的优化,性能肯定碾压并非 Golang 阵营最强的 Gin。然而架构和成熟度的差异 使得 Gin 面对更贴近现实极端情况的高并发短连接情形,性能极其优异。

Axum 在长连接的情形下性能远超 Gin,在高并发短连接下也不会和 gin 差太远。毕竟它还是个 beta 库,未来可期。

考虑到编码和编译复杂度,我想,如果只是写一个网络中间件,例如简单 CRUD 的项目,Golang 和 Gin 一定是更好的选择;它成熟稳定,性能优异,代码优雅,编译快速,线上占用较低。 而如果要写一个功能丰富、CPU 密集型的后端,我会选择 Rust 和 Axum。因为 Rust 有着 Cargo 这个 活跃的社区和强大优雅的模块系统设计。