在这篇文章中,我对异步和多线程编程在Rust、Go、Java、C#、Python、Node.js和Elixir等流行语言中的内存消耗进行了比较。
不久前,我需要比较一些处理大量网络连接的计算机程序的性能。我发现这些程序的内存消耗差异巨大,甚至超过了20倍。有些程序仅消耗100 MB左右的内存,而其他一些在处理1万个连接时却达到了接近3 GB的内存消耗。不幸的是,这些程序非常复杂,而且在功能上也有所不同,因此很难直接进行比较并得出有意义的结论,因为这不是一个公平的比较。这促使我想到创建一个合成基准测试。
我在各种编程语言中创建了以下程序:
让我们启动N个并发任务,每个任务等待10秒钟,然后在所有任务完成后程序退出。任务的数量由命令行参数控制。
借助ChatGPT的一点帮助,我可以在几分钟内编写出这样的程序,即使是在我日常不常用的编程语言中也可以。为了方便起见,所有的基准测试代码都可以在我的GitHub上找到。
我在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语言中,goroutine是并发的基本构建块。我们不会单独等待它们,而是使用WaitGroup:
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 提供了虚拟线程的预览,这是与 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# 与 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 也是如此:
const delay = util.promisify(setTimeout);
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000);
}
await Promise.all(tasks);
而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 也以其异步功能而闻名:
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
所有程序均在可用的发布模式下运行。其他选项保持默认设置。
让我们从一些小的测试开始。由于一些运行时需要一些内存供自身使用,我们先只启动一个任务。
图1:启动一个任务所需的峰值内存
我们可以看到,程序明显分为两组。
使用静态编译为本机二进制的Go和Rust程序需要非常少的内存。而在托管平台或解释器上运行的其他程序消耗更多的内存,尽管在这种情况下Python表现得非常好。这两组程序之间的内存消耗差异大约是一个数量级。
令我惊讶的是,.NET在内存占用方面表现最差,但我猜这可能可以通过一些设置进行调优。如果有任何窍门,请在评论中告诉我。我没有看到调试模式和发布模式之间有太大的区别。
图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个任务对它来说并不重要。
我无法在我的系统上启动100,000个线程,所以线程的基准测试必须被排除在外。可能可以通过更改系统设置来进行一些调整,但经过尝试了一个小时后,我放弃了。因此,在100,000个任务下,你可能不想使用线程。
图3:启动100,000个任务所需的峰值内存
在这一点上,Go程序不仅被Rust超越,还被Java、C#和Node.js超越。
而Linux下的.NET可能有点作弊,因为它的内存使用量仍然没有增加。;) 我不得不再次核实它是否确实启动了正确数量的任务,但事实上确实如此。它仍然在大约10秒后退出,因此不会阻塞主循环。神奇!干得好,.NET。
现在让我们来进行极限测试。
在100万个任务下,Elixir因为** (SystemLimitError) 达到系统限制而放弃了。其他语言仍然坚持下来。
图4:启动100万个任务所需的峰值内存
最后,我们看到了C#程序内存消耗的增加。但它仍然非常有竞争力。它甚至成功略微超过了一个Rust运行时!
Go和其他语言之间的差距增大了。现在,Go相对于获胜者的差距超过12倍。它也比Java多花费了2倍的内存,这与JVM是一个内存贪婪的普遍看法和Go是轻量级的观点相矛盾。
Rust的tokio保持了无敌的地位。这并不令人意外,毕竟我们在10万个任务时已经见识过它的表现。
正如我们观察到的,大量并发任务可能会消耗大量内存,即使这些任务并不执行复杂的操作。不同的语言运行时在权衡上存在差异,有些在处理少量任务时轻量高效,但在处理数十万任务时扩展性不佳。相反,其他具有较高初始开销的运行时可以轻松处理高负载。需要注意的是,并非所有运行时都能在默认设置下处理非常大量的并发任务。
本次比较仅关注内存消耗,而其他因素如任务启动时间和通信速度同样重要。值得注意的是,在100万个任务下,我观察到任务启动的开销变得明显,大多数程序需要超过12秒才能完成。
更多技术干货请关注公号“云原生数据库”
squids.cn,基于公有云基础资源,提供云上 RDS,云备份,云迁移,SQL 窗口门户企业功能,
帮助企业快速构建云上数据库融合生态。