Skip to content

Latest commit

 

History

History
869 lines (598 loc) · 60.5 KB

File metadata and controls

869 lines (598 loc) · 60.5 KB

Вы не знаете JS: Асинхронность и Производительность

Глава 1: Асинхронность: Сейчас и Потом

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

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

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

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

Асинхронное программирование существует с самого появления JS. Однако, большинство JS-разработчиков не слишком внимательно изучали пути появления асинхронности в своих программах или не слишком внимательно изучали способы применения асинхронности. Хорошим подходом всегда была коллбэк-функция. Многие разработчики до сих пор настаивают, что использования колбэков по-прежнему достаточно для работы.

Но, по мере того, как JS продолжает расти по объему и сложности, для удовлетворения постоянно растущих требований к языку программирования, который работает и в браузере, и на сервере, приходится искать все новые и новые подходы.

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

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

Программа по частям

Вы можете написать свою JS-программу в одном .js файле, но наверняка ваш код будет разбит на несколько частей. И только одна из частей будет выполняться сейчас, а оставшаяся часть выполнится потом. Функция является наиболее используемым приемом деления программы на части.

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

Рассмотрим пример:

// ajax(..) некоторая Ajax-функция, предоставляемая библиотекой
var data = ajax( "http://some.url.1" );

console.log( data );
// Упс! в `data` не будут записаны результаты Ajax-операции

Вероятно, вы знаете, что стандартный Ajax-запрос не завершается синхронно. Это значит, что функция ajax(...) еще не имеет никакого значения, которое можно было бы вернуть и присвоить переменной data. Если ajax(...) можно заблокировать до тех пор пока ответ не вернется, то присваивание data = ... будет работать нормально.

Но Ajax-запросы так не применяются. Мы выполняем асинхронный Ajax-запрос сейчас, и мы не получим данные до наступления потом.

Простейший (но не единственный и не обязательно самый лучший) способ «подождать» от сейчас до потом, это использование колбэков:

ajax( "http://some.url.1", function myCallbackFunction(data){

	console.log( data ); // Даа, я получил данные!

} );

Внимание: вы могли слышать о возможности выполнять синхронные Ajax-запросы. С технической точки зрения такое действительно возможно. Однако, вы не должны делать этого ни при каких обстоятельствах, потому что это блокирует интерфейс браузера (кнопки, меню, скроллинг и т.д) и блокирует пользовательское взаимодействие. Это ужасная идея и ее следует избегать.

Стремление избежать т.н callback hell, не является оправданием для использования синхронного Ajax.

Рассмотрим такой пример:

function now() {
	return 21;
}

function later() {
	answer = answer * 2;
	console.log( "Meaning of life:", answer );
}

var answer = now();

setTimeout( later, 1000 ); // Meaning of life: 42

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

Сейчас:

function now() {
	return 21;
}

function later() { .. }

var answer = now();

setTimeout( later, 1000 );

Потом:

answer = answer * 2;
console.log( "Meaning of life:", answer );

Часть сейчас запустится сразу, по ходу выполнения программы. Но setTimeout() добавляет новое событие (задержку, таймаут), которое выполнится потом, поэтому содержимое функции later() будет выполнено позднее (через 1000 миллисекунд).

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

Асинхронный console()

Нет спецификации или требований, описывающих работу console.* методов — они не являются официальной частью JavaScript. Вместо этого они добавлены в JS с помощью среды исполнения.

Таким образом, различные браузеры и среды работают с console.* так, как им заблагорассудится, что приводит к запутанным ситуациям.

В частности, есть некоторые браузеры и некоторые условия при которых console.log() выведет не то, что ему передали. Основная причина, по которой это может произойти, заключается в том, что операции I/O медленные и блокируют выполнение некоторых программ (не только в JS). Таким образом, лучше (с точки зрения UI), чтобы консольные методы работали асинхронно в фоновом режиме.

Вот не очень распространенный, но возможный сценарий, где такое поведение можно наблюдать:

var a = {
	index: 1
};

// позже
console.log( a ); // ??

// еще позже
a.index++;

Обычно мы предполагаем, что console.log() выведет, что-то типа {index: 1}, так что в следующей строке, где происходит a.index++, меняется что-то другое, отличное от результата вывода a.

В большинстве случаев код выше выполнится в девтулзах, как и предполагается. Но что если выполнить этот же код в ситуации, когда браузеру нужно отложить консольный I/O? В таком случае возможно, что когда объект попадет в консоль, a.index++ уже выполнится, и мы увидим результат {index: 2}.

Замечание: Если вы столкнулись с этим редким сценарием, то хорошим решением будет использование брейкпоинтов в дебаггере, вместо логов в console. Еще хорошим решением будет создание «снэпшота» объекта, путем сериализации его в строку с помощью JSON.stringify(..).

Цикл событий

Сделаю замечание (возможно, шокирующее): несмотря на то, что мы выполняем асинхронный JS-код (например таймаут из примеров выше), до недавнего времени (до ES6), в JS не было прямого представления об асинхронности внутри себя.

Что?! Похоже на дурацкую шутку. Но по факту это правда. JS-движок никогда не делал ничего, кроме выполнения части кода по запросу.

«По запросу». От кого? Это важно!

Движок JS не работает изолированно. Он работает в среде хостинга. Для большинства разработчиков такой средой является веб-браузер. В течение последних лет JS вышел за пределы браузеров и стал использоваться в других средах, например на серверах через Node.js. На сегодняшний день JavaScript внедряется во всевозможные устройства, от роботов до лампочек.

Одной общей концепцией, связывающей эти среды, является наличие в них механизма, обрабатывающего несколько частей кода с течением времени, при каждом вызове движка. Этот механизм назвали «циклом событий».

Другими словами, JS-движок не имел врожденного чувства времени, зато имел специфическую среду выполнения по требованию любого произвольного фрагмента кода. В этой среде существует некое «расписание» событий (выполнения JS-кода).

Например, когда ваша программа делает Ajax-запрос, чтобы получить какие-то данные от сервера, вы устанавливаете получение ответа в виде колбэка, а JS-движок сообщает среде выполнения: «Эй, я собираюсь приостановить выполнение кода, но всякий раз, когда ты завершаешь этот сетевой запрос, то вызывай эту функцию обратно

Затем браузер настраивается на обработку ответа от сети и, когда есть, что вернуть, «планирует» выполнение колбэка, вставив его в цикл событий.

Что же такое цикл событий? Давайте представим его в виде некоего псевдокода:

// `eventLoop` — это массив, который работает как очередь
var eventLoop = [ ];
var event;

// keep going "forever"
while (true) {
	// шаг цикла
	if (eventLoop.length > 0) {
		// получение следующего события в очереди
		event = eventLoop.shift();

		// выполнение следующего события
		try {
			event();
		}
		catch (err) {
			reportError(err);
		}
	}
}

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

Как вы видите, существует непрерывный цикл, представленный циклом while. Каждая итерация этого цикла называется «тиком». Если в тике есть событие, «ожидающее» очереди, то оно берется и выполняется. Эти события — это ваши колбэки.

Важно отметить, что setTimeout() не помещает ваш колбэк в очередь цикла событий. Он устанавливает таймер; когда таймер истекает, среда помещает колбэк обратно в цикл, так, что будущий тик забирает его и выполняет.

Но что если в цикле событий уже есть 20 элементов? Ваш колбэк ждет. Он находится в очереди за другими событиями, обычно нет способа обойти эту очередь. Это объясняет, почему setTimeout() таймеры не срабатывают вовремя. Можете быть уверены, что коллбэк не сработает в запланированное время. Срабатывание зависит от состояния очереди.

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

Замечание: мы упомянули «до недавнего времени» относительно ES6, который изменил характер управления очередью событий. В основном это формальная информация, но теперь в ES6 появилась спецификация, описывающая работу цикла событий. Это означает, что механизм цикла событий теперь входит в компетенцию JS-движка, а не только в компетенцию среды выполнения. Одной из основных причин таких изменений является появление в ES6 Промисов (о которых будет рассказано в 3-й главе), потому что они требуют возможности иметь прямой контроль над процессами цикла событий.

Параллельные потоки

Очень часто понятия «асинхронность» и «параллельность» объединяют. Но, конечно, это разные вещи. Асинхронный процесс связан с промежутком между сейчас и потом, а параллельность связана с одновременным выполнением процессов.

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

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

Чередование параллельных потоков выполнения и чередование асинхронных событий происходит на очень разных уровнях.

Например:

function later() {
	answer = answer * 2;
	console.log( "Meaning of life:", answer );
}

Выполнение содержимого later() может рассматриваться как операция добавления в очередь цикла событий с помощью одного потока. Но на самом деле тут выполняется целая дюжина низкоуровневых операций.

Например, answer = answer * 2 требует сначала загрузить текущее значение answer, затем положить 2 в другое место, затем выполнить умножение, затем взять результат и сохранить его обратно в answer.

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

Например:

var a = 20;

function foo() {
	a = a + 1;
}

function bar() {
	a = a * 2;
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

В однопоточном варианте, если foo() выполняется до bar(), то в результате a будет равно 42, если bar() выполнится раньше, то результат будет 41.

Если события JS совместно используют одни и те же данные, выполненные параллельно, проблемы будут гораздо более тонкими. Рассмотрим два варианта псевдокода в виде потоков, которые выполняют код в foo() и bar() одновременно:

Поток 1 (X и Y - временные ячейки памяти)

foo():
  a. загружает значение `a` в `X`
  b. сохраняет `1` в `Y`
  c. складывает `X` и `Y`, сохраняет результат в `X`
  d. сохраняет значение `X` в `a`

Поток 2 (X и Y - временные ячейки памяти)

bar():
  a. загружает значение `a` в `X`
  b. сохраняет `2` в `Y`
  c. умножает `X` на `Y`, сохраняет результат в `X`
  d. сохраняет`X` в `a`

Подчеркнем, что потоки работают параллельно. Вероятно, вы уже заметили проблему? Потоки используют общие ячейки памяти Х и Y.

Что в результате мы получим в a, если потоки выполнятся в таком порядке?

1a  (загружает значение `a` в `X`   ==> `20`)
2a  (загружает значение `a` в `X`   ==> `20`)
1b  (сохраняет `1` в `Y`   ==> `1`)
2b  (сохраняет `2` в `Y`   ==> `2`)
1c  (складывает `X` и `Y`, сохраняет результат в `X`   ==> `22`)
1d  (сохраняет значение `X` в `a`   ==> `22`)
2c  (умножает `X` на `Y`, сохраняет результат в `X`   ==> `44`)
2d  (сохраняет значение `X` в `a`   ==> `44`)

Результат будет равен 44. А если в таком порядке?

1a  (загружает значение `a` в `X`   ==> `20`)
2a  (загружает значение `a` в `X`   ==> `20`)
2b  (сохраняет `2` в `Y`   ==> `2`)
1b  (сохраняет `1` в `Y`   ==> `1`)
2c  (умножает `X` на `Y`, сохраняет результат в `X`   ==> `20`)
1c  (складывает `X` и `Y`, сохраняет результат в `X`   ==> `21`)
1d  (сохраняет значение `X` в `a`   ==> `21`)
2d  (сохраняет значение `X` в `a`   ==> `21`)

Результат будет равен 21.

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

JavaScript никогда не делит данные по потокам, а это означает, что уровень неопределенности выполнения кода не вызывает беспокойства. Но это не значит, что JS всегда выполняет код определенно точно. Помните пример, когда относительное упорядочение выполнение foo() и bar() давало два разных результата (41 или 42)?

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

От выполнения до завершения

Из-за однопоточности JavaScript код внутри foo()bar()) является атомарным, что означает, что после запуска foo() весь его код завершится до того, как какой-либо код в bar() запустится или наоборот. Это называется поведением «от выполнения до завершения».

На самом деле семантика «от выполнения до завершения» более очевидна, когда foo() и bar() имеют больше кода, например:

var a = 1;
var b = 2;

function foo() {
	a++;
	b = b * a;
	a = b + 3;
}

function bar() {
	b--;
	a = 8 + b;
	b = a * 2;
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

Поскольку foo() не может быть прерван выполнением bar(), а bar() не может быть прерван выполнением foo(), эта программа имеет только два возможных результата. В зависимости от того, что начнет выполнятся в первую очередь. Но если есть многопоточность, то количество возможных результатов будет значительно увеличено!

Часть 1 синхронная (выполнится сейчас), но части 2 и 3 являются асинхронными (выполнятся потом), что означает, что их выполнение будет отделено промежутком времени.

Часть 1:

var a = 1;
var b = 2;

Часть 2 (foo()):

a++;
b = b * a;
a = b + 3;

Часть 3 (bar()):

b--;
a = 8 + b;
b = a * 2;

Выполнение частей 2 и 3 может чередоваться, поэтому для этой программы возможны два варианта результата:

Вариант 1:

var a = 1;
var b = 2;

// foo()
a++;
b = b * a;
a = b + 3;

// bar()
b--;
a = 8 + b;
b = a * 2;

a; // 11
b; // 22

Вариант 2:

var a = 1;
var b = 2;

// bar()
b--;
a = 8 + b;
b = a * 2;

// foo()
a++;
b = b * a;
a = b + 3;

a; // 183
b; // 180

Два результата одного и того же кода означают, ту самую неопределенность! Но она связана с порядком выполнения функции (события), а не с порядком выполнения операций внутри, как в случае с потоками. Другими словами, такой способ выполнения кода более определенный, чем потоки.

Что касается поведения JavaScript, то эта неопределенность функции называется термином «состояние гонки». foo() и bar() участвуют в гонке друг против друга, чтобы выполниться раньше оппонента. В частности, это «состояние гонки» является причиной того, почему вы не можете точно предсказать конечный результат a и b.

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

Параллелизм

Представим себе сайт, на котором отображается список обновлений. Например, лента новостей социальных сетей, которая постепенно загружается, когда пользователь прокручивает список вниз. Для работы такой функции требуется, по крайней мере, два отдельных «процесса», выполняемых одновременно (то есть в течение одного и того же времени, но не обязательно в одно и то же мгновение).

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

Первый «процесс» будет запускаться при событии onscroll (делая Ajax-запросы для нового контента), когда пользователь прокручивает страницу вниз. Второй «процесс» с помощью Ajax возвращает ответ (чтобы отобразить содержимое на странице).

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

Параллелизм — это когда два или более «процесса» выполняются одновременно за тот же период, независимо от того, происходят ли их отдельные составляющие операции параллельно (в тот же момент на отдельных процессорах или ядрах) или нет. Вы можете думать о параллелизме, как о параллельности «процесса», в отличие от параллелизма на уровне операций (выполнение потоков отдельных процессоров).

Давайте визуализируем каждый независимый «процесс», как последовательность событий / операций в течение заданного времени (несколько секунд прокрутки пользователем):

«Процесс» 1 (onscroll события):

onscroll, запрос 1
onscroll, запрос 2
onscroll, запрос 3
onscroll, запрос 4
onscroll, запрос 5
onscroll, запрос 6
onscroll, запрос 7

«Процесс» 2 (Ajax-ответ):

ответ 1
ответ 2
ответ 3
ответ 4
ответ 5
ответ 6
ответ 7

Вполне возможно, что событие onscroll и Ajax-ответ могут быть готовы к выполнению в один и тот же момент. Давайте визуализируем эти события на временной шкале:

onscroll, запрос 1
onscroll, запрос 2          ответ 1
onscroll, запрос 3          ответ 2
ответ 3
onscroll, запрос 4
onscroll, запрос 5
onscroll, запрос 6          ответ 4
onscroll, запрос 7
ответ 6
ответ 5
ответ 7

Но, памятуя о работе цикла событий, мы знаем, что JS может обрабатывать только одно событие за раз (тик), так что либо onscroll, запрос 2 произойдет первым, либо ответ 1 первым, но они не могут произойти буквально в один и тот же момент. Так же, как дети в школьной столовой, независимо от того, какую толпу они образуют за дверью, им придется встать в одну линию за обедом!

Давайте визуализируем чередование всех этих событий в очереди цикла событий:

onscroll, запрос 1   <--- Процесс 1 начался
onscroll, запрос 2
ответ 1            <--- Процесс 2 начался
onscroll, запрос 3
ответ 2
ответ 3
onscroll, запрос 4
onscroll, запрос 5
onscroll, запрос 6
ответ 4
onscroll, запрос 7   <--- Процесс 1 завершился
ответ 6
ответ 5
ответ 7            <--- Процесс 2 завершился

«Процесс 1» и «Процесс 2» выполняются одновременно (параллелизм «процессов»), но их отдельные события выполняются последовательно в очереди цикла событий.

Кстати, обратите внимание, ответ 6 и ответ 5 выполнились в ожидаемом порядке? Однопоточный цикл событий является одной из ипостасей параллелизма (есть, безусловно, другие, о которых мы поговорим позже).

Неинтерактивность

Поскольку два или более «процессов» чередуют свои шаги / события одновременно в рамках одной и той же программы, они не обязательно должны взаимодействовать друг с другом, если задачи, которые они выполняют, не связаны друг с другом. Если они не взаимодействуют, то такая неопределенность вполне приемлема.

Например:

var res = {};

function foo(results) {
	res.foo = results;
}

function bar(results) {
	res.bar = results;
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo() и bar() являются двумя параллельными «процессами», порядок их выполнения неопределен. Но мы построили программу так, что порядок их выполнения не имеет значения, потому что результат выполнения этих методов независим друг от друга.

Здесь нет «состояния гонки», поэтому такой код всегда будет работать правильно, вне зависимости от порядка выполнения.

Интерактивность

Но чаще всего нам приходится иметь дело с «процессами», которым необходимо взаимодействовать друг с другом. Косвенно через область видимости и / или DOM. Такие взаимодействия необходимо координировать для предотвращения «состояния гонки».

var res = [];

function response(data) {
	res.push( data );
}

ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

Параллельными «процессами» тут являются два вызова response(), которые будут выполняться для обработки Ajax-ответа.

Предположим, что ожидаемое поведение заключается в том, что res[0] получит результат обработки http: //some.url.1, а res[1] получит результат обработки http: //some.url.2. Да, такое поведение имеет место быть, но возможна и обратная ситуация. Все зависит от того, какой метод завершится первым. Налицо «состояние гонки».

Примечание: Будьте предельно осторожны в предположениях, которые вы могли бы сделать в этих ситуациях. Например, разработчик нередко замечает, что обработка http: //some.url.2 «всегда» гораздо медленнее выполняется, чем обработка http: //some.url.1. Например, один метод выполняет работу с базой данных, а другой просто извлекает статический файл. Может возникнуть иллюзия очевидности порядка выполнения методов. Даже если оба запроса переходят на один и тот же сервер и выполняются в определенном порядке, нет никакой гарантии того, какой порядок ответов будет возвращен.

Итак, чтобы предотвратить «состояние гонки», мы должны согласовать порядок взаимодействия:

var res = [];

function response(data) {
	if (data.url == "http://some.url.1") {
		res[0] = data;
	}
	else if (data.url == "http://some.url.2") {
		res[1] = data;
	}
}

ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

Независимо от того, какой Ajax-ответ возвращается первым, мы проверяем data.url (при условии, что ответ есть, конечно!), чтобы понять, какую позицию данные ответа должны занимать в массиве res. res[0] всегда будет содержать результаты обработки http: //some.url.1, а res[1] всегда будет содержать результаты обработки http: //some.url.2. Благодаря такой простой координации мы устранили «состояние гонки».

Те же наблюдения из этого сценария будут применяться, если несколько параллельных вызовов функций взаимодействуют друг с другом через общий DOM. Например, один обновляет содержимое <div>, а другой обновляет стиль или атрибуты <div> (например, сделать элемент видимым, если тот имеет контент). Вероятно, вы не захотите показывать элемент до того, как он будет иметь контент, поэтому необходимо обеспечить правильный порядок взаимодействий.

Некоторые сценарии параллелизма всегда нарушаются без согласования взаимодействий. Например:

var a, b;

function foo(x) {
	a = x * 2;
	baz();
}

function bar(y) {
	b = y * 2;
	baz();
}

function baz() {
	console.log(a + b);
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

В этом примере нет разницы, какой из методов будет вызван первым: foo() или bar(). В обоих случаях вызов baz() произойдет слишком рано (либо a, либо b будут неопределены). Но второй вызов baz() будет работать, поскольку и a и b будут доступны.

Существуют различные способы решения этой проблемы. Вот один из них:

var a, b;

function foo(x) {
	a = x * 2;
	if (a && b) {
		baz();
	}
}

function bar(y) {
	b = y * 2;
	if (a && b) {
		baz();
	}
}

function baz() {
	console.log( a + b );
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

Условие if (a && b), связанное с вызовом baz(), называется «затвором». Происходит ожидание, пока a и b будут определены, и только потом затвор будет открыт (произойдет вызов baz()).

Рассмотрим еще пример:

var a;

function foo(x) {
	a = x * 2;
	baz();
}

function bar(x) {
	a = x / 2;
	baz();
}

function baz() {
	console.log( a );
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

Вне зависимости от того, какой из методов будет вызван последним (foo() или bar()), последний не только перезапишет значение a, но также продублирует вызов baz() (что нежелательно).

Мы можем координировать взаимодействие с помощью простой «защелки», чтобы позволить выполняться только одному методу:

var a;

function foo(x) {
	if (a == undefined) {
		a = x * 2;
		baz();
	}
}

function bar(x) {
	if (a == undefined) {
		a = x / 2;
		baz();
	}
}

function baz() {
	console.log( a );
}

ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

Условие if (a == undefined) разрешает вызов только из foo() или bar(). Второй (и даже любой последующий) вызов будет игнорироваться.

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

Кооперация

Другим приемом координации является «совместный параллелизм». Здесь основное внимание уделяется не взаимодействию с глобальными переменными (хотя это, очевидно, все еще разрешено!). Прием состоит в том, чтобы выполнить длительный «процесс» и разбить его на этапы, чтобы другие параллельные «процессы» имели возможность чередовать свои операции в очереди цикла событий.

Рассмотрим обработчик Ajax-ответа, который должен перебрать длинный список результатов, чтобы преобразовать значения. Мы будем использовать Array# map(..), чтобы сократить код:

var res = [];

// response(..) получает массив значений от Ajax-запроса
function response(data) {
	// добавляет значение в массив `res`
	res = res.concat(
		// создает новый массив с удвоенным результатом
		data.map( function(val){
			return val * 2;
		} )
	);
}

ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

Если http: //some.url.1 вернет ответ первым, весь список отобразится в res. Если это несколько тысяч записей или меньше, это мало повлияет на производительность. Но если говорить о 10 миллионах записей, это может занять некоторое время (несколько секунд на мощном ноутбуке и намного больше на мобильном устройстве и т.д).

Пока такой «процесс» запущен, ничего не происходит на странице, в том числе другие вызовы response(..), нет обновлений интерфейса, даже пользовательских событий, таких как прокрутка, ввод, клики и т.п. Это «больно».

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

Вот простое решение:

var res = [];

// response(..) получает массив значений от Ajax-запроса
function response(data) {
	// будет обрабатывать по 1000 результатов за раз
	var chunk = data.splice( 0, 1000 );

	// добавляет значение в массив `res`
	res = res.concat(
		// создает новый массив с удвоенным результатом
		chunk.map( function(val){
			return val * 2;
		} )
	);

	// Еще есть что то для обработки?
	if (data.length > 0) {
		// асинхронный запуск обработки следующей части результатов
		setTimeout( function(){
			response( data );
		}, 0 );
	}
}

ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

Мы обрабатываем набор данных по частям максимального размера из 1000 элементов. Поступая таким образом, мы обеспечиваем краткосрочный «процесс». Даже если таких процессов будет много, то это все равно будет гораздо более производительный вариант.

Конечно, мы не координируем порядок «процессов», поэтому порядок результатов в res не будет предсказуемым. Если требуется контролировать порядок, вам нужно будет использовать методы взаимодействия, подобные тем, которые мы обсуждали ранее, или те, которые мы рассмотрим в следующих главах этой книги.

Мы используем setTimeout(.. 0)-хак для асинхронного выполнения, что означает «поставь эту функцию в конце текущей очереди цикла событий».

Примечание: setTimeout(.. 0) технически не вставляет элемент непосредственно в очередь цикла событий. Таймер введет событие при первой возможности. Например, два последовательных вызова setTimeout(.. 0) не будут иметь строгого порядка вызова. В Node.js аналогичным подходом является process.nextTick (..). Несмотря на то, что это удобно (и, как правило, более результативно), не существует серебряной пули (по крайней мере, пока) для обеспечения упорядочения асинхронных событий. Мы более подробно рассмотрим эту тему в следующем разделе.

Задания

Начиная с ES6, существует новая концепция, расположенная поверх очереди цикла событий, называемая «очередь заданий». Подробнее с ней мы познакомимся в Главе 3, когда будем изучать асинхронное поведение промисов.

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

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

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

Задания похожи на setTimeout(.. 0)-хак, но реализованы таким образом, чтобы иметь гораздо более четкий и гарантированный порядок: выполнить позже, но как можно скорее.

Представим себе API для планирования заданий (без хаков) и назовем его schedule(..):

console.log( "A" );

setTimeout( function(){
	console.log( "B" );
}, 0 );

// API Задания
schedule( function(){
	console.log( "C" );

	schedule( function(){
		console.log( "D" );
	} );
} );

Вы можете предположить, что результат будет A B C D, но вместо этого получите A C D B, потому что "Задания" выполнятся в конце текущего тика цикла события, а выполнения таймера будет запланировано на следующий тик цикла события (если он доступен!).

В Главе 3 мы увидим, что асинхронное поведение промисов основано на Заданиях, поэтому важно четко понимать, какое отношение они имеют к поведению цикла событий.

Порядок операторов

Порядок, в котором мы выражаем операторы в нашем коде, не обязательно повторится в JS-движке. Это может показаться довольно странным утверждением, поэтому мы кратко рассмотрим его.

Но прежде чем мы это сделаем, мы должны четко понять: правила / грамматика языка (см. Раздел «Типы и грамматика» этой серии книг) диктуют очень предсказуемое и надежное поведение для упорядочения операторов с программной точки зрения. То, что мы собираемся обсудить, это не то, что вы когда-либо могли наблюдать в своей программе.

var a, b;

a = 10;
b = 30;

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

Этот код не имеет выраженной асинхронности (кроме редких консольных асинхронных операций ввода-вывода, обсуждавшихся ранее!). Поэтому наиболее вероятным предположением является то, что он будет обрабатываться по строкам сверху вниз.

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

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

var a, b;

a = 10;
a++;

b = 30;
b++;

console.log( a + b ); // 42

Или так:

var a, b;

a = 11;
b = 31;

console.log( a + b ); // 42

Или даже так:

// поскольку `a` и `b` больше нигде не используются, то можно заинлайнить их
console.log( 42 ); // 42

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

Но вот сценарий, когда эти оптимизации будут небезопасными:

var a, b;

a = 10;
b = 30;

//нам нужны a и b именно в том виде, в котором их объявили.
console.log( a * b ); // 300

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

Другие примеры, когда переупорядочение при компиляции может создавать наблюдаемые сайд-эффекты (и, следовательно, должны быть запрещено), включая такие вещи, как вызов функций с сайд-эффектами (особенно геттеров) или объекты ES6 Proxy:

function foo() {
	console.log( b );
	return 1;
}

var a, b, c;

// ES5.1 синтаксис геттеров
c = {
	get bar() {
		console.log( a );
		return 1;
	}
};

a = 10;
b = 30;

a += foo();				// 30
b += c.bar;				// 11

console.log( a + b );	// 42

Если бы не console.log(..) в этом фрагменте (использующийся как удобная форма наблюдения сайд-эффекта), JS-движок, вероятно, был бы волен использовать любые оптимизации, чтобы изменить порядок кода:

// ...

a = 10 + foo();
b = 30 + c.bar;

// ...

Хотя семантика JS, к счастью, защищает нас от таких кошмаров, по-прежнему важно понять, насколько тонкая связь существует между тем, как создается исходный код (сверху вниз) и тем, как он запускается после компиляции.

Переупорядочение операторов при компиляции — это почти микрометафора для параллелизма и интерактивности. Она может помочь вам лучше понять проблемы с асинхронным JS-кодом.

Итоги:

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

Всякий раз, когда есть события для выполнения, цикл событий запускается до тех пор, пока очередь не будет пуста. Каждая итерация цикла событий называется «тиком». Действия пользователя, IO и таймеры задерживают события в очереди.

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

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

Часто бывает необходимо выполнить некоторые координации взаимодействия между этими параллельными «процессами». Например, для обеспечения определенного порядка выполнения или предотвращения «состояния гонки». Эти «процессы» также могут разбиваться на более мелкие куски, позволяя другим «процессам» чередоваться.