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

Введение

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

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

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

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

Прежде чем перейти к основной части статьи, я хотел бы рассказать о том, почему асинхронность считается важным аспектом в науке о данных и почему я использовал JavaScript, а не Python, чтобы объяснить синтаксис async/await.

1. Почему следует помнить об асинхронности в науке о данных?

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

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

2. Почему JavaScript?

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

Кроме того, по сравнению с Python, JavaScript имеет более мощную встроенную поддержку работы с асинхронностью и обычно служит лучшим примером при глубоком изучении этой темы. 

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

Асинхронный код в JavaScript

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

// Пример проблемы "ада обратных вызовов"

functionOne(function () {
functionTwo(function () {
functionThree(function () {
functionFour(function () {
...
});
});
});
});

Промисы предоставляют удобный интерфейс для асинхронной разработки кода. Промис принимает в конструкторе асинхронную функцию, которая будет выполнена в определенный момент времени в будущем. До выполнения функции промис находится в состоянии ожидания (pending). В зависимости от того, успешно или нет завершилась асинхронная функция, промис меняет свое состояние на «выполнено» (fulfilled) или «отклонено» (rejected) соответственно. Для последних двух состояний программисты могут подключать к промису методы .then() и .catch(), чтобы объявлять логику обработки результата асинхронной функции в различных сценариях.

Диаграмма состояний промиса

Кроме того, группу промисов можно объединить в цепочку с помощью методов комбинирования, таких как any()all()race() и т. д.

Недостатки промисов

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

  • Многословность. Промисы обычно требуют написания большого количества шаблонного кода. В некоторых случаях создание промиса с простой функциональностью требует дополнительных строк кода из-за его многословного синтаксиса.
  • Проблемы с читаемостью. Наличие нескольких задач, зависящих друг от друга, приводит к вложению промисов один в другой. Эта печально известная проблема очень похожа на «ад обратных вызовов«. Она делает код сложным для чтения и сопровождения. Кроме того, при обработке ошибок обычно сложно проследить логику кода, когда ошибка распространяется на несколько цепочек промисов.
  • Отладка. Проверяя вывод трассировки стека, может быть сложно определить источник ошибки внутри промисов, поскольку они обычно не предоставляют четких описаний ошибок.
  • Интеграция с устаревшими библиотеками. Многие устаревшие библиотеки JavaScript были разработаны в прошлом для работы с необработанными обратными вызовами, поэтому они несовместимы с промисами. Если код пишется с использованием промисов, то необходимо создать дополнительные компоненты кода для обеспечения совместимости со старыми библиотеками.
И обратный вызов, и промисы могут привести к печально известной проблеме «ада обратных вызовов»

Async/await

Конструкция async/await была добавлена в JavaScript как синтетический сахар поверх промисов. Как следует из ее названия, она вводит в код два новых ключевых слова.

  • async используется перед сигнатурой функции и помечает функцию как асинхронную. Эта функция всегда возвращает промис, даже если промис не возвращается явно (в таком случае он будет обернут в неявном виде).
  • await используется внутри функций, помеченных как async, и объявляется в коде перед асинхронными операциями, возвращающими промис. Если строка кода содержит ключевое слово await, то следующие строки кода внутри асинхронной функции не будут выполняться до тех пор, пока возвращенный промис не будет разрешен (пока не будет либо в состоянии «выполнено», либо в состоянии «отклонено»). Таким образом, если логика выполнения последующих строк зависит от результата асинхронной операции, они не будут запущены.

Ключевое слово await может быть использовано несколько раз внутри асинхронной функции.

  • Если await используется внутри функции, которая не помечена как async, будет выброшена ошибка SyntaxError.
  • Возвращаемый результат функции, помеченной await, — это разрешенное значение промиса.

Пример использования async/await показан в приведенном ниже фрагменте.

// Пример Async / await
// Этот фрагмент кода выводит в консоль начальные и конечные слова

function getPromise() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('end');
},
1000);
});
}

// Поскольку эта функция помечена как async, она вернет промис
async function printInformation() {
console.log('start');
const result = await getPromise();
console.log(result) // Эта строка не будет выполняться до тех пор, пока не будет разрешен промис
}

Важно понимать, что await не блокирует выполнение основного потока JavaScript. Вместо этого он лишь приостанавливает выполнение вложенной async-функции (в то время как другой программный код за пределами async-функции может выполняться).

Обработка ошибок

Конструкция async/await предоставляет стандартный способ обработки ошибок с помощью ключевых слов try/catch. Для обработки ошибок необходимо обернуть весь код, который потенциально может привести к сбою (включая объявления await), в блок try и написать соответствующие механизмы обработки в блоке catch.

На практике обработка ошибок с помощью блоков try / catch оказывается проще и читабельнее, чем попытка достижения той же цели в промисах с применением цепочки отказов .catch().

// Шаблон обработки ошибок внутри асинхронной функции

async function functionOne() {
try {
...
const result = await functionTwo()
} catch (error) {
...
}
}

Промисы или конструкция async/await?

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

Простой синтаксис async/await устраняет проблему «ада обратных вызовов»

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

Взаимозаменяемость кода

Рассмотрим пример, в котором тот же код написан с использованием async/await и промисов. Будем считать, что наша программа подключается к базе данных и в случае установления соединения запрашивает данные о пользователях для дальнейшего отображения их в пользовательском интерфейсе.

// Пример асинхронных запросов, обрабатываемых с помощью async/await

async function displayUsers() {
try {
const response = await connectToDatabase();
...
const users = await getData(data);
showUsers(users);
...
} catch (error) {
console.log(`An error occurred: ${error.message}`);
...
}
}

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

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

Диаграмма для визуализации описанного примера

Теперь напишем тот же фрагмент кода, используя промисы:

// Пример асинхронных запросов, обрабатываемых с помощью промисов

function displayUsers() {
...
connectToDatabase()
.then((response) => {
...
return getData(data);
})
.then((users) => {
showUsers(users);
...
})
.catch((error) => {
console.log(`An error occurred: ${error.message}`);
...
});
}

Такой вложенный код выглядит более многословным и трудным для чтения. Кроме того, мы видим, что каждый оператор await был преобразован в соответствующий метод then(), а блок catch теперь находится внутри метода .catch() промиса.

Следуя той же логике, любой код async/await можно переписать с помощью промисов. Это утверждение демонстрирует тот факт, что async/await — просто синтетический сахар поверх промисов.

Код, написанный с использованием async / await, может быть преобразован в синтаксис промиса, где каждому объявлению await будет соответствовать отдельный метод .then(), а обработка исключений будет выполняться в методе .catch().

Пример получения данных

В этом разделе рассмотрим реальный пример того, как работает async/await.

Будем использовать REST API стран, который предоставляет демографическую информацию для запрашиваемой страны в формате JSON по следующему URL-адресу: https://restcountries.com/v3.1/name/$country.

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

const retrieveInformation = function (data) {
data = data[0]
return {
country: data["name"]["common"],
capital: data["capital"][0],
area: `${data["area"]} km`,
population: `{$data["population"]} people`
};
};

Затем мы воспользуемся API fetch для выполнения HTTP-запросов. Fetch — это асинхронная функция, которая возвращает промис. Поскольку нам немедленно нужны данные, возвращенные fetch, мы должны подождать, пока fetch завершит работу, прежде чем выполнять следующие строки кода. Для этого используем ключевое слово await перед fetch.

// Пример работы fetch с использованием async/await

const getCountryDescription = async function (country) {
try {
const response = await fetch(
`https://restcountries.com/v3.1/name/${country}`
);
if (!response.ok) {
throw new Error(`Bad HTTP status of the request (${response.status}).`);
}
const data = await response.json();
console.log(retrieveInformation(data));
} catch (error) {
console.log(
`An error occurred while processing the request.\nError message: ${error.message}`
);
}
};

Аналогичным образом помещаем еще один await перед методом .json(), чтобы парсить данные, которые используются сразу после этого в коде. В случае ненадлежащего ответа или невозможности парсить данные возникает ошибка, которая затем обрабатывается в блоке catch.

В целях демонстрации перепишем также фрагмент кода с использованием промисов:

// Пример работы fetch с использованием промисов

const getCountryDescription = function (country) {
fetch(`https://restcountries.com/v3.1/name/${country}`)
.then((response) => {
if (!response.ok) {
throw new Error(`Bad HTTP status of the request (${response.status}).`);
}
return response.json();
})
.then((data) => {
console.log(retrieveInformation(data));
})
.catch((error) => {
console.log(
`An error occurred while processing the request. Error message: ${error.message}`
);
});
};

Вызов either-функции с указанным названием страны выведет основную информацию о ней:

// Результат вызова getCountryDescription("Argentina")

{
country: 'Argentina',
capital: 'Buenos Aires',
area: '27804000 km',
population: '45376763 people'
}

Заключение

В этой статье мы рассмотрели конструкцию async/await в JavaScript, которая появилась в этом языке в 2017 году. Эта конструкция, задуманная как средство усовершенствования промисов, позволяет писать асинхронный код синхронно, устраняя проблему вложенных фрагментов кода. Если ее использовать правильно в сочетании с промисами, можно сделать код максимально чистым.

Наконец, информация, представленная в этой статье о JavaScript, будет ценна и для разработчиков на Python, ведь в этом языке есть та же конструкция async/await. Я рекомендую всем, кто хочет глубже погрузиться в изучение асинхронности, сосредоточиться больше на JavaScript, чем на Python. Зная о множестве инструментов, существующих в JavaScript для разработки асинхронных приложений, легче понять те же концепции в других языках программирования.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Vyacheslav Efimov: Intuitive Explanation of Async / Await in JavaScript

Предыдущая статьяReacType (v21): низкий барьер входа и высокая планка разработки на React
Следующая статьяСоздание локального озера данных с нуля