• 2024年,各大编程语言在内存优化方面都有哪些进步?
  • 发布于 1个月前
  • 90 热度
    0 评论
  • 雾月
  • 0 粉丝 26 篇博客
  •   

你还记得 2023 年时不同编程语言间异步编程的内存消耗分别是多少吗?再有一个月 2024 年就过去了,我们看看在过去一年中,随着新版本的不断发布,会有哪些变化。我们重新进行了基准测试,一起来看看结果!


一.基准测试

用于基准测试的程序与去年的一样:我们将启动 N 个并发任务,每个任务等待 10 秒钟,之后程序会在所有任务完成后退出。任务的数量通过命令行参数进行控制。这次,我们将重点关注协程,而不是多个线程。所有基准测试代码都可以在下面链接里看到:https://github.com/hez2010/async-runtimes-benchmarks-2024


什么是协程?

协程是计算机程序组件,允许执行暂停和恢复,它通过协作式多任务处理来推广子程序。协程非常适合实现常见的程序组件,如协作任务、异常处理、事件循环、迭代器、无限列表和管道。


Rust

我在 Rust 中创建了 2 个程序。一个使用 tokio:
use std::env;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let args: Vec<String> = env::args().collect();
    let num_tasks = args[1].parse::<i32>().unwrap();
    let mut tasks = Vec::new();
    for _ in 0..num_tasks {
        tasks.push(sleep(Duration::from_secs(10)));
    }
    futures::future::join_all(tasks).await;
}
另一个使用 async_std:
use std::env;
use async_std::task;
use futures::future::join_all;
use std::time::Duration;

#[async_std::main]
async fn main() {
    let args: Vec<String> = env::args().collect();
    let num_tasks = args[1].parse::<usize>().unwrap();
    
    let mut tasks = Vec::new();
    for _ in 0..num_tasks {
        tasks.push(task::sleep(Duration::from_secs(10)));
    }

    join_all(tasks).await;
}
两者都是 Rust 中常用的流行异步运行时。

C#
C# 与 Rust 类似,对 async/await 支持非常友好:
int numTasks = int.Parse(args[0]);
List<Task> tasks = new List<Task>();
 // 堆代码 duidaima.com
for (int i = 0; i < numTasks; i++)
{
    tasks.Add(Task.Delay(TimeSpan.FromSeconds(10)));
}
await Task.WhenAll(tasks);

.NET 自 .NET 7 起也提供了 NativeAOT 编译,它将代码直接编译为最终的二进制文件,因此不再需要虚拟机来运行托管代码。因此,我们也添加了 NativeAOT 的基准测试。


NodeJS
NodeJS 也是这样:
const util = require('util');
const delay = util.promisify(setTimeout);
async function runTasks(numTasks) {
  const tasks = [];
 // 堆代码 duidaima.com
  for (let i = 0; i < numTasks; i++) {
    tasks.push(delay(10000));
  }

  await Promise.all(tasks);
}

const numTasks = parseInt(process.argv[2]);
runTasks(numTasks);
Python
import asyncio
import sys

async def main(num_tasks):
    tasks = []

    for task_id in range(num_tasks):
        tasks.append(asyncio.sleep(10))

    await asyncio.gather(*tasks)

if __name__ == "__main__":
    num_tasks = int(sys.argv[1])
    asyncio.run(main(num_tasks))  
Go
在 Go 中,goroutine 是并发的基本构件。我们不会单独等待它们,而是使用 WaitGroup 来实现等待:
package main

import (
    "fmt"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    numRoutines, _ := strconv.Atoi(os.Args[1])
    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
自 JDK 21 起,Java 提供了虚拟线程,它们与 goroutine 概念类似:
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

public class VirtualThreads {

    public static void main(String[] args) throws InterruptedException {
        int numTasks = Integer.parseInt(args[0]);
        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) {
                    // Handle exception
                }
            });
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }
    }
}
二.测试环境
硬件:13th Gen Intel(R) Core(TM) i7-13700K
操作系统:Debian GNU/Linux 12 (bookworm)
Rust:1.82.0
.NET:9.0.100
Go:1.23.3
Java:openjdk 23.0.1 build 23.0.1+11-39
Java (GraalVM):java 23.0.1 build 23.0.1+11-jvmci-b01
NodeJS:v23.2.0
Python:3.13.0

所有程序均使用发布模式启动(如果可用),并且禁用了国际化和全球化支持,因为我们的测试环境中没有 libicu。


三.结果
最小内存占用  
我们从小的情况开始,因为某些运行时需要一定的内存来支持自身,所以我们首先只启动一个任务。

我们可以看到,Rust、C#(NativeAOT)和 Go 结果相似,因为它们被静态编译成原生二进制文件,所需内存非常少。Java(GraalVM native-image)也表现不错,但消耗的内存稍微比其他静态编译的语言多。其他在托管平台或通过解释器运行的程序内存消耗更多。
在这种情况下,Go 似乎具有最小的内存占用。

使用 GraalVM 的 Java 有点令人惊讶,因为它消耗的内存远高于使用 OpenJDK 的 Java,但我猜这个问题可以通过一些设置进行调整。


10K Tasks

这里有一些小惊喜!两个 Rust 基准测试结果非常让人满意:它们都使用了非常少的内存,与最小内存占用的结果相比,内存消耗几乎没有增长,尽管后台运行着 10K 个任务!C#(NativeAOT)紧随其后,仅使用了大约 10MB 的内存。我们需要更多任务来对它们施加更大压力!


Go 的内存消耗急剧增长。尽管 goroutine 应该是非常轻量的,但它们实际消耗的内存远高于 Rust。在这种情况下,Java 中的虚拟线程(GraalVM native image)似乎比 Go 中的 goroutine 更加轻量。令人惊讶的是,尽管 Go 和 Java(GraalVM native image)都是静态编译为原生二进制文件,它们消耗的内存比运行在虚拟机上的 C# 还要多!


100K Tasks

当我们将任务数增加到 100K 时,所有语言的内存消耗开始明显增加。Rust 和 C# 在这方面做得非常好。一个大惊喜是,C#(NativeAOT)甚至比 Rust 消耗内存更少,击败了所有其他语言,真是个大惊喜!至此,Go 程序不仅被 Rust 超越,还被 Java(除 GraalVM 外)、C# 和 NodeJS 超越。


1 Million Tasks
现在让我们来看看极限情况。

最后,C# 毫无疑问地击败了所有其他语言,它非常有竞争力,真的成了 “怪物”。正如预期,Rust 在内存效率方面继续表现出色。Go 与其他语言的差距拉大了。Go 的内存消耗是第一名的 13 倍,是 Java(除 GraalVM 外)的 2 倍,这与通常认为 JVM 是内存消耗大户而 Go 是轻量级的观念相矛盾。

四.总结

如我们所观察到的,尽管许多并发任务并未执行复杂操作,但它们仍然会消耗大量内存。不同语言的运行时有不同的权衡,一些运行时在任务数量较少时表现轻量高效,但在处理数十万个任务时扩展性较差。


自去年以来,很多事情发生了变化。通过最新编译器和运行时的基准测试结果,我们看到 .NET 有巨大改进,NativeAOT 的 .NET 在与 Rust 的竞争中也非常有优势。使用 GraalVM 构建的 Java 原生镜像在内存效率方面也很不错。但是,goroutine 在资源消耗方面仍然低效。
用户评论