你还记得 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);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
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
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(); } } }二.测试环境
所有程序均使用发布模式启动(如果可用),并且禁用了国际化和全球化支持,因为我们的测试环境中没有 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# 还要多!
如我们所观察到的,尽管许多并发任务并未执行复杂操作,但它们仍然会消耗大量内存。不同语言的运行时有不同的权衡,一些运行时在任务数量较少时表现轻量高效,但在处理数十万个任务时扩展性较差。