Владислав Власов, инженер-программист в Developer Soft и преподаватель курса Нетологии, специально для блога написал цикл статей о EcmaScript6. В первой части на примерах рассмотрели динамический анализ кода в EcmaScript с помощью Iroh.js. В этой статье расскажем, как реализовать отменяемые Promises.

Асинхронность и планировщик событий в 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, представляющие собой инкапсулированные цепочки логических действий. В общем же случае концепции отменяемости не существует.

От редакции


Курсы «Нетологии» по теме:

Комментарии (17)


  1. inoyakaigor
    09.02.2018 15:35

    Вот поэтому разработчики, если мне не изменяет память, Axios, под капотом используют вместо fetch старый добрый XMLHttpRequest


    1. Finesse
      10.02.2018 03:02

      А ещё с fetch нельзя реализовать индикатор загрузки файла на сервер (upload progress). Поэтому убийцей XMLHttpRequest его никак не назвать.


  1. Zenitchik
    09.02.2018 18:29

    Можно уточнить, при отмене Promise что должно происходить с ожидающим его onRejected? Раз мы говорим об интерфейсном соглашении, надо договориться о какой-то осмысленной ошибке.


  1. 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


  1. vintage
    10.02.2018 09:01
    -2

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


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


    1. 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();
      }

      Такой код насчитает два запроса вместо одного.


      С таким подходом далеко в продакшен пойти не получится. Одно неловкое движение, интеграция с посторонней библиотекой — и ваш код взрывается и вы даже не понимаете как.


      1. 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();
        }


        1. justboris
          10.02.2018 20:21
          +1

          То, что есть решение — это хорошо, но не стоит забывать зачем все эти fiber-подобные решения появились — чтобы сделать работу с асинхронностью более удобной и компактной. Заворачивание в $mol_fiber_sync в этом не помогает, я уж лучше на промисах как-нибудь, от них хотя бы понятно чего ждать


          1. vintage
            10.02.2018 21:18

            Ситуаций, где необходим неидемпотентный код крайне мало. Соответственно, и $mol_fiber_sync вставлять приходится крайне редко. И то в основном для оборачивания тяжёлых функций, чтобы они не блокировали поток на долго.


  1. jehy
    10.02.2018 14:28
    -1

    В bluebird есть возможность отмены уже много-много лет. Как и другие вещи, которых нет в нативных промисах. Как и скорость, превосходящая нативные промисы...


    1. justboris
      10.02.2018 15:12

      Как и скорость, превосходящая нативные промисы...

      В этот момент всегда нужно приводить пруфлинк. Результаты в репозитории проекта устарели года на 3. Я сейчас на коленке собрал свой бенчмарк, и у меня нативные Promise выиграли (браузер Chrome, MacOS).


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


      1. jehy
        10.02.2018 20:44

        Дата обновления бенчмарка — март 2017.
        Дата выхода используемой версии node.js — март 2017. Далеко не три года, как вы говорите — меньше года.


        Или вот вам ещё более свежие бенчмарки прямо от ребят из nodejs. На turbofan всё равно быстрее оказывается bluebird, и их это огорчило.


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


        Ну и так, чисто поржать — например, Promise.finally есть только в ES2018. А в bluebird я им уже много лет пользуюсь.


        1. justboris
          11.02.2018 00:42

          Или вот вам ещё более свежие бенчмарки прямо от ребят из nodejs.

          Спасибо за интересную ссылку, много полезной информации. Несколько важных пунктов оттуда:


          1. Bluebird заточен чтобы побеждать в своем конкретном бенчмарке. Совпадает ли это с реальными use-case — неясно.
          2. Тем не менее разрыв между Bluebird и нативными промисами сокращается с каждой новой версией V8.
          3. Большинство пользователей сейчас пользуются async/await функциями, которые могут быть сильнее оптимизированы, чем цепочки .then(). В идеале нас будут ждать async/await функции с оверхедом как у синхронных.

          Вывод все такой же: Bluebird — прекрасный проект, образцовый пример для разработчиков нативных промисов, но считать его быстрейшим на все времена — не стоит.


          Ну и так, чисто поржать — например, Promise.finally есть только в ES2018. А в bluebird я им уже много лет пользуюсь.

          Добавить функцию в библиотеку намного проще, чем в стандарт. Библиотека всегда может поменять API в следующем мажорном релизе, если что-то пойдет не так. Для отменяемых промисов это уже случалось. Их API в 3.0 сильно поменялось по сравнением с 2.х.


          Добавить фичу в основу языка намного сложнее, нужно поддерживать обратную совместимость. Поэтому ничего смешного в том, что нативные промисы развиваются медленее, нет. Они учитывают опыт user-land библиотек, в том числе и Bluebird.


          1. jehy
            11.02.2018 10:38
            -1

            Bluebird заточен чтобы побеждать в своем конкретном бенчмарке. Совпадает ли это с реальными use-case — неясно.

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


            Тем не менее разрыв между Bluebird и нативными промисами сокращается с каждой новой версией V8.

            Да, поэтому когда-нибудь в 2020 году, когда нативные таки обгонят bluebird по производительности и добавят всё нужное, я спокойно удалю его из своих проектов. Хотя, может это будет и 2030… До тех пор не вижу в этом смысла.


            Добавить функцию в библиотеку намного проще, чем в стандарт.

            Естественно проще. Но для этого и есть функции, помечаемые как экспериментальные. Более того, в стандарте ещё и breaking changes случаются так же, как и в библиотеках — навскидку могу сказать, что очень позабавило изменение crypto.md5 с аргументами по умолчанию. Это, правда, пример из node.js а не ES, что не совсем корректно, но тем не менее.


            Поэтому ничего смешного в том, что нативные промисы развиваются медленее, нет.

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


  1. justboris
    10.02.2018 15:11

    del


  1. myemuk
    11.02.2018 23:05

    Не кажется ли это действие попыткой угнаться за RxJs?


  1. mr_amirka
    11.02.2018 23:05

    Спасибо за статью! В своё время пытылся решить проблему с недостатком отмены асинхронных действий в Promise. Разработал решение именуемое Deal — потомок Promise с встроенным медотом cancel и eventEmitter для взаимодействия с асинхронным процессом через Promise, которое находится в составе библиотеки Minimalist (https://github.com/mr-amirka/minimalist). Здесь метод cancel может отменять всю цепь и агрегацию из Promise. К сожалению, пока не хватает времени дописать всю документацию. Если кому-то действительно нужен отменяемый Promise, то рекоммендую обратить внимание.