前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >运行 100 万个并发任务究竟需要多少内存?

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

作者头像
米开朗基杨
发布2023-09-09 18:50:26
4310
发布2023-09-09 18:50:26
举报
文章被收录于专栏:云原生实验室云原生实验室

❝🌐 原文链接: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 个程序。第一个使用传统的线程,其核心代码如下:

代码语言:javascript
复制
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 版本的核心代码:

代码语言:javascript
复制
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

代码语言:javascript
复制
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 类似的概念。因此,我创建了两个基准测试的变体。

传统线程版本如下:

代码语言:javascript
复制
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();
}

虚拟线程版本与线程类似,只是创建线程的方法略有不同:

代码语言:javascript
复制
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 的支持都比较完善:

代码语言:javascript
复制
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 也一样:

代码语言:javascript
复制
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,因此我们可以这样写:

代码语言:javascript
复制

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 语言的版本,该语言以其异步能力而闻名:

代码语言:javascript
复制
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 模式下运行(如有该模式)。其他选项保持默认。

结果

最小内存占用

让我们从小处着眼。考虑到每种运行环境都需要一定的内存,因此我们先只启动一个任务。

图1:启动一个任务所需的最高内存

此图表明,程序可以明显分为两类。Go 与 Rust 程序,作为编译成静态本机二进制文件的形式,消耗的内存非常少。相反,运行在管理平台或通过解释器运行的程序需要更多内存,尽管在这种情况下 Python 的表现相当出色。两类程序之间的内存占用大约相差一个数量级

令我惊讶的是,.NET 的内存占用最大,但我想或许可以通过调整一些设置来解决。如果您有任何解决方案,欢迎在评论区分享。我在调试模式与发布模式之间并未发现显著差异。

10k 并发任务

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

这张图有几个意料之外的结论!大家可能都预测线程会是这项基准测试的落败者。对于 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 并发任务的情况下,线程可能并非理想选择。

图3:启动 10 万个任务所需的最高内存

现在,我们看到了一些显著变化。Go 和 Python 消耗的内存迅速增长,而 Java 虚拟线程,Rust async 和 Node.JS 保持相对较低的内存消耗。我们还可以看到 .NET 在这个基准测试中的优秀表现,它的内存使用量仍然没有增加,也没有阻塞主循环,太厉害了!

100w 并发任务

最后,我尝试增加任务的数量,试图启动一百万个任务。在这个数量级下,我们可以清晰地看到一些运行环境的真正优势。

图4:启动100万个任务所需的最高内存

在这个数量级下,只有 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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-05-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云原生实验室 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 基准测试
    • Rust
      • Go
        • Java
          • C#
            • Node.JS
              • Python
                • Elixir
                • 测试环境
                • 结果
                  • 最小内存占用
                    • 10k 并发任务
                      • 100k 并发任务
                        • 100w 并发任务
                        • 结论
                          • 引用链接
                          相关产品与服务
                          腾讯云服务器利旧
                          云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
                          领券
                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档