JavaScript: Управление потоком
JavaScript регулярно называют асинхронным. Что это значит? Как это влияет на развитие, Как изменился подход за последние годы?
Рассмотрим следующий код:
result1 = doSomething1();
result2 = doSomething2(result1);
Большинство языков обрабатывает каждую строку синхронно. Первая строка запускается и возвращает результат. Вторая строка запускается после завершения первой — независимо от того, сколько времени это занимает.
Одно-поточная обработка
JavaScript работает в одном потоке обработки. При выполнении во вкладке браузера всё остальное останавливается. Это необходимо, потому что изменения в DOM страницы не могут происходить в параллельных потоках; было бы опасно иметь один поток, перенаправляющий на другой URL-адрес, в то время как другой пытается добавить дочерние узлы.
Это редко бывает очевидным для пользователя, поскольку обработка происходит небольшими порциями. Например, JavaScript обнаруживает нажатие кнопки, выполняет расчёт и обновляет DOM. После завершения браузер может обрабатывать следующий элемент в очереди.
Примечание: другие языки, такие как PHP, также используют один поток, но могут управляться многопоточным сервером, таким как Apache. Два запроса к одной и той же странице PHP могут инициировать два потока, запускающих изолированные экземпляры среды выполнения PHP
Переход на асинхронность с обратными вызовами/callbacks
Одно-поточность вызывает проблему. Что происходит, когда JavaScript вызывает медленный
процесс, такой как Ajax запрос в браузере или операцию с базой данных на сервере? Эта операция может занять несколько секунд — даже минут. Браузер блокировался, пока ждал ответа. На сервере, Node.js не сможет обрабатывать дальнейшие запросы пользователей.
Решение — асинхронная обработка. Вместо того чтобы ждать завершения, процессу предлагается вызвать другую функцию, когда результат будет готов. Это называется обратным вызовом и передаётся в качестве аргумента любой асинхронной функции.
Например:
doSomethingAsync(callback1);
console.log('finished');
// вызывается после завершения doSomethingAsync
function callback1(error) {
if (!error) console.log('doSomethingAsync complete');
}
Функция doSomethingAsync
принимает обратный вызов/callback в качестве параметра (передаётся только ссылка на эту функцию, поэтому накладные расходы незначительны). Неважно сколько времени будет выполняться doSomethingAsync
; всё, что мы знаем, это то, что callback1()
будет выполнен в какой-то момент в будущем. Консоль отобразит следующее:
finished
doSomethingAsync complete
Больше об обратных вызовах можно узнать в JavaScript: Что такое функции обратного вызова/Callback
Ад обратных вызовов / Callback Hell
Часто обратный вызов вызывается только одной асинхронной функцией. Поэтому можно использовать краткие анонимный встроенные функции.
doSomethingAsync(error => {
if (!error) console.log('doSomethingAsync complete');
});
Серия из двух или более асинхронных вызовов может быть выполнена последовательно путём вложения функций обратного вызова. Например:
async1((err, res) => {
if (!err) async2(res, (err, res) => {
if (!err) async3(res, (err, res) => {
console.log('async1, async2, async3 complete.');
});
});
});
К сожалению, это ведёт в ад обратного вызова — печально известную концепцию. Код трудно читаем, и он станет ещё хуже, когда будет добавлена логика обработки ошибок.
Ад обратного вызова относительно редко встречается в коде на стороне клиента. Если вы выполняете ajax-вызов, обновляете DOM и ждёте завершения анимации, он может углубляться на два или три уровня, но обычно остаётся управляемым.
Ситуация отливается в Операционных Системах или серверных процессах. Вызов Node.js API может получать загружаемые файлы, обновлять несколько таблиц баз данных, писать в логи, и выполнять дополнительные вызовы API, прежде чем можно будет отправиться ответ.
Более подробно об аде обратных вызовов можно почитать в статье JavaScript: Спасение из ада обратных вызовов.
Промисы
ES2015 (ES6) представил промисы — Promise
. Обратные вызовы по-прежнему неявно используются, но промисы обеспечивают более ясный синтаксис связывающий асинхронные команды, поэтому они выполняются последовательно (подробнее об этом в следующем разделе).
Для включения выполнения на основе промисов, асинхронные функции обратного вызова должны быть изменены таким образом, чтобы немедленно возвращать объект Promise
. Объект Promise
гарантирует запуск одной из двух функции (переданных в качестве аргумента) в какой-то момент времени в будущем:
resolve
: функция обратного вызова запускается после успешного завершения обработкиreject
: необязательная функция обратного вызова запускающаяся при возникновении сбоя.
В приведённом ниже примере, API базы данных предоставляет метод connect
принимающий функцию обратного вызова. Внешняя функция asyncDBconnect
немедленно возвращает новый промис и запускает либо resolve
, либо reject
после соединения или сбоя соединения:
const db = require('database');
// Подключение к базе данных
function asyncDBconnect(param) {
return new Promise((resolve, reject) => {
db.connect(param, (err, connection) => {
if (err) reject(err);
else resolve(connection);
});
});
}
Node.js 8.0+ предоставляет утилиту util.promisify()
для преобразования функции на основе обратного вызова в альтернативу на основе промиса. Есть пара условий:
- обратный вызов должен быть передан в качестве последнего параметра асинхронной функции
- функция обратного вызова должна ожидать ошибку, за которой следует значение параметра
Пример:
// Node.js: promisify fs.readFile
const
util = require('util'),
fs = require('fs'),
readFileAsync = util.promisify(fs.readFile);
readFileAsync('file.txt');
Асинхронные цепочки
Всё, что возвращает промис, может запустить серию асинхронных вызовов функции, определённых в методе .then()
. Каждому передаётся результат предыдущего resolve
:
asyncDBconnect('http://localhost:1234')
.then(asyncGetSession) // результат переданный из asyncDBconnect
.then(asyncGetUser) // результат переданный из asyncGetSession
.then(asyncLogAccess) // результат переданный из asyncGetUser
.then(result => { // не асинхронная функция
console.log('complete'); // (результат передачи asyncLogAccess)
return result; // (результат передан в следующий .then())
})
.catch(err => { // вызывается при любой ошибке/отклонении
console.log('error', err);
Синхронные функции также могут выполняться в блоках .then()
. Возвращённое значение передаётся следующему .then()
(если оно есть).
Метод .catch()
определяет функцию вызываемую при любом предыдущем отказе. В этот момент дальнейшие .then()
запускаться не будут. У вас может быть несколько методов .catch()
по всей цепочке для захвата различных ошибок.
ES2018 представил метод .finally()
выполняющий любую финальную логику независимо от результата — например, для очистки, закрытия соединения с базой данных, и т.д. Поддерживается во всех современных браузерах:
function doSomething() {
doSomething1()
.then(doSomething2)
.then(doSomething3)
.catch(err => {
console.log(err);
})
.finally(() => {
// tidy-up here!
});
}
Будущее Промисов
Промисы уменьшают ад обратных вызовов, но привносят свои собственные проблемы.
В руководствах часто не упоминается, что вся цепочка промиса является асинхронной. Любая функция использующая серию промисов должна либо возвращать собственный промис, либо запускать функции обратного вызова в финальных методах .then()
, .catch()
или .finally()
.
У меня тоже есть признание: меня долго смущали промисы. Синтаксис часто кажется более сложным, чем обратные вызовы, есть много ошибок, и отладка может быть проблематичной. Тем не менее необходимо изучить основы.
async/await
Промисы могут быть обескураживающими, поэтому в ES2017 были введены async
и await
. Хотя это только синтаксический сахар, он делает промисы намного слаще, и вы можете избежать цепочек .then()
. Рассмотрим пример на основе промисов ниже:
function connect() {
return new Promise((resolve, reject) => {
asyncDBconnect('http://localhost:1234')
.then(asyncGetSession)
.then(asyncGetUser)
.then(asyncLogAccess)
.then(result => resolve(result))
.catch(err => reject(err))
});
}
// run connect (self-executing function)
(() => {
connect();
.then(result => console.log(result))
.catch(err => console.log(err))
})();
Чтобы переписать его с помощью async/await
:
- внешней функции должен предшествовать оператор
async
; - вызов асинхронных функций на основе промисов должны предваряться
await
для гарантии завершения обработки до выполнения следующей команды.
async function connect() {
try {
const
connection = await asyncDBconnect('http://localhost:1234'),
session = await asyncGetSession(connection),
user = await asyncGetUser(session),
log = await asyncLogAccess(user);
return log;
}
catch (e) {
console.log('error', err);
return null;
}
}
// run connect (self-executing async function)
(async () => { await connect(); })();
await
эффективно заставляет каждый вызов выглядеть так, как если бы он были синхронным, не задерживая при этом единственный поток обработки JavaScript. Кроме того, async
функции всегда возвращают Promise
, поэтому их, в свою очередь, могут вызвать другие асинхронные функции.
Код async/await
не может быть короче, но имеет значительные преимущества:
- Синтаксис чище. Меньше скобок и меньше ошибок.
- Отладка проще. Точки останова могут быть установлены для любого выражения
await
. - Обработка ошибок лучше. Блоки
try/catch
можно использовать также, как в синхронном коде. - Хорошая поддержка. Реализован во всех современных браузерах и Node.js 7.6+.
Тем не менее не всё так идеально…
Промисы, Промисы
async/await
опираются на промисы, которые в конечном итоге полагаются на обратные вызовы. Это означает, что вам всё равно нужно понимать, как работают промисы.
Кроме того, при работе с несколькими асинхронными операциями не существует прямого эквивалента Promise.all
или Promise.race
. Легко забыть о Promise.all
, который более эффективен, чем использование ряда несвязанных команд await
.
Уродство try/catch
async
функции будут молча завершаться, если вы пропустите try/catch
обёртку любого не сработавшего await
. Если у вас длинный набор асинхронных await
команд, может понадобиться несколько блоков try/catch
.
Одной из альтернатив является функция высокого порядка, перехватывающая ошибки, так что блоки try/catch
становятся ненужными.
Однако этот вариант может оказаться непрактичным в ситуациях, когда приложение должно реагировать на одни ошибки иначе, чем на другие.
Тем не менее, несмотря на некоторые подводные камни, async/await
является элегантным дополнением к JavaScript.
Вы можете узнать больше об использовании async/await
в статье JavaScript: Руководство по async/await, с примерами
Путешествие по JavaScript
Асинхронное программирование — это вызов, которого невозможно избежать в JavaScript. Обратные вызовы необходимы в большинстве приложений, но легко запутаться в глубоко вложенных функциях.
Промисы абстрактные обратные вызовы, но есть много синтаксических ловушек. Преобразование существующих приложений может быть рутинной работой, а цепочки .then()
по-прежнему выглядят беспорядочно.
К счастью, async/await
обеспечивают ясность. Код выглядит синхронным, но он не может монополизировать единственны поток обработки. Это изменит ваш способ написания JavaScript и даже заставит ценить промисы — если вы не делали этого раньше!
Дополнительные материалы
- JavaScript: Что такое функции обратного вызова/Callback
- JavaScript: Понимание асинхронных вызовов
- JavaScript: Руководство по async/await, с примерами
- JavaScript: Спасение из ада обратных вызовов