Помните сравнение потребления памяти для асинхронного программирования на популярных языках 2023 года?
Мне стало любопытно, как поменялась ситуация за один год на примере самых новых версий языков.
Давайте снова проведём бенчмарки и изучим результаты!
Бенчмарк
Программа для бенчмаркинга будет той же, что и в прошлом году:
Запустим N конкурентных задач, каждая будет ждать в течение 10 секунд. После завершения всех задач программа завершается. Количество задач указывается как аргумент командной строки.
На этот раз используем корутину вместо множественных потоков.
Весь код бенчмарков выложен в async-runtimes-benchmarks-2024.
Что такое корутина?
Корутины — это компоненты компьютерных программ, позволяющих приостанавливать и возобновлять исполнение, обеспечивая кооперативную многозадачность подпрограмм. Корутины хорошо подходят для реализации таких знакомых нам компонентов программ, как кооперативные задачи, исключения, циклы событий, итераторы, бесконечные списки и конвейеры.
Rust
Я создал на Rust две программы. В одной используется 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>();
for (int i = 0; i < numTasks; i++)
{
tasks.Add(Task.Delay(TimeSpan.FromSeconds(10)));
}
await Task.WhenAll(tasks);
Кроме того, .NET с версии 7 обеспечивает компиляцию NativeAOT, которая компилирует код непосредственно в конечный двоичный файл, не требующий виртуальной машины для выполнения. Поэтому мы добавили бенчмарк и для NativeAOT.
NodeJS
Поддержка асинхронности есть и в NodeJS:
const util = require('util');
const delay = util.promisify(setTimeout);
async function runTasks(numTasks) {
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000));
}
await Promise.all(tasks);
}
const numTasks = parseInt(process.argv[2]);
runTasks(numTasks);
Python
И в 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 строительными блоками конкурентности стали горутины. Мы не ждём их по отдельности, а используем 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
В Java начиная с JDK 21 есть виртуальные потоки — концепция, схожая с горутинами:
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) {
// Обрабатываем исключение
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
}
}
В то же время существует новый вариант JVM под названием GraalVM. Кроме того, GraalVM тоже обеспечивает создание нативного образа, то есть схожей с NativeAOT концепции из .NET. Поэтому мы добавили бенчмарк и для GraalVM.
Тестовое окружение
-
Оборудование: 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
Все программы по возможности запускались в release mode, интернационализация и глобализация были отключены, поскольку в тестовом окружении отсутствовал libicu.
Результаты
Минимальный объём
Давайте начнём с чего-нибудь маленького, поскольку некоторые среды выполнения требуют память для себя; начнём со всего одной задачи.
Мы видим, что Rust, C# (NativeAOT) и Go показали схожие результаты, потому что они статически скомпилированы в нативные двоичные файлы и требуют очень мало памяти. Java (нативный образ GraalVM) тоже проделала отличную работу, но потребовала чуть больше памяти, чем другие статически компилируемые программы. Прочие программы, работающие на управляемых платформах или через интерпретаторы, потребляют больше памяти.
Похоже, в данном случае меньше всего ресурсов тратит Go.
Java с GraalVM проявила себя немного неожиданно, потому что потребляет гораздо больше памяти, чем Java с OpenJDK, но думаю, это можно сконфигурировать какими-то настройками.
10 тысяч задач
Здесь особо ничего удивительного. Два бенчмарка Rust показали очень многообещающие результаты: оба они использовали очень мало памяти, которая особо не выросла по сравнению с результатами малого количества задач, хотя их запущено было 10 тысяч! C# (NativeAOT) дышал им в спину, использовав всего около 10 МБ памяти. Для серьёзной нагрузки им нужно больше задач!
Существенно выросло потребление памяти у Go. Горутины должны быть очень легковесными, но на самом деле они потребили гораздо больше ОЗУ, чем потребовалось Rust. В данном случае более легковесными кажутся виртуальные потоки Java (нативный образ GraalVM). К моему удивлению, и Go, и Java (нативный образ GraalVM), компилирующие нативные двоичные файлы статически, занимали больше памяти, чем C#, работающий в VM!
100 тысяч задач
После увеличения количества задач до 100 тысяч потребление памяти всеми языками начало существенно расти.
И Rust, и C# проявили себя очень хорошо. Большим сюрпризом стало то, что C# (NativeAOT) даже потребовал меньше ОЗУ, чем Rust, и победил все остальные языки. Впечатляет!
На этом этапе программу на Go побил не только Rust, но и Java (за исключением кода, работающего в GraalVM), C# и NodeJS.
1 миллион задач
Давайте доведём всё до максимума.
C# наконец-то без колебаний победил все остальные языки; он очень конкурентоспособный и стал настоящим монстром. Как и ожидалось, Rust продолжает эффективно использовать память.
Расстояние между Go и другими языками увеличилась. Теперь Go отстаёт от победителя в тринадцать раз. Также он вдвое проигрывает Java, что противоречит стереотипу о том, что JVM ест память, а Go легковесный.
Заключение
Как мы выяснили, большое количество конкурентных задач может потреблять существенные объёмы памяти, даже если они не выполняют сложных операций. Среды выполнения языков обеспечивают различающиеся компромиссы: некоторые оказываются легковесными и эффективными для малого количества задач, но плохо масштабируются до сотен тысяч задач.
За последний год многое изменилось. Проведя бенчмарки только с самыми новыми компиляторами и средами выполнения, мы увидели существенные улучшения в .NET, а .NET с NativeAOT составляет реальную конкуренцию Rust. Нативный образ Java, собранный при помощи GraalVM, тоже хорошо справляется с обеспечением эффективности использования памяти. Горутины же продолжают оставаться неэффективными в потреблении ресурсов.
Автор: PatientZero