Подписывайтесь на мой твиттер, там всегда что-нибудь интересное!

Полное понимание синхронного и асинхронного JavaScript с Async/Await

В этой статье вы узнаете о том, что такое синхронное и асинхронное программирование в JavaScript и как применяя эти знания работать с Async/Await.

По факту это адаптированный перевод двух отличных статей:

Выполнение синхронного и асинхронного кода в JavaScript

Недавно мы вели беседу с несколькими начинающими JS разработчиками, относительно того, как JS распределяет память и как парсится код, ну и само собой как он выполняется. Это одна из самых важных тем, которая никогда не являлась частью какой-либо программы обучения, но её, в принципе и не обязательно знать, чтобы написать программу на JavaScript. Такие темы очень важны для любопытных разработчиков, которые серьёзно относятся к своему делу. Я решил написать о ней, так как я нахожу её довольно неоднозначной, а люди имеют свойство сравнивать вещи, в особенности это склонны делать те, кто знаком с такими языками программирования как PHP, C++, Java и т.д, но учтите, что JavaScript это дикий зверь и с самого начала у меня он забрал довольно прилично времени для того, чтобы осознать некоторые важные аспекты, например то, как будучи однопоточным, JavaScript может быть синхронным и неблокируемым процессом?

Теперь перед тем как мы копнем глубже, давайте проясним основную концепцию и разницу между JavaScript Engine (движок) и JavaScript Run-time Environment.

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

С другой стороны, JavaScript Run-time Environment это среда, отвечающая за создание экосистемы с возможностями, сервисами и поддержкой, такими как массивы, функции, ключевые библиотеки и тп, которые необходимы для того, чтобы код запустился верно.

Функциональная модель

Почти все браузеры имеют на борту 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.

Узрите: промисы

Промисы это очень мудрый и хороший способ работы с асинхронным кодом.

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


Промис это объект, который представляет собой асинхронный таск, который должен завершиться. При использовании это выглядит как-то так:

function buyCoffee() {
  return new Promise((resolve, reject) => {
    asyncronouslyGetCoffee(function(coffee) {
      resolve(coffee);
    });
  });
}

buyCoffee возвращает промис, который является процессом покупки кофе. Функция resolve указывает промису на то, что он выполнен. Он получает значение как аргумент, который будет доступен в промисе позже.

В самом экземпляре промиса есть два основных метода:

then — запускает колбек, который вы передали, когда промис завершен.

catch — запускает колбек, который вы передали, когда что-то идет не так, что вызывает reject вместо resolveReject вызывает как вручную, так и автоматически, если необработанное исключение появилось внутри кода промиса.
Важно: промисы которые были выкинуты из-за исключения, поглотят это исключение. Это означает то, что если ваши промисы не связаны должным образом или нет вызова 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); // This logs: "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() это тоже асинхронная функция, как нам ожидать её? Да никак. Не с нашим новым синтаксисом. У нас есть три варианта:

  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;
}
// Ошибки полетят сюда
(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);
  });
 );
}
// Ошибки полетят сюда
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. Но как бы мы это выполнили с ним?

Явно не так:

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 () {
    // отдаётся промис
    return doSomethingAsync(value);
  });
}

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

Также, в наших примерах, нам не надо было ожидать вызова forEach.

Так как теперь решить эту проблему? К сожалению, мы не можем использовать forEach. По факту, никакие синхронные итераторы не будут работать. Нам нужны итераторы, которые знают как работать с промисами.

И есть один, который будет. Это современная версия цикла for, “for of”, которая понимает await для промисов.

Это сработает:

for (item of someArray) {
 await foo();
}

Если вы не можете использовать “for of”, то вы можете применить итератор, который поддерживает промисы или использовать библиотеку, такую как bluebird Promise.each

В общем, поймите промисы и вы поймете async/await.

Заключение

Я надеюсь, что это прояснило картину, async/await это просто, если вы хорошо понимаете промисы.