运行 100 万个异步并发任务需要多少内存

299 天前
 hez2010

去年有一个 “How Much Memory Do You Need to Run 1 Million Concurrent Tasks?” 的文章测试了各种语言在运行 1 个、1 万、10 万、100 万个异步并发任务下需要多少内存,不过当时测试的版本都很旧,代码里也多多少少有各种槽点,不能反映最新的情况。

这次把所有的语言版本都更新到最新,并且还加入了针对 GraalVM 、GraalVM native-image 和 .NET NativeAOT 的测试,然后修掉了之前被人指出代码中不对的地方,测了一个 2024 年版本的 “How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks?”。

可以在这里看详细测试: https://hez2010.github.io/async-runtimes-benchmarks-2024 。测试环境和代码也在里面有说明。

这里简单贴一下最终的测试结果:

1 个任务,测各语言 runtime 本身的 footprint:

1 万个并发任务:

10 万个并发任务:

100 万个并发任务:

Go 在最开始的时候内存占用很小,但是 Goroutine 的开销非常的大,随着并发任务的数量上升内存大量上涨,个人怀疑是 Go 的 GC 跟不上分配了,估计再接着增大并发数的话 Go 很可能会 OOM 。

Rust 则是发挥稳定,从始至终都表现着非常好的占用水平。

C# 的 NativeAOT 的表现则是直接把 Rust 比下去了,甚至随着并发数量的增大,到后期不做 NativeAOT 的 C# 内存占用都要比 Rust 更小了,可能是因为 tokio 和 async_std 在内存分配和调度这一块儿还有改进空间?

Java 的 GraalVM 表现要比 OpenJDK 差很多,而 GraalVM 的 native-image 表现就要好不少。另外就是忽略 GraalVM 的成绩的话,从结果来看 Java 的 Virtual Thread 要远比 Goroutine 更轻量。

10889 次点击
所在节点    程序员
193 条回复
lesismal
298 天前
@CRVV 刚又搜了下, 你说的"用户态线程"可能是指"纤程"吧? 对不起, 这玩意好像是 Windows 的, Windows 开发相关的知识我确实是不熟悉, 我说的主要是*nix 的
james122333
298 天前
@Kauruus

我不知道它怎么实现 但队列长度过大细微调度型同虚设 就测试来看 没有看出与无队列相差多大
james122333
298 天前
@Kauruus

所以我推测它应该是无队列 因为效果与一般线程是差不多的 都有消秏过多资源问题
lesismal
298 天前
@CRVV #140

自己在那不 at 别人偷塔断言别人不懂说别人错了还理直气壮, 然后老夫硬刚你, 技术和逻辑都说不过就飙脏话是吗?
既要又要, 又 x 又 x 的, 当别人眼瞎好欺负呢?

谢谢你 Block 我, 免的以后再来浪费我时间
Kauruus
298 天前
@lesismal

> 而一个 golang 进程首先是进程自己有一组(1-N 个)用户态线程, goroutine 是基于用户态线程之上的应用层语言 runtime 自己进行调度的, 最大的区别, goroutine 可不是由内核调度的。

“这组线程”就是内核线程,有对应内核调度实体,Goroutine 才是“用户态线程”。

这是非常普遍的 M:N 调度模型。M 个 Goroutine/Green thread/virtual thread/ 调度到 N 个内核调度实体上。

当然 Go 为了方便迁移 Goroutine ,还有个 P 的概念,不影响 M:N 的关系。

按照你的说法,就变成 M:N:O 三层了。
linshenqi
298 天前
😺
ykrank
298 天前
@kneo 啥呀,人家是阴阳怪气你技术不够才会和他吵呢,等你技术进步到和它“一个层次”了
kneo
298 天前
@ykrank 哈哈,无所谓了,我心里有数。
lesismal
298 天前
@Kauruus #145

> “这组线程”就是内核线程,有对应内核调度实体,Goroutine 才是“用户态线程”。

在对线程的细分定义上讲, 用户线程你说的对, @CRVV 说的也对.
我之前没有深究过用户线程这个细分概念, 把它理解成非内核创建的线程了, 是我的错.

不论严格定义如何, 把 goroutine 叫成协程已经是大家的惯用叫法, 日常讨论说线程用于代表严格意义的内核线程多些, 除非涉及细分定义, 否则也不会去区分用户线程还是内核线程.
但是如果把 goroutine 叫成线程, 是更让人混淆的, 把 goroutine 叫成用户线程是严格定义上正确但在日常交流上更多也是会带来麻烦
各种细分的严格定义, 例如说线程的时候不叫线程而是叫轻量进程, 也是给非学术交流带来沟通障碍, 约定俗成的叫法可能会更适合日常讨论.

> 按照你的说法,就变成 M:N:O 三层了。

我只是想表达: 线程 -> go runtime -> goroutine, 没有想表达是几层, 具体到 goroutine 的调度就是 go runtime 的 GMP,

而且几层也得看怎么划分:
如果按照调度实体分类, 那就是两层: 线程被内核调度, goroutine 被 runtime GMP 调度
如果是按这些抽象角色分类, M/P/G 也可以说是三层
lesismal
298 天前
@ykrank

> 等你技术进步到和它“一个层次”了

你这个"它"很会用啊, 你技术强的话可以输出技术观点, 技术的观点一点没有, 有话都不敢直接说, 阴阳最有一套是吧?
你咋不上天呢?
lesismal
298 天前
@Kauruus
@CRVV

补充一点, 工作久了也不是钻研学术的, 很多概念定义也早忘光了, 我更多的是专注于实践, 所以有说的不对的地方是我不懂, 说错了我就认, 不会赖账的

另外, 就像我前面说的没必要咬文嚼字, 聊具体的代码问题, 不需要纠结严格定义的, 咱就少点学术氛围, 免得搞半天也搞不出个成果来
charles0
298 天前
@lesismal
goroutine 按定义不是协程,Go 语言官方的文档也从未直接称其为协程
你可以直接叫它 goroutine ,不用翻译了,我相信 Go 语言设计的时候选择发明这个词是有原因的
lesismal
298 天前
@lesismal #151

甚至, golang 的 GMP, 我都忘记了, 要说 10 年 8 年 5 年甚至 3 年前, 应该都还记得, 但是现在都不记得了, 刚才回复 @Kauruus 也是临时搜了下 GMP 才又知道了个大概.
而且这几年自己可见的速度在记忆力下降, 每次看到 golang 面试题的帖子, 很多也是不会, 只剩下一些实践的套路经验了, 上年纪了也没体力重新读这些书了, 不是不想, 而是真的力不从心了
幸好工作上也不太需要这些概念了

华山派剑宗风清扬我做不到, 但是像他一样的风格还是适合工程实践的, 什么这个气那个气的内力之类的, 先能把工程搞定并且搞好才是好
lesismal
298 天前
@charles0 #152 叫 goroutine 没毛病, 但是打字中文的时候协程比 goroutine 快, 而且很多人约定俗称都这么叫了, 日常讨论, 何必搞得学术氛围甚至法庭审判那样? 你们好几位出来说这个, 我反向建议下你们在实际生活中要灵活, 否则方言俗语一切约定俗成的谬误的词汇就都不能说出口了, 这样的活法, 会很累而且并不会对交流效率带来提升, 反倒会因为这种死板给更多遵循约定俗成的人带来麻烦, 让大家交流效率更低
sagaxu
298 天前
Goroutine / Coroutine / Virtual Thread / Async-Await ... 名词一大堆,大家的共同点,都是提供比 OS thread 更轻量的并发执行上下文管理。提供这个设施的主要原因,无非是为了解决写复杂回调代码时的心智负担。

内存占用差异,本质上就是这些实现在 stack 实现上的选择,大致有 2 类,
stackful ,Goroutine 这种跑上来就分配 2KB/4KB 的栈,1M 并发时内存就要 2G/4G
stackless ,async-await 方案大都如此,因为无栈,内存占用往往有很大优势


把这两种方案放在一起 PK 内存占用,stackful 天然吃亏。Java virtual thread 本质上也是 stackful ,只不过很取巧的使用了可变尺寸栈,JVM 可以动态调整栈大小,不需要一开始就分配的很大。

stackful 和 stackless 都是上个世纪的理论了,这些语言的设计者们不可能对此不精通。那为何没有一边倒的选择某种方案?显然是两种方案各有利弊。我不谈底层实现差异(主要是不懂),也不谈哪个更强(于我都够用),仅从使用者的角度谈一下感受。

一,函数颜色问题。很多协程的实现,会把所有 IO 函数分成两类,一类会阻塞线程,另一类会挂起让出线程,两者之间往往不能互相调用,Kotlin 的 suspend 关键字就是给函数打标记用的,有些语言用 async 来标记也是一样的。使用这类语言的时候,我们需要清楚的了解调用的方法是不是能用于协程,使用第三方库甚至标准库的时候,也要小心翼翼,一旦弄错后果非常严重。

二,是否抢占式调度。非抢占式调度,需要使用者自己主动交出资源,很多实现提供了 yield 方法/关键字,避免大循环独占线程太久饿死其它协程。

Virtual thread(JVM)和 Goroutine 很好的解决了这两个问题,在集成第三方库的时候,基本上(当前版本 JVM 不能 100%做到)不用考虑会不会阻塞线程的问题,牺牲点内存提高开发效率,是好事还是坏事,没有定论,这要看具体的场景。

理想中的协程应该是这样的,
1. 足够轻量,fire and forget ,不需要 pool ,即便 pool 化了收益也很小
2. 函数不分类,不存在不能用于协程上下文的函数
3. 抢占式调度

但这 3 点根本无法同时实现,Go 和 JVM 都选择了 2 和 3 ,Go 在 2 上面做的最好,毕竟你想写出点阻塞 OS thread 的代码还要动点脑筋。JVM 在 1 上优化的比 Go 好,Java 官方文档敢写“Represent Every Concurrent Task as a Virtual Thread; Never Pool Virtual Threads”。

题外话,有了很轻的协程,就可以肆无忌惮的开了吗?并不是,DB 扛不住啊,所以像 Kotlin 这种 1G 内存能开 2M 个协程的语言,也特地提供 limitedParallelism 控制协程的并发度。这是调度层面做的,不一定要用协程池的方式来做。
dogfeet
297 天前
不是,这测的有啥问题吗?

要测 100 万异步并发任务内存占用,可不就是构造个 100 万异步任务同时运行的场景吗?
你可以说知道 100 万异步并发任务内存的占用了的指导意义有限,但你不能说用 `sleep` 去构造这个场景有问题吧。

看的头疼,感觉没那么难理解啊。

还有就是,虚拟线程也是 `stackful` 的,咋就不公平了呢?

怎么没人替 java 喊喊冤啊。
trzzzz
297 天前
@grzhan 我之前看到很多框架都这样做,当时没太明白这样做的目的。这样做有啥好处可以简单说下嘛
james122333
297 天前
@dogfeet

内文强调的是内存占用 以 go 范例这种用法内存不可能不爆的 所以称不公平合理
很多人讲 sleep 不好的意思应该在通常不会有执行 10 秒以上的任务
grzhan
297 天前
@trzzzz 是指定时器的 duration 会加上 10% 抖动的做法吗?
mightybruce
297 天前
我就几句话说, 先看看 rust, node.js 到底会不会每次在 async/await 中创建协程,这种 sleep 首先都没有什么资源消耗, 相关实现基于调度器或基于计时器的完全可以。
node.js 直接通过 event loop 和 timer 来调度就行。
其他讨论很多人已经提到了。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1093492

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX