< 返回版块

lithbitren 发表于 2024-07-30 00:12

Tags:axum,actix,salvo,web


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

前几年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处理性能,百万并发的瓶颈涉及到很多方面,这里不做过多讨论。

评论区

写评论
shenghui0779 2024-10-12 18:16

请问 max_concurrent_streams 参数 Axum 如何设置? 没找到😅

zhaoqidi001 2024-08-19 17:04

为什么不测试一下DashMap,有没有进行内存释放

作者 lithbitren 2024-08-07 09:52

个人来说,易用性salvo更胜一筹,但对于内存增长的问题,除了加mi_malloc似乎没法有更好的解决方案了。

不过如果是axum的话,可以在low-level的方式里用自定义static mut的runtime来spawn,然后用lockfree的方式来在适当的场合重建runtime,我试了下,百万并发后,通过三到五次刷新,可以让内存从4.5g降到0.5g左右,如果配合mi_malloc,可以从4.5g回缩成20m。

但这么做的前提是保证包括http2/sse/ws等长链接已经关闭,不然就要在服务端和客户端做更复杂的关闭响应和重试重连策略。

--
👇
g-mero: 这三个框架更推荐哪个呢,之前用axum,然后爆出那个问题之后就用的salvo,我也是用salvo的时候发现内存不会释放,我是写的一个图片处理的程序,请求测试的时候,明显能看到内存的增长与请求的图片大小相关联,我还以为是图片处理的crate的原因。

g-mero 2024-08-06 01:01

这三个框架更推荐哪个呢,之前用axum,然后爆出那个问题之后就用的salvo,我也是用salvo的时候发现内存不会释放,我是写的一个图片处理的程序,请求测试的时候,明显能看到内存的增长与请求的图片大小相关联,我还以为是图片处理的crate的原因。

作者 lithbitren 2024-08-01 17:21

缓存可以放在rust内部实现,tps几百万问题不大,但数据库的瓶颈太小,实际这也是主贴里没法全链路测量的原因之一,能够匹配上rust百万并发持久化系统起码要部署几十上百个实例或节点,但这在小破物理机上不太现实,放线上则需要准备符合系统的实例数量和千兆甚至更高的带宽,作为测试来说成本有点高。

作者 lithbitren 2024-08-01 17:07

可能也要看协程内部的内存复杂程度,比如回复区里的纯协程tcp百万并发,最高内存两三百兆,最终内存才几十兆,但主贴的web服务百万并发整体放大了tcp十几二十倍的内存。

而且数据库本身瓶颈就在那,一般来说写qps很难过万,内部又不可能设计专门等待,实际并发量跟百万并发比并不高。

在一个系统里,一万左右的qps,才多了几兆几十兆的内存,一般来说也没啥可担忧的,只要并发数不继续增长,内存也很难涨的太多,毕竟这应该是系统内存分配问题而不是真正意义上的内存泄露问题。

--
👇
zhuxiujia: tokio 在数据库 轮询查询上是没问题,内存使用量稳定,可以基本排除。

zhuxiujia 2024-08-01 16:08

tokio 在数据库 轮询查询上是没问题,内存使用量稳定,可以基本排除。

--
👇
lithbitren: 在tokio的仓库搜spawn、memory leaks可以搜到相关的内容,tokio就报告过的内存不释放问题。一堆rust的web框架报告的内存泄露查不清楚原因,感觉大多都是tokio的runtime问题。

作者 lithbitren 2024-07-31 07:25

在tokio的仓库搜spawn、memory leaks可以搜到相关的内容,tokio就报告过的内存不释放问题。一堆rust的web框架报告的内存泄露查不清楚原因,感觉大多都是tokio的runtime问题。

作者 lithbitren 2024-07-30 23:39

我刚刚又试了下。

hashmap的情况,启动3M,插入100万对不同usize,内存38M,删除所有元素还是38M,删除容器后,恢复4M,重复多轮误差很小。

如果hashmap的键值换成<usize, Vec<usize>>,启动内存5M,hashmap插入100万个数组后内存260M左右,然后销毁hashmap后内存会在10M-260M变动。

hashmap这种现象以前我用go的时候也遇到过,感觉区别不大。

然后换成tokio百万并发纯tcp连接,客户端和服务端放在同一个代码里通过不同tokio::spawn启动,即每个连接休眠10秒后关闭。

如果用tokio::main或者同一个runtime,启动内存4M,创建连接后340M-380M,服务器停机销毁,客户端也全部销毁,内存还是360M,重复多轮,这个三百多兆没有大幅度下降过,不同轮次的实时内存最多就下降10M-20M这样。

如果每个轮次创建一个runtime,首次启动内存是4M,后面的轮次启动前内存会在30M-350M里变动,随机性很强,但起码说明有时候是可以释放内存的。

所以我觉得,tokio的runtime就像一个容器,开启的协程只是其中的元素,容器存在的前提下,元素销毁不一定立刻释放内存,但销毁runtime这个容器,就能把所有关联的协程一同让系统回收空间。

如果是hashmap那种情况键值都是字面量,系统回收内存就比较稳定,但如果是存在堆上的指针,回收率就不稳定了,但只要销毁了容器,大体还是可以回收的,而且外部监控内存有一定延迟性,不完全准确。tokio的运行时对协程的内存回收跟hashmap里第二种情况有点像。

--
👇
liming01: 你这样测试可以回收,是因为需要回收的空间都集中在同一片区域中。 而上面的服务器并发很多,处理前面请求分配的空间和处理后面请求的空间可能交叉在一起分配,导致虽然回收了处理前面请求的空间,但因为处理后面请求的空间零星地占用着,所以操作系统还是无法成片回收空间。

liming01 2024-07-30 22:31

你这样测试可以回收,是因为需要回收的空间都集中在同一片区域中。 而上面的服务器并发很多,处理前面请求分配的空间和处理后面请求的空间可能交叉在一起分配,导致虽然回收了处理前面请求的空间,但因为处理后面请求的空间零星地占用着,所以操作系统还是无法成片回收空间。

--
👇
lithbitren: 可以的,我试过,如果是基础数据结构,比如hashmap之类的,每个过程都会用pause()暂停。插入巨量数据后,外部监控内存会增长,删除元素后,内存不会变,手动drop容器变量,外部监控可以很直接明显的看到手动drop前后的内存的变化(但可能不会和初始内存一致)。而且这个过程复现性很强,这个过程重复不过多少次,每个阶段的内存在不同轮次里,误差不超过5%。

比如某程序启动几百K,定义容器并插入元素后内存变100M,删除元素后还是100M,drop变量后变成5M,再新定义容器插入元素后还是100M,drop后还是5M,连续100次都差不多。 (具体数字不记得了,但大概是这个意思)

--
👇
liming01: 并发量上来之后又下去,内存上去了下不来,会不会是OS系统分配采用类似buddy算法,内存碎片化分配导致无法正片回收?你可以用普通程序测试一下:随机分配大小和回收,看膨胀之后能否很快缩回来。

作者 lithbitren 2024-07-30 21:48

可以的,我试过,如果是基础数据结构,比如hashmap之类的,每个过程都会用pause()暂停。插入巨量数据后,外部监控内存会增长,删除元素后,内存不会变,手动drop容器变量,外部监控可以很直接明显的看到手动drop前后的内存的变化(但可能不会和初始内存一致)。而且这个过程复现性很强,这个过程重复不过多少次,每个阶段的内存在不同轮次里,误差不超过5%。

比如某程序启动几百K,定义容器并插入元素后内存变100M,删除元素后还是100M,drop变量后变成5M,再新定义容器插入元素后还是100M,drop后还是5M,连续100次都差不多。 (具体数字不记得了,但大概是这个意思)

--
👇
liming01: 并发量上来之后又下去,内存上去了下不来,会不会是OS系统分配采用类似buddy算法,内存碎片化分配导致无法正片回收?你可以用普通程序测试一下:随机分配大小和回收,看膨胀之后能否很快缩回来。

liming01 2024-07-30 18:07

并发量上来之后又下去,内存上去了下不来,会不会是OS系统分配采用类似buddy算法,内存碎片化分配导致无法正片回收?你可以用普通程序测试一下:随机分配大小和回收,看膨胀之后能否很快缩回来。

ianfor 2024-07-30 10:13

牛的

1 共 13 条评论, 1 页