Асинхронность и планировщик событий в EcmaScript
Концепция Promise (обещаний) — одна из ключевых в современном EcmaScript. Promise позволяют обеспечить последовательное выполнение асинхронных действий за счет организации их в цепочки, которые вдобавок предоставляют перехват ошибок. Современный синтаксис async/await операторов технически также основан на Promise, и является лишь синтаксическим сахаром.
При всей своей богатой функциональности, Promise обладают одним недостатком — не поддерживают возможность отмены запущенного действия. Для того чтобы понять, как обойти это ограничение, необходимо рассмотреть, как возникают и функционируют асинхронные действия в EcmaScript, ведь Promise — лишь обёртка для них.
Движок языка EcmaScript, будь это V8 или Chakra, является однопоточным, и позволяет в один момент времени выполнять только одно действие. В браузерной среде довольно современные движки поддерживают технологию WebWorkers, а в Node.js можно создать отдельный дочерний процесс, и это позволит параллелизировать выполнение кода. Однако созданный поток исполнения — это независимый процесс, который может обмениваться информацией с создавшим его потоком только посредством сообщений, так что это сама по себе не многопоточная модель.
Вместо этого, традиционный EcmaScript основывается на модели мультиплексирования: чтобы выполнить несколько действий параллельно, они разбиваются на небольшие фрагменты, каждый из которых выполняется относительно быстро и никогда не блокирует поток исполнения. За счет перемешивания таких фрагментов, действия, ассоциированные с ними, фактически выполняются параллельно.
Так как пользовательский код и функции хост-среды, такие как рендеринг визуального интерфейса (UI) веб-страницы, выполняются в одном и том же потоке, то, к примеру, долгий или бесконечный цикл в пользовательском коде приводит к приостановке действий по рендерингу веб-страницы и ее зависанию. Для разделения отрезков времени, в которые будут выполняться те или иные фрагменты кода, применяется планировщик событий — event loop. Каким же образом может возникнуть исполняемый фрагмент в event loop?
Обычный клиентский код выполняет только последовательный набор действий, состоящий из потока исполнения с условиями, циклами и вызовами функций. Для того чтобы осуществить отложенное исполнение, необходимо зарегистрировать клиентскую функцию обратного вызова в хост-среде.
В браузерной среде это сводится, как правило, к одной из трех возможностей: таймеры, события и асинхронные запросы к ресурсам. Таймеры обеспечивают вызов функции по истечении времени (
setTimeout
), в первом свободном слоте в планировщике событий (setImmediate
) или же даже в процессе отрисовки веб-страницы (requestAnimationFrame
). События — это реакция на произошедшее действие, как правило, в DOM-модели, и могут инициироваться как пользователем (событие: щелчок по кнопке), так и внутренними процессами отображения UI-элементов (событие: пересчет стилей завершен). В отдельную категорию вынесены запросы к ресурсам, но в действительности они относятся к событиям, с той лишь разницей, что изначальным инициатором является сам клиентский код.Это наглядно показано на схеме ниже:
Обертка асинхронный действий
Далее важно рассмотреть, как вышеуказанные асинхронные действия оборачиваются в Promise. Для того чтобы затронуть максимальное количество аспектов для отмены Promise, следующий код будет сочетать использование таймеров, событий DOM-модели и произвольного клиентского кода, который связывает их. Пример предполагает выполнение AJAX-запроса, возвращающего большой объем данных в CSV-формате, и последующую обработку в потенциально медленной функции в построчном виде для предотвращения зависания основного потока.
function fetchInformation() {
function parseRow(rawText) {
/* Some function for row parsing which works very slow */
}
const xhrPromise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', '.../some.csv'); // API endpoint URL with some big CSV database
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(String(xhr.response));
} else {
reject(new Error(xhr.status));
}
};
xhr.onerror = () => {
reject(new Error(xhr.status));
};
xhr.send();
});
const delayImpl = window.setImmediate ? setImmediate : requestAnimationFrame;
const delay = () => new Promise(resolve => delayImpl(resolve))
const parsePromise = (response) => new Promise((resolve, reject) => {
let flowPromise = Promise.resolve();
let lastDemileterIdx = 0;
let result = [];
while(lastDemileterIdx >= 0) {
const newIdx = response.indexOf('\n', lastDemileterIdx);
const row = response.substring(
lastDemileterIdx,
(newIdx > -1 ? newIdx - lastDemileterIdx : Infinity)
);
flowPromise = flowPromise.then(() => {
result.push(parseRow(row));
return delay();
});
lastDemileterIdx = newIdx;
}
flowPromise.then(resolve, reject);
});
return xhrPromise.then(parsePromise);
}
В качестве события DOM-модели используется успешное или ошибочное завершение AJAX-запроса, а таймеры обеспечивают последовательную порционную обработку большого объема данных, чтобы предоставить рабочее время UI-потоку. Легко заметить, что с внешней точки зрения такой Promise представляет собой монолитный элемент, на завершении которого вызывающей стороне доступна обработанная база данных в надлежащем формате, или же описание ошибки, если в процессе выполнения произошел сбой.
С точки зрения вызывающей стороны удобно иметь возможность отмены такого Promise, как единого целого. Например, в случае если пользователь закрыл визуальный элемент, которому требовались эти данные для отображения. Однако, с точки зрения внутреннего строения, Promise представляет собой набор синхронных и асинхронных действий, часть из которых возможно уже запущена и завершена. Поскольку эту последовательность определяет произвольный клиентский код, этапы экстренного завершения также должны быть описаны мануальным образом.
Реализация отменяемого Promise
Важно помнить, что прерывание синхронного кода, такого как циклы, не может произойти в принципе, поскольку если код уже выполняется (а движок EcmaScript — однопоточный), то в этот момент не может исполняться никакой другой код, который бы осуществил его прерывание. Таким образом, завершения требуют только действительно асинхронные действия, описанные выше: таймеры, события и обращения к внешним ресурсам.
Функции установки таймеров обладают дуальными операциями для их отмены:
clearTimeout
, clearImmediate
и cancelAnimationFrame
соответственно. Для событий DOM-модели достаточно удалить подписку на соответствующую функцию обратного вызова. Также для таймеров можно воспользоваться более простым подходом — предварительно обернуть их в Promise-объект, имеющий мануальный isCancelled
-флаг. Если по истечении таймера Promise должен быть отменен, то функция обратного вызова просто не выполняется. В таком случае таймер остается в планировщике, но в случае отмены по его окончании ничего не происходит.В случае обращения к внешним ресурсам ситуация более сложная: в любом случае можно игнорировать результат операции, выполнив отписку от соответствующего события, но прервать саму операцию не всегда возможно. С точки зрения логики выполнения Promise, это может быть несущественно, однако непрерванная операция потребляет излишние ресурсы.
В частности, метод
fetch
, призванный на замену классическому XMLHttpRequest
для проведения AJAX-запросов, и обеспечивающий сразу возврат Promise-объекта без необходимости дополнительной обертки, не позволяет выполнить отмену запроса. По этой причине для реальной отмены HTTP-запроса необходимо использовать метод abort
в объекте XMLHttpRequest
. Итоговый код с поддержкой отмены Promise может выглядеть следующим образом. Для лучшей наглядности показан только изменившийся код, а старый заменен комментарием с многоточием.
function fetchInformation() {
/* ... */
let isCancelled = false;
let xhrAbort;
const xhrPromise = new Promise((resolve, reject) => {
/* ... */
xhrAbort = xhr.abort.bind(xhr);
});
const delayImpl = window.setImmediate ? setImmediate : requestAnimationFrame;
const delay = () => new Promise((resolve, reject) =>
delayImpl(() => (!isCancelled ? resolve(): reject(new Error('Cancelled'))))
);
/* ... */
const promise = xhrPromise.then(parsePromise);
promise.cancel = () => {
try { xhrAbort(); } catch(err) {};
isCancelled = true;
}
return promise;
}
Поскольку Promise — это обычный объект с точки зрения EcmaScript, то метод
cancel
легко может быть добавлен в него. Также, поскольку во внешнюю среду возвращается только один результирующий Promise-объект, то метод cancel
добавляется только для него, а вся внутренняя логика инкапсулирована в текущем лексическом блоке генерирующей функции.Итоги
Реализация отменяемого Promise в EcmaScript — сравнительно несложная задача, которая может быть легко выполнена даже для асинхронной цепочки, имеющей внутри нетривиальную логику последовательных вызовов: за счет сохранения флага отмены в объектах и активационных контекстов генерирующих функций. Отмена может быть как поверхностной, когда Promise прерывается с ошибкой и не производит выполнение сторонних эффектов, так и глубокой, когда все инициированные асинхронные операции (таймеры, обращения к внешним ресурсам и прочие) действительно отменяются.
Ключевой аспект отменяемых Promise — необходимость полной мануальной реализации операции отмены. Она не может быть достигнута автоматически, например, за счет реализации собственного класса Promise. Теоретически задача может быть решена при выполнении кода в виртуальной машине, при котором будут записываться все асинхронные действия, инициированные в стеке инициализации Promise и зависимых then-ветках, но это довольно нетривиальная в реализации и малополезная на практике задача.
Таким образом, отменяемые Promises в EcmaScript — это всего лишь интерфейсное соглашение, позволяющее прерывать и отменять эффекты от Promise, представляющие собой инкапсулированные цепочки логических действий. В общем же случае концепции отменяемости не существует.
От редакции
Курсы «Нетологии» по теме:
- профессия «Frontend-разработчик»;
- профессия «Веб-разработчик»;
- онлайн-программа «Основной курс по JavaScript»;
- онлайн-программа «Node, AngularJS и MongoDB: разработка полноценных веб-приложений»;
- онлайн-программа «JavaScript в браузере: создаем интерактивные веб-страницы».
Комментарии (17)
Zenitchik
09.02.2018 18:29Можно уточнить, при отмене Promise что должно происходить с ожидающим его onRejected? Раз мы говорим об интерфейсном соглашении, надо договориться о какой-то осмысленной ошибке.
justboris
09.02.2018 19:23+4Вообще-то в стандарте уже появился официальный способ отмены fetch:
https://developer.mozilla.org/en-US/docs/Web/API/AbortController
А вот и pull-request с добавлением этой функциональности в самый популярный полифилл:
https://github.com/github/fetch/pull/592
vintage
10.02.2018 09:01-2В вашем коде куча ошибок. Я, конечно, понимаю, что вы достиги дзена и можете интерпретировать код в голове. Но браузеры ещё не достигли вашего совершенства, чтобы выходить из бесконечного цикла за конечное время.
- Я тут переписал ваш код на файберах с отменяемыми запросами на любой стадии исполнения:
Заголовок спойлераconst log = document.getElementById( 'log' ) let fetching = null const fetchInformation = ()=> { fetching = $mol_fiber_make( ()=> { log.innerText = 'Fetching...' const text = fetch({ uri : 'some.csv' }).responseText log.innerText = JSON.stringify( parseCSV( text ) ) } ) fetching.start() } const cancelFetch = ()=> { fetching.destructor() fetching = null log.innerText = 'Cancelled' } const fetch = ({ uri })=> { return $mol_fiber_async( back => { const xhr = new XMLHttpRequest() xhr.open( 'GET', uri ) // API endpoint URL with some big CSV database xhr.onload = back( () => { if( Math.floor( xhr.status / 100 ) !== 2 ) { throw new Error( xhr.statusText ) } return xhr } ) xhr.onerror = back( () => { throw new Error( xhr.statusText ) } ) xhr.send() return ()=> xhr.abort() } ) } const parseCSV = $mol_fiber_func( text => { let lastDemileterIdx = 0 let result = [] do { const newIdx = $mol_fiber_sync( ()=> text.indexOf( '\n' , lastDemileterIdx ) ) const row = $mol_fiber_sync( ()=> { log.innerText += '.' const end = newIdx > -1 ? newIdx : Infinity return parseCSVRow( text.substring( lastDemileterIdx , end ) ) } ) result.push( row ) lastDemileterIdx = newIdx + 1 } while( lastDemileterIdx > 0 ) return result } ) /* Some function for row parsing which works very slow */ const parseCSVRow = text => { for( const from = Date.now() ; Date.now() < from + 5 ; ) {} return text.split( ',' ) }
justboris
10.02.2018 18:54+2Решил разобраться в вашей fibers магии и обнаружил, что
$mol_fiber_make
обязана быть чистой функцией:
let requestCount = 0; const fetchInformation = () => { fetching = $mol_fiber_make(() => { requestCount++; log.innerText = requestCount; const text = fetch({ uri : 'some.csv' }).responseText; }); fetching.start(); }
Такой код насчитает два запроса вместо одного.
С таким подходом далеко в продакшен пойти не получится. Одно неловкое движение, интеграция с посторонней библиотекой — и ваш код взрывается и вы даже не понимаете как.
vintage
10.02.2018 19:28Нет, функия должна быть идемпотентной, но не обязательно чистой. Неидемпотентные вызовы просто заворачиваются в $mol_fiber_sync и они становятся идемпотентными.
Дока с тестами тут: https://github.com/eigenmethod/mol/tree/master/fiber
Вообще весь $mol построен на той же концепции, так что в продакшене вполне нормально с нею живётся.
А ваш код должен выглядеть так:
let requestCount = 0; const fetchInformation = () => { fetching = $mol_fiber_make(() => { $mol_fiber_sync( ()=> requestCount++ ); log.innerText = requestCount; const text = fetch({ uri : 'some.csv' }).responseText; }); fetching.start(); }
justboris
10.02.2018 20:21+1То, что есть решение — это хорошо, но не стоит забывать зачем все эти fiber-подобные решения появились — чтобы сделать работу с асинхронностью более удобной и компактной. Заворачивание в $mol_fiber_sync в этом не помогает, я уж лучше на промисах как-нибудь, от них хотя бы понятно чего ждать
vintage
10.02.2018 21:18Ситуаций, где необходим неидемпотентный код крайне мало. Соответственно, и $mol_fiber_sync вставлять приходится крайне редко. И то в основном для оборачивания тяжёлых функций, чтобы они не блокировали поток на долго.
jehy
10.02.2018 14:28-1В bluebird есть возможность отмены уже много-много лет. Как и другие вещи, которых нет в нативных промисах. Как и скорость, превосходящая нативные промисы...
justboris
10.02.2018 15:12Как и скорость, превосходящая нативные промисы...
В этот момент всегда нужно приводить пруфлинк. Результаты в репозитории проекта устарели года на 3. Я сейчас на коленке собрал свой бенчмарк, и у меня нативные Promise выиграли (браузер Chrome, MacOS).
На абсолютную истину не претендую, просто заметил, что бенчмарки нужно регулярно пересматривать и лидеры могут меняться.
jehy
10.02.2018 20:44Дата обновления бенчмарка — март 2017.
Дата выхода используемой версии node.js — март 2017. Далеко не три года, как вы говорите — меньше года.
Или вот вам ещё более свежие бенчмарки прямо от ребят из nodejs. На turbofan всё равно быстрее оказывается bluebird, и их это огорчило.
Привет минусующим, берущим на веру утверждения о устаревших бенчмарках и свой бенчмарк с коленки. Почему этот бенчмарк некорректен — разбирать откровенно лень, мне за это деньги не платят.
Ну и так, чисто поржать — например, Promise.finally есть только в ES2018. А в bluebird я им уже много лет пользуюсь.
justboris
11.02.2018 00:42Или вот вам ещё более свежие бенчмарки прямо от ребят из nodejs.
Спасибо за интересную ссылку, много полезной информации. Несколько важных пунктов оттуда:
- Bluebird заточен чтобы побеждать в своем конкретном бенчмарке. Совпадает ли это с реальными use-case — неясно.
- Тем не менее разрыв между Bluebird и нативными промисами сокращается с каждой новой версией V8.
- Большинство пользователей сейчас пользуются async/await функциями, которые могут быть сильнее оптимизированы, чем цепочки
.then()
. В идеале нас будут ждать async/await функции с оверхедом как у синхронных.
Вывод все такой же: Bluebird — прекрасный проект, образцовый пример для разработчиков нативных промисов, но считать его быстрейшим на все времена — не стоит.
Ну и так, чисто поржать — например, Promise.finally есть только в ES2018. А в bluebird я им уже много лет пользуюсь.
Добавить функцию в библиотеку намного проще, чем в стандарт. Библиотека всегда может поменять API в следующем мажорном релизе, если что-то пойдет не так. Для отменяемых промисов это уже случалось. Их API в 3.0 сильно поменялось по сравнением с 2.х.
Добавить фичу в основу языка намного сложнее, нужно поддерживать обратную совместимость. Поэтому ничего смешного в том, что нативные промисы развиваются медленее, нет. Они учитывают опыт user-land библиотек, в том числе и Bluebird.
jehy
11.02.2018 10:38-1Bluebird заточен чтобы побеждать в своем конкретном бенчмарке. Совпадает ли это с реальными use-case — неясно.
Видимо, вы не очень внимательно читали ссылку — там приводилась и сразу же опровергалась эта гипотеза, и был сделан вывод, что блюбёрд просто сделан так, чтобы побеждать любые бенчмарки.
Тем не менее разрыв между Bluebird и нативными промисами сокращается с каждой новой версией V8.
Да, поэтому когда-нибудь в 2020 году, когда нативные таки обгонят bluebird по производительности и добавят всё нужное, я спокойно удалю его из своих проектов. Хотя, может это будет и 2030… До тех пор не вижу в этом смысла.
Добавить функцию в библиотеку намного проще, чем в стандарт.
Естественно проще. Но для этого и есть функции, помечаемые как экспериментальные. Более того, в стандарте ещё и breaking changes случаются так же, как и в библиотеках — навскидку могу сказать, что очень позабавило изменение crypto.md5 с аргументами по умолчанию. Это, правда, пример из node.js а не ES, что не совсем корректно, но тем не менее.
Поэтому ничего смешного в том, что нативные промисы развиваются медленее, нет.
Смешно то, сколько лет потребовалось для введения промисов — и то они получились куцые и медленные. Несмотря на "учтение всего опыта". Ну и особенно смешно, что постоянно вижу агрессивную защиту и одновременно допиливание нативных промисов с одним единственным аргументом: "ну они же нативные!"
mr_amirka
11.02.2018 23:05Спасибо за статью! В своё время пытылся решить проблему с недостатком отмены асинхронных действий в Promise. Разработал решение именуемое Deal — потомок Promise с встроенным медотом cancel и eventEmitter для взаимодействия с асинхронным процессом через Promise, которое находится в составе библиотеки Minimalist (https://github.com/mr-amirka/minimalist). Здесь метод cancel может отменять всю цепь и агрегацию из Promise. К сожалению, пока не хватает времени дописать всю документацию. Если кому-то действительно нужен отменяемый Promise, то рекоммендую обратить внимание.
inoyakaigor
Вот поэтому разработчики, если мне не изменяет память, Axios, под капотом используют вместо fetch старый добрый XMLHttpRequest
Finesse
А ещё с fetch нельзя реализовать индикатор загрузки файла на сервер (upload progress). Поэтому убийцей XMLHttpRequest его никак не назвать.