Pull to refresh

Comments 126

Асинхронные операции, которые нужны для оптимизации IOWait плохо работают там, когда нет никакого IOWait. Срыв покровов века!

Не всегда это можно разделить, асинхронщина расползается по приложению, а ee нужно держать в узде )
В с# не обязательно авейтить асинковую операцию. Это можно сделать только в одном месте, где нужны реальные данные. И вот на всю цепочку вызовов у вас перерасход 0.076 микро секунды. А теперь напишите реальный тест, например с записью и чтением из редиса, потоков в 100. И удивитесь выигрышу. Мораль: думай и знай свой инструмент.
А как я узнаю? См. мой пример — мемоизированная функция, пользователь API не знает есть запись в кэше или нет, ему нужен результат, а решение принимается внутри функции. В лоб через await как оказалось проблема не решается, проще завести отдельный воркер-тред, и сделать интерфейс для пользователя чисто синхронным.

Кроме сомнительного async-метода в C#, который не ожидает другую задачу внутри, а значит выполняется синхронно, о чем вас предупредит IDE/компилятор, есть простое улучшение — ValueTask. Этот тип специально разработан для ситуаций, похожих на вашу, и позволяет избежать аллокации жирных Task'ов. Кроме того, ваш метод можно даже переписать так:


static ValueTask<double> f(long i) => new ValueTask(i / 3.1415926);

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


EDIT01: Пример разницы генерируемого IL для Task и ValueTask.

Хороший аргумент для перевода проекта с Node на .Net Core, пока он еще в стадии pet. Я вполне серьезно, для меня тормоза асинков это киллер-фича.

При чем ValueTask это не какой-то там ультра JIT-костыль, это фактически функциональный Either<T, Task<T>>, который содержит либо готовый результат (и практически нулевой оверхед), либо может тащить в себе полноценный аллоцированный Task, если вдруг нужно. По этой причине он доступен как nuget в, например, .NET Standard 2.0 проектах и даже legacy-фремворке.

Да, последнее время MS молодцы, их языки самые приятные, вот и Rust по слухам собрались форкать, хотя непонятно что там еще можно улучшить )

В вашем примере можно:


static async Task Main(string[] args)
{
    var cached = true;
    var b = DateTime.Now;
    var j = 0.0;
    for (var i = 0L; i < 1_000_000_000L; i++)
    {
        if (cached)
            j += i / 3.1415926;
        else
            j += await f(i);
    }
    Console.WriteLine(j + ", " + (DateTime.Now - b).TotalMilliseconds / 1000 + "s");
}

На моей машине время выполнения упало с 19 сек. до 2ух, практически сравнявшись с синхронной версией.


Кстати, даже банальное:


Task<double> f(long i) => Task.FromResult(i / 3.1415926);

дает выигрыш в 2 раза

В общем случае о признаке cached вызывающий код знать не должен, а в целом C# приемлемо работает, в 5 раз всего оверхед если с FromResult-ом.
В общем случае о признаке cached вызывающий код знать не должен

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

Ей богу не понимаю что это у Вас за финт такой
Перед циклом:
var cached = true;

и в цикле:
if (cached)
j += i / 3.1415926;
else
j += await f(i);

Сам флаг «cached» нигде не меняется — он всегда истина — и тут попросту никогда не возникает асинхронного вызова функции — всегда выполняется ветка в цикле:
" j += i / 3.1415926;"
Это какой то развод!

Ну и автор тут тоже устроил развод читателей — для TypeScript и для C# — просто использование меток для функуии async await не делают её асинхронной (МНОГОПОТОЧНОЙ)! (про Rust ничего сказать не могу — не знаком). Такой подход в примере из статьи попросту оборачивает алгоритм не самой тривиальной машиной состояний (причём всего из пары состояний) — но выполняется она по факту синхронно В ОДНОМ (ОСНОВНОМ) потоке! Многопоточность тут вообще не используется — и — как следствие — замедление — это чисто замер накладных расходов выполнения этой машины состояний! Которые на таком примитивном примере будут, конечно, колоссально заметны!
Вы, вот перепишите это на много поточность (с применением «Task.Run()» — для C#) и возьмите всё-таки более серьёзную функцию — хотя бы вычисления хеша sha — и запустите асинхронную обработку файлов хотя бы на 8-10 ядрах! Вот тогда и посмотрим — кто кого!

А так — ясно, что асинхронность не нужно пихать везде куда не поподя!
замедление — это чисто замер накладных расходов выполнения этой машины состояний

Именно это и хотел замерить автор

Результаты замеров:
Реньше накладных расходов не было
Теперь накладные расходы есть
Разница в (1/0) = бесконечность раз

Пойду писать статью: Можно уменьшить время выполнения программы в бесконечность раз! (При условии что программа ничего не делает, правда)

Так ведь об этом и статья. Мы берём какой-то код. Который по факту является синхронным. И отмечаем его как асинхронный. В JS таких ситуаций ужасно много. Вполне логично, что появляются накладные расходы, но тут именно что указывается их размеры.


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


Я провел собственный небольшой тест и получил просто фантастический оверхед (в несколько тысяч раз). Но в моём случае скорее всего дело в самом тесте и как заметили в этом комментарии мог вмешаться JIT.

И отмечаем его как асинхронный

Ну вот в C# так делать не надо, и даже предупреждение в компиляторе выдается.

Ну был бы у меня там внутри If, по одной ветке берем из памяти, по другой ждем базу. Наверное, пример нужно было было сделать более жизненным, хотя мне для принятия решения уже информации достаточно — слазить с ноды в сторону C# либо Kotlin.

Достаточно же просто не использовать в коде async, если функция чисто расчетная… разве нет?

Мы платим, ваше время в таблице / 1 000 000 000 за каждый вызов async/wait
В случае с JS это ‭0.000000173‬ секунды за каждый вызов это 0.173 микро секунды, не мили секунды, а МИКРО секунды, т.е. вообще ни о чем и в реальной жизни это роли не играет, удобство написание кода превосходит эти нано накладные расходы на много порядков.
У меня все застряло, когда я под Deno стал огромные последовательные файлы читать и парсить, внутри миллионы объектов, на каждый объект await, внутри вложенные запросы к кэшу (тоже await), в итоге пришлось откатиться на синхронный ридер.
PS
Я не против async, просто удивила реализация JS.
У меня все застряло, когда я под Deno стал огромные последовательные файлы читать и парсить, внутри миллионы объектов, на каждый объект await, внутри вложенные запросы к кэшу (тоже await), в итоге пришлось откатиться на синхронный ридер.

А в чём проблема заменять асинхронный геттер на сихнронный, в зависимости от того, есть ли у вас значение в кеше, или нет?
Инкапсуляция жеж, кэширование это детали реализации.
Я про «внутри миллионы объектов, на каждый объект await, внутри вложенные запросы к кэшу (тоже await)».
Если «наружу» вы от возврата промисов конечно не избавитесь, если хотите всё красиво скрыть, то внутри-то вот это столпотворение промисов к чему?

Очень странно когда программисты так начинают рассуждать. Этот цикл, или что там у автора, это не синтетическая фигня, которая никогда в коде не появится. В том-то и дело, что мы пишем код для машин, которые делают много операций. И нам часто это и нужно. Много операций. И да, можно конечно говорить, что на одну операцию это "ничего не стоит", но если мы используем какие-то фичи более активно, то все это имеет большое значение. Асинк/эвейт это не какая-то редко используемая фича, особенно учитывая асинхронную природу ноды, как пример. Поэтому вполне можно себе представить приложение, где такие проблемы могут возникать.

Какие фичи более активно? Чтение огромных файлов? Так делается синхронно в отдельном потоке/процессе.

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

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

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


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


Потому что ну невозможно на серьезных щах брать тест кейсом однопоточную CPU bound. Очевидно что вся эти махинации с колбеками, ожиданиями будут медленнее синхронного кода (для синхронной-то задачи, вот это поворот).

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


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

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

«Есть, лож, большая лож и статистика.»
В этом примере вычислительная работа столь мизерна, что любые дополнительные расходы имеют большой вес.
Есть ещё асинхронность без async/await, на коллбеках. Было бы неплохо сравнить.
Вы же не призываете отказаться от асинхронного подхода, всё-таки плюсы превышают минусы, особенно в вёб приложениях?
Конечно будет медленнее, конечно будет больше накладных расходов, асинхронность не для синхронных операций придумана. С точки зрения здравого смысла, зачем математическую операцию делать асинхронной? Она не падает в ожидание в процессе выполнения (разве что на уровне квантов). О чем статья, какой посыл?
Подскажите как решить мою задачу с кэшируемой функцией. На Rust двукратный оверхед меня не пугает, но на JS двадцатикратный это уже перебор. И в JS я не знаю способа остановить распостранение асинков вверх, тогда как в Rust он есть — запустить ручками вложенный экзекутор.
Если я правильно понял, то вам нужно:
1. Прочитать данные из кеша.
2. В случае отсутствия данных выполнить расчет.
3. В случае выполнения расчета, записать данные в кеш.
4. Вернуть результат.
Где проблема? Я чего-то не понимаю?
Да не суть, задача была такая — предоставить пользовательскому коду библиотеку для прозрачного доступа к хранилищу. Библиотека либо из кэша достает и пользователю возвращает, либо делает запрос в хранилище, и уже потом пользователю отдает. Логично что функция должна быть асинхронная. Я попал на производительность именно в JS, в других языках этот вызов не был бы узким местом.
Ну у меня претензии были к C# в JS я не разбираюсь.
Решение — не делать в цикле операции со значимыми накладными расходами. Async нужен для I/O вроде запросов в базу. Запросы в базу в цикле — это сразу же красный флаг. Если вам нужно много чего получить из хранилища, то ваша библиотека должна, как минимум, сразу принимать список данных для получения и возвращать один большой промис, а внутри себя уже лезть в кэш и т.д… А хорошо бы и в хранилище обращаться сразу с запросом на все нужные данные сразу.
У меня конкретный кейс — потоковая обработка финансовых документов, там без подзапросов никуда, и они должны быть максимально дешевыми.
Вам уже оветили выше — кешировать нужно не документы, а Task/Promise на эти документы.
На educative.io есть прекрасный курс C# Concurrency for Senior Engineering Interviews. В курсе очень много общей теории, не касающейся C#, своих денег он однозначно стоит. Прочитав его и, самое важное — поняв, что такое асинхронность, для чего она нужна и как работает, вы научитесь не то что решать проблемы из вашей статьи, а даже не приближаться к ним в принципе
Идея про кэширование тасков смутно понятна, спасибо. Что до общей архитектуры — тут дело в том, что приложение написано на ноде, в стиле ноды, но ввиду плохой производительности придется переносить его на шарп либо котлин — с редизайном либо без. Язык я пока не выбрал, похоже это будет отдельный квест )
Скажем так, сам принцип асинхронности — практически одинаков для всех языков, его поддерживающих «из коробки». Для начала вам нужно разобраться, какая именно у вас проблема, ее корень.
Правильные практики применения асинхронности — это не просто какое то там FAQ а-ля «делайте так и так». Это годится в 95% случаев, но вот в 5% нужно погрузиться в эту тему достаточно глубоко, чтоб не делать такие тесты производительности, как в вашей статье.

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

Из-за этого ваша статья очень спорная с практической точки зрения, потому что неокрепшие джуны (и не только) могут подумать, что асинхронность — это зло и ее стоит избегать. А ведь это достаточно сложная штука (не условный синглтон), которая при правильном приеменении приностит очень много пользы, но и в ногу стрельнуть позволяет с легкостью. Вы вот стрельнули ))
Да, выводы из статьи слишком провокационны, я честно, про джунов не подумал, привык что профи с полуслова понимают ) Что касается моей прикладной задачи — тут ничего лучшего уже не придумать — с одной стороны нужен быстрый кэш для ранее вычисленных сложных объектов, с другой стороны их вычисление (может) зависеть от хранилища. Асинхронщина тут — это основа пользовательского алгоритма, который не должен заморачиваться подробностями, пользователю нужно предоставить максимально простое API типа await getObject(), и конечно алгоритм пользователя превращается в стековый конечный автомат.
Про кеширование Task. Не скажу точно про js/ts (вероятно тоже самое), но в .net, если коротко:
Асинхронный метод мы совершенно спокойно (если результат не нужен прямо сейчас) можем вызвать без await.
Когда метод доходит до await Task (возвращенный из async метода, сразу или чуть позже), совсем необязательно начинают создаваться все вот эти асинхронные «усложнения». Если Task уже к этому моменту выполнен, то вызываемый метод завершается синхронно, потому что результат уже готов. Если не завершен — тогда вызывающий поток приостанавливает выполнение, вероятно уходит в пул, выполнение потом продолжается в другом (возможно) потоке и т.д.
Это если очень коротко и очень грубо.
Я думаю, вы уже поняли, почему нужно кешировать именно Task, а не документ, как результат выполнения :)
Результат из Task/Promise, если он уже выполнен, можно получать многократно, создавать каждый раз новый Task с результатом — совершенно необязательно.
Конкретно в .net недавно добавили еще и ValueTask, который это все вообще максимально упрощает, но там тоже есть свои тонкости.
Результат из Task/Promise, если он уже выполнен, можно получать многократно, создавать каждый раз новый — совершенно необязательно.
Спасибо, все исчерпывающе! Собственно, имея произвольный объект, я смогу принудительно завернуть его в таск / промис и т.о. получить единообразие, вопрос лишь в накладных расходах — протестирую. Что касается .net, уважаю что они развивают язык.
Накладные расходы у вас обязательно будут, нужно только правильно их использовать.
Если у вас в async завернуты только простые CPU-Bound operations — лучше попробовать избежать асинхронности (в статье это наглядно продемонстрировано). Исключение — LongRunning tasks, но и тут есть свои нюансы (где их нет? ))).
Асинхронность дает очень заметный выигрыш при IO-Bound operations при множественных одновременных запросах. Там тоже есть накладные расходы, но в целом — утилизация ресурсов гораздо более качественная, чем при синхронном подходе.
А вот с одним условным пользователем пользователем вы даже не заметите разницы.
в JS я не знаю способа остановить распостранение асинков вверх

Ну, могу предложить весьма не изящный способ:


Вместо обычной async функции (которая всегда возвращает промис) пишем функцию которая возвращает либо данные либо промис:


function getValue(key) {
  if (globalCache.has(key)) 
    return globalCache.get(key)

  return new Promise(r => fetchValue().then(value => r(value)))
}

И тогда нужна какая-то функция-обертка, которая будет принимать колбэк и вызывать его на данных, либо после завершения промиса:


function doWithValue(key, callback) {
  const value = getValue(key)

  if (value instanceof Promise)
    value.then(callback)
  else
    callback(value)
}

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

Все так, но в пользовательском коде сотни раз такую лапшу никто писать не будет, значит нужна библиотека. А что будет делать эта библиотека? Правильно, принимать пользовательские колбэки. Привет аду колбэков. Похоже, выхода нет, надо просто оптимизировать накладные расходы как это делает тот же C# со своим ValueTask. То есть нам нужен более умный рантайм.
Вот так как раз не надо делать. Функция должна быть полностью синхронной или асинхронной.

Почитайте про “release zalgo”.
Прикольно однако, синхронные вызовы заворачиваем в thenable и получаем всегда асинхронный код, если я правильно понял идею.
Только в выше приведённых примерах Kozack это не так.

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

И далее в комментариях подобные примеры приводят с вероятностью, что рано или поздно Zalgo все же вылезет :)

Ну нет, ваша doWithValue тут ничем не лучше промисов.


Если уж настолько позарез требуется оптимизация — надо без всяких doWithValue делать две разные ветки для кода. И не забыть про Typescript, потому что ошибиться при таком подходе проще простого.

А смысл делать await в for? В C# точно можно по другому. Использование DateTime тоже не уместно.
Интересно, но уж слишком синтетически. Больше похоже на тестирование оптимизационных возможностей конкретных компиляторов.
Лучше было бы сделать cached не всегда true, а бросать рандом и допустим с шансом 1% таки делать какую-нибудь смену контекста (читать из файла например). Это как раз та задача, которую изначально моделирует автор.
В этом случае синхронные варианты могли бы заметно просесть. Особенно если вспомнить, что await это всего лишь надстройка над promise, и можно делать promiseAll или как оно в этом языке называется сразу на сотню вызовов.
Особенно если вспомнить, что await это всего лишь надстройка над promise, и можно делать promiseAll или как оно в этом языке называется сразу на сотню вызовов.

На самом деле если вы сделаете миллиард await в цикле — произойдет почти то же самое (разница в fail-fast и обработке ошибок), как если б вы вызвали Promise.all с миллиардом тасков.
Неа, если под промисом есть смена контекста (вызов ядра и т.п.), то будет не так как вы пишете. Ну, если интерпретатор/компилятор не совсем тупой и умеет в waitformultipleobjects/younameit.

Для nodejs пример какой-то странный, обычно, когда много нужно асинков получить мы вызывает все без await, все что не конфликтуют между собой и в конце, метода где нам нужны данные мы делаем
Let results = Await Promise.all(pull);


Опять сравниваем однопоточную ноду, с rust и #c где реализация совсем по другому выглядит, подправить если ошибаюсь.

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

В данной ситуации в шарпе тоже однопоточно. Добавляется лишь оверхед на переключение стейтмашины.

Да надо бы. Гошка отпугивает отсутствием дженериков и исключений, все время возвращать кортежи это так себе.
  1. ValueTask/кэширование/куча других способов добиться уменьшения оверхеда
  2. замер оверхеда на ИО операции на деление двух чисел это очень мощно. Я даже удивлен как хорошо рантайм справляется, я бы ожидал разницы в сотни раз.
Я вот например другому удивился — почему такая разница в цифрах между языками?
не языками, а компиляторами, точнее их оптимизаторами.
В принципе успехи Rust понятны, он AOT-компилируемый, есть время все грамотно заинлайнить. Но шарп с v8 по идее в равных условиях. Просто в JS этим никто не заморачивался, так как в браузере не нужно, а для бека никто и не обещал.

В Rust особым образом задизайнен async/await, он там сложнее устроен, чем в языках с рантаймом: благодаря тому, что есть отслеживание времен жизни ссылок и с помощью Pin можно делать ссылающиеся на себя структуры, под капотом ржавая асинхронщина не делает лишних аллокаций в памяти.


Об этом немного было в докладе Клабника: https://youtu.be/lJ3NC-R3gSI

Спасибо! С учетом того, что в моем дурацком примере функция заинлайнена, получаем просто затраты на вызов, надо дальше потестить.

Потому что в Javascript утиная типизация, и оптимизатор не может полагаться на то, что все передаваемые ему промисы — "родные", их зачастую приходится обрабатывать как произвольные объекты. Плюс принудительная асинхронность (каждое продолжение вызывается строго с пустым стеком!) тоже не ускоряет выполнение.


Вот на C# всё проще, и CLR прекрасно знает какие методы и у какого объекта вызываются. Плюс реально в коде не вызывается ни одного продолжения — весь код выполняется синхронно. Но вот нагрузка на сборщик мусора получается значительная.


На Rust же управление памятью лучше.

Мысль интересная про динамическую типизацию, но не понял что значит родные промисы. В JS промис — это просто объект с двумя колбэками, самая простая реализация из всех возможных. А стек вызовов сохраняется в замыкании — этот механизм был и до промисов, когда мы внутреннюю функцию передаем вовне как колбэк, внешняя функция завершается, а все ее переменные оказываются захваченными. Единственное что приходит на ум — из-за динамической природы JS — рантайм не знает, какие именно внешние переменные нужно захватывать, а какие нет. А в том же расте где память выделяется статически, компилятор захватывает в замыкание только то, что реально используется внутри замыкания, поэтому и оверхед меньше на порядок. Но это только гипотеза, я не погружался в реализацию промисов / тасков.
В JS промис — это просто объект с двумя колбэками, самая простая реализация из всех возможных.

В JS есть такое понятие как Thenable — это любое объект с методом then. И реализация промисов должна быть всегда готова к тому, что вместо "родного" промиса её подсунут какой-то Thenable-объект.


А стек вызовов сохраняется в замыкании — этот механизм был и до промисов, когда мы внутреннюю функцию передаем вовне как колбэк, внешняя функция завершается, а все ее переменные оказываются захваченными.

Неа, там другой механизм.


Единственное что приходит на ум — из-за динамической природы JS — рантайм не знает, какие именно внешние переменные нужно захватывать, а какие нет.

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

Поигрался с примером на js. Вот что интересно, если сравнивать версию с функцией и без функции (for (...) { j += i / 3.1415692 }), то выясняется, что при количестве итераций < 10 000 разница в скорости больше трех раз, однако позже скорость выполнения выравнивается и держится на уровне 1.2 (365 527 293 ns против 336 979 720 ns).


Что-то мне подсказывает, что в такой базовый пример врывается JIT. Если заморочаться дальше, можно запустить ноду с --print-opt-code, в выводе справедливо появится строчка


Inlined functions (count = 1)
 0x2130056d3829 <SharedFunctionInfo f>

Отсюда можно смело полагать, что после N (тысячи или типа того) итераций JIT просто заинлайнил функцию, сохранив кучу времени. async функцию заинлайнить он, к сожалению, не может, поэтому такая большая разница, вызов функции не бесплатный. Ближе к реальности — реальные функции, особенно читающие с диска, он заинлайнить не сможет, поэтому результат теста нерепрезентативный.


P.S. Точно до нс в nodejs время померять можно с помощью process.hrtime();

Оo, наконец-то дельный комментарий, бешено плюсую! Я про инлайн не подумал, по идее все 3 языка такую простую функцию инлайнят, но тогда чтобы замерить чистую потерю от await, нужно делать сложную функцию, и внутри нее тоже включать таймер.
PS
Я наслышан, что в V8 вызов функции дорогой, поэтому сам удивился цифрам.
Извините, в интернете кто-то не прав.

Ваш код:
for (let i = 0; i < 1_000_000_000; i++) {
        j += await f(i)
}

Будет всегда работать медленнее синхронного кода вне зависимости от сложности функции f. Итерации цикла исполняются последовательно. В каждой итерации ожидается (avait) завершение вызова f и только после этого выполняется переход к следующей итерации. Т.е. тут просто код спроектирован не подходящим образом для использования асинхронных функций.

Чтобы увидеть выгоду от асинхронных функций, нужно чтобы выполнилось два условия:
  1. Сначала породить стрим/список/массив (или что там вам по задаче больше подходит) промисов, а только потом их ждать и что-то делать с результатами.
  2. Убедиться, что время выполнения функции существенно больше времени необходимого на старт потока и синхронизацию.

В ваших тестовых примерах не выполнено ни одно из этих условий — каждый вызов асинхронной функции выполняется строго после завершения предыдущего и одна операция деления намного быстрее затрат на синхронизацию.

Этот код сравнивался с другим:


for (let i = 0; i < 1_000_000_000; i++) {
        j += f(i)
}

Здесь точно так же В каждой итерации ожидается завершение вызова f и только после этого выполняется переход к следующей итерации.

Именно поэтому примеры кода из статьи годятся, только чтобы оценить стоимость вызова функции обернутой в async/await. Но никак не годятся для оценки возможной выгоды от применения этих конструкций на практике. Поэтому вывод о применимости асинхронного API базируется на не верной предпосылке выведенной из некорректного применения конструкций языка.

Вот это вот утверждение:
Интересно, что там где использование async/await наиболее распространено (и даже пропагандируется), издержки на асинхронный вызов просто запредельные.

Иллюстрируется примерами с некорректным применением этих самых async/await. Если я правильно понял уважаемого автора balajahe, то он своими примерами хотел проиллюстрировать случай когда у нас нужные данные уже однажды получены и закешированы и мы уже можем получить данные быстро. Но сопроводил это не корректным использованием конструкций async/await. Даже если f функция какого-то внешнего API, нельзя ее вызывать в цикле и тут же в той же итерации ожидать ее завершения, приходится сначала породить необходимое количество промисов и только после этого дожидаться их завершения, чтобы дальше что-то делать с результатами вычислений.

Async/await по сути синтаксический сахар для запуска функции в отдельном потоке, придуманный чтобы кода поменьше писать. Но правильно применять параллелизм в решении задачи все еще задача программиста.

Собственно об этом и статья — о накладных расходах при использовании асинхронных функций.


Все примеры автора сводятся к простому: если у нас есть какой-то синхронный код, то обернув его в async функцию мы получаем вот столько-то оверхеда.


И описанная автором проблема мне тоже очень хорошо знакома.


Известно, что await можно использовать только внутри функций или блоков async, а это значит, что если у вас самый нижний уровень API асинхронный, то придется использовать async/await практически везде, даже там, где оно очевидно не нужно.

Действительно такое встречается часто. Когда есть какой-то низкоуровневое хранилище. Ну, для примера вот такая функция


async function getData() {
    if (!globalCache.data) {
        globalCache.data = await readDataFromFS(/* ... */)
    }

    return globalCache.data
}

Фактически такая функция выполняет асинхронную операцию только один раз. А всё остальное время — это самый простое синхронное обращение к свойству объекта. Но не смотря на это, весь стек опирающийся на эту функцию — обязан быть асинхронным. Хоть это и не нужно.


async function getValue(key) {
    return (await getData())[key]
}

И как следствие какие-нибудь компоненты интерфейса (на Vue там или на React) которые базируются на данных из этого хранилища — обязаны использовать асинхронные функции, хотя повторюсь — вся выполняемая работа это извлечение свойства из объекта — которая вполне может выполняться синхронно.


И вот я вижу такой себе "вирус": Одна асинхронная функция в стеке, делает весь стек асинхронным. Что увеличивает бесполезные накладные расходы.


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

Известно, что await можно использовать только внутри функций или блоков async

Верно.
, а это значит, что если у вас самый нижний уровень API асинхронный, то придется использовать async/await практически везде, даже там, где оно очевидно не нужно.

А это уже ложное утверждение. Асинхронная функция возвращает промис, завершение работы которого можно дождаться и без использования await. Никто не запрещает написать, например, так:
function getData() {
    if (!globalCache.data) {
        globalCache.data = resolve(readDataFromFS(/* ... */));
    }

    return globalCache.data
}

И все, никакой «вирус» async не распространяется выше по уровням кода.
Ну так вы подвесили вызывающий тред.
Конечно, до тех пор пока не завершится readDataFromFS(/*… */). Вы полагаете await делает что-то иное?
В однопоточном JS тред продолжает обслуживать фоновые задачи, евентлуп работает, то есть тред не вешется. В этом и смысл — вся программа от метода main превращается в стековый конечный автомат. Это легко проверить — сделайте ваш пример в браузере, и у вас все повиснет, обработчики событий перестанут реагировать, кнопки перестанут нажиматься и т.д.
Я, возможно, не самый удачный пример привел, как можно оборвать цепочку async. Ну так это и не единственный из доступных вариантов, выбирайте подходящий для вашей задачи. Это ведь вы утверждаете, что если в программе на нижнем уровне завелся async, то все до самого неба должно быть в него завернуто? Нет такой необходимости, делайте как вам удобно и подходит для эффективного решения задачи.
Конечно, await делает что то иное
А как вы по-другому предполагаете синхронно читать? Если у вас посередине кода операция, которой нужно дождаться, то либо делаете его целиком асинхронным (= умеющим «вставать на паузу»), чтобы он мог ждать асинхронно, либо синхронно ждёте результата операции.
Ну, если прицелиться на обслуживание 10к сессий, то делать конечно приложение целиком асинхронным, а какой вариант? Просто async/await это кооперативная многозадачность, а, например, горутины — вытесняющая (частично — до первого системного вызова), и непонятно пока — где я получу больше накладных расходов.
Async/await по сути синтаксический сахар для запуска функции в отдельном потоке, придуманный чтобы кода поменьше писать. Но правильно применять параллелизм в решении задачи все еще задача программиста.
В моём понимании, всё-таки многопоточность и асинхронность, это не совсем одно и то же. Да, асинхронности можно добиться посредством многопоточности. Но для асинхронности как таковой многопоточность не обязательна. Вот, в node, например, все наши асинхронные функции запускаются в одном единственном потоке. А с синтаксическим сахаром я согласен. Правда, без него, начинается кошмар.

balajahe ведь async/await неспроста «вирусный». Если мы предполагаем в коде наличие асинхронного вызова, то очевидно, что все предыдущие вызовы по стеку должны знать, что каждый вызов может быть преостановлен.

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

Такова цена асинхронности. Либо она есть (асинхронность), либо её нет.

Если у вас предполагается, что всё будет закэшировано, попробуйте раздельно заниматься заполнением кэша и использованием его. Может, вам и не нужно асинхронности — всё может прекрасно работать, скажем просто многопоточно?

Выше уже говорили, что более правильным подходом было бы кэшировать promise, а не объекты напрямую. Как минимум, создание одного promise можно было бы избежать. Умный runtime (jit) даже возможно что-нибудь оптимизирует.

P.S. В некоторых рантаймах можно извернуться и «избавиться» от асинхронности на каком-нибудь уровне, сделав синхронный вызов executor-а, который в других потоках выполнит всю асинхронную часть, заблокировав наш поток. Решение, по крайней мере для большинства моих задач, сомнительное. Нужно очень хорошо и не один раз подумать, прежде чем его использовать. Но для вашей задачи может быть подойдёт.
попробуйте раздельно заниматься заполнением кэша и использованием его. Может, вам и не нужно асинхронности — всё может прекрасно работать, скажем просто многопоточно
Думал. Очень сложно, тогда пользовательские сессии придется запускать в пуле тредов, отделяя тех кто ждет, и тех кто получает кэшированные данные. Ужас и возврат в 90-е )

Корутина это прекрасная абстракция, просто нужно выбрать правильный язык, где накладные меньше. Про кэширование промисов и про ValueTask, да, я все понял, спасибо.
В некоторых рантаймах можно извернуться и «избавиться» от асинхронности на каком-нибудь уровне, сделав синхронный вызов executor-а, который в других потоках выполнит всю асинхронную часть, заблокировав наш поток. Решение, по крайней мере для большинства моих задач, сомнительное
Можно, но тредпул не резиновый, вижу как мучаются асинхронные фреймворки, когда им приходится использовать синхронные API например для доступа к БД — вся красота рушится, приходится содержать отдельный тредпул для синхронных акторов, и отдельный для асинхронных.

На самом деле, функция не обязательно должна быть асинхронной. Она может спокойно возвращать / пробрасывать наверх промис, если конкретно для неё информации из этого нет. Ну или можно добавить старый добрый then, а асинхронность добавлять когда уже идёт финальное использование функции. Это позволит не генерировать стейт машину с while (true) (я сейчас говорю конкретно по тому, как это делает babel с js) внутри каждого уровня абстракции. Но это экономие на спичках — если на просадку в скорости веб-сервера заметно влияет async — nodejs не подходящая платформа для вашей задачи, во всех остальных случаях время отъедает что-то другое. Да даже обращение к ssd будет в несколько сотен раз медленнее чем интерпретация async функции и избавление от неё (не знаю, с переносом в веб-воркер с синхронной логикой например) вряд ли сильно поможет.


Если вы, конечно, не рассчитывает орбиту Аринсторфа, вызывая в методе Ронге Кута асинхронный обсчёт для каждой итерации. Тогда это просто корявая архитектура.

Да, у меня задача странная, или я ее странно решаю — большинство читателей просто не поняли проблемы ) Думал с ноды на jvm соскочить, но посмотрел — производительность openjdk раза в 2..3 ниже чем у V8, а у оракла теберь лицензионные танцы. Склоняюсь к .net core пока, под линуксом она шустрая оказалась, на удивление.
Так вы бы взяли и описали, проблему-то. А то у вас какие-то общие слова вместо этого.
habr.com/ru/post/482938
Прототип показал низкую производительность уже на миллионе объектов (по 2 асинхронных вложенных вызова на каждый объект), плюс асинхронное API чтения файла тормозит. Вторая проблема решаемая — перейти на сихронное, чего нет — дописать, парсинг юникода переписать, но вложенные вызовы await это основа алгоритма, к сожалению.
Я правильно понял, что вы хотели на ноде написать СУБД?
(тут должно быть что-то умное про безумство храбрых)
Нет, это конечно здорово, но как..? Масштабирование однопоточной ноды для увеличения производительности полагает запуск N процессов той же ноды — но для этого алгоритмы должны параллелиться таким образом, а ваше описание из того поста как-то совсем на это непохоже. Что вы собирались делать, когда вы бы просто упёрлись в потолок производительности одной ноды (а это бы и на статике случилось)?

Мне после прочтения того поста просто видится, что у вас архитектура в принципе не годится для ноды, в том виде, в котором она там описана.
Это всего лишь прототип, на TS его писать было бы проще, динамический json, меньше кода, для многопоточки есть воркеры, и т.д. Но я не могу показать даже прототип, который не умеет миллион объектов в секунду.

Поправьте меня, но вы читаете миллион файлов в секунду? Ну это многовато, даже если дескрипторы файлов таскать через сервис и не закрывать их, все равно. Асинхронный код тут ни при чем. Более того, чтение из файла физически всегда асинхронное по своей природе, поэтому можно дать потоку заняться чём-то другим эти несколько сотен (тысяч?) тактов, пока мы ждём пока данные на диске подготовятся.


А чтобы не тормозило все приложение, нужно использовать Promise.all, но опять же, даже в данном примере у вас проблема не с асинхронностью, а с большим количеством случайных чтений с диска, что достаточно медленно и следует сериализовывать в большие структуры. И вообще использовать базу данных, а не файлы на ноде. Нода сильно не предназначена чтобы ворочать миллионы файлов

Я, например, не уверен можно ли в ноде вообще считать с 1000 по 2000 байты файла, не помещая весь файл в буфер.

Можно. Я правда с Deno работаю, но это очень похоже. Есть не только буферизованные стримы, но и seek(). По поводу отображения файла в память — похоже нет такого.
Файл один, внутри миллион Json. Синхронный ридер на той же Java в 5 раз быстрее асинхронного в JS, причину не понимаю, исходники не ковырял, возможно мосты EPOLL => сишный (растовый) рантайм => V8 съедают все время.

Так, поправьте меня, если я не прав, но вроде как если хранить JSON внутри файла, вы не можете гарантировать сдвиг до нужного объекта внутри файла. Как следствие — вам нужно прочитать все строки перед этим JSON до того, пока вы найдете нужный, вы не можете сказать просто read(offset struct_size, (offset + 1) struct_size) (ну или в этом духе, не важно).


Как следствие, каждый раз вы ищите файл. Синхронный код в java может работать быстрее по множеству причин, в том числе сама асинхронность само собой увеличивает издержки, но так же тут может участвовать что угодно, например, неумение JS частично парсить объект и ему нужно передавать всю строку, а в JAVA движке он может читать частично и все в этом духе.


В любом случае, речь идет о написании своей БД. Проще использовать в крайнем случае https://www.sqlitetutorial.net/sqlite-nodejs/, а лучше нормальную бд, потому что вам в любом случае придется возиться с бинарными структурами, смещениями и всем прочим вместо линейного чтения всего файла до первого попадания. Так же использовать индексы и куча другого геморроя, который вы не хотите (я уверен).

А вообще я вот подумал, даже интересно результат увидеть. Сделайте честный тест на js/ts, примерно такой:


Мы не можем держать в памяти 1 млн объектов, многовато, поэтому будем генерировать их на лету:


let ind = 0;
getNextLineSyn = () => {
  const [, now] = process.hrtime();
  while (true) {
     // simulate sync reading from file
     const [, diff] = process.hrtime(now);
     if (diff > 1_000_000) {
        break;
     }
  }
  return { id: ind++; ...};
}

и его асинхронную версию


getNextLineAsync = async () => {
   await preciseTime(1_000_000, 'ns'); // simulate async reading from file
   return { id, ...};
}

И дальше две функции: searchSync, searchAsync.


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


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


P.S. PreciseTime, само собой, не может быть while (true) внутри очевидно, так что придется повозиться, впрочем можно обойтись, конечно, и setTimeout(1);

Сделаю, но чуть позже, сейчас текучка навалилась.
Документы в файле неизменяемые, любой потоковый ридер читает весь файл от начала и до конца с фиксированным буфером, поэтому неважно где хранить. Так работает, насколько я понимаю, спарк со своим map/reduce. Сдвиг нужен если мы хотим распараллелить обработку, режем файл на куски, отдаем в разные треды, и особо обрабатываем места стыка. В пред. статье алгоритм описан и даже работающий код приведен.
но вложенные вызовы await это основа алгоритма, к сожалению.
Тогда тут проблема в алгоритме, если я правильно его себе представляю.
Для обработки миллиона объектов делать 2 миллиона чтений ФС — это абсурд.
Тут нужно сначала в один проход заполнить всеми нужными данными кэш, а потом во второй проход синхронно его обрабатывать.
А чем для этого на устраивают потоки в норде? :)
Не надо весь файл читать!
А чем для этого на устраивают потоки в норде? :)
2 миллиона потоков?
Не надо весь файл читать!
Я, вроде, и не предлагал читать весь файл? Я предлагал считать нужные данные из него.
Я, вроде, и не предлагал читать весь файл?


Возможно, я не так понял эту мысль:
Тут нужно сначала в один проход заполнить всеми нужными данными кэш, а потом во второй проход синхронно его обрабатывать.
Ну, в кэш ведь не весь файл запихивается (он представляет собой, как я понял, всю БД), а только нужные куски его?
От целей и задачи зависит. Может весь, может нет.

В любом случае, обработку (а это могут быть разные операции: чтение, парсинг строк/json etc) файлов лучше решать через Stream API NodeJs.

Возможно пригодится и Worker Threads API NodeJs (недавно появились).
Stream API NodeJs.
Я на Deno пишу, выглядит так:
const db = new BufReader(Deno.openSync('file.json'))
while(true) {
    const chunk = await db.readString('\x01')
    if (chunk == Deno.EOF) break
    try {
        const doc = JSON.parse(chunk.slice(0,-1))
        try {
           // бизнес-логика
Здесь буферизованный асинхронный стрим. И я недовлен производительностью. А как такое на чистой ноде буде выглядеть, я протестирую для сравнения?
У вас сразу идёт чтение файла openSync — синхронно? Зачем?
Это всего лишь открытие дескриптора
К тому же, судя по коду, чтение файла происходит всего сразу. Это лишнее. И это совсем не стрим уже.

На node js у Вас должно получиться что-то вроде (условно):
FileReadStream.pipe(ParseStream).pipe(WriteStream)


где:
  • FileRead — читаем файл «кусками», а не весь сразу
  • ParseStream — обработка «кусков» по мере их чтения (у Вас это JSON)
  • WriteStream — если надо, сохраняем куда нужно результат обработки «кусков» (кэш, БД etc.)


Тогда у Вас и не будет создаваться куча объектов Promise (db.readString) в цикле

while(true) {
...
const chunk = await db.readString('\x01')
...
}
Да, спасибо, собственно создания промисов и хотелось бы избежать. В Deno тоже есть стримы.
file.json — каких объемов файл?

При синхронном чтение весь пишется в память. Если он у вас >= Гб, быстро память закончится.
Даже если меньше, и их у Вас несколько, но они читаются параллельно, тоже можете упереться в память.
Когда мы пишем async, то просто не задумываемся, а как это реализовано «под капотом».

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

Потому минус.
Под капот не было цели. Сравнивались накладные расходы в разных языках при идентичном коде. И выводы уже сделаны.
Респект за тест, а код не кинете?

Я никуда код не загружал пока. Сейчас бенч докрутится — все таки миллиард вызовов на одно измерение многовато, и я приведу сводную таблицу.
UPD01: Результаты наивного бенчмарка


(кривая таблица)
BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362
Intel Core i7-2700K CPU 3.50GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.1.100
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT
  Job-FAYYSD : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT

Runtime=.NET Core 3.1  

Method| Mean| Error| StdDev| Ratio| RatioSD| Gen 0| Gen 1| Gen 2| Allocated
| Synchronous| 4.902 s| 0.0162 s| 0.0143 s| 1.00| 0.00| -| -| -| 208 B
| StateMachineTask| 27.911 s| 0.7889 s| 2.3136 s| 5.85| 0.16| 17216000.0000| 2000.0000| -| 72000000216 B
| FromResultTask| 12.415 s| 0.1193 s| 0.1116 s| 2.53| 0.03| 17216000.0000| 1000.0000| -| 72000000280 B
| ValueTask| 9.423 s| 0.1762 s| 0.1809 s| 1.93| 0.04| -| -| -| 320 B


Читаемой картинкой


Примерный код, мимикрирующий под автора
using System;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
#pragma warning disable 1998

namespace AsyncBenchmarks
{
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [MemoryDiagnoser]
    [AsciiDocExporter]
    [PlainExporter]
    [MarkdownExporter]
    public class AsyncBenchmark
    {
        private static async Task<double> FTask(long i) => i / Math.PI;
        // ReSharper disable once InconsistentNaming
        private static Task<double> FRTask(long i) => Task.FromResult(i / Math.PI);

        private static double FSynchronous(long i) => i / Math.PI;
        private static ValueTask<double> FValueTask(long i) => new ValueTask<double>(i / Math.PI);

        [Benchmark(Baseline = true)]
        public void Synchronous()
        {
            var b = DateTime.Now;
            var j = 0.0;
            for (var i = 0L; i < 1_000_000_000L; i++) 
                j += FSynchronous(i);
            Console.WriteLine(j + ", " + (DateTime.Now - b).TotalMilliseconds / 1000 + "s");
        }

        [Benchmark]
        public async Task StateMachineTask()
        {
            var b = DateTime.Now;
            var j = 0.0;
            for (var i = 0L; i < 1_000_000_000L; i++)
                j += await FTask(i);
            Console.WriteLine(j + ", " + (DateTime.Now - b).TotalMilliseconds / 1000 + "s");
        }

        [Benchmark]
        public async Task FromResultTask()
        {
            var b = DateTime.Now;
            var j = 0.0;
            for (var i = 0L; i < 1_000_000_000L; i++)
                j += await FRTask(i);
            Console.WriteLine(j + ", " + (DateTime.Now - b).TotalMilliseconds / 1000 + "s");
        }

        [Benchmark]
        public async Task ValueTask()
        {
            var b = DateTime.Now;
            var j = 0.0;
            for (var i = 0L; i < 1_000_000_000L; i++)
                j += await FValueTask(i);
            Console.WriteLine(j + ", " + (DateTime.Now - b).TotalMilliseconds / 1000 + "s");
        }
    }
}

Если коротко, все, что работает с Task, очень медленно, ненужная стейт-машина вообще тормоз. Task аллоцирует… я даже не знаю как воспринимать эту метрицу. Много. Собирает тоже много, одного Gen0 17 миллионов сборок. Тем не менее, худший performance degradation это 6 раз, а вот ValueTask всего лишь в два раза медленнее, ну и конечно 0 аллокаций. Так что C# возможно способен в чем-то потягаться и с Rust.

Спасибо, а что у вас внутри 4-х функций, простое деление, можете дословно код привести? Мне тут указали, что нельзя такое простое бенчить, ибо оно может инлайниться или векторизоваться, и реально деградация еще меньше. Компилятор Rust вообще беспардонен — dead code просто выбрасывает, линейные циклы векторизует и т.д. Тут надо заморочиться чтобы что-то релевантное набенчить, но в целом .NET порадовал, особенно в сравнении с Java, которая уже на синхронном вызове в 2 раза хуже была — похоже, она в SIMD не умеет совсем.

Да код весь приведен, в начале класса 4 примитивные функции возвращающие значения. Это если я правильно понял ваш вопрос. У меня не было цели провести аккуратное измерение разных тасков — у меня для этого нет познаний, времени и опыта, — я просто взял ваш код (ну или почти его). Как я понял, вы привели в таблицах время исполнения, которое у вас вывелось в терминале, я же использовал Benchmark .NET чтобы собрать немного статистики. Безусловно здесь будут оптимизации, но различия между подходами видны сразу:
1) использование async генерирует гигансткую стейт-машину там, где это не нужно, и это всегда будет очень медленно;
2) Task аллоцируется, чтобы вы там не возвращали
3) ValueTask, сконструированный из готового значения (кэша), практически бесплатен, а весь оверхед скорее всего в стейт машине вызывающего метода (тот что гонит цикл).


С# не должен векторизовать то, что его не просят, однако в новых версиях .NET Core опять таки завезли compiler intrinsics, так что если SIMD и прочие ништяки вам по зубам, вы можете свою числодробилку ускорить еще сильнее.


В любом случае, сейчас, в эпоху Span, stackalloc, ref structs, интринсиков, array-пулов и прочих вкусностей, из C# можно кроссплатформенно выжимать довольно неслабый перфоманс, при этом оставаясь в рамках привычной инфраструктуры, используя знакомые библиотеки и инструменты.

Спасибо! Про векторизацию у меня есть только пример на Rust, но там ее нужно ручками заводить, и пока не до этого. Почти уговорили меня перейти на .net )
Only those users with full accounts are able to leave comments. Log in, please.

Articles