去年有一个 “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?”。
可以在这里看详细测试: 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 更轻量。

有意思

1 秒 10 个块的 Rust ,25 q1 发布 via kaspa

  1. 实际场景里也不可能全是 cpu 消耗的 task, 所以 sleep 类型的 task 是合理的
  1. 并发度极大超过系统核心数量的情况下, go 全用标准库直接 go func()是不公平的, 因为协程数量太大了, 这种完全可以优化成 size 可控的协程池, sleep 可以 timer 回调
  2. 如果 sleep 是为了模拟实际场景里的 io 消耗例如网络 io, go 标准库主要是提供同步 io 接口, 那么多数需求确实需要每个 conn 一个协程, 但是, 你可以选择不用标准库, 例如我的 nbio, 百万连接 websocket echo 1k payload 压测, server 部署在 4c 8g ubuntu vm 上, 实际内存占用可以控制到 1g 以内:
    github.com/lesismal/go-websocket-benchmark?tab=readme-ov-file#1m-connections-1k-payload-benchmark-for-nbiogreatws

请注意, 3 里说的百万链接占用 1g 内存, 比 OP 的这种简单 task 占用多, 不代表相同 task 测试优化后的方案就比其他语言多这么多.
另外, 不同语言的这个测试, OP 用的都是未经优化的方式, 方案并不是好的解决方案, 所以这个测试本身就是不合理的.
比如, 如果只是用这种 sleep task, go 直接用 for { time.AfterFunc() } 占用很低的. 但我相信这并不对应任何实际应用场景.

毫无意义的测试, 却顺便拉踩, 捧 java 踩 go, 实在看不下去了我才必须出来澄清下.

go 可以试下潘少的 ants

毫无意义的测试, 却顺便拉踩, 捧 java 踩 go, 实在看不下去了我才必须出来澄清下.

跟 Go 的 goroutine 同样是 green thread 方案的 Java virtual thread ,在并没有做任何池化的情况下,只是简单的 new Thread ,在 1M tasks 占用确实比 goroutine 小了很多,这难道不能说明 goroutine 的资源占用确实不佳吗?

况且文章前面也肯定了 Go 在轻量负载时的占用小、否定了 Java 在轻量负载时的占用大,怎么就能被理解成踩一捧一?
如果你认为 Go 就是天下第一,一切 Go 表现不好的测试都是因为测试不好,而 Go 没有任何问题的话那我也没话说。况且这测试也不是我设计的。

试了下这个:
var wg sync.WaitGroup
for i := 0; i < 1000000; i++ {
wg.Add(1)
time.AfterFunc(10*time.Second, func() {
wg.Done()
})
}
wg.Wait()

macos m3 占用 150M, 应该是有不少定时器到时然后并发创建的 goroutine 较多
如果改用时间轮或者堆的定时器, 数量可控的协程池, 这个占用会降更多.

可以看下这个帖子: /t/1089474
实际对比下其他的看看, 我和一些朋友测试, ants 跟其他实现方式相比没有优势, 甚至是劣势, 以及实现复杂可能存在的不确定性

如 老板所说,这里 golang 的开销应该是有栈协程的开销,一个 goroutine 栈默认最小是 2K ,1000,000 * 2K / 1024 / 1024 = 1.9G ,算上 sudog 等其他的结构加起来可能差不多吧。

实际 golang 处理异步任务的时候确实不会开这么多协程,更多是把协程池化然后基于 channel 通信来处理。

在实际 Golang 开发过程中确实会有每个 conn 对应一个 goroutine 大量连接导致内存过高的 case ,然后社区里确实有对应魔改 netpoll 以及 nbio 这样的方案来解决类似的问题。

我觉得不一定是拉踩,针对 Golang 的这一情况可以添加更多的说明会好一些。

所以一般定时要求不严格的话很多 Golang 开源项目会给定时 duration 加个 10% 左右的随机抖动吧
例如 VictoriaMetrics 的 timeutil.AddJitterToDuration - github.com/VictoriaMetrics/VictoriaMetrics/blob/master/lib/timeutil/timeutil.go

#5

这个确实是毫无意义的测试, 你可以看下 #6, 当然, 其他语言也一样可以不用虚拟线程或者协程之类的, 都没必要用这么多内存. 但文章却非要选择使用虚拟线程和协程来对比, 然后海量的有栈 goroutine 就劣势了, 这不搞笑呢嘛

从结果来看 Java 的 Virtual Thread 要远比 Goroutine 更轻量。
这个是文章里的结论之一, 用错误的测试方法, 得出一个作者想要的正确结论, 有意思?
别说什么都各自有褒贬, 方法和结论都不正确的前提下, 褒贬也都是不准确的. 然后这个所谓的结论对于大多数读者而言, 就是 java 的虚拟线程更优秀, 这本身算不算误导就各自琢磨吧

如果你认为 Go 就是天下第一,一切 Go 表现不好的测试都是因为测试不好,而 Go 没有任何问题的话那我也没话说。

我可从来没说过这个, go 标准库海量并发占用高我自己就知道, 所以我才搞 nbio 之类的方案优化, 而且相对成熟了, 可以替换标准库方案, 但是你们非要"只用标准库方案"的方式来评价整个 golang, 就不合理了

况且这测试也不是我设计的。

那你可以看下我的观点, 顺便多思考下, 避免只看表象, 避免不知其中深意, 避免人云亦云

这个测试如果是哪个老外设计的, 那他是不专业的, 我本来也是说这个测试而不是针对你

#9

所以一般定时要求不严格的话很多 Golang 开源项目会给定时 duration 加个 10% 左右的随机抖动吧
例如 VictoriaMetrics 的 timeutil.AddJitterToDuration -

通常的定时功能没必要加抖动, 这个库的具体内容我没看. 定时加抖动有可能是为了避免同时创建大量具有相同超时时间的内容, 后续同时到期去创建大量协程异步删除内容导致 goroutine 过多造成不稳定, 或者其他什么特殊的需要

#10

我可从来没说过这个, go 标准库海量并发占用高我自己就知道, 所以我才搞 nbio 之类的方案优化, 而且相对成熟了, 可以替换标准库方案, 但是你们非要"只用标准库方案"的方式来评价整个 golang, 就不合理了

标准库目前有计划改善这个问题吗?毕竟标准库用起来最简单,如果标准库能解决这个问题的话那岂不是不需要 nbio 这类的方案优化了。
另外看了一眼 nbio ,似乎是针对 client-server 网络场景特化的,牺牲了通用性。例如通过 goroutine 来代替多线程进行并行计算也是一个有效的场景。

#11

通常的定时功能没必要加抖动, 这个库的具体内容我没看. 定时加抖动有可能是为了避免同时创建大量具有相同超时时间的内容, 后续同时到期去创建大量协程异步删除内容导致 goroutine 过多造成不稳定, 或者其他什么特殊的需要

应该是这样,像 Kubernetes 项目里也会用到很多 Jitter 去避免相同的超时时间( github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/wait/backoff.go#L203 ),像 kubelet , 以及几个组件的 LeaderElection 都会用到,确实还蛮常见的。

#8
可能作者本意不是拉踩, 或者不是故意拉踩. 但不正确的方法, 得出的结论就会误导很多人. 因为绝大部分开发者不了解系统知识, 不了解不同语言在这个不适合的测试场景与实际应用中的区别, 却给他们造成了错误的印象——例如 java 虚拟线程优秀, 然后可能误解 java 就是比 go 好.
刚看了下文章作者的 github, 主要是 rust java, 所以可能他自己也是默认偏好 rust 和 java 多些, 所以也没考虑过要对 go 的做多一些说明吧, 非故意的默认的放纵某些他以为的正确结论
所以我出来澄清一下

OP 或者其他人不要误解, 这并不是 go 邪教, 而只是实事求是
如果有人说 go 性能比 c/cpp/rust 强, 我要是有时间有兴趣也是会出来反驳的, 所以请 OP 或者其他人不要拿"我认为"golang 天下第一"说事

标准库目前有计划改善这个问题吗?毕竟标准库用起来最简单,如果标准库能解决这个问题的话那岂不是不需要 nbio 这类的方案优化了。

如我前面提到的, 标准库目前的 net.Conn 这些只提供了阻塞的 io 接口, 除非是特别简单的场景, 否则没法避免一个连接一个协程. 例如读, 不一定什么时候读到一个完整 request, 就只能阻塞在那等待, 这就需要占用一个协程.

另外看了一眼 nbio ,似乎是针对 client-server 网络场景特化的,牺牲了通用性。例如通过 goroutine 来代替多线程进行并行计算也是一个有效的场景。

nbio 的 http 与标准库基本兼容, 写法和标准库基本一样的:
github.com/lesismal/nbio-examples/blob/master/http/server/server.go

少量特殊情况不能像标准库那样, 例如 nbio 的 epoll 部分负责的 conn 是非阻塞的, 不适合拿去像标准库那样 io.Copy.
因为 nbio 本来就是为了避免持续占用协程, 如果还像标准库那样提供阻塞的 io 接口可以去 io.Copy, nbio 就没有意义了

#13

嗯嗯

以及几个组件的 LeaderElection 都会用到,确实还蛮常见的。

election 这种, 我记得不太清楚了, 但好像记得个大概.
分布式一致性算法里的选举, 通常要避免各个节点都在相同的时间参选(发出自己选举的协议给其他节点), 因为如果大家都同时参选, 同时以相同的概率获得其他人的选票, 就会更难选出 leader.
所以在参选过程中, 例如启动, leader 掉线时的重新选举, 或者选举失败时的重选, 是可以每个节点自己随机一个时间抖动来尽量避免选举冲突的

可以考虑使用折线图📈,放在一张图里看的更清楚

这难道不是在捧 C#, 不知道你们怎么理解成捧 java 的

C#用 Valuetask 会不会更低一点

看来还得是用 C#

java 就是一坨屎 明眼人这么大的方案 肯定会有第三方池化方案 谁无脑原生的 go func(){}

经典半吊子 benchmark ,一个 sample 的代码,没有不同的场景,得出一个简单的结论。