skip to main content

Асинхронность в JS

js

Кратко 🔗

Чтобы понять, что такое асинхронность, сперва поговорим о синхронном коде и том, как в принципе JavaScript выполняет код.

Чтобы выполнить код, нам нужен JavaScript Engine (движок) — программа, которая «читает и выполняет» то, что мы написали. Самый распространённый движок среди всех — это V8, он используется в Google Chrome и Node.js.

Выполнение js-кода — однопоточное. Это значит, что в конкретный момент времени движок может выполнять не более одной строки кода. То есть, вторая строка не будет выполнена, пока не выполнится первая.

Такое выполнение кода (строка за строкой) называется синхронным.

Синхронный код и его проблемы 🔗

Синхронный код понятно читать, потому что он выполняется ровно так, как написан:

console.log("A")
console.log("B")
console.log("C")

// A
// B
// C

// Никаких сюрпризов:
// в каком порядке команды указаны —
// в таком они и выполнились.

Однако с ним могут возникать некоторые проблемы. Представим, что нам нужно выполнить какую-то операцию, требующую некоторого времени — например, напечатать в консоли приветствие, но не сразу, а через 5 секунд.

// Ниже псевдокод —
// синхронная функция задержки delay() вымышленная:

function greet() {
console.log("Hello!")
}

delay(5000)
greet()

// Получилось бы:
// ...5 секунд бездействия...
// Hello!

И всё вроде хорошо, приветствие бы действительно напечаталось спустя 5 секунд, однако проблема здесь в другом.

Если бы мы запустили синхронную функцию задержки delay(), то движок бы ничем другим заниматься в это время не мог.

Мы помним, что выполнение синхронного кода — строка за строкой. То есть пока delay() не выполнится до конца, к следующей строке интерпретатор не перейдёт.

А это значит, что пока не пройдёт 5 секунд, и delay() не выполнится, мы в вообще ничего сделать не сможем: ни вывести что-то в консоль ещё, ни выполнить другие функции, в особо запущенных случаях — даже передвинуть курсор мы бы не смогли.

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

Асинхронный код 🔗

Теперь попробуем решить эту же задачу, но так, чтобы наш код не блокировал выполнение. Для этого мы воспользуемся функцией setTimeout():

setTimeout(function greet() {
console.log("Hello!")
}, 5000)

// ...5 секунд молчания...
// Hello!

Задача решена. В этот раз, однако, в эти «5 секунд молчания» мы можем выполнять другие действия.

setTimeout(function greet() {
console.log("Hello!")
}, 5000)

consloe.log(`I'm being called before greet function.`)

// I'm being called before greet function.
// ...примерно 5 секунд молчания...
// Hello!

Возникает несколько вопросов:

  1. Почему «вторая строка кода» выполнилась до «первой», если JS однопоточный?
  2. Куда девается setTimeout() на время, пока выполняется другой код?
  3. Как движок понимает, что пора выводить Hello!?

Чтобы с этим разобраться, нам надо понять, как функции вызываются «под капотом».

Стек вызовов 🔗

При вызове какой-то функции, она попадает в так называемый стек вызовов.

Стек — это структура данных, в которой элементы упорядочены так, что последний элемент, который попадает в стек, выходит из него первым (LIFO: last is, first out). Стек похож на стопку книг: та книга, которую мы кладём последней, находится сверху.

В стеке вызовов хранятся функции, до которых дошёл интерпретатор, и которые надо выполнить.

function outer() {
function inner() {
console.log("Hello!") // (3)
}

inner() // (2)
}

outer() // (1)

// Вызов функции: Стек:
// ==================================================================
// Пока ничего нет... Стек пуст.
// ===
// (1) Вызываем функцию outer();
// она попадает в стек -> outer;
// ===
// (2) Вызываем функцию inner();
// теперь в стеке 2 функции,
// потому что первая ещё не выполнилась до конца: -> inner;
// outer;
// ===
// (3) Вызываем console.log();
// теперь в стеке 3 функции: -> console.log;
// inner;
// outer;
// ===
// (4) Как только console.log() выполнится,
// он уйдёт из стека, там останется 2 функции: -> inner;
// outer;
// ===
// (5) Выполнившись, функция inner
// тоже уйдёт из стека, в нём останется лишь одна: -> outer;
// ===
// (6) После выполнения всего блока
// стек станет пустым. Стек пуст.

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

Теперь посмотрим, как ведёт себя стек вызовов при работе с асинхронным кодом:

function main() {
setTimeout(function greet() {
console.log("Hello!")
}, 2000)

console.log("Bye!")
}

main()

// Вызов функции: Стек:
// ==================================================================
// Пока ничего нет... Стек пуст.
// ===
// (1) Вызываем функцию main() -> main;
// ===
// (2) Вызываем setTimeout() -> setTimeout;
// main;
// ===
// (3) setTimeout завершился, он выходит из стека: main;
// ===
// (4) Вызываем console.log('Bye!'); -> console.log('Bye!');
// main;
// ===
// (5) Его вызов завершён, он выходит из стека: main;
// ===
// (6) Вызов main тоже завершён, стек становится пуст: Стек пуст.
// ===
// (7) ...Проходит около 2 секунд,
// вызывается функция greet, она попадает в стек: -> greet;
// ===
// (8) Она вызывает console.log('Hello!'); -> console.log('Hello!');
// greet;
// ===
// (9) Отработав, она уходит из стека: greet;
// ===
// (10) После выполнения всего блока,
// стек снова становится пустым. Стек пуст.

Первое, что бросается в глаза — setTimeout() завершается сразу, хотя колбэк внутри него ещё не отработал, более того, он даже ещё не был вызван! Здесь нам понадобится ещё одно понятие — цикл событий.

Цикл событий 🔗

Сперва откроем страшную правду, setTimeout() — это не JavaScript! 😱

Ну... не совсем так, конечно. Функция setTimeout() не является частью JavaScript-движка, это по сути Web API, включённое в среду браузера как дополнительная функциональность.

Эта дополнительная функциональность (Web API) берёт на себя работу с таймерами, интервалами, обработчиками событий. То есть когда мы регистрируем обработчик клика на кнопку — он попадает в окружение Web API. Именно оно знает, когда обработчик нужно вызвать.

Управление тем, как должны вызываться функции Web API, берёт на себя цикл событий (Event loop).

Цикл событий — отвечает за выполнение кода, сбора и обработки событий и выполнения подзадач из очереди.

Именно цикл событий ответственен за то, что setTimeout() пропал из стека, в прошлом примере. Чтобы увидеть картину целиком, давайте включим в нашу схему все недостающие части:

// Возьмём тот же самый пример:

function main() {
setTimeout(function greet() {
console.log("Hello!")
}, 2000)

console.log("Bye!")
}

main()

// Но теперь у нас будет не только стек вызовов,
// но также мы включим Web API и очередь задач,
// которую Web API использует для хранения того,
// что нужно выполнить.

// Вызов Стек Web API Очередь задач
// =============================================================================
// main -> main;
// ===
// setTimeout -> setTimeout;
// main;
// ===
// Когда setTimeout исчезает из стека, он попадает в видимость Web API,
// где интерпретатор понимает, что внутри него есть функция greet,
// которую надо выполнить через 2 секунды:
// main; -> setTimeout(greet)
// ===
// После этого Web API отправляет функцию greet в очередь задач,
// ждать времени, когда ей нужно будет выполниться:
// main; -> greet;
// ===
// Далее выполняется вызов консоли, в очереди задач в этом время
// всё ещё находится функция greet. Она будет там до тех пор,
// пока не истечёт 2 секунды.
// console.log -> console.log; greet;
// main;
// ===
// main; greet;
// ===
// Даже когда стек опустеет, очередь задач ещё не пуста.
// Стек пуст. greet;
// ===
// И вот, когда 2 секунды прошли,
// цикл событий проталкивает функцию greet из списка задач в вызов:
// greet -> greet;
// ===
// console.log -> console.log;
// greet;
// ===
// greet;
// ===
// Стек пуст.

Заметьте, что стек вызовов и очередь задачи называются именно стеком и очередью. Потому что вызовы из стека работают по принципу «последний зашёл, первый вышел» (LIFO), а в очереди — по принципу «первый зашёл, первый вышел» (FIFO: first in, first out).

Очередь — структура данных, в которой элементы упорядочены так, что первый попавший в очередь элемент покидает её первым.

И таким образом цикл событий работает с асинхронным кодом — то есть таким, который выполняется не построчно.

Очень хорошо работу цикла событий иллюстрирует инструмент Loupe Филипа Робертса, а также его доклад “What the heck is the event loop anyway?”.

Loupe интерактивный, попробуйте ввести какой-нибудь код в поле слева, и справа будет показываться, что и в какой момент попадает в стек вызовов и очередь событий:

Веб-интерфейс инструмента Loupe

Колбэки 🔗

Пример с setTimeout, который мы рассмотрели, показывает, как работают функции обратного вызова — колбэки.

Callback (колбэк, функция обратного вызова) — функция, которая вызывается в ответ на совершение некоторого события.

В нашем случае таким событием было срабатывание таймера через 2 секунды, а колбэком — функция greet(). В целом событием может быть что угодно:

  • ответ от сервера;
  • завершение какой-то длительной вычислительной задачи;
  • получение доступа к каким-то API устройства, на котором выполняется код.

Таким образом колбэк — это первый способ обработать какое-либо асинхронное действие.

Изначально колбэки были единственным способом работать с асинхронным кодом в JavaScript. Большая часть асинхронного API Node.js была написана именно на колбэках и заточена под использование с колбэками.

Это в принципе логично — ментальная модель достаточно простая: «выполни эту функцию, когда случится это событие».

Однако, у колбэков есть неприятный минус, так называемый колбэк-хел (callback-hell, ад колбэков).

Ад колбэков (Callback-hell) 🔗

Нагляднее всего его можно показать на примере.

Допустим, у нас есть ряд асинхронных задач, которые зависят друг от друга: то есть первая задача запускает по завершении вторую, вторая — третью и т. д.

setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
console.log("Hello!")
}, 5000)
}, 5000)
}, 5000)
}, 5000)

// Если одна задача запускает другую, та — третью и так далее,
// мы можем получить вот такую «башню» из обратных вызовов.

И такая башня может получиться в чём угодно. Если мы делаем несколько последовательных запросов к серверу, зависящих друг от друга, то это может выглядеть вот так:

function request(url, onSuccess) {
/*...*/
}

request("/api/users/1", function (user) {
request(`/api/photos/${user.id}/`, function (photo) {
request(`/api/crop/${photo.id}/`, function (response) {
console.log(response)
})
})
})

Читать такое сложно, не говоря уже о тестировании, которое здесь становится сверх-накладным.

Решить эту проблему были призваны Промисы (Promise).

Промисы (Promise) 🔗

Промис — это объект-обёртка для асинхронного кода. Он содержит в себе состояние: вначале pending («ожидание»), затем — одно из: fulfilled («выполнено успешно») или rejected («выполнено с ошибкой»).

В понятиях цикла событий Промис работает так же, как колбэк: функция, которая должна выполниться (resolve или reject), находится в окружении Web API, а при наступлении события — попадает в очередь задач, откуда потом — в стек вызова.

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

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

// Та же последовательность запросов из прошлого примера,
// но переписанная с использованием Промисов.

function request(url) {
return new Promise(function (resolve, reject) {
let responseFromServer
/*...*/
resolve(responseFromServer)
})
}

request("/api/users/1")
.then((user) => request(`/api/photos/${user.id}/`))
.then((photo) => request(`/api/crop/${photo.id}/`))
.then((response) => console.log(response))

// Код избавился от лишней вложенности,
// стал плоским и более тестируемым.

Дополнительным плюсом стала возможность обрабатывать ошибки от цепочки промисов в одном месте — последним catch:

request("/api/users/1")
.then((user) => request(`/api/photos/${user.id}/`))
.then((photo) => request(`/api/crop/${photo.id}/`))
.then((response) => console.log(response))
.catch((error) => console.error(error))

// Если что-то пошло не так, то программа не упадёт,
// а управление мгновенно перейдёт к последней строчке с `catch`,
// причём независимо оттого, в каком из запросов ошибка появится.

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

Однако Промисы — это тоже не серебряная пуля. У них есть несколько недостатков:

  • Код не такой лаконичный, как мог быть.
  • В цепочке Промисов, как на примере (со стрелочными функциями), невозможно выставить брейкпоинт, потому что нет тела функции. Приходится раскрывать функцию.
  • Стек ошибок может содержать в себе then.then.then.then....
  • Вложенные условия сильно увеличивают количество кода и ухудшают читаемость.

Для решения этих проблем придумали асинхронные функции.

Асинхронные функции 🔗

Если коротко, асинхронные функции — функции, которые возвращают Промисы.

Асинхронная функция помечается специальным ключевым словом async:

async function request() {}
const req = async () => {}

class SomeClass {
async request() {}
}

Они всегда возвращают Промис. Даже если мы явно этого не указывали, как в примерах выше, при вызове они всё равно вернут Промис.

async function request() {}

// Сработает:
request().then(() => {})

Однако с асинхронными функциями можно не обращаться с then — есть более изящное решение.

Связка async/await 🔗

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

async function loadPosts() {
const response = await fetch(`/api/posts/`)
const data = await response.json()
return data
}

В примере выше мы используем fetch внутри функции loadPosts.

Все асинхронные функции внутри мы вызываем с await — таким образом Промис, который функция возвращает, автоматически разворачивается, и мы получаем значение, которое внутри Промиса было.

Плюсы async/await 🔗

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

Условия и вложенные конструкции становятся чище и читаемее.

Мы можем обрабатывать ошибки с try-catch. Как и с синхронным кодом обработка ошибок сводится к оборачиванию опасных операций в try-catch:

async function loadPosts() {
try {
const response = await fetch(`/api/posts/`)
const data = await response.json()
return data
} catch (e) {
console.log(e)
}
}

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

Нам не нужно промисифицировать промежуточные значения. Если, например, мы хотели сделать цепочку из then, чтобы красиво обработать какие-то операции, но одна из операций Промис не возвращала...

function request(url) {
return new Promise(function(resolve, reject) {
let responseFromServer;
/*...*/
resolve(responseFromServer);
});
}

function findId(user) {
let id;
/* ... */
return id;
}

// ...то вызывать цепочку просто так
// у нас бы уже не получилось:
request('/api/users/1')
.then(user => findId(user)
// Упс, функция findId Промис не возвращает, так нельзя.
.then(user => request(`/api/photos/${user.id}/`))
.then(photo => request(`/api/crop/${photo.id}/`))
.then(response => console.log(response))
.catch(error => console.error(error));

Нам бы приходилось делать нечто вроде:

request('/api/users/1')
.then(user => Promise.resolve(findId(user))
// ...

// А если операция включала бы в себя обработку ошибок,
// то возможно, и...

request('/api/users/1')
.then(user => {
return new Promise(function(resolve, reject) {
/* ... */
});
})
// ...

С асинхронными функциями такой проблемы нет, await автоматически разворачивает значение сам.

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

В работе 🔗

🛠️ «Отложить выполнение» 🔗

Иногда нам бывает нужно отложить выполнение функции на потом, но не через какой-то промежуток времени, а чтобы она выполнилась сразу после каких-то операций. Для этого можно использовать setTimeout с нулевым таймером.

Мы помним, что колбэк из setTimeout откладывается в очередь задач. Если мы поставим интервал 0 миллисекунд, то эта задача выполнится ровно через один цикл событий — то есть сразу после синхронного кода.

Абсолютное временное значение одного цикла событий может варьироваться от 4 до 100 миллисекунд.

В Node.js и в некоторых браузерах есть setImmediate, который делает то же, что и setTimeout с нулевым таймером.

🛠️ «Дождаться всех» или «Кто первее» 🔗

Когда мы работаем с запросами к серверу, нам не всегда бывает нужно вызывать запросы последовательно друг за другом.

Бывают ситуации, когда мы хотим:

  • либо подождать, когда выполнятся все запросы, и потом сделать что-то;
  • либо дождаться выполнения первого запроса из списка и сделать что-то сразу после этого.

Для этого мы можем использовать Promise.all и Promise.race.

// Когда мы хотим дождаться выполнения всех запросов
// и сделать что-то после этого:
const request1 = fetch("/api/users")
const request2 = fetch("/api/posts")
const request3 = fetch("/api/comments")

Promise.all([request1, request2, request3]).then((values) => {
console.log("Загрузились все данные!")
console.log(values)
})

// Загрузились все данные!

// В переменной values будет массив со значениями каждого из Промисов,
// порядок значений в нём будет соответствовать порядку запросов:
// [ [user1, user2], [post1, post2], [comment1, comment2] ]

// Когда нам важно, чтобы выполнился хотя бы один:
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "First")
})

const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, "Second")
})

Promise.race([promise1, promise2]).then((value) => {
console.log(value)
})

// Second

🛠️ «Отдельный поток» 🔗

В браузерном JavaScript есть некое подобие многопоточности. Мы можем выносить тяжёлые операции в Web Worker.

Не следует путать Web Worker и Service Worker — это разные технологии.

🛠️ «Асинхронные циклы» 🔗

Просто использовать цикл for или метод forEach с асинхронными операциями мы не можем. И цикл for и метод forEach ожидают синхронный код.

Однако, мы можем использовать for await ... of, который появился в ES2018, для итерирования над асинхронными итерируемыми сущностями.

const urls = ["/api/users", "/api/posts", "/api/comments"]

// Простой генератор создаёт итерируемую сущность,
// которую можно «перебрать» через for ... of:
function* requestGenerator() {
for (const url of urls) {
yield url
}
}

for (const item of requestGenerator()) {
console.log(item)
}

// Выведет каждый url по очереди.
// Порядок гарантируется — так как код синхронный.

// Асинхронный же генератор почти не отличается от обычного,
// только вместо значений он выбрасывает промисы.
// И итерировать его придётся через for await ... of:
async function* removeDataGenerator() {
for (const url of urls) {
const response = await fetch(url)
const data = await response.json()
yield data
}
}

;(async () => {
for await (const item of removeDataGenerator()) {
console.log(item)
}
})()

// Выведет данные, которые получит от сервера.
// Порядок не гарантируется, потому что неизвестно,
// какой запрос выполнится раньше.

Как вариант, это можно использовать для управления состоянием приложений.