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

Комментарии (76)


  1. panzerfaust
    07.12.2024 06:12

    Интересный кейс с C#. Кто-то может на пальцах объяснить, что они такого в языке сделали, что теперь джаву кратно превосходят?


    1. Gromilo
      07.12.2024 06:12

      Ну таски - это не треды, у них нет стека, только контекст. Такая абстракция кооперативной многозадачности.

      Есть пул тредов, который выполняет таски по мере возможности. Если таски занять длительной CPU работой, то обработка всех остальных тасков встанет.

      Думаю, если вместо тасков использовать треды (но зачем?), то разница по памяти будет незначительной.


      1. panzerfaust
        07.12.2024 06:12

        Здесь джаву тоже тестируют на виртуальных тредах, а не на обычных. Для них так же справедливы все преимущества, о которых вы говорите. Разве что async-await на завезли. Наверное, дело в каких-то оптимизациях хранения объектов в куче. В джаву все никак не доедет Project Valhalla с value-объектами, и только весной следующего года будет релиз с уменьшением хедеров объектов.


        1. mentin
          07.12.2024 06:12

          В Java все таки потоки, им стандартный стек нужен, пусть и небольшой. В С# пока код спит, до следующего async исполнения, стековые переменные сохраняется в объект (если точнее, то наоборот, в объекте состояние, вытаскиваемое в стек по необходимости) а стек выделяется только при исполнении.


          1. Lewigh
            07.12.2024 06:12

            Нет никаких там дополнительных потоков, те же таски но на уровне JVM исполняемые платформенными потоками-носителями. Вся киллер фича Java подхода - это отсутствие необходимости явного этим управления. Для этого в коде буквально напиханы If-чики

                public static void sleep(long millis) throws InterruptedException {
                    if (millis < 0) {
                        throw new IllegalArgumentException("timeout value is negative");
                    }
            
                    long nanos = MILLISECONDS.toNanos(millis);
                    ThreadSleepEvent event = beforeSleep(nanos);
                    try {
                        if (currentThread() instanceof VirtualThread vthread) {
                            vthread.sleepNanos(nanos);
                        } else {
                            sleep0(nanos);
                        }
                    } finally {
                        afterSleep(event);
                    }
                }


        1. Lewigh
          07.12.2024 06:12

          Наверное, дело в каких-то оптимизациях хранения объектов в куче.

          Нет все проще. Если не ошибаюсь, в Java только к 2017 году проснулись и осознали что оказывается в современном яп нужна нормальная поддержка неблокирующей асинхронности. Можно было скопировать async/await который уже был отработан много лет но в Oracle решили стать нитакусиками и начали пилить свои идеи.
          Как результат, в Kotlin корутины зарелизили в 2018 году а в Java виртуальные потоки увидели мир только через 6 лет после этого.
          Итого мы сейчас в Java имеем виртуальные потоки, которые по факту те же корутины без особых преимуществ, только без необходимости писать async/await и пока что непонятно насколько эффективные в будущем.
          Просто для сравнения: в C# async/await появился в 5.0 версии в 2012 году.


          1. thesunlight17
            07.12.2024 06:12

            Отсутствие async/await синтаксиса это огромное преимущество. Async распространяется и в отдельном взятом проекте, и на уровне всей экосистемы языка как плющ. Из-за этого вся экосистема языка становится раздробленной, возникают проблемы совместимости между библиотеками, необходимость поддержки нескольких версий библиотек, трудность перевода на асинк старых больших "синхронных" проектов и т.п. Раст и питон тому примеры.


            1. withkittens
              07.12.2024 06:12

              Конкретно в .NET никакой проблемы с async/await нет. Есть давно устоявшиеся паттерны вызова async-over-sync и наоборот - это помогает переводить легаси по мере необходимости. В новых проектах вы просто сразу пилите async.


              1. Morthan
                07.12.2024 06:12

                Есть давно устоявшиеся паттерны вызова async-over-sync и наоборот

                Можете привести примеры или ссылку, где почитать? Меня интересует вызов async из синхронного кода. Иногда попадаются библиотеки, предоставляющие только асинхронный API, для них приходится писать синхронную обёртку и хотелось бы это делать правильно.

                Находил когда-то официальное микрософтовское руководство по асинхронному программированию и там была фраза, что существует много способов вызова async из синхронного кода, но все они плохие, поэтому либо делайте асинхронным весь проект, либо не используйте асинхронность вообще. Получается, ситуация изменилась?


                1. fedorro
                  07.12.2024 06:12

                  Меня интересует вызов async из синхронного кода.

                  Вроде всё просто:

                  вместо

                  var result = await asyncMethod();

                  пишем

                  var result = asyncMethod().Result;

                  или

                  asyncMethod().Wait();

                  если нет возвращаемого значения. Можно словить дедлок, но тоже выход есть.


                1. MonkAlex
                  07.12.2024 06:12

                  Ничего не изменилось, все они плохие.

                  Всё ещё нужно понимать, что и зачем ты делаешь, и тогда будет в целом достаточно делать task.GetResult().


            1. grisha9
              07.12.2024 06:12

              Поддерживаю. Подход выбранный Java намного более прагматичный. А ключевые слова типа async/await и suspend это также допольнитая "раскраска(colored)" кода. О всем этохо хорошо расказал Иван Углянский в своем докладе. И сделал сравнение по имплеменатции "легковесных" потоков во многих языках https://www.youtube.com/watch?v=kwS3OeoVCno


          1. yrub
            07.12.2024 06:12

            но в Oracle решили стать нитакусиками и начали пилить свои идеи.

            вы так пишете, словно умнее всех их архитекторов. все кто сталкивался с async/await знают что это натуральный рак кода, стоит добавить в одном месте async и вам придется отрефакторить пол проекта. ну и в целом это не избавляет от граблей со случайной блокировкой потока и влечет кстати перфомансные проблемы, которые хороши измеримы на kotlin - там как раз решили никого не ждать и сделали async/await (если вкратце jit сложнее инлайнить код) . Трудно сказать насколько сильно нужна эта асинхронищина на беке, в том смысле что если у вас пару мест асинхронного вызова, то можно использовать апи, которое еще с java 8 было,  не самое красивое, с нюансами, но работает. По этому может и с большим запозданием, но в java это сделано так как должно (вам не надо знать ни про какие async/await и использовать еще специальный api, которые реально неблокирующий), а лучше всего в go.


            1. Lewigh
              07.12.2024 06:12

              Трудно сказать насколько сильно нужна эта асинхронищина на беке, в том смысле что если у вас пару мест асинхронного вызова, то можно использовать апи, которое еще с java 8 было,  не самое красивое, с нюансами, но работает.

              Если это шутка то не очень понятная.

              По этому может и с большим запозданием, но в java это сделано так как должно

              Есть такое хорошее выражение "лучшее - враг хорошего". Может быть async/await это не идеальное решение, но это решение которое работало, работает и будет работать. В то время как другие ЯП (JS, TS, Swift, Kotlin, Python, Rust) скопировали не идеальное но рабочее решение и многие годы используют их в боевых проектах, в Java семь лет пытались сделать "лучшее" решение, которое еще не факт что себя оправдает. Хотя нет же, в Java все это время закрывали проблему реактивными библиотеками, там прям страх и ненависть в комплекте и стакан молока за вредность, но зато не async/await.

              в java это сделано так как должно

              только время покажет должное ли оно

              вам не надо знать ни про какие async/await

              Как показывает практика знать придется еще больше, но про другое


              1. yrub
                07.12.2024 06:12

                Если это шутка то не очень понятная.

                лично я из своего опыта редко видел какие-то асинхронные подзадачи в коде, видимо особенность проектов, монолит, который делает много работы сам, а не раскидывает ее другим (ну и клиентов не сильно много, можем позволить держать поток блокированным). все эти случаи вполне нормально решались через простой future даже.

                которое еще не факт что себя оправдает

                в смысле не оправдает? сделали полноценные виртуальные потоки, как в го (с некоторыми минусами, но близко), закрыли руками все опасные места. итого у нас как минимум 1) есть нормальный стек трейс 2) мы не боимся, что в коде, где-то глубоко, там где мы не контролируем, случайно окажется блокировка (да, сейчас в java есть проблема с synchronized, но ее решают). эти 2 пункта киллер фичи. Как бы по этому в C# и добавили сахар с async/await в 2012, потому что работы не очень много на него надо, а нормально когда сделают? Я например недавно ковырял spring rest client который асинхронный + oauth и мне совершенно не понравилось, эта реативщина одна большая проблема и усложнение всего на ровном месте. Уж не знаю как бы повлияло наличие async/await но наличие виртуальных потоков сняло бы все проблемы сразу. Единственное за что можно поставить java минус - это все делают медленно, потому что, как я понимаю, занимается этим десяток людей.

                только время покажет должное ли оно

                тут ноу комментс у меня. вам сделали конфетку, а вы тянетесь по привычке к сухарику, потому что он такой много где еще и его давно рекламируют продавцы. Я этот феномен не первый раз наблюдают и не могу его объяснить.


                1. Lewigh
                  07.12.2024 06:12

                  лично я из своего опыта редко видел какие-то асинхронные подзадачи в коде

                  Все эти корутины и виртуальные потоки это история не столько про асинхронность сколько про эффективную утилизацию ресурсов давая возможность не блокировать потоки. Любой backend это практически одни IO операции.

                  сделали полноценные виртуальные потоки, как в го (с некоторыми минусами, но близко)

                  Что там общего с Go? В Go потоки не деляться на платформенные и виртуальные, там вся работа на уровне языка идет с горутинами. Горутины сделаны по stackful схеме, в Java виртуальные потоки по stackless. Горутины порождаются явно, и не нужно думать в любой точке кода в каком контексте мы работаем, потому что все работает на горутинах. На Java мы будем работать в неявном контексте. Ну разве что общее что и там и там поддержка со стороны рантайма.

                  Как бы по этому в C# и добавили сахар с async/await в 2012, потому что работы не очень много на него надо

                  И как итог, в C# 12 лет как пишут по новому а в Java все эти годы давились реактивщиной, или просто забивали на нужды разработчиков. В принципе можно было и другие проблемы также решать: нужен новый сборщик мусора? Подождите лет 20, зато мы выкатим самый модный.

                   вам сделали конфетку, а вы тянетесь по привычке к сухарику

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


                  1. yrub
                    07.12.2024 06:12

                    Любой backend это практически одни IO операции.

                    можно с другой стороны зайти: если вы делаете не прокси сервер, то сильно ли вы потеряете, если вместе с диском и базой зависнет еще и машина с приложением? ;) все равно есть какой-то пул потоков, типичный бекенд это oltp, нет никакого смысла пускать 5 тысяч клиентов на одну базу чтобы они все вместе ее грузили по чуть-чуть, эффективнее дать доступ только 50-и в один момент. соответственно у вас и на сервере будет пул из условно 50-и потоков и очередь для всех остальных. я к тому что это не всегда правда высеченная в камне, но есть варианты и есть разный бекенд. Да если мне надо сходить и проверить рекапчу в гугл, то это идеально для настоящей асинхронщины (или если у меня notification сервис), но в остальном хватает случаев когда бек себя отлично чувствует и на обычном блокирующем io, исходя из предполагаемой специфики применения.

                    в Java виртуальные потоки по stackless

                    вы полностью не правы и видимо не в курсе что сделали в java. У виртуального потока есть свой стек, за счет этого как раз и имеем нормальный тред дамп, реализация похожа именно на go.

                    И как итог, в C# 12 лет как пишут по новому а в Java все эти годы давились реактивщиной

                    я так понимаю что боялись рака async/await, вам же надо что-то делать со стандартным апи, иначе какой смысл в асинхронщине, если вызов чтения с файла все сломает и заблокирует. Т.е. пришлось бы менять еще апи или вводить новое, такое же но с припиской async, или городить какой-то волшебный метод в который все надо заворачивать. Сборщик мусора тоже теперь самый модный кстати ;) в принципе по части самой vm java сильно лучше .net, теперь вот оракл еще ее и сделан заново (graalvm).

                    Вот когда это все добро обкатают, внедрят поддержку на всех необходимых библиотеках,

                    все что не содержит в себе synchronized работает хорошо, как-то особо катать там нечего. на данный момент не хватает только возможности кастомизировать пул потоков (стратегию его работы, а тут есть нюансы) и как-то отбираться работу у потока, который не в базу пошел, а сел считать нейронные сети на пол часа.

                    Речь вообще о том, что java и .net как бы похожие, но в аспектах применения и положения на рынке сильно разные. По-этому пошли разными путями. В ms более гибкие и не считают что они кому-то должны обратную совместимость, в oracle делают что-то новое, когда в этом есть реальная необходимость и без этого никак, там ничего не хотят добавлять просто по приколу, например в лепешку разобьются, чтобы не добавить новое ключевое слово. Сильно java упала в популярности из-за того, что в ней небыло асинхронщины на уровне языка? По-моему не сильно.


                    1. Lewigh
                      07.12.2024 06:12

                      У виртуального потока есть свой стек, за счет этого как раз и имеем нормальный тред дамп

                      Согласен тут видимо меня немного занесло.

                      oracle делают что-то новое, когда в этом есть реальная необходимость и без этого никак, там ничего не хотят добавлять просто по приколу, например в лепешку разобьются, чтобы не добавить новое ключевое слово.

                      11->17
                      теперь в языке два switch с разной семантикой, + yield + record + sealed + permits + сломанная семантика get/set в records, + сам синтаксис records сломал весь стандартный привычный синтаксис.
                      Все что вы пишите было актуально до 11 версии. Сейчас уже никто в лепешку не разбиваеться.

                      Сильно java упала в популярности из-за того, что в ней небыло асинхронщины на уровне языка?

                      Java популярна по причине того что исторически заняла финансовый сектор и успешно держит а не по причине качества, быстродействия или каких то фич языка. Там все обкатано и большой пул разработчиков а это главное.

                       в принципе по части самой vm java сильно лучше .net

                      Сильное заявление. А аргументы будут?

                      теперь вот оракл еще ее и сделан заново (graalvm)

                      Молодцы конечно, в .net с 2019 года AOT есть из коробки. Без установок дополнительных VM и прочего.

                      можно с другой стороны зайти: если вы делаете не прокси сервер, то сильно ли вы потеряете, если вместе с диском и базой зависнет еще и машина с приложением? ;) все равно есть какой-то пул потоков, типичный бекенд это oltp, нет никакого смысла пускать 5 тысяч клиентов на одну базу чтобы они все вместе ее грузили по чуть-чуть, эффективнее дать доступ только 50-и в один момент.

                      Да действительно, используются 200 потоков когда можно 8, обрабатываются запросы параллельно или пусть постоят в очереди. какая разница. Прилетело 1000 запросов и мы вывозим по кэшам базам и интеграциям обрабатывать их параллельно, не беда, пусть постоят в очереди для их же блага.

                      все что не содержит в себе synchronized работает хорошо, как-то особо катать там нечего. 

                      Это конечно очень здорово что Вы сказали что все работает хорошо и тестить там нечего но я предпочту старый добрый метод опытной эксплуатации на больших проектах как показатель качества работы и ее зрелости.


                      1. yrub
                        07.12.2024 06:12

                        Java популярна по причине того что исторически заняла финансовый сектор и успешно держит а не по причине качества, быстродействия или каких то фич языка. Там все обкатано и большой пул разработчиков а это главное.

                        это объяснение которое "хипстеры" хотят слышать о том почему их модный язык не используется там где есть java. по части скорости vm она быстрее всех, даже средняя программа на вроде бы компилируемым go хуже будет - на гитхабе есть CardRaytracerBenchmark (и еще один похожий, сейчас не нахожу), там люди один вычислительный код пилят на разных платформах. В java все по канонам, все на объектах, но за счет оптимизаций после прогрева кода там нулевое выделение памяти, хотя в коде мы вроде на каждый чих создаем объект vector. Так что по части самой vm там все лучше чем например .net и по-моему netчики никогда особо не спорили на этот счет (а про node, python даже стыдно говорить, они на таких тестах проиграют 50-100x). Другая причина может быть в том, что java появилась давно и давно кроссплатформенная, по этому кастомер может купить мейнфрейм от ibm и там тоже будет java (хоть и от ibm), но очевидно не на одних только мейнфреймах java держится.

                        теперь в языке два switch с разной семантикой, + yield + record + sealed + permits + сломанная семантика get/set в records, + сам синтаксис records сломал весь стандартный привычный синтаксис.

                        ну тут вы сильно сгущаете, но насчет yield и get/set в рекордс могу согласиться, в принципе я видел что на стековерфлоу тот же Brian Goetz отвечает на подобные вопросы, надо поисать. С yield очевидно не хотели вводить новое ключевое слово. В остальном я не вижу ничего спорного и ломающего в нововведениях, ни в свичах ни в sealed классах.

                        Молодцы конечно, в .net с 2019 года AOT есть из коробки. Без установок дополнительных VM и прочего.

                        ну мы ж не о том кто раньше, в hotspot (обычная джава) тоже был AOT и наверно раньше  19г (добавляла по российская команда из сторонней компании) но в том виде он оказался не нужен и его выпилили, лично я не пробовал его и всю историю не помню. сейчас вот другой заход, но смысл graalvm куда больше чем просто компиляция в exe - там 1) jit компилятор на самой java, более агрессивный и продвинутый (потому что с hotspot сегодня разрабам сложно разобраться и добавлять новое), итого +20% к перформансу на текущий день 2) возможность выполнять вместе все остальное, типа питона или wasm 3) ну и компилировать можно, но не ради этого все затевалось, а ради 1 и 2.

                        Да действительно, используются 200 потоков когда можно 8, обрабатываются запросы параллельно или пусть постоят в очереди. какая разница. Прилетело 1000 запросов и мы вывозим по кэшам базам и интеграциям обрабатывать их параллельно, не беда, пусть постоят в очереди для их же блага.

                        я к тому что не каждая база умеет переваривать тысячи конкурентных запросов и не каждый сервис, и так чтобы он вис подолгу а не на 5 мс. с точки зрения хипстеров это все конечно отстой, но они же парадоксально не замечают что их любимая платформа работает с одним потоком на процесс (хоть тут речь не про .net и go). Поэтому те кто реально на java деньги зарабатывал не испытывал каких-то особых проблем, даже "одноклассники", а у них стек на java (просто как пример хайлоуда с большим канкарэнси). В общем время шло, а люди как-то с java не разбегались ну и теперь вознаграждены что-ли ;)


    1. gandjustas
      07.12.2024 06:12

      Банальный ответ:

      Microsoft вложил в развитие асинхронного рантайма и сокращение аллокаций 10млн человеко-часов. Я думаю в сумме как половина остальных языков из теста.

      продвинутый ответ:

      Код на C# из статьи вообще не порождает потоков и не использует пул. Delay порождает объект с одним полем, а один поток таймера (один на весь процесс) меняет значение поля через 10 сек и запускает асинхронное продолжение в конце.

      по сути это вырожденный случай, потому что если бы была хоть какая-то реальная работа, то результаты забегов были бы совсем другие.

      с другой стороны асинхронность в современном мире это не про параллельность вычислений, а про параллельность ожидания. И c#/dotnet под это очень хорошо заточен.


      1. Jijiki
        07.12.2024 06:12

        простите, а сколько ожидать придётся по тому коду который в этой статье написан на C#? 10 секунд ?

        по непроверенному моему коду на компетенцию соотвествия где я складываю 1+1

        1 миллион раз я получаю примерно 640 милисекунд


        1. gandjustas
          07.12.2024 06:12

          примерно 10 сек. В цикле создается n задач, каждая из которых завершается через 10 сек. Поэтому весь код завершится за 10сек+время создания n объектов и помещение n значений в очередь таймера.


  1. Fox_exe
    07.12.2024 06:12

    А если питон запустить через PyPy или другие варианты?

    Сам спросил, сам ответил: Код из статьи, 100.000 тасков:

    • PyPy_v7.3.17 (Python 3.10): 236.3MB

    • Python 3.9.1: 178.2MB

    • Python 3.12.1: 140MB

    • Python 3.13.1: 140.4MB


    1. Andrey_Solomatin
      07.12.2024 06:12

      Более 8mb в питоновском коде занимает список ссылок на такси. Стоит попробовать просто стартовать корутины и закрывать евенлуп когда он выполнил их все.


  1. Andrey_Solomatin
    07.12.2024 06:12

    Асинхронность это когда есть коллекция тасков. В таске хранятся локальные переменные и номер строки с которой продолжить. Такси и коллекция занимают место в памяти. Оптимизировать по этому параметру мне кажется смысла не имеет, так как в реальности процессор и сеть будут узкими местами.


    1. PrinceKorwin
      07.12.2024 06:12

      Не скажите. Есть у меня сервис, на Java, который работает с граф данными. под нагрузкой этот сервис кушает 4Gb оперативки.

      Ради интереса переписал его на Rust. При тех же данных, при той же нагрузке. Версия на Rust: потребляет в 2 раза меньше CPU и вместо 4Gb кушает 200Mb.

      Поэтому для меня очевидно, что имеет смысл оптимизировать по этим параметрам.


      1. Vest
        07.12.2024 06:12

        А вы профилировали Яву прежде, чем начать переписывать на Раст?


        1. PrinceKorwin
          07.12.2024 06:12

          И в хвост и в гриву.

          Apache Gremlin весьма прожорлив и создаёт тонны промежуточных обьектов.

          В моей реализации Gremlin спецификации операций выделения памяти на порядки меньше.

          Плюс сам граф хранится в памяти компактнее.


          1. ris58h
            07.12.2024 06:12

            Т.е. дело не в Яве, а в какой-то сторонней библиотеке. Так и стоит формулировать.


            1. PrinceKorwin
              07.12.2024 06:12

              Если бы проблемы GC можно было бы решить на стороне библиотеки, то да.

              Если кратко, GC не успевает подчищать память и поэтому ее потребление растет. Потом наступает stop the world и она чистится.

              Не спорю. Тут есть и вина сторонней библиотеки. Но сказать, что это полностью ее вина не могу.


      1. Andrey_Solomatin
        07.12.2024 06:12

        Я про расход памяти на корутину. Точнее на ту часть корутины, которая техническая и не зависит от кода.


  1. Andrey_Solomatin
    07.12.2024 06:12

    Автору большой минус за графики. Цифры с огромным количеством нулей и единица измерения в килобайтах. Было бы логичнее масштабировать дальше до гигабайтов.


    1. sovmirich
      07.12.2024 06:12

      https://hez2010.github.io/async-runtimes-benchmarks-2024/take2.html


  1. vektory79
    07.12.2024 06:12

    Java, если её явно не ограничить, берёт память про запас. Так что тут может быть весьма сильная наводка.
    Я бы попробовал для каждого теста подбирать параметр -Xmx. Подбирая его так, чтобы он был минимальным, но позволял выполняться приложению.


    1. Sly_tom_cat
      07.12.2024 06:12

      Читерство в чистом виде!
      Вы в проде тоже станете душить по памяти приложение пока оно не сдохнет?
      Так то там в любом языке можно шлифануть напильничком :)

      Но в целом сравнение действительно не совсем актуальное - треды/горутины это не то же самое что async-и.


      1. Vitimbo
        07.12.2024 06:12

        В c# тоже есть такие же настройки для уменьшения аппетитов gc, но тут они не применялись.


      1. vektory79
        07.12.2024 06:12

        Ну почему же? Мы же смотрим именно аппетиты задачи по памяти? Значит должны смотреть именно на реально использованную память, а не на то, что jvm взяла прозарас. Так можно jvm заставить хоть 64гб, хоть 1тб забрать себе. Но реально используемая память от этого не изменится.


        1. Sly_tom_cat
          07.12.2024 06:12

          Ну про запас то оно не просто так берет?


          1. vektory79
            07.12.2024 06:12

            Конечно нет!
            Но и смысл таких тестов не в том чтобы посмотреть у какой среды какие эвристики по выделению памяти, а в том, чтобы узнать сколько потребляют корутины/асинки-авейты/зелёные треды/у кого что.
            А в таком случае надо смотреть на реально используемую память а не на жировые запасы, которые по факту могут и не использоваться никогда.

            Грубо говоря нужна оценка "сколько влезет зелёных потоков в 1гб хипа", а не "сколько захочет забрать у системы тот или иной gc при 100500 потоках.

            К слову у OpenJDK много разных gc. И при разных gc исходный тест без явных ограничений может показать разные результаты. Потому как эвристики будут разные.


          1. yrub
            07.12.2024 06:12

            просто так, лучше больше чем меньше. по дефолту по-моему 25% от хипа и под 75% на машинах с малым количеством памяти. берется с запасом и с избытком


      1. vektory79
        07.12.2024 06:12

        Вообще если такой тест делать по уму, то он ой как не просто строится.
        По хорошему там для jvm надо загрузить таски в пул, а потом через jmx выуживать сколько хипа и оффхипа потрачено.
        Смотреть на процесс снаружи для jvm несколько бессмысленно.


        1. urvanov
          07.12.2024 06:12

          Для C# смотреть на потребление памяти снаружи тоже бессмысленно.


          1. Sly_tom_cat
            07.12.2024 06:12

            А для какого языка это "осмысленно"?

            Там везде кучи и везде они аллокируются с запасом, а дальше уже соревнование в прижимистости нахапывания памяти.


        1. Sly_tom_cat
          07.12.2024 06:12

          Тогда и в других языках нужно так же мерить.


  1. Sly_tom_cat
    07.12.2024 06:12

    Сравнивать горутины и async - это как автомобили со смартфонами.
    Там где под капотом по сути своей колбеки, у го практически полновесные процессы (свой стек).
    Полноценных async-ов в golang пока не завезли. Но уже немного пилят в рамках итераторов.


    1. Gromilo
      07.12.2024 06:12

      Я так понимаю, что есть, то и сравнивали. Будут другие возможности запускать потоки, будут их использовать. Скажем, не было бы в шарпах тасков, в тестах были бы треды.


    1. Lewigh
      07.12.2024 06:12

      И корутины и горутины решают одну и ту же задачу - легковесное управление большим числом асинхронных неблокирующих задач. То что там в реализации используется stackful подход вместо stackless не делает из автомобиля смартфон.


      1. Sly_tom_cat
        07.12.2024 06:12

        Ну не совсем. Одно дело python с GIL на одном ядре со своим Async и совсем другое golang на машине со 100500 ядрами. И вот это будет уже не одинаковое решение казалось бы одинаковой задачи.

        Ну а короче - асинхронность не равна параллелизму.


        1. Lewigh
          07.12.2024 06:12

          Смотря что мы решаем. Если наша задача оптимизировать большое количество IO операций с минимальными вычислениями на CPU то разница между одним и несколькими потоками уже не будет такой драматичной - большую часть времени задачи будет находиться в состоянии ожидания. Так как горутины в основном для этого и создавались (так как с вычислительными CPU операциями хорошо справляются и обычные потоки) то справедливо заметить что в данном контексте как раз горутины и async это решениям одной и той же проблемы, немного отличающимися но все равно довольно близкими методами. Если мы возьмем не питон а C# или Kotlin где неблокирующая асинхронность перекликается с параллелизмом то получим очень близкие решения.


          1. Sly_tom_cat
            07.12.2024 06:12

            Кроме большого числа IO бывают и другие же профили нагрузки. И вот если главным ресурсом будет cpu, то решения станут совсем не равнозначными.


      1. mrobespierre
        07.12.2024 06:12

        Так в тесте никакой работы не производится. Т.е. измеряется размер всех стеков при stackful подходе и при stackless. Результаты в принципе ожидаемы. Вот только с реальными задачами миллион горутин, выполняющих работу, - обычный кейс даже на обычном серверном железе, а что будет с другими языками, особенно если учитывать ещё расходы на CPU, а не только на память?


  1. evgeniy_kudinov
    07.12.2024 06:12

    В Go неудивительно, так как горутина инициализируется со стеком в 2 КБ. Но если очень сильно надо и понимаешь, что делаешь, собрать свой компилятор со своим размером начального стека, например, в 1 КБ.TinyGo вообще, если не ошибаюсь, позволяет установить размер через аргумент -stack-size=8KB.


  1. vektory79
    07.12.2024 06:12

    К слову было бы любопытно включить сюда ещё и Kotlin с его корутинами включить.


  1. crackcraft
    07.12.2024 06:12

    А полное время выполнения этого теста замерялось?

    У меня подозрение, что async на самом деле работает через пул и потребеление памяти будет пропорционально размеру этого пула. При этом параллелизм в миллион корутин, ясное дело, не соблюдается и полное время теста будет кратно больше 10 сек.


    1. Andrey_Solomatin
      07.12.2024 06:12

      Упрощённо асинк работает через очередь. Корутины можно создавать пока памяти хватит. Асинк может паралельно ждать все корутины. Пул под капотом просто ускорит их запуск.


    1. yrub
      07.12.2024 06:12

      не измерялось, а стоило бы. вцелом не очень понятно что хотел узнать автор и как потом пользоваться результатами. + как я понимаю только java и go имеют понятный стек вызовов и не зависнут если случайно блокирующийся метод попадется или объект синхронизации. за удобство и безопасность платим памятью.


      1. blind_oracle
        07.12.2024 06:12

        и не зависнут если случайно блокирующийся метод попадется или объект синхронизации.

        Ну, за блокирующими методами нужно следить. В том же tokio в расте есть свой тредпул для блокирующих вызовов, который не влияет на обычную асинхронку.

        Ну и специальные мютексы, которые могут прохождение .await точек переживать корректно.

        Так что в общем и целом это не проблема, но я согласен что подход Go и ко более интуитивен т.к. там асинхронность встроена в сам язык, а не приделана снаружи потом.


  1. Jijiki
    07.12.2024 06:12

    без временной конкретики 1 миллион тасков 131.1КB C++23(я мог ошибиться). а нет я ошибся не сделаю не хватит опыта)


    1. Jijiki
      07.12.2024 06:12

      спасибо за статью я более менее разобрался с асинхронным выполнением


      1. Jijiki
        07.12.2024 06:12

        Скрытый текст
        #include <iostream>
        #include <thread>
        #include <mutex>
        #include <vector>
        std::mutex m_console;
        std::vector<std::thread> t;
        std::vector<int> v(10000, 0); // 1 start, 2 end, 3 skip
        int k = 0;
        void foo(int i)
        {
            // simulate expensive operation
            v[i] = 1;
            std::this_thread::sleep_for(std::chrono::seconds(10));
            v[i] = 2;
        }
        void Log()
        {
            for (int i = 0; i < 10000; i++)
            {
                std::lock_guard<std::mutex> lock(m_console);
                std::thread th = std::thread([=]()
                                             { foo(i); });
                std::thread::id th1 = th.get_id();
                t.push_back(std::move(th)); //<=== move (after, th doesn't hold it anymore
        
                std::cout << "Thread started :" << i << std::endl;
                std::this_thread::sleep_for(std::chrono::milliseconds(1));
            }
        }
        void Lag1()
        {
            for (int i = 0; i < 10000; i++)
            {
                std::lock_guard<std::mutex> lock(m_console);
                if (v[i] == 2 && v[i] != 3)
                {
                    v[i] = 3;
                    k++;
                    t[i].join();
                    std::cout << "Thread eraser :" << i << std::endl;
                }
        
                std::this_thread::sleep_for(std::chrono::milliseconds(1));
            }
        }
        int main(int argc, char *argv[])
        {
            // don't call join
            std::thread one(Log);
            for (;;)
            {
        
                if (k == 10000)
                {
                    std::cout << "Threads END WORK" << std::endl;
                    break;
                }
                Lag1();
                std::this_thread::sleep_for(std::chrono::seconds(1));
            }
            one.join();
            return 0;
        }

        вот что у меня получилось с автоочищением памяти в момент исполнения, но код не так чтобы професиональный - у меня получилось вобщем


        1. Uprt
          07.12.2024 06:12

          Вы сделали совершенно другое - у вас создаются полновесные треды, а тест из статьи ориентирован на корутины.

          В C++ корутины завезли только недавно (C++20), и они пока довольно сложные для использования (1, 2, 3), хотя есть и библиотеки с удобными обертками (например, cppcoro).


  1. rPman
    07.12.2024 06:12

    Прогулялся по ссылкам, не нашел, как именно автор считает оперативную память, ведь потоки и процессы это сущность ОС, и она для своих нужд может потреблять оперативную память.


    1. blind_oracle
      07.12.2024 06:12

      Потребление памяти процессами, по-моему, очевидным образом видно в ps/top/whatever, причём тут ремарка про "сущность ОС" не понятно.


      1. rPman
        07.12.2024 06:12

        Ох, потребление памяти это НЕ ОЧЕВИДНО, и сильно зависит от выбранной ОС и ее настроек.

        Единственное что имеет смысл использовать - это сравнивать свободную память перед стартом и после, тщательно подбирая тестовое окружение, что бы минимизировать его влияние на результат.

        В конечном счете нас волнует как сильно мы сможем нагрузить машину и как выбор языка программирования/фреймворка отразится на наших возможностях

        Тупой пример, не важно что приложение по top будет потреблять оперативную память, если из-за повышенного потребления памяти самой ОС из-за него станет выше.


        1. blind_oracle
          07.12.2024 06:12

          Столько общих слов.

          Есть ли конкретные сравнения когда потребление памяти процессом сильно влияло на общее потребление системы вне этого процесса?

          Да, треды сами по себе требуют в ядре какой-то памяти для учёта и шедулинга, но их стек и прочее хранится в памяти процесса, а это основное их потребление.

          Ну и делать миллион тредов это убийство ОС, так что в любом случае они не окажут большого влияния на потребление.


    1. Jijiki
      07.12.2024 06:12

      когда пытался вьехать в тему о чем тут, если делать без реализации по join память будет выделяться в этом случае надо настроить диспатч и смотреть кто завершил работу, по другому я не знаю, допустим не знаю какая реализация у других языков внутри, описал в общих чертах, так же отмечу если вы выделите на домашнем пк кучей 1 лям thread пк ляжет, так как нету диспатча который нужен выполненому thread, всё зависит от языка как реализованы такие вызовы в каком либо языке и есть ли в таком языке инструмент отслеживания выполнения thread, тоесть всё приходит либо к ексклюзивному выделению памяти с последующими нюансами либо к диспатчингу в момент выполнения


      1. Jijiki
        07.12.2024 06:12

        важное доролнение если в языке нету таких инструментов отслеживания выполнения thread делать придётся через очередь поблочно (например 100 раз по 10 000 тридов)


    1. unreal_undead2
      07.12.2024 06:12

      Тут вроде речь про выполнении в одном процессе через тредпул, число потоков по умолчанию сравнимо с числом ядер - так что накладные расходы ОС копейки. Чтобы реально запустить миллион потоков одновременно, ресурсов нужно побольше )


      1. rPman
        07.12.2024 06:12

        .


  1. amarkevich
    07.12.2024 06:12

    может проблема в задачах?


  1. yarcat
    07.12.2024 06:12

    Сравнивать апельсины с яблоками - нехорошо. Необходима хоть какая-то нагрузка, иначе это просто нечестное сравнение. Или же мы должны спросить, сколько памяти займет миллион ожиданий и тогда код на языках с зелёным тредами будет другим. Включая го:

    var wg sync.WaitGroup
    for range 1e6 {
      wg.Add(1)
      time.AfterFunc(10*time.Second, wg.Done)
    }
    wg.Wait()

    и давайте посмотрим, сколько это займет памяти теперь


    1. blind_oracle
      07.12.2024 06:12

      А что такое полезная нагрузка? Весь смысл асинхронных задач - в асинхронном ожидании по сути. Отправили байтики по сети, ждём ответа, делаем другие задачи.

      А процессором молотить лучше в пуле тредов, а не в асинхронке.


  1. rrgbv
    07.12.2024 06:12

    Горутины же продолжают оставаться неэффективными в потреблении ресурсов.

    В Go под каждую горутину выделяется 2кб стека по умолчанию. В чем смысл этого сравнения, если в этих горутинах ничего не происходит?


  1. UbuRus
    07.12.2024 06:12

    Решительно непонятно что вы тут обсуждаете, нет ни методики воспроизведения, ни параметров стенда.
    Забайтились на попугаев в вакууме


  1. Teapot
    07.12.2024 06:12


  1. mSnus
    07.12.2024 06:12

    Жаль, PHP со Swoole не протестировали