[Перевод] Сколько памяти нужно для запуска 1 миллиона параллельных задач?

pxhjzmxldpkgrlxtclpuvgg1tt4.png


В этой статье я углублённо сравню потребление памяти между асинхронными и многопоточными программами популярных языков вроде Rust, Go, Java, C#, Python, Node.js и Elixir.

Недавно я проводил сравнение производительности нескольких программ, предназначенных для обработки большого количества сетевых подключений. В итоге я увидел огромную разницу в потреблении этими программами памяти, порой в 20 раз и больше. Некоторые потребляли при 10К подключений чуть более 100 МБ в то время, как другие занимали почти 3 ГБ. К сожалению, эти программы были довольно сложными и также отличались своим функционалом, поэтому было бы трудно сравнить их непосредственно и сделать какие-то осмысленные выводы. Тут то у меня и возникла идея создать специальный синтетический бенчмарк.

Бенчмарк


Я написал на различных языках простую программу, работающую следующим образом:

Мы запускаем N параллельных задач, каждая из которых ожидает 10 секунд. Когда все эти задачи завершаются, программа закрывается. Количество задач управляется аргументом командной строки.


С небольшой помощью ChatGPT я смог создать эту программу всего за несколько минут, даже на языках, которые использую редко. Для вашего удобства весь код бенчмарка доступен в моём репозитории GitHub.

▍ Rust


На 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();
}


В двух остальных версиях иcпользуется асинхронная обработка, одна в среде выполнения 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


В Go многопоточность строится на основе горутин. При этом мы не ожидаем их по-отдельности, а используем 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


В Java традиционно используются потоки, но JDK 21 предлагает предварительную версию виртуальных потоков, которые очень похожи на горутины. В связи с этим я создал два варианта бенчмарка. Мне было интересно сравнить потоки 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#


В 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


То же касается 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


В версии 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


Elixir тоже известен своими асинхронными возможностями:

tasks =
    for _ <- 1..num_tasks do
        Task.async(fn ->
            :timer.sleep(10000)
        end)
    end

Task.await_many(tasks, :infinity)


▍ Тестовая среда


  • Оборудование: Intel® Xeon® 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


Все программы по возможности запускались с использованием режима Release. Остальные опции я оставлял по умолчанию.

Результаты


▍ Минимальная нагрузка


Начнём с небольшой нагрузки. Поскольку некоторые среды выполнения требуют памяти сами по себе, сначала мы запустим всего одну задачу.

r4cpuyymkdglhjyep-_vr1ivtf8.png
Рис. 1: пиковое потребление памяти при запуске одной задачи

Здесь у нас явно выделяется две группы программ. Программы Go и Rust, скомпилированные статично в нативные бинарники, требуют очень мало памяти. Другие программы, выполняющиеся на управляемых платформах или через интерпретаторы, потребляют её уже больше, хотя Python тут выглядит очень достойно. Потребление памяти между этими группами отличается почти на порядок.

Меня удивило, что .NET умудрилась продемонстрировать худший показатель, но это наверняка можно скорректировать настройками. Поделитесь в комментариях, если знаете какие-либо уместные приёмы. Между режимами Debug и Release я особой разницы не заметил.

▍ 10К задач


wnv4nygqf9zjfzwu4iluwpjj1kq.png
Рис. 2: пиковое потребление памяти при запуске 10,000 задач

Здесь у нас обнаружилось несколько сюрпризов. Вы, пожалуй, ожидали, что потоки в этом бенчмарке сильно проиграют. И это оказалось верно для потоков Java, которые действительно потребили почти 250 МБ. Однако нативные потоки Linux, используемые из Rust, оказались достаточно легковесными, чтобы в количестве 10,000 нагружать память меньше, чем при простое во многих других процедурах. Асинхронные задачи, или виртуальные потоки, могут быть легче нативных потоков, но при всего 10К задач это преимущество мы не заметим.

Ещё один сюрприз связан с Go. Ожидалось, что горутины являются очень легковесными, но по факту они потребили более 50% от объёма памяти, задействованной потоками Rust. Честно говоря, я ожидал более значительное отличие в пользу Go. Так что можно сделать вывод, что при 10К параллельных задач потоки всё равно оказываются довольно конкурентной альтернативой. Ядро Linux определённо хорошо здесь справляется.

Go также утратил своё крохотное преимущество перед Rust, продемонстрированное в предыдущем бенчмарке, и теперь потребляет в 6 раз больше памяти, чем наиболее оптимальная программа Rust. При этом его также обошёл Python.

И последним сюрпризом стало то, что при 10К задач потребление памяти программой .NET по сравнению с состоянием простоя возросло незначительно. Возможно, она использует заранее выделенную память, либо потребление этого ресурса в режиме простоя настолько велико, что 10К задач большой разницы не вносят.

▍ 100К задач


Мне не удалось запустить 100,000 потоков в моей системе, поэтому соответствующие бенчмарки пришлось исключить. Возможно, это можно было как-то наладить через настройки, но после часа безуспешных попыток я сдался. Так что при 100К задач вряд ли стоит использовать потоки.

pxhjzmxldpkgrlxtclpuvgg1tt4.png
Рис. 3: пиковое потребление памяти при запуске 100,000 задач

В этом тесте программу Go обошёл не только Rust, но также Java, C# и Node.js.

А .NET, похоже, здесь хитрит, поскольку её потребление памяти так и не возросло. Мне пришлось перепроверить, действительно ли она запускает правильное количество задач, но оказалось, что так и есть. При этом она также выходит через 10 секунд, значит основной цикл не блокируется. Просто магия! Хорошая работа, .NET.

▍ 1 миллион задач


Перейдём к экстремальным испытаниям.

При 1 миллионе задач программа Elixir сдалась с ошибкой ** (SystemLimitError) a system limit has been reached.

Дополнено: в комментариях мне указали, что можно увеличить лимит процессов. После добавления в вызов elixir параметра --erl '+P 1000000' программа заработала.


drk8vgytrjuaxxas94dddb4gy24.png
Рис. 4: пиковое потребление памяти при запуске 1 миллиона задач

Наконец, мы видим увеличение потребления памяти программой C#. Но она всё ещё остаётся весьма конкурентной и даже немного обошла одну из сред выполнения Rust.

Отставание Go от соперников увеличилось. Теперь этот язык проигрывает победителю в 12 раз. Он также в 2 раза уступает Java, что противоречит распространённому мнению о том, что JVM является пожирателем памяти, а Go — легковесным языком.

Среда tokio осталась непревзойдённой. И это не удивительно после того, как она показала себя при 100К задач.

Выводы


Как мы пронаблюдали, большое число параллельных задач могут потреблять значительный объём памяти, даже без обработки сложных операций. Различные среды выполнения языков несут в себе различные компромиссы. Некоторые являются легковесными и эффективно справляются с небольшим числом задач, плохо масштабируясь на сотни тысяч. При этом другие накладывают большие изначальные издержки, но могут с лёгкостью обрабатывать тяжёлую нагрузку. Важно отметить, что не все среды выполнения смогли осилить очень большое число параллельных задач при базовых настройках.

Это сравнение было сосредоточено исключительно на потреблении памяти, хотя другие факторы вроде скорости запуска задачи и передачи данных являются не менее важными. Примечательно, что при 1 миллионе задач стали очевидны издержки на их запуск, и большинству программ для завершения потребовалось более 12 секунд.

hc88sbzi7apcmcvqt2icby4azas.jpeg

© Habrahabr.ru