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

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

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

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

Теперь вы нанимаете шеф-повара, чтобы сделать ваш ресторан более эффективным. Вы берете первый заказ и передаете его шеф-повару, который сообщит вам, когда блюдо будет готово, чтобы вы могли подать его клиенту.

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

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

Приложения JavaScript работают в одном потоке обработки и выполняют одну команду за раз. Чтобы сделать это более эффективным, ОС обрабатывает операции ввода-вывода, такие как HTTP-запросы, чтение файлов или обновления базы данных. Приложение не ждет завершения операции: оно просит движок выполнить функцию обратного вызова, когда ОС завершит задачу. Этот обратный вызов получает данные об успехе или ошибке по мере необходимости.

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

Синхронный и асинхронный код

Рассмотрим следующий код PHP для записи текста в файл:

<?php
echo 'saving file';
$err = file_put_contents('file.txt', 'content');
if ($err !== false) echo 'file saved';
echo 'complete';
?>

При запуске код выводит:

saving file
file saved
complete

Интерпретатор PHP выполняет оператор file_put_contents() и ожидает завершения, прежде чем перейти к следующей команде.

Теперь рассмотрим аналогичный код JavaScript (Node.js):

import { writeFile } from 'node:fs';

console.log('saving file');
writeFile('file.txt', 'content', 'utf8', err => {
  if (!err) console.log('file saved');
});

console.log('complete');

Код выводит:

saving file
complete
file saved

Обработка завершается до записи файла!

Четвертый аргумент, передаваемый в writeFile(), — это анонимная функция обратного вызова ES6 с одним параметром err. Обратный вызов запускается, когда файл сохраняется или не удается сохранить. Это занимает несколько миллисекунд, но операция ввода-вывода операционной системы выполняется в фоновом режиме. Интерпретатор JavaScript может выполнять больше кода, переходя к следующей строке, и выводит complete.

Примечание: возврат ошибки в качестве первого аргумента функции обратного вызова является стандартной практикой. Если ошибки не возникает, аргумент должен быть ложным значением, таким как null или undefined.

Зачем нужен асинхронный код

Неблокирующий цикл событий ввода-вывода в JavaScript позволяет избежать проблем при выполнении кода в одном потоке:

  • Браузерному JavaScript не нужно ждать, пока пользователь нажмет кнопку — браузер вызывает событие, которое вызывает функцию, когда происходит щелчок.
  • Браузерному JavaScript не нужно ждать ответа на Fetch запрос — браузер вызывает событие, которое вызывает функцию, когда сервер возвращает данные.
  • Node.js JavaScript не нужно ждать результата запроса к базе данных — среда выполнения вызывает событие, которое вызывает функцию, когда данные возвращаются.

Приложения JavaScript зависли бы, если бы движок выполнял операции ввода/вывода синхронно.

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

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

Асинхронное программирование — одна из основных причин путаницы разработчиков при переходе на JavaScript с других языков. Это может показаться сложным, но следующие советы помогут избежать распространенных проблем.

Совет 1: не забудьте return после выполнения обратного вызова

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

// pause for ms milliseconds
function pause(ms, callback) {

  ms = parseFloat(ms);

  // invalid ms value?
  if (!ms || ms < 1 || ms > 5000) {

    const err = new RangeError('Invalid ms value');
    callback( err, ms );

  }

  // wait ms before callback
  setTimeout( callback, ms, null, ms );

}

(() => {

  console.log('starting');

  // pause for 500 ms
  pause(500, (err, ms) => {

    if (err) console.log(err);
    else console.log(`paused for ${ ms }ms`);

  });

})();

Синтаксис выглядит правильно, и он работает, как и ожидалось, с сообщением paused, появляющимся через полсекунды после started:

starting
paused for 500ms

Теперь попробуйте передать недопустимый аргумент ms в pause(), например 0. Выход:

started
RangeError: Invalid ms value
paused for 0ms

Выдает ошибку, но setTimeout тоже выполняется, и программа выводит paused for 0ms. Функция обратного вызова выполняется дважды, поскольку pause() не завершается при возникновении ошибки.

Важно помнить, что выполнение обратного вызова не завершает выполнение функции. Оператор return может решить проблему, например.

// invalid ms value?
  if (!ms || ms < 1 || ms > 5000) {

    const err = new RangeError('Invalid ms value');
    callback( err, ms );
    return;

  }

Или вы можете выполнить обратный вызов в return:

// invalid ms value?
  if (!ms || ms < 1 || ms > 5000) {

    const err = new RangeError('Invalid ms value');
    return callback( err, ms );

  }

Приложение выполняет обратный вызов один раз, и результат правильный:

started
RangeError: Invalid ms value

Наш код обратного вызова работает. Или нет?…

Совет 2: убедитесь, что функции на 100% синхронны или на 100% асинхронны

Приведенный выше исправленный код выглядит правильно и может пройти автоматическое тестирование, но есть более тонкая проблема: обратный вызов запускается немедленно при возникновении ошибки. В этот момент функция больше не является асинхронной — она синхронная. . Это может привести к утечке памяти в больших, долго работающих приложениях, которые в конечном итоге вылетают из-за ошибок «переполнения памяти», которые трудно отлаживать.

Функции JavaScript должны быть на 100 % синхронными или на 100 % асинхронными. Не должно быть никакого пути через асинхронную функцию, которая немедленно выполняет обратный вызов на текущей итерации цикла обработки событий.

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

// pause for ms milliseconds
function pause(ms, callback) {

  ms = parseFloat(ms);

  // invalid ms value?
  if (!ms || ms < 1 || ms > 5000) {

    const err = new RangeError('Invalid ms value');
    setTimeout( callback, 1, err, ms );
    return;

  }

  // wait ms before callback
  setTimeout( callback, ms, null, ms );

}

Примечание. Node.js предлагает setImmediate(), который вызывает функцию во время следующей итерации цикла событий. Возможно, вы также использовали process.nextTick(), который работает аналогично, но выполняет обратный вызов до окончания текущей итерации цикла обработки событий. (Это может вызвать бесконечный цикл обработки событий, если nextTick() вызывается рекурсивно.)

Совет 3: переключитесь на промисы

Код на основе обратного вызова становится все сложнее поддерживать, когда вы хотите сделать серию асинхронных вызовов функций. Это может привести к глубоко вложенному аду обратных вызовов:

asyncFn1(err => {

  console.log('asyncFn1 complete');

  asyncFn2(err => {

    console.log('asyncFn2 complete');

    asyncFn3(err => {

      console.log('asyncFn3 complete');

    });

  });
});

Вы найдете способы сгладить эту структуру в ECMAScript 5, но в ES6/2015 появились Promises. Обещания — это синтаксический сахар, а обратные вызовы по-прежнему используются скрыто. Асинхронная функция должна возвращать объект Promise, созданный с двумя параметрами:

  1. resolve: функция запускается после завершения обработки и
  2. reject: функция запускается при возникновении ошибки.

Альтернатива функции pause(), которая возвращает Promise:

// pause for ms milliseconds
function pausePromise(ms) {

  ms = parseFloat(ms);

  return new Promise((resolve, reject) => {

    if (!ms || ms < 1 || ms > 5000) {
      reject( new RangeError('Invalid ms value') );
    }
    else {
      setTimeout( resolve, ms, ms );
    }

  });

}

Примечание. Node.js предоставляет util.promisify(). Вы можете передать ему функцию на основе обратного вызова (которая возвращает ошибку в качестве первого параметра), и она возвращает альтернативу на основе Promise.

Все, что возвращает Promise, может запускать:

  1. then() метод. Ему передается функция с одним аргументом, содержащая результат предыдущего resolve()
  2. catch() метод. Ему передается функция с одним аргументом, содержащая результат предыдущего reject()
  3. finally() метод. Функция, вызываемая в конце обработки независимо от успеха или неудачи.

Пример кода для вызова pausePromise():

pausePromise(500)
  .then(ms => console.log(`paused ${ ms }ms`) )
  .catch(err => console.log( err ) )
  .finally( () => console.log('complete') );

Функция .then() может возвращать другое обещание или значение (которое JavaScript преобразует в асинхронное обещание), поэтому вы можете связать последовательные асинхронные функции, используя более простой и удобный для чтения синтаксис:

pausePromise(100)
  .then(ms => {
    console.log(`paused ${ ms }ms`);
    return pausePromise(200);
  })
  .then(ms => {
    console.log(`paused ${ ms }ms`);
    return pausePromise(300);
  })
  .then(ms => {
    console.log(`paused ${ ms }ms`);
  })
  .catch(err => {
    console.log( err );
  });

Повтор сеанса для разработчиков

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

Удачной отладки! Попробуйте использовать OpenReplay сегодня.

Совет 4: используйте async/await

Цепочки плоских обещаний по-прежнему могут сбивать с толку, и в них легко пропустить скобки. Также обратите внимание, что вся цепочка промисов асинхронна: любая функция, использующая промисы, должна возвращать свой промис… или запускать функцию обратного вызова, чтобы запутать вас в будущем!

ES2017 представил async и await, которые позволяют использовать функции на основе Promise с более понятным синтаксисом. Цепочка выше переписана для использования await:

try {

  const p1 = await pausePromise(100);
  console.log(`paused ${ p1 }ms`);

  const p2 = await pausePromise(200);
  console.log(`paused ${ p2 }ms`);

  const p3 = await pausePromise(300);
  console.log(`paused ${ p3 }ms`);

}
catch(err) {
  console.log(err);
}

Ключевое слово await перед любой асинхронной функцией на основе Promise заставляет JavaScript ждать, пока она не будет разрешена или отклонена. Полученный код очень похож на серию синхронных вызовов.

Любая функция, использующая await, должна иметь оператор async, чтобы указать, что она асинхронна, и превратить ее в функцию на основе промисов, например.

async function pauseSeries() {

  try {

    const p1 = await pausePromise(100);
    console.log(`paused ${ p1 }ms`);

    const p2 = await pausePromise(200);
    console.log(`paused ${ p2 }ms`);

    const p3 = await pausePromise(300);
    console.log(`paused ${ p3 }ms`);

  }
  catch(err) {
    console.log(err);
  }

}

async/await отлично, но иногда на него нельзя положиться…

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

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

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

try {

  const fetch1 = await fetch('/f1');
  console.log(`fetch1 status ${ fetch1.status }`);

  const fetch2 = await fetch('/f2');
  console.log(`fetch2 status ${ fetch2.status }`);

  const fetch3 = await fetch('/f3');
  console.log(`fetch3 status ${ fetch3.status }`);

}
catch(err) {
  console.log(err);
}

Запускать серии неэффективно: быстрее запускать их параллельно с помощью Promise.all(). Метод принимает массив обещаний, выполняет каждое из них параллельно и возвращает новое внешнее обещание, где resolve() возвращает массив выходных значений в том же порядке:

Promise.all([
  fetch('/f1'),
  fetch('/f2'),
  fetch('/f3')
])
  .then(result => {
    console.log(`fetch1 status ${ result[0].status }`);
    console.log(`fetch2 status ${ result[1].status }`);
    console.log(`fetch3 status ${ result[2].status }`);
  })
  .catch(err => {
    console.log( err );
  })

.catch() срабатывает, когда выполняется один промис reject() — он также отменяет все ожидающие промисы.

Код работает так же быстро, как и самый медленный Promise. Эквивалентного синтаксиса await не существует, но функции async возвращают Promise, поэтому их можно использовать в массиве Promise.all.

Аналогичные методы Promise включают в себя:

  • Promise.allSettled()
  • Запускает все обещания в массиве и ждет, пока все не будут разрешены или отклонены. Каждый элемент в возвращаемом массиве является объектом со свойством .status (либо 'fulfilled', либо 'rejected') и свойством .value с возвращаемым значением.
  • Promise.any()- Запускает все обещания в массиве, но разрешается, как только разрешается первое обещание, и прерывает все остальные. Он возвращает одно значение.
  • Promise.race() — Запускает все обещания в массиве, но разрешает или отклоняет, как только разрешается первое обещание, или отклоняет и прерывает все остальные. Он возвращает одно значение.

Совет 6. Избегайте использования асинхронных функций в синхронных циклах

Следующий код использует метод Array.forEach() для трехкратной паузы и суммирования каждого результата до totalWaited:

const pause = [100, 200, 300];
let totalWaited = 0;

pause.forEach(async p => {

  const ms = await pausePromise(p);
  console.log(`paused ${ ms }ms`);
  totalWaited += ms;

});

console.log(`total time waited: ${ totalWaited }ms`);

Результат не такой, как вы ожидали: цикл заканчивается до выполнения промисов:

total wait time: 0ms
paused 100ms
paused 200ms
paused 300ms

Это происходит потому, что forEach() ожидает синхронную функцию. Он выполняет асинхронные функции, но не будет ждать, пока они разрешатся или отклонятся. Сам цикл является синхронным: промисы выполняются параллельно, и невозможно передать результат одного в качестве аргумента следующему. Promise.all() будет лучшим вариантом.

Методы, включающие map() и reduce(), также являются синхронными и демонстрируют такое же поведение.

Можно использовать умные способы решения задачи, но самый простой вариант — это стандартный for цикл, который будет await между каждой итерацией:

const pause = [100, 200, 300];
let totalWaited = 0;

for (let p = 0; p < pause.length; p++) {

  const ms = await pausePromise( pause[p] );
  console.log(`paused ${ ms }ms`);
  totalWaited += ms;

}

console.log(`total time waited: ${ totalWaited }ms`);

Выход:

paused 100ms
paused 200ms
paused 300ms
total time waited: 600ms

Заключение

Асинхронное программирование требует времени для понимания и может заинтересовать самых опытных разработчиков JavaScript. Я подозреваю, что это основная причина нестабильности приложений Node.js, которые вылетают со странными ошибками «Недостаточно памяти». Ключевые советы:

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

См. также Как использовать клиентский и серверный веб