Я преобразовал код JavaScript для Node.js (v10.13.0), который ранее был синхронным, в асинхронный код с использованием async/await. То, что я заметил впоследствии, было ухудшением производительности примерно в 3 раза медленнее времени выполнения программы.
Есть ли снижение производительности при преобразовании цепочки вызовов синхронных функций в вызовы асинхронных функций?
Упрощенный пример
Изменение синхронного кода
function fn1() {
return 1;
}
function fn2() {
return fn1();
}
(function() {
const result = fn2();
});
в асинхронный код:
async function fn1() {
return 1;
}
async function fn2() {
return await fn1();
}
(async function() {
const result = await fn2();
})();
Есть ли магия циклов событий, которая может замедлить последний код в веб-приложении Node.js?
async
и await
— синтаксический сахар. Собственная реализация будет генерировать кучу дополнительного кода (функция генератора), который будет отслеживать, куда двигаться дальше, когда асинхронная операция завершится. Это увеличивает нагрузку и снижает производительность.
@MarkMeyer, чтобы прояснить вариант использования: в моем сценарии асинхронная операция выборки из базы данных должна быть реализована в fn1(), а вызывающие функции fn1() должны дождаться возврата результата. Поэтому я должен как-то изменить функции в цепочке вызовов, например fn2(). Я не пытаюсь сделать что-то асинхронным без причины. :) Было бы эффективнее передать функцию обратного вызова?
@MartinLöper Если вы ничего не делаете, удалите объявление await
. т.е. если async
является вашим асинхронным вызовом БД, вы можете пропустить вызов и напрямую вернуть fn(1)
, если вам НЕ нужно что-то делать с результатом после возврата вызова БД. Это означает, что вы можете изменить Promise
, чтобы он не был fn2()
, и напрямую вернуть async
. Единственное место, где вам нужно ключевое слово fn1()
, это когда вы хотите получить результат от асинхронного вызова, и это находится в вашем IIFE.
Казалось бы совершенно очевидным, что добавление ненужных накладных расходов к функциям замедляет их работу. Это действительно неожиданно? А почему вы вообще "код рефакторинга, который ранее был синхронным, в асинхронный код"?
@ Берги Хороший вопрос! Я хотел, чтобы чистая функция получения (условно) выполняла вызов базы данных. Допустим, 1 из 1000 звонков — это обращение к базе данных. Остаток возвращает значение из памяти. Функция вызывалась дюжину раз как часть алгоритма перебора строк, который по замыслу представляет собой большой цикл for. Функция, ставшая асинхронной, вызывалась очень глубоко в стеке вызовов, потому что это была какая-то служебная функция. Мне пришлось сделать все функции в цепочке вызовов асинхронными, чтобы они могли вызывать эту служебную функцию. Это привело к резкому замедлению работы, что и послужило мотивом для этого поста SO.
@MartinLöper Геттер, особенно тот, который вы называете «чистым», определенно не должен вызывать базу данных. Загрузите данные заранее, а затем запустите на них свой алгоритм.
Вы определенно правы, что вам не следует писать такой код. Мне просто интересно, что произойдет, если вы все же сделаете это. Интересно, что этот пост привел к обсуждению того, что должны делать JS и JIT-компилятор и что является частью ответственности разработчика. Очень ценю ваше мнение по этой теме, ребята :)
Вот более продвинутый тест, который вычисляет ряды Фибоначчи с синхронной или асинхронной функцией:
async function benchmark(M = 1000000, N = 100) {
function fibonacci_sync(num) {
let a = 1, b = 0, temp
while (num >= 0) {
temp = a; a = a + b; b = temp; num--
}
return b
}
async function fibonacci_async(num) {
let a = 1, b = 0, temp
while (num >= 0) {
temp = a; a = a + b; b = temp; num--
}
return b
}
timeitSync ('sync', M, () => {for(let i = 0; i < N; i++) fibonacci_sync(i)})
await timeit('async', M, async () => {for(let i = 0; i < N; i++) await fibonacci_async(i)})
}
Пример времени выполнения в node.js — async оказывается в 2,8 раза медленнее:
sync: 4.753s
async: 13.359s
С большим M
, но меньшим N = 10
вместо N=100 (более короткий расчет, поэтому ожидания оказывают большее влияние), асинхронная функция становится в 14,5 раз медленнее (упс!!):
sync: 0.499s
async: 7.258s
Это на узле v16.13.1. Эталонный тест был вдохновлен этим сообщением: https://madelinemiller.dev/blog/javascript-promise-overhead/
Для полноты картины вот функции timeit
, использованные выше:
async function timeit(label, repeat, fun) {
console.time(label)
for (let i = 0; i < repeat; i++) await fun()
console.timeEnd(label)
}
function timeitSync(label, repeat, fun) {
console.time(label)
for (let i = 0; i < repeat; i++) fun()
console.timeEnd(label)
}
При измерении fibonacci_sync
с помощью async timeit
вместо timeitSync
время выполнения растет с 0.499s
до 1.2s
в последнем примере, что является еще одним подтверждением того, что async
вызывает сильное замедление.
Так что да, действительно, асинхронные вызовы могут привести к ОГРОМНОМУ снижению производительности, даже на порядок. Каждый вызов должен проходить через очередь событий, управление которой создает значительные накладные расходы. Это определенно следует учитывать при реализации кода, который плотно набит асинхронными функциями.
Учитывая, насколько «заразна» парадигма async
— с одной низкоуровневой функцией, являющейся асинхронной, все вызывающие ее функции вверх по дереву также должны быть асинхронными — я был бы рад, что JS вводит оптимизации, позволяющие await...
выполняться мгновенно в некоторых (большинстве ) случаев, а не помещаться в очередь снова и снова через каждую пару инструкций. Это может принести пользу всем сценариям, где await
завернуто в условное выражение и редко требует фактической остановки функции, но все же требует, чтобы функция была объявлена как «асинхронная», независимо от того, как часто достигается «ожидание».
«Каждый вызов должен проходить через очередь событий» — это не вызовы функций, это возобновления кода await
ing после разрешения промисов, которые проходят через очередь микрозадач. "JS вводит оптимизации, позволяющие мгновенно выполнять await...
в некоторых (большинстве) случаях." - нет, нет и не будет. То, что await
-выражения гарантируют асинхронность (точно так же, как .then()
-вызовы, для которых они являются синтаксическим сахаром), является важной особенностью асинхронных функций.
Ключевой вывод из статья, которую вы связали: "Самое простое решение здесь — выполнять выборку данных или другие асинхронные операции ближе к корню приложения и передавать полученные данные вниз. Часто структуры программ, которые включают глубоко вложенные асинхронные/ожидающие пути, демонстрируют плохое разделение задач. В идеале одна система не должна загружать и использовать данные; вместо этого он должен получать данные из другой системы, которая их загружает. Эта структура также имеет дополнительное преимущество, заключающееся в том, что ее гораздо легче тестировать."
@Bergi: в некоторых случаях тот факт, что выражения ожидания гарантируют асинхронность... является ошибкой, а не функцией - приведенный выше тест показывает, почему. Чего программист обычно хочет достичь с помощью «ожидания», так это разрешать, а не обеспечивать соблюдение, асинхронности в этом конкретном месте. Задача интерпретатора и JIT-компилятора заключается в том, чтобы решить, желательна ли отсрочка выполнения в данный момент или нет - именно для такой оптимизации и нужен JIT-компилятор. Нет, я не думаю, что возложение ответственности за эти оптимизации на программистов поможет им создавать более качественное программное обеспечение.
Интересная дискуссия о том, что должен или не должен делать движок JS, ребята! Я совершенно не собирался навязывать асинхронное поведение при расширении нашей кодовой базы. Я просто хотел разрешить асинхронность глубоко вложенному методу, который вызывался десятки раз из-за некоторого цикла for.
Все, что это делает, это создает ненужные промисы с функциями
async
и немного меняет порядок выполнения. Вы не сделаете синхронный код асинхронным таким образом.