【社区投稿】rust的web框架单机百万并发的性能与开销

avatar
作者
猴君
阅读量:0

周末做了点小实验,赶紧把结果发出来,免得之后忘了。

前几年golang号称能够百万并发,我当时拿我的小破机子试了,结果卡了半个多小时没有响应,只能强行关机,后来又试了一次,还是差不多,就没再试过了,所以我一直以为中低配服务器web百万并发只是传说,这次终于在rust上实现了。

但是,rust所有的web框架,都有内存不释放的问题。


  • 前情提要:

axum内存泄漏问题,更换内存分配器的后续测试

  • 原帖省流:

测试了更换MiMalloc和JeMalloc,MiMalloc效果最好,JeMalloc次之,如果不更换内存分配器,axum的内存不会自动归还系统


  • 准备

之前主要用的http1.1测的axum,并发量不是很足,这次换了http2连接,可以轻松百万并发了,然后就再测了一下,顺便测了最流行的actix,以及之前在论坛看到国人团队开源的基于hyper的salvo.rs。

测试环境这次找来了两台8c16g的机子一个客户端和一个服务端,两台机子都是用千兆wifi内网连接的,linux系统做了基本的调优,都是ubuntu24.04,rust 1.81.0 nightly,其他流行库全部用2024/7最新的。

客户端用reqwest的初始化8(核心数)个client实现的客户端池进行并发http2请求,设定60秒超时时间。

三个框架的句柄设计都是用time::sleep(Duration::from_millis(20000)).await;来模拟20秒的长等待,不然不好观测并发时的状态,axum、salvo都是用tokio的runtime,actix用其自己runtime,响应会返回http_version、remote_addr和服务器标识,方便客户端统计信息。

其中hyper系的axum/salvo需要对http2服务器修改max_concurrent_streams参数,hyper默认http2单条并发上限是200,为了方便测试我调到了100万。

服务端大概用了几个原子计数器来统计请求id,并发数,最大并发数来监控连接情况,不过开销不大,在几十秒几个G的测试里基本可以忽略不计。


  • qps性能

累计进行了一两千轮百万并发测试,结果测试下来,稳定后,三个框架的性能都很一致,几乎一模一样。

reqwest这边会以1秒左右的时间发送完所有100万个请求,快的时候0.5秒多点,慢的时候也很难超过1.5秒。

服务端这边会在4秒以内接收完所有100万个请求。

客户端接收到所有响应的时间大约时28-31秒。

客户端这边统计所有的响应,单个请求响应的平均时间在22-23秒这样。

综合算下来,对于这种复用率极高的http2请求,8c的单机能处理http2请求极限至少能在20w+qps。

每次启动系统和部署的二进制文件表现都不完全一样,可能跟编译抖动有关,但同一个二进制文件在同一启动的系统里,运行稳定后性能数字都很稳定。


  • 内存开销

actix、axum、salvo三个框架的启动内存都在十兆左右。

一开始以为所谓的内存泄漏是axum/hyper的问题,后来测了actix、salvo,发现全都一样,内存全都不释放。

其中actix和axum在百万连接的时候,每秒监控进程内存,总内存在3.5G-4.5G这样,每轮测试很不稳定,但数量级基本是确定的。

salvo在进行百万连接的时候表现得差点,大约会在5-6G这样。

actix、axum、salvo三个服务跑在同一个机子的不同端口,经过连续的百万并发测试,外部监控内存16g的内存都被占满了,基本一直是90-95%,但又没有任何地方报错内存溢出。

默认内存分配器基本不会主动归还内存,但也不能严格定义为内存泄漏,感觉如果内存占用满了,可能就会触发内存归还,所以可能并不会报内存溢出错误。

更换内存分配器JeMalloc后,并发内存没变,但静态零连接内存actix、axum、salvo三个框架都在1.5-2.5G这样。

更换内存分配器MiMalloc后,并发内存没变,但静态零连接内存actix、axum、salvo三个框架都在0.7-1.5G这样。

每次停止并发后下次并发前,静态内存基本都不会变动,但这个内存数值并不固定,可能某轮静止内存是700M,某轮是1.5G,都有可能。


  • 开销分析

首先内存分配器更换基本对web框架的性能基本不影响。

actix、axum、salvo在百万并发时,salvo的内存和另外两者略有差距,但更换内存分配器后,可以看出静态内存基本一致。

不过即使是动态内存,每个连接折算下来也就是几K,这其中包括了协程、休眠、请求体、响应体等的开销,salvo可能稍微多点,不过看其功能也更丰富一些,不过一个请求也就多个一两K而已。

当然,http2的影响不可忽略,请求头进行编号压缩,tcp端口的异步复用,对实际性能开销有着极大优化。

使用mi_malloc的最终开销平均来说小于1G,根据上一个帖子,理论上再间歇性地以低qps发送一些请求,还能再降低一些内存,不过感觉这个方法有点trick了,不知道有没有更加好的方法。

不过不管怎么说,web服务百万并发后,这个接近至少1G的静态内存似乎都是客观存在的,如果服务器被攻击方抓到耗时长的api,通过手段来一次超高并发,尽管服务器不会挂,但内存报警还是没法解决,只有重启进程才能恢复正常。

静态内存具体存在的原因不是很清楚,以前以为是hyper的问题,但现在所有的框架都有这个问题,那我就不得不怀疑是不是tokio的问题的了。

感觉就像基础数据结构在插入巨量元素后,容量被扩开,然后即使删除所有元素,只要你不drop掉这个变量,之前给变量分配的空间也不会立即归还系统,这个现象在其他语言也是存在的。

我之后又试了纯tokio百万协程并发,即使所有协程都销毁了,进程内存还是降不下来。除非不用tokio::main宏,用tokio::runtime::Runtime::new()?.block_on启动runtime,然后执行脚本,最后销毁runtime,此时就能大大降低进程内存。所以我怀疑是tokio的运行时内的开销无法立即归还系统的问题,不过还不能完全下结论。


  • 结论和建议

rust的web框架在中低配服务器能实现几十万的qps,即使加上业务,下降到万级的qps也够用了,其他语言上业务后性能经常会下降到几百的qps,但很多时候也够用了。单机部署对外服务也好,作为反向代理、网关也好,作为rpc网络的基础节点也好,全都问题不大,在大多数业务里基本全都算超配了。

虽然有可能被并发攻击,但hyper系的框架如axum/salvo可以实现在开协程读写tcp缓存前进行ip拒绝,通过这种ip拒绝功能,合理设计限流熔断策略,理论上还是可以一定程度上避免内存爆炸式增长的。actix在这方面则比较困难,一直以来都没有开放tcp和web分离解耦的底层api,我两年前提过相关的issue,也没啥回应。

如果能够确定是tokio运行时的问题,或许可以设计出一种策略,可以在需要的场合刷新runtime,但runtime的重启会造成毫秒级的停机,不中断tcp请求接收和保持服务的状态算是其中的挑战。

目前看来,不管哪个框架,上mi_malloc分配器都是个相对较好的实践。

上个帖子说了预热的问题,我现在对预热也持保留态度了,可以在测试环境压测具体的服务内存,但在生产环境来个百万并发的预热,服务器内存的数字恐怕不太好看。


本帖主要测试的还是web框架的io处理性能,百万并发的瓶颈涉及到很多方面,这里不做过多讨论。

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!