Клавиша / esc
Синхронистки, одна плывёт головой вверх - кролем, другая делает фигуры ногами над водой, головой вниз
Иллюстрация: Кира Кустова

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

Как устроена асинхронность: что такое Event loop и очередь событий, при чём здесь Web API, и как работают промисы и async/await

Время чтения: 14 мин

Кратко

Скопировано

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

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

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

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

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

Скопировано

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

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

        
        
          
        
      

Выведется:

A
B
C

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

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

        
          
          function greet() {  console.log('Hello!')}delay(5000)greet()
          function greet() {
  console.log('Hello!')
}

delay(5000)
greet()

        
        
          
        
      

Через 5 секунд бездействия вывелось бы:

Hello!

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

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

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

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

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

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

Скопировано

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

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

        
        
          
        
      

5 секунд молчания, и выведется «Hello!»

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

        
          
          setTimeout(function greet() {  console.log('Hello!')}, 5000)console.log("I'm being called before greet function.")
          setTimeout(function greet() {
  console.log('Hello!')
}, 5000)

console.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 in, first out). Стек похож на стопку книг: та книга, которую мы кладём последней, находится сверху.

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

        
          
          function outer() {  function inner() {    // Функция 3    console.log('Hello!')  }  // Функция 2  inner()}// Функция 1outer()
          function outer() {
  function inner() {
    // Функция 3
    console.log('Hello!')
  }

  // Функция 2
  inner()
}

// Функция 1
outer()

        
        
          
        
      

Вызываем функцию 1 — outer(), она попадает в стек:

        
          
          outer;
          outer;

        
        
          
        
      

Вызываем функцию 2 — inner(), теперь в стеке 2 функции, потому что первая ещё не выполнилась до конца:

        
          
          inner;outer;
          inner;
outer;

        
        
          
        
      

Вызываем console.log(), теперь в стеке 3 функции:

        
          
          console.log;inner;outer;
          console.log;
inner;
outer;

        
        
          
        
      

Как только console.log() выполнится, она уйдёт из стека, там останется 2 функции:

        
          
          inner;outer;
          inner;
outer;

        
        
          
        
      

Выполнившись, функция inner() тоже уйдёт из стека, в нём останется лишь одна:

        
          
          outer;
          outer;

        
        
          
        
      

После выполнения всего блока стек станет пустым.

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

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

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

  console.log('Bye!')
}

main()

        
        
          
        
      

Вызываем функцию main(). Стек:

        
          
          main();
          main();

        
        
          
        
      

Вызываем setTimeout(). Стек:

        
          
          setTimeout();main();
          setTimeout();
main();

        
        
          
        
      

setTimeout завершился, он выходит из стека:

        
          
          main();
          main();

        
        
          
        
      

Вызываем console.log('Bye!'):

        
          
          console.log('Bye!');main();
          console.log('Bye!');
main();

        
        
          
        
      

Его вызов завершён, он выходит из стека:

        
          
          main();
          main();

        
        
          
        
      

Вызов main() тоже завершён, стек становится пуст.

Проходит около 2 секунд, вызывается функция greet(), она попадает в стек:

        
          
          greet();
          greet();

        
        
          
        
      

Она вызывает console.log('Hello!'):

        
          
          console.log('Hello!');greet();
          console.log('Hello!');
greet();

        
        
          
        
      

Отработав, она уходит из стека:

        
          
          greet();
          greet();

        
        
          
        
      

После выполнения всего блока стек снова становится пустым.

Первое, что бросается в глаза — 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()
          function main() {
  setTimeout(function greet() {
    console.log('Hello!')
  }, 2000)

  console.log('Bye!')
}

main()

        
        
          
        
      

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

Вызов main():

Стек Web API Очередь задач
main()

В Web API и очереди задач пока пусто.

Вызов setTimeout():

Стек Web API Очередь задач
setTimeout()
main()

Когда setTimeout() исчезает из стека, он попадает в видимость Web API, где интерпретатор понимает, что внутри него есть функция greet(), которую надо выполнить через 2 секунды:

Стек Web API Очередь задач
main() setTimeout(greet)

После этого выполняется вызов консоли console.log('Bye!'). В Web API находится функция setTimeout(greet). Она будет там до тех пор, пока не пройдёт 2 секунды:

Стек Web API Очередь задач
console.log('Bye!') setTimeout(greet)
main()

Отработал console.log(), заканчивается работа main():

Стек Web API Очередь задач
main() setTimeout(greet)

main() отработал, стек пуст. 2 секунды ещё не прошло, поэтому setTimeout(greet) все ещё в Web API:

Стек Web API Очередь задач
setTimeout(greet)

Наконец, 2 секунды прошли - функция greet() перемещается в очередь задач:

Стек Web API Очередь задач
greet()

Теперь цикл событий перемещает функцию greet() из списка задач в вызов:

Стек Web API Очередь задач
greet()

Затем вызов console.log('Hello!'):

Стек Web API Очередь задач
console.log('Hello!')
greet()

И наконец стек пуст.

Заметьте, что стек вызовов и очередь задач называются именно стеком и очередью. Потому что вызовы из стека работают по принципу «последний зашёл, первый вышел» (LIFO: last in, first out), а в очереди — по принципу «первый зашёл, первый вышел» (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)
          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)    })  })})
          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))
          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))
          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():

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

        
        
          
        
      

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

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

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

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

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

Скопировано

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

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

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

class SomeClass {
  async request() {}
}

        
        
          
        
      

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

        
          
          async function request() {}// Сработает:request().then(() => {})
          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}
          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)  }}
          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 поймает не только ошибки, которые были внутри асинхронных функций, но также и ошибки, которые возникли во время обычных синхронных операций.

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

На практике

Скопировано

Саша Беспоясов советует

Скопировано

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

Скопировано

Иногда нам бывает нужно отложить выполнение функции на потом, но не через какой-то промежуток времени, а чтобы она выполнилась сразу после каких-то операций. Для этого можно использовать 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)})
          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]]
          [
  [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
          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, для обхода асинхронных итерируемых сущностей.

Простой генератор создаёт итерируемую сущность, которую можно «перебрать» через for...of:

        
          
          const urls = ['/api/users', '/api/posts', '/api/comments']function* requestGenerator() {  for (const url of urls) {    yield url  }}for (const item of requestGenerator()) {  console.log(item)}
          const urls = ['/api/users', '/api/posts', '/api/comments']

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)  }})()
          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)
  }
})()

        
        
          
        
      

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

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