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

在这篇文章中,我对异步和多线程编程在Rust、Go、Java、C#、Python、Node.js和Elixir等流行语言中的内存消耗进行了比较。

不久前,我需要比较一些处理大量网络连接的计算机程序的性能。我发现这些程序的内存消耗差异巨大,甚至超过了20倍。有些程序仅消耗100 MB左右的内存,而其他一些在处理1万个连接时却达到了接近3 GB的内存消耗。不幸的是,这些程序非常复杂,而且在功能上也有所不同,因此很难直接进行比较并得出有意义的结论,因为这不是一个公平的比较。这促使我想到创建一个合成基准测试。

基准测试

我在各种编程语言中创建了以下程序:

让我们启动N个并发任务,每个任务等待10秒钟,然后在所有任务完成后程序退出。任务的数量由命令行参数控制。

借助ChatGPT的一点帮助,我可以在几分钟内编写出这样的程序,即使是在我日常不常用的编程语言中也可以。为了方便起见,所有的基准测试代码都可以在我的GitHub上找到。

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语言中,goroutine是并发的基本构建块。我们不会单独等待它们,而是使用WaitGroup:

var wg sync.WaitGroupfor 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 类似的概念。因此,我创建了基准测试的两个变体。我也很好奇 Java 线程与 Rust 的线程相比如何。

List 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 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 tasks = new List();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

所有程序均在可用的发布模式下运行。其他选项保持默认设置。

结果 

最小占用内存 

让我们从一些小的测试开始。由于一些运行时需要一些内存供自身使用,我们先只启动一个任务。

运行 100 万个并发任务需要多少内存?_第1张图片

图1:启动一个任务所需的峰值内存 

我们可以看到,程序明显分为两组。

使用静态编译为本机二进制的Go和Rust程序需要非常少的内存。而在托管平台或解释器上运行的其他程序消耗更多的内存,尽管在这种情况下Python表现得非常好。这两组程序之间的内存消耗差异大约是一个数量级。

令我惊讶的是,.NET在内存占用方面表现最差,但我猜这可能可以通过一些设置进行调优。如果有任何窍门,请在评论中告诉我。我没有看到调试模式和发布模式之间有太大的区别。

1000个任务

运行 100 万个并发任务需要多少内存?_第2张图片

图2:启动10,000个任务所需的峰值内存 

这里有一些令人惊讶的结果!大家可能都预计到了线程在这个基准测试中表现不佳。对于Java线程来说,情况确实如此,它们实际上消耗了将近250 MB的内存。但是在Rust中使用的本机Linux线程似乎足够轻量级,在10,000个线程的情况下,内存消耗仍然低于许多其他运行时的空闲内存消耗。异步任务或虚拟(绿色)线程可能比本机线程更轻量级,但是在只有10,000个任务的情况下,我们不会看到这种优势。我们需要更多的任务。

另一个令人惊讶的结果是Go。Goroutines应该非常轻量级,但实际上它们消耗的内存超过了Rust线程所需内存的50%。老实说,我预计Go会有更大的优势。因此,我得出结论,在10,000个并发任务下,线程仍然是相当具有竞争力的选择。Linux内核在这方面肯定做对了一些事情。

Go在之前的基准测试中所拥有的微小优势也已经消失,现在它的内存消耗比最好的Rust程序高出6倍以上。它还被Python超越。

最后一个令人惊讶的是,当任务数达到10,000个时,.NET的内存消耗并没有显著增加。可能它只是使用了预分配的内存,或者它的空闲内存使用量非常高,10,000个任务对它来说并不重要。

10万个任务

我无法在我的系统上启动100,000个线程,所以线程的基准测试必须被排除在外。可能可以通过更改系统设置来进行一些调整,但经过尝试了一个小时后,我放弃了。因此,在100,000个任务下,你可能不想使用线程。

运行 100 万个并发任务需要多少内存?_第3张图片

图3:启动100,000个任务所需的峰值内存 

在这一点上,Go程序不仅被Rust超越,还被Java、C#和Node.js超越。

而Linux下的.NET可能有点作弊,因为它的内存使用量仍然没有增加。;) 我不得不再次核实它是否确实启动了正确数量的任务,但事实上确实如此。它仍然在大约10秒后退出,因此不会阻塞主循环。神奇!干得好,.NET。

100万个任务 

现在让我们来进行极限测试。

在100万个任务下,Elixir因为** (SystemLimitError) 达到系统限制而放弃了。其他语言仍然坚持下来。

运行 100 万个并发任务需要多少内存?_第4张图片

图4:启动100万个任务所需的峰值内存 

最后,我们看到了C#程序内存消耗的增加。但它仍然非常有竞争力。它甚至成功略微超过了一个Rust运行时!

Go和其他语言之间的差距增大了。现在,Go相对于获胜者的差距超过12倍。它也比Java多花费了2倍的内存,这与JVM是一个内存贪婪的普遍看法和Go是轻量级的观点相矛盾。

Rust的tokio保持了无敌的地位。这并不令人意外,毕竟我们在10万个任务时已经见识过它的表现。


总结 

正如我们观察到的,大量并发任务可能会消耗大量内存,即使这些任务并不执行复杂的操作。不同的语言运行时在权衡上存在差异,有些在处理少量任务时轻量高效,但在处理数十万任务时扩展性不佳。相反,其他具有较高初始开销的运行时可以轻松处理高负载。需要注意的是,并非所有运行时都能在默认设置下处理非常大量的并发任务。

本次比较仅关注内存消耗,而其他因素如任务启动时间和通信速度同样重要。值得注意的是,在100万个任务下,我观察到任务启动的开销变得明显,大多数程序需要超过12秒才能完成。


更多技术干货请关注公号“云原生数据库

squids.cn,基于公有云基础资源,提供云上 RDS,云备份,云迁移,SQL 窗口门户企业功能,

帮助企业快速构建云上数据库融合生态。

你可能感兴趣的:(rust,go,java,python,开发语言)