Почему JavaScript не ждёт Promise?

mr. Cooper 2 часа назад Веб-разработка
Почему JavaScript не ждёт Promise?

Посмотрите на этот код:

//js

console.log("start");

fetch("/api/users").then(() => {
    console.log("response");
});

console.log("finish");

Результат будет таким:

//text

start
finish
response

fetch() уже вызван. Запрос отправился. Но следующая строка не стала ждать сервер.

С Promise это нормально. Более того, примерно так и устроена вся асинхронная модель JavaScript.

Но есть интересная деталь: проблема становится ещё заметнее, если вообще убрать fetch().

//js

console.log("1");

Promise.resolve().then(() => {
    console.log("2");
});

console.log("3");

Здесь нет сети, задержки или долгой операции. Promise уже выполнен.

Тем не менее:

//text

1
3
2

Почему?

Потому что Promise - это не команда «подожди». Он связывает текущую программу с результатом, который можно получить позже, и позволяет заранее определить, что делать после этого результата.

Promise - это не данные

После такой строки:

//js

const promise = fetch("/api/users");

в переменной promise находится не ответ сервера.

Это объект Promise.

У него есть состояние. Сначала обычно:

//text

pending

После завершения операции:

//text

fulfilled

или:

//text

rejected

Пока Promise находится в pending, JavaScript не стоит в каком-то специальном режиме ожидания. Текущий код просто продолжает выполняться.

Поэтому это совершенно обычная ситуация:

//js

const promise = fetch("/api/users");

console.log("Запрос уже запущен");

Сообщение может появиться задолго до того, как сервер закончит обработку запроса.

Когда Promise завершится, можно выполнить связанный с ним код:

//js

promise.then(response => {
    console.log(response);
});

then() здесь не останавливает выполнение. Он добавляет обработчик результата.

И это различие лучше всего видно на Promise.resolve().

Даже уже выполненный Promise не вызывает then() сразу

//js

console.log("A");

Promise.resolve().then(() => {
    console.log("B");
});

console.log("C");

Получаем:

//text

A
C
B

Promise.resolve() возвращает уже выполненный Promise, но обработчик из then() всё равно не запускается посреди текущего синхронного кода.

Сначала движок заканчивает текущую работу:

//js

console.log("C");

Затем получает возможность обработать микрозадачу, связанную с Promise.

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

Что на самом деле делает await

Возьмём простой пример:

//js

async function loadUsers() {
    console.log("start");

    const response = await fetch("/api/users");

    console.log("response");
}

loadUsers();

console.log("outside");

Если запрос занимает какое-то время, получится:

//text

start
outside
response

На await выполнение loadUsers() действительно не пошло дальше.

Но остальной JavaScript продолжил работать.

Вызов:

//js

loadUsers();

запустил функцию. Она дошла до:

//js

await fetch("/api/users");

fetch() вернул Promise, а ответа пока нет. Поэтому продолжение loadUsers() откладывается.

Следующая строка находится уже за пределами этой функции:

//js

console.log("outside");

Она ничего не знает о том, что loadUsers() сейчас ждёт результат.

Именно поэтому появляется:

//text

start
outside
response

Если сформулировать это технически, await приостанавливает дальнейшее выполнение текущей async-функции, пока ожидаемый Promise не будет разрешён. Он не блокирует выполнение JavaScript целиком.

Почему await так легко неправильно понять

Потому что код выглядит синхронным.

//js

const response = await fetch("/api/users");
const data = await response.json();

render(data);

Читается буквально:

//text

получить response
получить data
отрисовать data

И внутри этой функции порядок действительно сохраняется.

Но между этими строками выполнение может прерываться.

Если первый fetch() ещё не завершён, функция не продолжает выполнение второй строки. Когда Promise будет разрешён, продолжение функции снова попадёт в механизм выполнения JavaScript.

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

Promise не создаёт поток

Из предыдущего легко сделать неправильный вывод: если функция не блокирует программу, значит она выполняется где-то ещё.

Promise такого механизма не предоставляет.

Например:

//js

new Promise(() => {
    while (true) {}
});

Никакой магии не произошло. Внутри executor выполняется обычный JavaScript, и бесконечный цикл может заблокировать главный поток.

То же самое с вычислениями:

//js

function calculate() {
    for (let i = 0; i < 10_000_000_000; i++) {
        // тяжёлая работа
    }
}

async function run() {
    await calculate();
}

Сначала полностью выполнится calculate(). Только потом await получит его результат.

await не переносит эту работу в другой поток.

Если задача действительно требует вынести вычисления из главного потока, для этого существуют другие механизмы, например Web Workers.

Почему fetch() тогда не блокирует страницу

Потому что сетевой запрос - не то же самое, что вычисление JavaScript.

Когда выполняется:

//js

fetch("/api/users");

браузер передаёт работу своей сетевой подсистеме и возвращает Promise.

JavaScript не должен самостоятельно выполнять цикл вроде:

//js

while (!responseReceived) {
    // проверяем, пришёл ли ответ
}

Именно такой цикл и был бы блокирующим.

В случае fetch() код может продолжить выполнение:

//js

const promise = fetch("/api/users");

console.log("Я пока занимаюсь другим кодом");

Когда сетевой запрос завершится, Promise изменит состояние, а связанные с ним продолжения получат возможность выполниться.

Поэтому здесь полезно разделять две вещи: сама операция и код, который должен обработать её результат.

Где появляется Event Loop

Возьмём пример, в котором Promise и таймер находятся рядом:

//js

console.log("1");

setTimeout(() => {
    console.log("2");
}, 0);

Promise.resolve().then(() => {
    console.log("3");
});

console.log("4");

Результат:

//text

1
4
3
2

Сначала заканчивается текущий синхронный код:

//text

1
4

Затем выполняется обработчик Promise:

//text

3

И только потом доходит очередь до callback таймера:

//text

2

Поэтому setTimeout(..., 0) тоже не означает «выполнить прямо сейчас».

И Promise не даёт обработчику then() права вклиниться посреди уже выполняющегося синхронного кода.

Это как раз та часть поведения, которую невозможно нормально объяснить фразой «JavaScript ждёт Promise». Он ничего не ждёт в этом смысле. Очередь продолжений просто обрабатывается в определённом порядке.

Один await может изменить время запуска операции

Вот здесь понимание Promise начинает приносить практическую пользу.

Допустим, нужно загрузить пользователей и новости:

//js

const users = await fetch("/api/users");
const posts = await fetch("/api/posts");

Второй запрос начнётся после того, как выполнение дойдёт до второй строки.

Если запросы друг от друга не зависят, можно начать оба заранее:

//js

const usersPromise = fetch("/api/users");
const postsPromise = fetch("/api/posts");

const users = await usersPromise;
const posts = await postsPromise;

Или:

//js

const [users, posts] = await Promise.all([
    fetch("/api/users"),
    fetch("/api/posts")
]);

Здесь особенно хорошо видно различие между запуском операции и ожиданием результата.

Операция начинается при:

//js

fetch("/api/users");

А await появляется позже - в тот момент, когда текущему коду действительно нужен результат.

Это не мелочь. Из-за неправильного расположения await независимые операции иногда случайно превращают в последовательную цепочку.

Promise не ускоряет сервер и не ускоряет процессор

Если сервер отвечает три секунды, Promise не уменьшит это время.

Если функция считает что-то пять секунд, Promise тоже не сделает вычисление мгновенным.

Он решает другую задачу: позволяет программе не блокировать выполнение JavaScript ожиданием внешнего результата.

Для сетевого запроса это выглядит примерно так:

//text

запустить запрос
       ↓
получить Promise
       ↓
продолжить доступный JavaScript
       ↓
сервер ответил
       ↓
продолжить связанный код

А для тяжёлого синхронного вычисления такой схемы автоматически не появляется.

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

Почему Promise вообще оказался удобным

Проблема, которую решает Promise, довольно практичная.

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

Promise позволяет представить эту будущую работу объектом:

//js

const promise = getUser();

И описать продолжение:

//js

promise.then(user => {
    render(user);
});

Позже появился async/await, который позволяет записать ту же логику привычнее:

//js

const user = await getUser();

render(user);

Но Promise никуда не исчез.

await работает именно с Promise или с thenable-значением и позволяет временно отложить продолжение текущей async-функции.

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

Самая полезная модель

Для меня здесь удобнее всего держать в голове не слово «ожидание», а последовательность:

//text

операция запускается
        ↓
возвращает Promise
        ↓
JavaScript продолжает выполнение
        ↓
Promise получает результат
        ↓
связанное продолжение ставится на выполнение
        ↓
async-функция продолжает работу

Тогда становится понятно, почему этот код:

//js

async function load() {
    const response = await fetch("/api/users");

    console.log(response);
}

load();

console.log("page continues");

не замораживает страницу.

load() действительно остановится на await, если ответ ещё не готов. Но это остановка продолжения одной функции, а не всей программы.

Именно здесь обычно и возникает путаница.

await выглядит как ожидание, но его задача не в том, чтобы поставить JavaScript на паузу. Его задача - сохранить последовательность действий внутри асинхронной функции, не заставляя весь код вокруг неё ждать тот же результат.

Поэтому JavaScript и не «ждёт Promise».

Он запускает работу, получает обещанный результат и продолжает выполнение. А когда результат появляется, возвращается к тому месту, где этот результат действительно понадобился.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи