闽公网安备 35020302035485号
你还记得 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 中常用的流行异步运行时。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 的基准测试。
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);
Pythonimport 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))
Gopackage 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()
}
Javaimport 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();
}
}
}
二.测试环境所有程序均使用发布模式启动(如果可用),并且禁用了国际化和全球化支持,因为我们的测试环境中没有 libicu。

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

这里有一些小惊喜!两个 Rust 基准测试结果非常让人满意:它们都使用了非常少的内存,与最小内存占用的结果相比,内存消耗几乎没有增长,尽管后台运行着 10K 个任务!C#(NativeAOT)紧随其后,仅使用了大约 10MB 的内存。我们需要更多任务来对它们施加更大压力!
Go 的内存消耗急剧增长。尽管 goroutine 应该是非常轻量的,但它们实际消耗的内存远高于 Rust。在这种情况下,Java 中的虚拟线程(GraalVM native image)似乎比 Go 中的 goroutine 更加轻量。令人惊讶的是,尽管 Go 和 Java(GraalVM native image)都是静态编译为原生二进制文件,它们消耗的内存比运行在虚拟机上的 C# 还要多!


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