Laf 公众号已接入了 AI 绘画工具 Midjourney,可以让你轻松画出很多“大师”级的作品。同时还接入了 AI 聊天机器人,支持 GPT、Claude 以及 Laf 专有模型,可通过指令来随意切换模型。欢迎前来调戏👇
<<< 左右滑动见更多 >>>
❝🌐 原文链接:https://pkolaczk.github.io/memory-consumption-of-async/
本文深入研究了诸如 Rust、Go、Java、C#、Python、Node.js 和 Elixir 等流行编程语言在异步和多线程编程中的内存消耗对比。
前段时间我对几个设计处理海量网络连接的应用程序进行了性能评估。我发现它们在内存消耗上差异巨大,有时甚至超过了 20 倍。某些程序仅消耗略超过 100 MB 内存,而其他程序在处理 10k 连接时内存消耗了将近 3GB。这些程序都相当复杂,且特性各不相同,因此难以直接比较并得出有意义的结论。这明显不公平。因此,我决定创建一个合成基准测试来进行公平地对比。
基准测试
我将用各种编程语言来实现以下逻辑:
❝启动 N 个并发任务,其中每个任务等待 10 秒,所有任务完成后程序退出。任务的数量由命令行参数控制。
在 ChatGPT 的帮助下,我可以在几分钟内编写出这样的程序,即使对我来说并不常用的编程语言也可以轻松应对。为了方便大家,所有的基准测试代码都发布在我的 GitHub 上[1]。
Rust
我用 Rust 创建了 3 个程序。第一个使用传统的线程,其核心代码如下:
let mut handles = Vec::new();
for _ in 0..num_threads {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(10));
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
另外两个版本则使用了异步,一个使用 tokio
,另一个使用 async-std
。以下是 tokio
版本的核心代码:
let mut tasks = Vec::new();
for _ in 0..num_tasks {
tasks.push(task::spawn(async {
time::sleep(Duration::from_secs(10)).await;
}));
}
for task in tasks {
task.await.unwrap();
}
async-std
版本与上面的代码非常类似,我就不在这里引述了。
Go
在 Go 语言中,goroutines 是并发的基础模块。我们不会单独等待它们完成,而是使用 WaitGroup
:
var wg sync.WaitGroup
for i := 0; i < numRoutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Second)
}()
}
wg.Wait()
Java
Java 一般使用的都是线程,但是 JDK 21 提供了虚拟线程的预览版,这是一个与 goroutines 类似的概念。因此,我创建了两个基准测试的变体。
传统线程版本如下:
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
Thread thread = new Thread(() -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
}
});
thread.start();
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
虚拟线程版本与线程类似,只是创建线程的方法略有不同:
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
Thread thread = Thread.startVirtualThread(() -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
C#
C# 和 Rust 一样,对 async/await 的支持都比较完善:
List<Task> tasks = new List<Task>();
for (int i = 0; i < numTasks; i++)
{
Task task = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
});
tasks.Add(task);
}
await Task.WhenAll(tasks);
Node.JS
Node.JS 也一样:
const delay = util.promisify(setTimeout);
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000);
}
await Promise.all(tasks);
Python
Python 3.5 引入了 async/await,因此我们可以这样写:
async def perform_task():
await asyncio.sleep(10)
tasks = []
for task_id in range(num_tasks):
task = asyncio.create_task(perform_task())
tasks.append(task)
await asyncio.gather(*tasks)
Elixir
最后,我还编写了一个使用 Elixir 语言的版本,该语言以其异步能力而闻名:
tasks =
for _ <- 1..num_tasks do
Task.async(fn ->
:timer.sleep(10000)
end)
end
Task.await_many(tasks, :infinity)
测试环境
硬件:Intel(R) Xeon(R) CPU E3-1505M v6 @ 3.00GHz
操作系统:Ubuntu 22.04 LTS, Linux p5520 5.15.0-72-generic
Rust:1.69
Go:1.18.1
Java:OpenJDK “21-ea” build 21-ea+22-1890
.NET:6.0.116
Node.JS:v12.22.9
Python:3.10.6
Elixir:Erlang/OTP 24 erts-12.2.1, Elixir 1.12.2
所有程序均在 release 模式下运行(如有该模式)。其他选项保持默认。
结果
最小内存占用
让我们从小处着眼。考虑到每种运行环境都需要一定的内存,因此我们先只启动一个任务。
此图表明,程序可以明显分为两类。Go 与 Rust 程序,作为编译成静态本机二进制文件的形式,消耗的内存非常少。相反,运行在管理平台或通过解释器运行的程序需要更多内存,尽管在这种情况下 Python 的表现相当出色。两类程序之间的内存占用大约相差一个数量级。
令我惊讶的是,.NET 的内存占用最大,但我想或许可以通过调整一些设置来解决。如果您有任何解决方案,欢迎在评论区分享。我在调试模式与发布模式之间并未发现显著差异。
10k 并发任务
这张图有几个意料之外的结论!大家可能都预测线程会是这项基准测试的落败者。对于 Java 线程的确如此,因为它们耗费了近 250MB 的 RAM。然而,Rust 使用的本机 Linux 线程似乎非常轻量级,即使在 10k 线程的情况下,其内存消耗仍然低于许多其他运行环境的空闲内存消耗。异步任务或虚拟线程可能比本机线程更轻,但在仅有 10k 任务的情况下,我们并未看到这种优势。我们需要更多的任务来进行对比。
另一个出乎意料的是 Go。Goroutines 应该非常轻量,然而实际上它们消耗的内存超过了 Rust 线程所需内存的 50%。老实说,我原本以为 Go 会有更大的优势。因此,我得出的结论是,在 10k 并发任务的情况下,线程仍然是一种相当有竞争力的选择。Linux 内核在这方面表现得相当出色。
在之前的基准测试中,Go 与 Rust 异步相比具有微小的优势,但现在它已经失去了这个优势,并且消耗的内存比最优秀的 Rust多了 6 倍以上。同时,它也被 Python 超越了。
最出乎意料的是,在 10k 并发任务的情况下,.NET 的内存消耗与空闲内存使用相比并没有显著增加。可能它只是利用了预分配的内存,或者其空闲内存使用率非常高,10k 并发任务对它来说太少了,不足以产生重大影响。
100k 并发任务
我无法在我的系统上启动10万个线程,因此只能放弃线程基准测试。也许可以通过调整系统设置来解决,但是尝试了一个小时后我还是放弃了。所以在 100k 并发任务的情况下,线程可能并非理想选择。
现在,我们看到了一些显著变化。Go 和 Python 消耗的内存迅速增长,而 Java 虚拟线程,Rust async 和 Node.JS 保持相对较低的内存消耗。我们还可以看到 .NET 在这个基准测试中的优秀表现,它的内存使用量仍然没有增加,也没有阻塞主循环,太厉害了!
100w 并发任务
最后,我尝试增加任务的数量,试图启动一百万个任务。在这个数量级下,我们可以清晰地看到一些运行环境的真正优势。
在这个数量级下,只有 Rust async(无论是 tokio
还是 async-std
)、Java 虚拟线程和 .NET 才能运行。Go,Python 和 Node.JS 都耗尽了我的系统的 16GB 内存,而且并未完成基准测试。
Go 与其他语言之间的差距越来越大。现在,Go 的比分比最高分少了 12 倍。它比 Java 的分数也少了两倍多,这与 “JVM 占用内存较多、Go 轻量”的一般认识相矛盾。
这也表明 Java 虚拟线程和 Rust async 在内存使用效率上旗鼓相当。
结论
如果你需要处理的并发任务数量超过 100,000,那么 Java 虚拟线程和 Rust async 可能是最好的选择。如果你的任务数量在这个范围之下,那么线程(至少是 Rust 和 Linux 本地线程)可能仍然是一个具有竞争力的选择,尤其是在你想要避免引入异步编程复杂性的情况下。
另一方面,如果你正在开发一个需要处理大量并发任务的系统,那么选择支持异步编程的语言和运行时可能是必要的。在这种情况下,Rust 和 Java 可能是非常好的选择,因为它们在这些基准测试中表现优秀。
然而,请记住,这只是一个非常简单的基准测试,它不能考虑到所有可能影响真实世界应用程序的因素,如 CPU 使用,I/O 操作,垃圾收集等。因此,在选择编程语言和运行时时,需要综合考虑这些因素。
引用链接
[1]
发布在我的 GitHub 上: https://github.com/pkolaczk/async-runtimes-benchmarks
关于 Laf
Laf 是一款为所有开发者打造的集函数、数据库、存储为一体的云开发平台,助你像写博客一样写代码,随时随地发布上线应用!3 分钟上线 ChatGPT 应用!
🌟GitHub:https://github.com/labring/laf
🏠官网(国内):https://laf.run
🌎官网(海外):https://laf.dev
💻开发者论坛:https://forum.laf.run
关注 Laf 公众号与我们一同成长👇👇👇