Понимание синхронного и асинхронного JavaScript с Async/Await
В этой статье вы узнаете о том, что такое синхронное и асинхронное программирование в JavaScript и как применяя эти знания, работать с Async/Await
.
JavaScript выполнение синхронного и асинхронного кода
Недавно мы вели беседу с несколькими начинающими JS разработчиками, относительно того, как JS распределяет память и как парсится код, а также выполняется. что одна из самых важных тем, которая никогда не являлась частью любой программы обучения и ни одну из них не обязательно знать, чтобы написать программу на JavaScript. Но такие темы очень важны для любопытных разработчиков, которые серьёзно относятся к своему делу. Я решил написать об этой теме, так как я нахожу её довольно неоднозначной, а люди имеют свойство сравнивать вещи, в особенности те, кто знаком с такими языками программирования, как PHP, C++, Java и т.д, но учтите, что JavaScript это дикий зверь и с самого начала у меня он забрал довольно прилично времени для того, чтобы осознать некоторые важные аспекты JS, например то, как Будучи однопоточным, JavaScript может быть синхронным и неблокируемым процессом?
Теперь перед тем как мы копнем глубже, давайте проясним основную концепцию и разницу между JavaScript Engine (движок) и JavaScript Run-time Enviroment.
Движок JavaScript это программа, которая используется для обработки заданного кода и конвертирования его в конкретные команды для выполнения.
С другой стороны, JavaScript Run-time Enviroment это среда отвечающая за создание экосистемы с возможностями, сервисами и поддержкой, такими как массивы, функции, ключевые библиотеки и тп, которые необходимы для того, чтобы код запустился верно.
Функциональная модель
Почти все бразуеры имеют JavaScript движок. Самые популярные это V8 в Google Chrome и Node.js, SpiderMonkey Мазилы, Chakra для IE и т.д. Хоть все эти браузеры и выполняют JavaScript по-разному, но под капотом, они все работают по старой доброй модели.
Call Stack, Web APIs, Event loop, асинхронная очередь заданий, очередь на рендер и т.д. Все мы слышали эти шумные термины в нашей ежедневной работе. В совокупности, все они работают вместе, чтобы перевести и выполнить синхронные и асинхронные блоки кода, которые мы пишем каждый день. Давайте заглянем глубже в эту модель и попытаемся понять, что они делают и что самое важное — как они это делают.
Синхронные задачи
Что означает синхронность? Скажем, что у нас есть 2 строки кода. Первая идет за второй. Синхронность означает то, что строка 2 не может запуститься до тех пор, пока строка 1 не закончит своё выполнение.
JavaScript сам по себе однопоточный, что означает то, что только один блок кода может запускаться за раз. Так как движок JS выполняет наш код, обрабатывая строку за строкой, он использует один стек вызова, чтобы продолжать отслеживать код, который выполняется в соответствии с установленным порядком. Тоже самое, что и делает стек — структура данных, которая записывает строки выполняемых инструкций и выполняет их в стиле LIFO
, то есть Last In First Out
, что переводится как, "последний пришел — первый обслужен”. Давайте посмотрим на живом примере, как это происходит и работает, вот function foo() { foo()
отправляется в стек и затем, когда выполнение foo()
доходит до return;} foo()
прекращается и выкидывается из стека вызовов.
Что происходит в Exercise 1: итак, схема выше показывает нам типичное линейное выполнение кода. Когда код из трех console.log
объявлений отдается в JS.
Шаг 1: console.log("Print 1")
отправляется в стек вызовов и выполняется, после того, как процесс завершится, он будет выкинут из стека. Теперь стек пуст и готов к следующим инструкциям на выполнение.
Шаг 2: Следующей инстукцией на выполнение является console.log("Print 2");
, который также отправляется в стек и после выполнения оттуда выкидывается. Всё повторяется до тех пор, пока не останется ничего для выполнения.
Давайте посмотрим на следующий пример:
Exercise 2: что же тут происходит на самом деле:
Шаг 1: В стек вызовов попадает первое выполняемое объявление нашего скрипта — вызов функции First()
. Во время выполнения в области видимости функции First()
, наш движок встречает вызов ещё одной функции — Second()
.
Шаг 2: Следовательно, вызов функции Second()
отправляется в стек вызовов и движок начинает выполнение её содержимого, снова встречаясь с ещё одной функцией Third()
внутри Second()
.
Шаг 3: Функция Third()
также отправляется в стек запросов и движок начинается её выполнение. Пока функции Second()
и First()
находятся в стеке и ждут своей очереди в соответствии с порядком.
Шаг 4: Когда движок сталкивается с return; внутри функции Third()
, то это означает завершение Third()
. Следовательно Third()
выкидывается из стека, как завершенное исполнение. На этом моменте движок возвращается к выполнению Second()
.
Шаг 5: итак, как только движок столкнется с return;, функция Second()
будет выкинута из стека и начнется выполнение First()
. Теперь тут нет объявления return внутри области видимости First()
, так что выполнится только код до конца его области видимости и First()
будет выкинут из стека на шаге 6.
Вот то, как браузер работает с синхронными задачами без привлечения чего-либо ещё, кроме "легендарного” стека вызовов. Но всё становится куда сложнее, когда JavaScript сталкивается с асинхронными задачами.
Асинхронные задачи
Что такое вообще — асинхронность? В отличие от синхронности, асинхронность это модель поведения. Предположим, что у нас есть две строчки кода, первая за второй. Первая строка это инструкция, для которой нужно время. итак, первая строка начинает запуск этой инструкции в фоновом режиме, позволяя второй строке запуститься без ожидания завершения первой строки.
Нам нужно такое поведение в случае, когда что-то тормозит. Синхронность может казаться прямолинейной и незатейливой, но всё же может быть медленной. Такие задачи, как обработка изображений, операции с файлами, создание запросов сети и ожидание ответа — всё это может тормозить и быть долгим, производя огромные расчеты в 100 миллионов циклов итераций. Так что такие вещи в стеке запросов превращаются в "задержку”, ну или "blocking” по-английски. Когда стек запросов заблокирован, браузер препятствует вмешательству пользователя и выполнению другого кода до тех пор, пока "задержка” не выполнится и не освободит стек запросов. Таким образом, асинхронные колбэки (callback) используются в таких ситуациях.
Пример: Видимо функция setTimeout()
это простейший способ продемонстрировать основы асинхронного поведения.
Exercise 3: Давайте рассмотрим стек запросов, который только что увидели:
Шаг 1: Как и обычно console.log("Hello ")
отправляется в стек первым и сразу же из него выкидывается после выполнения.
Шаг 2: setTimeout()
отправляется в стек, но обратите внимание на то, что console.log("Siddhartha")
не может сразу выполниться, так как стоит отсрочка на 2 секунды. Так что пока эта функция для нас исчезнет, но мы позже разберем этот вопрос.
Шаг 3: Само собой, следующая строка это console.log(" I am ")
, которая отправляется в стек, выполняется и тут же выкидывается из него.
Шаг 4: Сейчас стек запросов пуст и в ожидании.
Шаг 5: Внезапно console.log( "Siddhartha" )
обнаруживается в стеке, после 2-х секунд задержки. Далее setTimeout()
выполняется и сразу после этого выкидывается из стека. На 6-м шаге, наш стек оказывается пустым.
что говорит о том, что пусть даже JavaScript и однопоточный, мы можем достичь согласованности действий через асинхронное исполнение задач.
Теперь у нас осталось несколько вопросов:
Вопрос 1: Что случилось с setTimeout()
?
Вопрос 2: Откуда оно вернулось?
Вопрос 3: и как это вообще произошло?
и тут появляется Event Loop (или цикл обработки событий) и Web API. Давайте представим каждого из вышесказанных и ответим на эти три вопроса в нашей следующей схеме.
Exercise 4: Давайте разберемся.
Шаг 2: С этого момента setTimeout(callback, 2000)
отправляется в стек запросов. Как мы можем видеть, тут имеются компоненты callback и задержка в 2000ms. Теперь setTimeout()
не является частью JavaScript движка, это по сути Web API включенное в среду браузера как дополнительный функционал.
Шаг 3: итак, Web API браузера берет на себя callback и запускает таймер в 2000ms, оставляя на фоне setTimeout()
, которое сделало свою работу и выкинуто из стека. Вот и ответ на первый вопрос.
Шаг 4: Следующая строка в нашем скрипте это console.log( "I am" )
, отправленное в стек и выкинутое оттуда после выполнения.
Шаг 5: Теперь у нас есть callback в WebAPI, который собирается сработать по прошествии 2000ms. Но WebAPI не может напрямую как попало закидывать что-то в стек запросов, потому что это может создать прерывание для другого кода, выполняемого в JavaScript движке, именно в этот момент. Так что callback поставится в очередь выполнения задач после 2000ms. А теперь WebAPI пуст и свободен.
Шаг 6: Цикл событий или Event Loop — ответственный за взятие первого элемента из очереди задач и передачу его в стек запросов, только тогда, когда стек пуст и свободен. На этом шаге нашего уравнения, стек запросов пуст.
Шаг 7: итак, callback отправлен в стек запросов, так как он был пуст и свободен. и тут же выполнился. Так что ответ на второй вопрос готов.
Шаг 8: Далее идет выполнение кода console.log("Siddhartha")
, который находится в области видимости callback, следовательно, console.log("Siddhartha")
отправляется в стек запросов.
Шаг 9: После того, как console.log("Siddhartha")
выполнен, он выкидывается из стека запросов и JavaScript приходит к завершению выполнения callback. Который в свою очередь после своего завершения будет выкинут из стека запросов. А вот и ответ на вопрос как.
итак, это была довольно простая демонстрация происходящего, но всё может стать сложнее в некоторых ситуациях, например тогда, когда есть несколько setTimeout
в очереди — в общем результаты разнятся, от того что обычно ожидается.
Теперь давайте посмотрим на пример с Async/Await.
Далее мы попытаемся понять синтаксис async/await
погружаясь ещё глубже в то, что же это на самом деле и как это работает.
итак, вы знаете что оно делает, но знаете ли вы как?
У большинства разработчиков неоднозначное отношение к JavaScript, отчасти из-за того, что они становятся жертвами одного из его лучших качеств: он легко учится, но тяжело применяется. что легко подметить посмотря на то, сколько разработчиков склонны полагать, что этот язык работает только в одном направлении, но на самом деле всё происходит по-другому, если взглянуть под капот. именно эта разница проявляется в деталях и вызывает разочарование.
Для примера, я не сомневаюсь в том, что изменения в стандартах вызвали у многих из вас недопонимание о поведении языка, например как с классами. В JavaScript нет классов, в реальности, JavaScript использует Prototypes, синглтон объекты, из которых наследуются другие объекты. По-факту, все объекты в JavaScript имеют прототип из которого они наследуются. что означает то, что классы в JS на самом деле не ведут себя как классы. Класс это схема для создания экземпляров объекта, а prototype это экземпляр, которому другие экземпляры объекта передают работу, prototype это не схема и не шаблон, он просто есть и всё.
именно поэтому вы можете добавить новый метод для Array и тут же все массивы смогут его использовать. что можно сделать в среде выполнения, затронув объект, ставший экземпляром.
var someArray = [1, 2, 3];
Array.prototype.newMethod = function() {
console.log('I am a new method!');
};
someArray.newMethod(); // I am a new method!
// Код выше был бы невозможен с реальными классами, так как изменение схемы, не изменяет того, что было сделано с ним.
В общем, классы в JavaScript это синтаксический сахар для прототипизирования.
Я хочу тут сделать акцент на том, что вам надо выучить то, как язык на самом деле работает, кроме своего синтаксиса, если вы хотите полностью понять его возможности и ограничения.
Async/Await спецификации
Асинхронные функции это дополнение к языку, уже включенное в последний драфт EcmaScript. Вы можете смело их использовать с применением Babel.
async/await
пытается решить одну из главных головных болей языка со времен его появления: это асинхронность. То, как работает концепция асинхронного кода, вы прочитали в первой части этой статьи, если вы ещё не поняли, то обязательно перечитайте и поймите, перед тем как читать дальше.
На протяжении лет у нас было несколько способов работы с асинхронностью без всякого сумасшествия. В большинстве случаев, мы полагались на Callbacks
:
setTimeout(function() {
console.log("This runs after 5 seconds");
}, 5000);
console.log("This runs first");
Всё это хорошо, но что если мы столкнемся с последовательностью?
doThingOne(function() {
doThingTwo(function() {
doThingThree(function() {
doThingFour(function() {
// Oh no
});
});
});
});
То, что вы видите выше иногда называется Pyramid of Doom и Callback Hell.
Ппромисы
Промисы это очень мудрый и хороший способ работы с асинхронным кодом.
Промис это объект, который представляет собой асинхронный таск, который должен завершиться. При использовании это выглядит как-то так:
function buyCoffee() {
return new Promise((resolve, reject) => {
asyncronouslyGetCoffee(function(coffee) {
resolve(coffee);
});
});
}
buyCoffee возвращает промис, который является процессом покупки кофе. Функция resolve указывает промису на то, что он выполнен. Он получает значение как аргумент, который будет доступен в промисе позже.
В самом экземпляре промиса есть два основных метода:
Then
— запускает колбек, который вы передали, когда промис завершен.
Catch
— запускает колбек, который вы передали, когда что-то идет не так, что вызывает reject вместо resolve. Reject вызывает как вручную, так и автоматически, если необработанное исключение появилось внутри кода промиса.
Важно: промисы, которые были выкинуты из-за исключения, поглотят это исключение. что означает то, что если ваши промисы не связаны должным образом или нет вызова catch
в каком-либо промисе из цепочки, то вы обнаружите, что ваш код просто втихую порушится, что может быть очень разочаровывающе, так что избегайте таких ситуаций любой ценой.
У промисов есть и другие очень интересные свойства, которые позволяют им быть связанными. Предположим, что у нас есть другие функции, которые отдают промис. Мы могли бы сделать так:
buyCoffee()
.then(function() {
return drinkCoffee();
})
.then(function() {
return doWork();
})
.then(function() {
return getTired();
})
.then(function() {
return goToSleep();
})
.then(function() {
return wakeUp();
});
В этом случае использование колбеков было бы ужасным для поддержания кода и его чистоты.
Если вы не использовали промисы, то код выше может выглядеть непонятным, так как промисы, которые отдают промисы в своем методе then, вернут промис, который решается только когда возвращенный промис сам решается. и они сделают это со значением возвращенного промиса. В общем извините, по-другому нельзя сказать.
А вот и пример:
const firstPromise = new Promise(function(resolve) {
return resolve("first");
});
const secondPromise = new Promise(function(resolve) {
resolve("second");
});
const doAllThings = firstPromise.then(function() {
return secondPromise;
});
doAllThings.then(function(result) {
console.log(result); // Выдаст: "second"
});
Если вы хотите подробно понять промисы, то прочтите эту статью — Три способа понять промисы
итак, мы уже почти подошли к самому интересному.
Async функции, это функции, которые возвращают промисы. что так. Вот почему я выделил время, чтобы кратко объяснить, что же такое промисы, так как чтобы реально понять Async/Await
, вам надо знать то, как работают эти самые промисы. что как с примером про классы в JavaScript, где вам нужно понимать прототипирование.
Как это работает
Вот Async
функции. Которые объявляются добавлением слова async
, например async function doAsyncStuff() { …code }
Ваш код может встать на паузу в ожидании Async
функции с await
Await
возвращает то, что асинхронная функция отдаёт при завершении.
Await
может быть использовано только внутри async
функции.
Если асинхронная функция выдает исключение, то оно поднимется к родительской функции, как в обычном JavaScript и может быть перехвачено с try/catch
. Как и в промисах, исключения будут проглочены, если они не будут перехвачены где-нибудь в цепочке кода. что говорит о том, что вы всегда должны использовать try/catch
, всякий раз когда запускается цепочка вызовов Async
функций. Хорошей практикой является включение хотя бы одного try/catch
в каждую цепочку, если только в игнорировании этого совета нет абсолютной необходимости. что даст одно единственное место для работы с ошибками во время работы async
и сподвигнет вас правильно связать ваши запросы async
функций.
Давайте посмотрим на код
// Просто рандомные асинхронные функции, работающие со значением
async function thingOne() { … }
async function thingTwo(value) { … }
async function thingThree(value) { … }
async function doManyThings() {
var result = await thingOne();
var resultTwo = await thingTwo(result);
var finalResult = await thingThree(resultTwo);
return finalResult;
}
// Вызовите doManyThings()
что то, как выглядит async/await
, он очень схож с синхронным кодом, а синхронный код куда проще понять.
итак, теперь doManyThings()
это тоже асинхронная функция, как нам await (ожидать) её? Да никак. Не с нашим новым синтаксисом. У нас есть три варианта:
1. Дайте оставшемуся коду выполниться и не ждать завершения, как нам и нужно во многих случаях.
2. Запустите её внутри ещё одной асинхронной функции, обернутой в блок try/catch
.
3. или используйте как промис.
// Вариант 1:
doManyThings();
// Вариант 2:
(async function() {
try {
await doManyThings();
} catch (err) {
console.error(err);
}
})();
// Вариант 3:
doManyThings().then((result) => {
// Делаем штуки, которым нужно подождать нашей функции
}).catch((err) => {
throw err;
});
Снова функции, которые возвращают промисы
итак, под конец я бы хотел показать несколько примеров того, как async/await
приблизительно переходят в промисы. Я надеюсь, что это поможет вам увидеть то, как async
функции выполняют роль синтаксического сахара для создания функций, которые отдают и ожидают промисы.
Простая async
функция:
// Async/Await version
async function helloAsync() {
return "hello";
}
// Promises version
function helloAsync() {
return new Promise(function (resolve) {
resolve("hello");
});
}
Async функция, которая ожидает результат другой async функции:
// == Async/Await version ==
async function multiply(a, b) {
return a * b;
}
async function foo() {
var result = await multiply(2, 5);
return result;
}
// Errors will be swallowed here
(async function () {
var result = await foo();
console.log(result); // Logs 5
})();
// == Promises version ==
function multiply(a, b) {
return new Promise(function (resolve) {
resolve(a * b);
});
}
function foo() {
return new Promise(function(resolve) {
multiply(2, 5).then(function(result) {
resolve(result);
});
);
}
// Errors will be swallowed here
new Promise(function() {
foo().then(function(result) {
console.log(result); // Logs 5
});
});
Вот пример того, почему понимание того, как работает async/await
реально важно.
async function foo() {
someArray.forEach(function (value) {
doSomethingAsync(value);
});
}
Пока что всё хорошо, мы параллельно выполняем doSomethingAsync
несколько раз, так как мы не используем await. Но как бы мы это выполнили с await?
Явно не так:
async function foo() {
someArray.forEach(function (value) {
await doSomethingAsync(value);
});
}
Пример выше выдаст синтаксическую ошибку, так как мы передаем forEach
синхронную функцию.
Не проблема, верно? Нам всего-лишь надо передать ей async
функцию. А вот и нет.
async function foo() {
someArray.forEach(async function (value) {
await doSomethingAsync(value);
});
}
Что тут не так? Давайте посмотрим на то, как это интерпретируется. Я не будут многословным с промисами и объясню то, как это было бы в случае с ними:
function foo() {
someArray.forEach(function () {
// this is returning a promise
return doSomethingAsync(value);
});
}
Проблема в том, что forEach
не await
(ожидает) async
функции или выражаясь промисами, она не ждет пока одна итерация вернет промис, чтобы завершить предыдущую.
Также, в наших примерах, нам не надо было ожидать вызова forEach
.
Так как теперь решить эту проблему? К сожалению, мы не можем использовать forEach
. По-факту, никакие синхронные итераторы не будут работать. Нам нужны итераторы, которые знают как работать с промисами.
и есть один, который будет. что современная версия цикла for, "for of
”, которая понимает await для промисов.
что сработает:
for (item of someArray) {
await foo();
}
Если вы не можете использовать "for of
”, то вы можете применить итератор, который поддерживает промисы или использовать библиотеку, такую как bluebird Promise.each:
В общем, поймите промисы и вы поймете async/await
.