Хабрахабр

[Перевод] Разъяснительная беседа об асинхронном программировании в Javascript

Привет всем!

Она вызвала огромную дискуссию, по результатам которой мы давно хотели вернуться к этой теме и предложить вам подробный разбор асинхронного программирования в этом языке. Как вы, возможно, помните, еще в октябре у нас переводилась интересная статья о применении таймеров в Javascript. Приятного чтения!
Асинхронное программирование в Javascript прошло многоэтапную эволюцию: от обратных вызовов к промисам и далее к генераторам, а вскоре – и к async/await. Рады, что нам удалось найти достойный материал и опубликовать его еще до конца года. На каждом этапе асинхронное программирование в Javascript немного упрощалось для тех, кто уже по колено протоптал себе путь в этом языке, однако для новичков становилось лишь более устрашающим, поскольку требовалось разбирать нюансы каждой парадигмы, осваивая применение каждой и, что не менее важно, понимать, как все это работает.

Надеемся, что так вы сможете уверенно применять различные парадигмы именно там, где они уместны. В этой статье мы решили кратко напомнить, как использовать обратные вызовы и промисы, дать краткое введение в генераторы, а потом помочь вам интуитивно усвоить, как именно «под капотом» устроено асинхронное программирование с применением генераторов и async/await.

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

Ад обратных вызовов

В Javascript нет синхронного ввода/вывода (далее — I/O) и вообще не поддерживаются блокировки. Вначале были обратные вызовы. Один обратный вызов не так уж и плох, но код растет, а обратные вызовы обычно порождают все новые обратные вызовы. Так что, для организации какого угодно I/O или для отсрочки любого действия избиралась такая стратегия: код, который требовалось выполнить асинхронно, передавался в функцию с отложенным выполнением, которая запускалась где-нибудь ниже в цикле событий. В итоге получается нечто подобное:

getUserData(function doStuff(e, a) ); }); });
})

Не считая мурашек, пробегающих при виде такого фрактального кода, есть еще одна проблема: теперь мы делегировали управление наше логикой do*Stuff другим функциям (get*UserData()), к которым у вас может не быть исходного кода, и вы не можете быть уверены, а выполняют ли они ваш обратный вызов. Отлично, не правда ли?

Промисы

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

getUserData() .then(getUserData) .then(doMoreStuff) .then(getEvenMoreUserData) .then(doEvenMoreStuff) .then(getYetMoreUserData) .then(doYetMoreStuff);

Уже не так неказисто, а?

Давайте рассмотрим более жизненный (но все равно во многом надуманный) пример обратных вызовов: Но, позвольте!!!

// Допустим, у нас есть метод fetchJson(), выполняющий запросы GET и имеющий интерфейс, // который выглядит примерно так: обратный вызов должен принимать ошибку в качестве первого аргумента, а разобранные данные отклика – в качестве // второго.
function fetchJson(url, callback) { ... } fetchJson('/api/user/self', function(e, user) { fetchJson('/api/interests?userId=' + user.id, function(e, interests) { var recommendations = []; interests.forEach(function () { fetchJson('/api/recommendations?topic=' + interest, function(e, recommendation) { recommendations.push(recommendation); if (recommendations.length == interests.length) { render(profile, interests, recommendations); } }); }); });
});

Итак, мы выбираем профиль пользователя, затем его интересы, далее, исходя из его интересов, подбираем рекомендации и, наконец, собрав все рекомендации, отображаем страницу. Такой набор обратных вызовов, которым, наверное, можно гордиться, но, все-таки, какой-то он лохматый. Ничего, применим здесь промисы – и все наладится. Верно?

Промис разрешается телом отклика, разобранным в формате JSON. Давайте изменим наш метод fetchJson() так, чтобы он возвращал промис, а не принимал обратный вызов.

fetchJson('/api/user/self') .then(function (user) { return fetchJson('/api/user/interests?userId=' + self.id); }) .then(function (interests) { return Promise.all[interests.map(i => fetchJson('/api/recommendations?topic=' + i))]; }) .then(function (recommendations) { render(user, interests, recommendations); });

Красиво, правда? Что же теперь не так с этим кодом?

Значит, ничего не работает! … Упс!..
У нас нет доступа к профилю или интересам в последней функции этой цепочки? Попробуем вложенные промисы: Что же делать?

fetchJson('/api/user/self') .then(function (user) { return fetchJson('/api/user/interests?userId=' + self.id) .then(interests => { user: user, interests: interests }); }) .then(function (blob) { return Promise.all[blob.interests.map(i => fetchJson('/api/recommendations?topic=' + i))] .then(recommendations => { user: blob.user, interests: blob.interests, recommendations: recommendations }); }) .then(function (bigBlob) { render(bigBlob.user, bigBlob.interests, bigBlob.recommendations); });

Да… теперь выглядит гораздо корявее, чем мы надеялись. Не из-за таких ли безумных матрешек мы, не в последнюю очередь, стремились вырваться из ада обратных вызовов? Что же теперь делать?

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

// Объявляем эти переменные, которые хотим сохранить заранее var user, recommendations; fetchJson('/api/user/self') .then(function (fetchedUser) { user = fetchedUser; return fetchJson('/api/user/interests?userId=' + self.id); }) .then(function (fetchedInterests) { interests = fetchedInterests; return Promise.all(interests.map(i => fetchJson('/api/recommendations?topic=' + i))); }) .then(function (recomendations) { render(user, interests, recommendations); }) .then(function () { console.log('We are done!'); });

Да, теперь все практически так, как мы хотели, но с одной причудой. Обратили внимание, как мы вызывали аргументы внутри обратных вызовов в промисах fetchedUser и fetchedInterests, а не user и interests? Если да – то вы весьма наблюдательны!

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

Асинхронные генераторы

Если пользоваться генераторами, то вся волнительность исчезает. Генераторы помогут! Правда. Просто волшебство. Взгляните только:

co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations);
});

Вот и все. Оно сработает. Вас не пробивает на слезу, когда вы видите, как прекрасны генераторы, не жалеете ли вы, что были столь недальновидны и стали учить Javascript еще до того, как в нем появились генераторы? Признаюсь, меня такая мысль однажды посетила.
Но… как же все это работает? В самом деле магия?

Нет. Конечно!.. Переходим к разоблачению.

Генераторы

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

Как понятно из названия, генератор делает значения:

function* counts(start) { yield start + 1; yield start + 2; yield start + 3; return start + 4;
} const counter = counts(0);
console.log(counter.next()); // {value: 1, done: false}
console.log(counter.next()); // {value: 2, done: false}
console.log(counter.next()); // {value: 3, done: false}
console.log(counter.next()); // {value: 4, done: true}
console.log(counter.next()); // {value: undefined, done: true}

Это довольно просто, но, все равно, давайте проговорим, что здесь происходит:

  1. const counter = counts(); — инициализируем генератор и сохраняем его в переменной counter. Генератор находится в подвешенном состоянии, никакой код в теле генератора до сих пор не выполнен.
  2. console.log(counter.next()); — Интерпретируется выдача (yield) 1, после чего 1 возвращается как value, и done результирует в false, так как на этом выдача не оканчивается
  3. console.log(counter.next()); — Теперь 2!
  4. console.log(counter.next()); — Теперь 3! Закончили. Все правильно? Нет. Выполнение приостанавливается на шаге yield 3; Для завершения нужно еще раз вызвать next().
  5. console.log(counter.next()); — Теперь 4, и оно возвращается, а не выдается, так что теперь мы выходим из функции, и все готово.
  6. console.log(counter.next()); — Генератор работу окончил! Ему нечего сообщить кроме как «все сделано».

Вот мы и разобрались, как работают генераторы! Но, подождите, а как же шокирующая правда: генераторы могут не только изрыгать значения, но и пожирать их!

function* printer() { console.log("We are starting!"); console.log(yield); console.log(yield); console.log(yield); console.log("We are done!");
} const counter = printer();
counter.next(1); // Начинаем!
counter.next(2); // 2
counter.next(3); // 3
counter.next(4); // 4\n Готово!
counter.next(5); // Ничего не выводит

Уф, что?! Генератор потребляет значения, вместо того, чтобы порождать их. Как такое возможно?

Она не только возвращает значения от генератора, но и может возвращать их генератору. Секрет в функции next. Вот почему первый counter.next(1) зарегистрирован как undefined. Если сообщить next() аргумент, то операция yield, которую сейчас ожидает генератор, фактически результирует в аргумент. Просто еще нет выдачи, которую можно было бы разрешать.

Ситуация практически такова, словно для генераторов Javascript задумывалась бы возможность реализовывать кооперативные конкурентно выполняемые процедуры, они же «корутины». Все равно, как если бы генератор разрешал вызывающему коду (процедуре) и коду генератора (процедуре) партнерское взаимодействие, чтобы те передавали значения друг другу по мере выполнения и дожидались друг друга. На самом деле, довольно напоминает co(), правда?

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

Внутреннее устройство генератора – генерируем генераторы

Но это не столь важно. Ладно, я в самом деле не знаю, как именно выглядят внутренности генератора в разных средах выполнения JS. «Конструктор» для инстанцирования генератора, метод next(value? Генераторы соответствуют интерфейсу. Если соответствие интерфейсу будет достигнуто – тогда все хорошо. : any), при помощи которого мы приказываем генератору продолжать работу и давать ему значения, еще метод throw(error) на случай, если вместо значения будет выдана ошибка, и, наконец, метод return(), о котором пока умолчим.

Пока можно игнорировать throw() и передавать значение в next(), поскольку метод не принимает никакого ввода. Итак, давайте попробуем собрать вышеупомянутый генератор counts() на чистом ES5, без ключевого слова function*. Как это сделать?

Знакомо выглядит? Но в Javascript же есть и другой механизм для приостановки и возобновления выполнения программы: замыкания!

function makeCounter() { var count = 1; return function () { return count++; }
} var counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Если ранее вы пользовались замыканиями – уверен, вам уже доводилось писать нечто подобное. Функция, возвращаемая makeCounter, может генерировать бесконечную последовательность чисел, точно как генератор.

Что нужно для универсального подхода к написанию генератороподобных функций? Однако, эта функция не соответствует интерфейсу генератора, и ее нельзя напрямую применить в нашем примере с counts(), который возвращает 4 значения и завершает работу.

Замыкания, машины состояний и каторжный труд!

function counts(start) { let state = 0; let done = false; function go() { let result; switch (state) { case 0: result = start + 1; state = 1; break; case 1: result = start + 2; state = 2; break; case 2: result = start + 3; state = 3; break; case 3: result = start + 4; done = true; state = -1; break; default: break; } return {done: done, value: result}; } return { next: go }
} const counter = counts(0);
console.log(counter.next()); // {value: 1, done: false}
console.log(counter.next()); // {value: 2, done: false}
console.log(counter.next()); // {value: 3, done: false}
console.log(counter.next()); // {value: 4, done: true}
console.log(counter.next()); // {value: undefined, done: true}

Запустив этот код, вы увидите те же результаты, что и в версии с генератором. Мило, правда?
Итак, мы разобрали порождающую сторону генератора; давайте разберем потребляющую?
На самом деле, отличий не много.

function printer(start) { let state = 0; let done = false; function go(input) { let result; switch (state) { case 0: console.log("We are starting!"); state = 1; break; case 1: console.log(input); state = 2; break; case 2: console.log(input); state = 3; break; case 3: console.log(input); console.log("We are done!"); done = true; state = -1; break; default: break; return {done: done, value: result}; } } return { next: go }
} const counter = printer();
counter.next(1); // Начинаем!
counter.next(2); // 2
counter.next(3); // 3
counter.next(4); // 4
counter.next(5); // Готово!

Всего-то и нужно, добавить input в качестве аргумента go, и значения выдаются по конвейеру. Опять смахивает на магию? Почти как генераторы?

Вот мы и воссоздали генератор в качестве поставщика и в качестве потребителя. Ура! Вот еще один довольно искусственный пример генератора: Почему бы не попытаться объединить в нем эти функции?

function* adder(initialValue) { let sum = initialValue; while (true) { sum += yield sum; }
}

Поскольку все мы уже спецы по генераторам, нам понятно, что этот генератор прибавляет значение, данное в next(value) к sum, после чего возвращает sum. Он работает точно как мы рассчитывали:

const add = adder(0);
console.log(add.next()); // 0
console.log(add.next(1)); // 1
console.log(add.next(2)); // 3
console.log(add.next(3)); // 6

Круто. Теперь давайте напишем этот интерфейс как обычную функцию!

function adder(initialValue) { let state = 'initial'; let done = false; let sum = initialValue; function go(input) { let result; switch (state) { case 'initial': result = initialValue; state = 'loop'; break; case 'loop': sum += input; result = sum; state = 'loop'; break; default: break; } return {done: done, value: result}; } return { next: go }
} function runner() { const add = adder(0); console.log(add.next()); // 0 console.log(add.next(1)); // 1 console.log(add.next(2)); // 3 console.log(add.next(3)); // 6
} runner();

Ого, мы реализовали полноценную корутину.

Как работают исключения? Остается еще кое-что обсудить о работе генераторов. Передача исключения генератору делается в методе throw(), о котором мы умолчали выше. С исключениями, возникающими внутри генераторов, все просто: next() сделает так, чтобы исключение проникло до вызывающей стороны, и генератор погибнет.

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

function* adder(initialValue) { let sum = initialValue; let lastSum = initialValue; let temp; while (true) { try { temp = sum; sum += yield sum; lastSum = temp; } catch (e) { sum = lastSum; } }
} const add = adder(0);
console.log(add.next()); // 0
console.log(add.next(1)); // 1
console.log(add.next(2)); // 3
console.log(add.throw(new Error('BOO)!'))); // 1
console.log(add.next(4)); // 5

Задача на программирование – проникновение ошибки генератора

Товарищ, как же нам реализовать throw()?

Ошибка – просто еще одно значение. Запросто! На самом деле, здесь нужна некоторая осторожность. Мы можем передать ее в go() как следующий аргумент. Это значит, что мы должны проверять на наличие ошибок каждое состояние нашей машины состояний, и валить программу, если не сможем обработать ошибку. При вызове throw(e) оператор yield сработает так же, как если бы мы написали throw e.

Начнем с предыдущей реализации слагателя, скопировано

Шаблон

Решение

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

Реализация машины состояний все сильнее отдаляется от реализации генератора. Но ситуация усугубляется, не правда ли? Для преобразования цикла while его нужно «расплести» в состояния. Мало того, что из-за обработки ошибок код обрастает мусором; код тем более усложняется из-за такого длинного цикла while, который здесь у нас получился. Наконец, приходится добавить лишний код для продвижения исключений от вызывающей стороны и обратно, если в генераторе не найдется блока try/catch для обработки этого исключения. Так, наш случай 1 фактически включает 2,5 итерации цикла while, поскольку yield обрывается на середине.

Мы завершили подробный разбор возможных вариантов реализации генераторов и, надеюсь, вы уже лучше усвоили, как генераторы работают. Вы сделали это!!! В сухом остатке:

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

Теперь, когда мы лучше разбираемся в генераторах, предлагаю потенциально удобный способ рассуждения о них: это синтаксические конструкции, при помощи которых можно писать конкурентно выполняемые процедуры, передающие друг другу значения через канал, пропускающий значения по одному (инструкция yield). Это пригодится нам в следующем разделе, где мы произведем реализацию co() от корутин.

Инверсия управления при помощи корутин

Если мы умеем писать генераторы как таковые, это еще не означает, что промисы в генераторах автоматически будут разрешаться. Теперь, поднаторев в работе с генераторами, давайте подумаем, как их можно применять при асинхронном программировании. Они должны взаимодействовать с другой программой, основной процедурой, той, что вызывает .next() и .throw(). Но, подождите, генераторы и не предназначены работать сами по себе.

Всякий раз, когда бизнес-логике попадется некоторое асинхронное значение, скажем, промис, генератор сообщит: «не хочу возиться с этой дурью, разбудите меня, когда она разрешится», приостановится и выдаст промис обслуживающей процедуре. Что, если помещать нашу бизнес-логику не в основную процедуру, а именно в генератор? После чего она регистрирует обратный вызов с этим промисом, выходит и дожидается, пока можно будет вызвать цикл событий (то есть, когда промис разрешится). Обслуживающая процедура: «хорошо, попозже тебя позову». Будет ждать, пока генератор сделает свое дело, а сама тем временем займется другими асинхронными делами… и так далее. Когда это произойдет, процедура возвестит: «эй, твоя очередь», и отправит значение через .next() спящему генератору. Вы прослушали грустную историю о том, как живется процедуре на услужении у генератора.

Теперь, когда мы знаем, как работают генераторы и промисы, нам не составит труда создать такую «служебную процедуру». Так, вернемся к основной теме. Служебная процедура сама будет конкурентно выполняться как промис, инстанцировать и обслуживать генератор, а затем возвращаться к конечному результату нашей основной процедуры при помощи обратного вызова .then().

co() – это служебная процедура, берущая на себя рабский труд, чтобы генератор мог работать только с синхронными значениями. Далее давайте вернемся к программе co() и подробнее ее обсудим. Уже гораздо логичнее выглядит, правда?

co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations);
});

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

Задача на программирование — co() простая

Теперь давайте сами соберем co(), чтобы интуитивно понять, как именно работает такая вспомогательная процедура. Отлично! co() должна

  1. Возвращать промис вызывающей стороне, которая его ждет
  2. Инстанцировать генератор
  3. Вызывать .next() в генераторе для получения первого выданного результата, который должен иметь вид {done: false, value: [a Promise]}
  4. Зарегистрировать обратный вызов с промисом
  5. Когда промис разрешится (будет сделан обратный вызов), вызвать .next() в генераторе, с разрешившимся значением и получить обратно другое значение
  6. Повторить все, начиная с шага 4
  7. Если в какой-то момент генератор вернет {done: true, value: ...}, разрешить промис, возвращенный co()

Пока давайте не задумываться об ошибках, напишем простой метод co(), позволяющий обработать приведенный ниже искусственный пример:

Шаблон

function deferred(val) { return new Promise((resolve, reject) => resolve(val));
} co(function* asyncAdds(initialValue) { console.log(yield deferred(initialValue + 1)); console.log(yield deferred(initialValue + 2)); console.log(yield deferred(initialValue + 3));
}); function co(generator) { return new Promise((resolve, reject) => { // Ваш код });
}

Решение

В каких-нибудь 10 строках кода мы в общих чертах воспроизвели функционал co(), которая еще недавно казалась нам волшебной и всемогущей. Вообще неплохо, правда? Как насчет обработки исключений? Давайте посмотрим, что здесь можно добавить.

Задача на программирование – обработка исключений в co()

Как вы помните, в интерфейсе генератора предоставляется метод .throw() для отправки исключений. Когда промис, выданный генератором, отклонен, мы хотим, чтобы co() сигнализировала процедуре генератора об исключении.

Шаблон

function deferred(val) { return new Promise((resolve, reject) => resolve(val));
} function deferReject(e) { return new Promise((resolve, reject) => reject(e));
} co(function* asyncAdds() { console.log(yield deferred(1)); try { console.log(yield deferredError(new Error('To fail, or to not fail.'))); } catch (e) { console.log('To not fail!'); } console.log(yield deferred(3));
}); function co(generator) { return new Promise((resolve, reject) => { // Ваш код });
}

Решение

Нам понадобятся разные обратные вызовы в зависимости от того, разрешен был выданный промис или отклонен, поэтому в решении следующий вызов .next() выносится в отдельный метод onResolve(). Тут все немного усложняется. Оба этих обратных вызова обернуты в блоки try/catch каждый, чтобы сразу же отклонять промис, если в генераторе не предусмотрен try/catch на случай ошибок. Также здесь используется отдельный метод onReject(), который при необходимости будет вызывать .throw().

Почти! Итак, мы построили co()! Но волшебства почти не осталось, правда? co() также поддерживает трамплинные функции, вложенные генераторы, массивы из вышеперечисленного, а также глубокие объекты.

Священный грааль: async/await

Но есть ли в них какой-нибудь прок, если в нашем распоряжении будет async/await? Вот мы и разобрались с генераторами и с co(). Поскольку мы со всеми ними уже разобрались, нам не составит труда понять и async await. Ответ — ДА!

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

Итак, чтобы наша функция использовала async/await, а не генераторы, всего-то и нужно заменить co() на async и yield на await, а также убрать из функции *, чтобы она перестала быть генератором.

co(function* () { var user = yield fetchJson('/api/user/self'); var interests = yield fetchJson('/api/user/interests?userId=' + self.id); var recommendations = yield Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations);
});

Становится:

async function () { var user = await fetchJson('/api/user/self'); var interests = await fetchJson('/api/user/interests?userId=' + self.id); var recommendations = await Promise.all( interests.map(i => fetchJson('/api/recommendations?topic=' + i))); render(user, interests, recommendations);
}();

Однако, здесь нужно отметить пару небольших особенностей:

  • co() сразу же выполняет асинхронный генератор. async создает функцию, но ее вам все равно еще нужно вызвать. async больше напоминает вариант co() под названием co.wrap().
  • С co() можно выдавать (yield) промисы, трамплинные функции, массивы промисов или объекты промисов. С async можно только ожидать (await) промисы.

Конец

Гордитесь? Мы рассмотрели историю асинхронного программирования в Javascript с некоторыми сокращениями, разобрались, как «за кулисами» устроена работа генераторов и co(), а затем, опираясь на изученный материал, освоили работу с async/await. Правильно.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»