Управление программой в течении её времени выполнения является глубокой и, в то же время, важной темой для понимания Javascript.
Имеется ввиду не только то, что происходит от начала 'for' цикла и до его завершения, выполнение которого занимает, в основном, небольшой промежуток времени (от микро до миллисекунд), но и, то, что случается между запуском одной части программы сейчас и другой позже -- в этом промежутке, когда она не в активном исполнении.
Практически, все нетривиальные программы (особенно, написанные на Javascript) управляют выше упомянутым промежутком, будто ожидание ввода данных пользователем, запрос информации с базы данных или файловой системы, отправка данных по сети и ожидание ответа, или выполнение повторяющихся действий в заданном интервале (анимация). Во всех этих случаях, ваша программа должна управлять своим состоянием во время этой "паузы" в ходе своего исполнения. Как говорят в Лондоне "Mind the gap!"(дословно -- "Помни о разрыве") -- надпись на платформе, предупреждающая о расстоянии между её краем и дверью поезда.
На самом деле, отношение сейчас и потом частей вашей программы является сердцем асинхронного программирования.
Асинхронное программирование существовало в JS изначально. Но многие разработчики не вдавались в подробности как именно оно работает в их программах, или находили другие способы управления им. Почти хорошим подходом считалась скромная callback функция. Многие до сих пор считают, что callback'и являются эффективным решением.
Но с ростом масштаба и сложности JS, для удовлетворения постоянно расширяющихся требований первоклассного языка программирования, работающего как в браузерах так и на серверах, и на любых мыслимых устройствах, между прочим, росла и боль с которой приходилось управлять асинхронностью.
Сейчас это все может показаться довольно абстрактным. Но я вас уверяю, что мы углубимся в детали и изучим множество новых методов для асинхронного программирования в JavaScript в следующих главах.
Для этого нам надо разобраться во всех тонкостях асинхронности в JS.
Даже, если вы пишете ваш код в одном .js файле, ваша программа почти наверняка состоит из нескольких кусочков, один из которых выполнится сейчас, а остальные потом. Наиболее распространенная единица кусочка - 'function'.
Большинство, новоприбывших в JS, разработчиков, думают, что асинхронный код приостанавливается для выполнения, но это не так.
Давайте посмотрим:
// ajax(..) обычная ajax функция
var data = ajax( "http://some.url.1" );
console.log( data );
// Упс! `данные` не получены
Вы вероятно знаете, что Ajax запросы не выполняются синхронно, а это значит, что ajax(..)
функция ещё не получила данных для присвоения в переменную data
. Если бы ajax(..)
запрос мог бы приостановить код для получения данных, тогда бы data
не была пуста.
Простейшая (но далеко не самая лучшая) реализация Ajax -- это использование функции callback.
ajax( "http://some.url.1", function myCallbackFunction(data){
console.log( data ); // Ура, Я получил `data`!
} );
Предупреждение. Возможно, вы слышали, что можно выполнять синхронные запросы Ajax. Хотя технически это верно, вы никогда не должны этого делать ни при каких обстоятельствах, потому что это блокирует пользовательский интерфейс браузера (кнопки, меню, прокрутку и т.д.) и предотвращает любое взаимодействие с пользователем. Это ужасная идея, и ее всегда следует избегать.
Прежде чем возражать против несогласия, нет, ваше желание избежать путаницы с колбеками не является оправданием для блокировки синхронного 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 );
Блок now запускается сразу же, как только вы запускаете свою программу. Но setTimeout(..)
также устанавливает событие (тайм-аут), которое произойдет позже, поэтому содержимое функции later()
будет выполнено позже (через 1000 миллисекунд).
Каждый раз, когда вы заключаете часть кода в функцию
и указываете, что она должна выполняться в ответ на какое-то событие (таймер, щелчок мышью, ответ Ajax и т.д.), вы создаете более позднюю* часть своего кода, и, таким образом, вводя асинхронность в вашу программу.
Не существует спецификации или набора требований относительно того, как работают методы console.*
— они официально не являются частью JavaScript, а вместо этого добавляются в JS средой всплытия (см. заголовок Типы и грамматика эту серию книг).
Таким образом, разные браузеры и среды JS делают то, что им заблагорассудится, что иногда может привести к путанице.
В частности, есть некоторые браузеры и некоторые условия, при которых console.log(..)
на самом деле не сразу выводит то, что ему дано. Основная причина, по которой это может произойти, заключается в том, что ввод-вывод является очень медленной и блокирующей частью многих программ (не только JS). Таким образом, браузер может лучше (с точки зрения страницы/интерфейса) обрабатывать «консольный» ввод-вывод асинхронно в фоновом режиме, и вы, возможно, даже не подозреваете об этом.
Не очень распространенный, но возможный сценарий, в котором это может быть наблюдаемо (не из самого кода, а извне):
var a = {
index: 1
};
// позже
console.log( a ); // ??
// ещё позже
a.index++;
Обычно мы ожидаем, что снимок объекта a
будет сделан точно в момент выполнения оператора console.log(..)
, выводя что-то вроде { index: 1 }
, так что в следующем операторе, когда a.index++
, он изменяет что-то отличное от вывода a
или сразу после него.
В большинстве случаев приведенный выше код, вероятно, будет создавать представление объекта в консоли инструментов разработчика, которое вы ожидаете. Но возможно, что этот же код может работать в ситуации, когда браузер считает необходимым отложить консольный ввод-вывод в фоновом режиме, и в этом случае возможно, что к тому времени, когда объект будет представлен в консоли браузера, a.index++
уже произошло, и он показывает { index: 2 }
.
Это движущаяся цель, при каких условиях именно «консольный» ввод-вывод будет отложен, или даже будет ли он наблюдаться. Просто имейте в виду эту возможную асинхронность в вводе/выводе на тот случай, если вы когда-нибудь столкнетесь с проблемами при отладке, когда объекты были изменены после оператора console.log(..)
, и тем не менее вы видите неожиданные изменения.
Примечание. Если вы столкнулись с этим редким случаем, лучше всего использовать точки останова в отладчике JS вместо того, чтобы полагаться на вывод консоли. Следующим лучшим вариантом было бы принудительно сделать «моментальный снимок» рассматриваемого объекта, сериализовав его в «строку», например, с «JSON.stringify(..)».
Давайте сделаем (возможно, шокирующее) утверждение: несмотря на явное разрешение асинхронного кода JS (например, тайм-аут, который мы только что рассмотрели), до недавнего времени (ES6) сам JavaScript на самом деле никогда не имел прямого встроенного понятия асинхронности.
Что!? Это кажется безумием, верно? На самом деле, это правда. Сам JS-движок никогда не делал ничего, кроме как выполнял один фрагмент вашей программы в любой момент, когда его об этом попросят.
"Попросят" Кто? Это важная часть!
Движок JS не работает изолированно. Он работает внутри среды хостинга, которая для большинства разработчиков является типичным веб-браузером. За последние несколько лет (но не исключительно) JS расширился за пределы браузера в другие среды, такие как серверы, с помощью таких вещей, как Node.js. Фактически, в наши дни JavaScript внедряется во все виды устройств, от роботов до лампочек.
Но один общий «поток» (это не очень тонкая асинхронная шутка, чего бы это ни стоило) всех этих сред заключается в том, что в них есть механизм, который обрабатывает выполнение нескольких фрагментов вашей программы с течением времени, в каждый момент времени. момент вызова JS-движка, называемый «циклом событий».
Другими словами, движок JS не имеет врожденного чувства времени, а вместо этого является средой выполнения по требованию для любого произвольного фрагмента JS. Это окружающая среда, которая всегда планирует «события» (выполнения кода JS).
Так, например, когда ваша программа JS делает запрос Ajax для получения некоторых данных с сервера, вы настраиваете код «ответа» в функции (обычно называемой «обратным вызовом»), и механизм JS сообщает среде хостинга, «Эй, я собираюсь приостановить выполнение на данный момент, но когда вы закончите с этим сетевым запросом, и у вас есть какие-то данные, вызовите эту функцию назад».
Затем браузер настраивается на прослушивание ответа из сети, и когда ему есть что вам дать, он планирует выполнение функции обратного вызова, вставляя ее в цикл событий.
Так что же такое цикл событий?
Давайте сначала концептуализируем это с помощью некоторого фальшивого кода:
// `eventLoop` - это массив, который действует как очередь (первым пришел, первым ушел)
var eventLoop = [ ];
var event;
// будет работать "всегда"
while (true) {
// выполняем "такт"
if (eventLoop.length > 0) {
// получаем следующее событие из очереди
event = eventLoop.shift();
// теперь выполняем следующее событие
try {
event();
}
catch (err) {
reportError(err);
}
}
}
Это, конечно, сильно упрощенный псевдокод для иллюстрации концепций. Но этого должно быть достаточно, чтобы помочь лучше понять.
Как видите, существует непрерывно работающий цикл, представленный циклом while, и каждая итерация этого цикла называется «тактом». Для каждого такта, если событие ожидает в очереди, оно снимается и выполняется. Эти события являются обратными вызовами ваших функций.
Важно отметить, что setTimeout(..)
не помещает ваш обратный вызов в очередь цикла событий. Что он делает, так это устанавливает таймер; когда таймер истекает, среда помещает ваш обратный вызов в цикл событий, так что какой-то будущий такт подберет его и выполнит.
Что делать, если в этот момент в цикле событий уже 20 элементов? Ваш обратный вызов ждет. Он становится в очередь позади других — обычно нет пути для опережения очереди и пропуска вперед в очереди. Это объясняет, почему таймеры setTimeout(..)
могут не срабатывать с идеальной временной точностью. Вам гарантируется (грубо говоря), что ваш обратный вызов не сработает до указанного вами временного интервала, но это может произойти в это время или позже, в зависимости от состояния очереди событий.
Таким образом, другими словами, ваша программа обычно разбивается на множество небольших фрагментов, которые выполняются один за другим в очереди цикла событий. И технически, другие события, не связанные напрямую с вашей программой, также могут чередоваться в очереди.
Примечание. Мы упоминали «до недавнего времени» в связи с тем, что ES6 изменил характер управления очередью цикла событий. В основном это формальная техническая особенность, но теперь ES6 определяет, как работает цикл обработки событий, что означает, что технически это находится в компетенции JS-движка, а не только среды хостинга. Одной из основных причин этого изменения является введение промисов ES6, которые мы обсудим в главе 3, потому что они требуют возможности иметь прямой, детальный контроль над операциями планирования в очереди цикла событий (см. обсуждение setTimeout (..0)
в разделе "Cooperation").
Очень часто смешивают термины «асинхронный» и «параллельный», но на самом деле они совершенно разные. Помните, что асинхронность — это разрыв между сейчас и позже. Но параллель означает, что вещи могут происходить одновременно.
Наиболее распространенными инструментами для параллельных вычислений являются процессы и потоки. Процессы и потоки выполняются независимо и могут выполняться одновременно: на разных процессорах или даже на разных компьютерах, но несколько потоков могут совместно использовать память одного процесса.
Цикл событий, напротив, разбивает свою работу на задачи и выполняет их последовательно, запрещая параллельный доступ и изменения в разделяемой памяти. Параллелизм и «сериализм» могут сосуществовать в виде взаимодействующих циклов событий в отдельных потоках.
Чередование параллельных потоков выполнения и чередование асинхронных событий происходит на очень разных уровнях детализации.
Например:
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(..) — это произвольная функция Ajax, заданная библиотекой
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
В однопоточном поведении JavaScript, если foo()
запускается до bar()
, результатом будет то, что a
имеет 42
, но если bar()
запускается до foo()
, результатом будет a
будет 41
.
Если бы события JS, совместно использующие одни и те же данные, выполнялись параллельно, проблемы были бы гораздо более тонкими. Рассмотрим эти два списка задач псевдокода как потоки, которые могли бы соответственно запускать код в foo()
и bar()
, и подумайте, что произойдет, если они будут выполняться точно в одно и то же время:
Поток 1 («X» и «Y» — временные ячейки памяти):
foo():
а. загрузить значение `a` в `X`
б. сохранить `1` в `Y`
в. добавить `X` и `Y`, сохранить результат в `X`
д. сохранить значение `X` в `a`
Поток 2 («X» и «Y» — временные ячейки памяти):
bar():
а. загрузить значение `a` в `X`
б. сохранить `2` в `Y`
в. умножить `X` и `Y`, сохранить результат в `X`
д. сохранить значение `X` в `a`
Теперь предположим, что два потока действительно выполняются параллельно. Вы, вероятно, можете определить проблему, верно? Они используют разделяемые ячейки памяти X
и 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(..) — это произвольная функция Ajax, заданная библиотекой
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
Поскольку foo()
не может быть прервана bar()
, а bar()
не может быть прервана foo()
, эта программа имеет только два возможных результата в зависимости от того, какой из них запустится первым: - если бы существовала многопоточность и отдельные операторы в foo()
и bar()
могли чередоваться, количество возможных результатов было бы значительно увеличено!
Блок 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».
Примечание: Если бы в JS была функция, которая каким-то образом не имела бы поведения выполнения до завершения, у нас могло бы быть гораздо больше возможных результатов, верно? Оказывается, ES6 представляет именно такую вещь (см. главу 4 «Генераторы»), но не волнуйтесь прямо сейчас, мы еще вернемся к этому!
Давайте представим себе сайт, на котором отображается список обновлений статуса (например, лента новостей в социальной сети), который постепенно загружается по мере того, как пользователь прокручивает список вниз. Чтобы такая функция работала правильно, (по крайней мере) два отдельных «процесса» должны выполняться одновременно (т.е. в течение одного и того же промежутка времени, но не обязательно в один и тот же момент).
Примечание. Мы используем здесь слово «процесс» в кавычках, потому что это не настоящие процессы уровня операционной системы с точки зрения информатики. Это виртуальные процессы или задачи, которые представляют собой логически связанные последовательные серии операций. Мы просто предпочтем «процесс», а не «задачу», потому что с точки зрения терминологии это будет соответствовать определениям изучаемых нами понятий.
Первый «процесс» будет реагировать на события «onscroll» (запросы Ajax для нового контента), поскольку они срабатывают, когда пользователь прокручивает страницу дальше вниз. Второй «процесс» будет получать ответы Ajax (для отображения содержимого на странице).
Очевидно, что если пользователь прокручивает страницу достаточно быстро, вы можете увидеть два или более события onscroll, запускаемых в течение времени, необходимого для получения и обработки первого ответа, и, таким образом, у вас будут события onscroll и события ответа Ajax, которые будут конкурировать друг с другом.
Конкурентность — это когда два или более «процессов» выполняются одновременно в течение одного и того же периода, независимо от того, происходят ли их отдельные составляющие операции параллельно (в один и тот же момент на разных процессорах или ядрах) или нет. Тогда вы можете думать о конкурентности как о параллелизме на уровне «процесса» (или на уровне задачи), в отличие от параллелизма на уровне операций (потоки, исполняемые на отдельных процессорах).
Примечание. Конкурентность также вводит необязательное понятие этих «процессов», взаимодействующих друг с другом. Мы вернемся к этому позже.
Для данного окна времени (несколько секунд пользовательской прокрутки) давайте визуализируем каждый независимый «процесс» как серию событий/операций:
"Процесс" 1 (onscroll
events):
onscroll, request 1
onscroll, request 2
onscroll, request 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
onscroll, request 7
"Процесс" 2 (Ajax response events):
response 1
response 2
response 3
response 4
response 5
response 6
response 7
Вполне возможно, что событие onscroll
и событие ответа Ajax могут быть готовы к обработке в один и тот же момент. Например, давайте визуализируем эти события на временной шкале:
onscroll, request 1
onscroll, request 2 response 1
onscroll, request 3 response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6 response 4
onscroll, request 7
response 6
response 5
response 7
Но, возвращаясь к нашему представлению о цикле событий из предыдущей главы, JS сможет обрабатывать только одно событие за раз, поэтому либо onscroll, request 2
будет происходить первым, либо response 1
должно произойти первым, но они не могут произойти буквально в один и тот же момент. Так же, как дети в школьной столовой, независимо от того, какую толпу они образуют за дверью, им придется выстроиться в одну очередь, чтобы получить свой обед!
Давайте визуализируем чередование всех этих событий в очереди цикла событий.
Очередь цикла событий:
onscroll, request 1 <--- Процесс 1 начинается
onscroll, request 2
response 1 <--- Процесс 2 начинается
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7 <--- Процесс 1 заканчивается
response 6
response 5
response 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>
(например, чтобы сделать элемент DOM видимым после того, как у него есть содержимое). Вы, вероятно, не захотите показывать элемент DOM до того, как у него будет содержимое, поэтому координация должна обеспечивать правильное взаимодействие с упорядочением.
Некоторые сценарии конкуренции всегда ломаются (а не просто иногда) без скоординированного взаимодействия.
Рассмотрим:
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
все еще будут undefined
), но второй вызов 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()
, а второй (и любые последующие) вызовы будут просто проигнорированы. Нет ничего хорошего в том, чтобы занять второе место!
Примечание. Во всех этих сценариях мы использовали глобальные переменные для упрощения иллюстрации, но в наших рассуждениях здесь нет ничего, что требовало бы этого. Пока рассматриваемые функции могут получить доступ к переменным (через область видимости), они будут работать так, как задумано. Использование переменных с лексической областью видимости (см. название этой серии книг Scope & Closures) и фактически глобальных переменных, как в этих примерах, является очевидным недостатком этих форм координации в условиях конкуренции. В следующих нескольких главах мы увидим другие способы координации, которые в этом отношении намного чище.
Другое выражение координации параллелизма называется «кооперативной конкуренцией» (cooperative concurrency). Здесь основное внимание уделяется не столько взаимодействию через совместное использование значений в областях видимости (хотя это, очевидно, все еще разрешено!). Цель состоит в том, чтобы взять длительный «процесс» и разбить его на шаги или пакеты, чтобы другие конкурирующие «процессы» имели возможность вставить свои операции в очереди цикла событий.
Например, рассмотрим обработчик ответа Ajax, которому необходимо просмотреть длинный список результатов для преобразования значений. Мы будем использовать Array#map(..)
, чтобы сделать код короче:
var res = [];
// `response(..)` получает массив результатов от Ajax-вызова
function response(data) {
// добавляем в существующий массив `res`
res = res.concat(
// создаем новый преобразованный массив со всеми удвоенными значениями `data`
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`
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, вы поняли, как эти действия планируются и обрабатываются.
Итак, лучший способ думать об этом, который я нашел, состоит в том, что «Очередь задач» — это очередь, свисающая с конца каждого такта в очереди цикла событий. Некоторые подразумеваемые асинхронные действия, которые могут произойти во время такта цикла событий, не приведут к добавлению нового события в очередь цикла событий, а вместо этого добавят элемент (также известный как задача) в конец очереди задач текущего такта.
Это все равно, что сказать: «О, вот еще одна вещь, которую мне нужно сделать позже, но убедитесь, что это произойдет прямо сейчас, прежде чем что-либо еще может произойти».
Или, если использовать метафору: очередь цикла событий подобна аттракциону в парке развлечений, где, как только вы закончите кататься, вам нужно вернуться в конец очереди, чтобы прокатиться снова. Но очередь задач похожа на то, как если бы вы закончили поездку, но затем встали в очередь и сразу же вернулись.
Задача также может привести к добавлению дополнительных задач в конец одной и той же очереди. Таким образом, теоретически возможно, что «цикл» задач (задача, которая продолжает добавлять другую задачу и т.д.) может вращаться бесконечно, лишая программу возможности перейти к следующему такту цикла событий. Концептуально это было бы почти так же, как простое выражение длительного или бесконечного цикла (например, while (true) ..
) в вашем коде.
Задачи похожи на дух хака 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-движок будет их выполнять. Это может показаться довольно странным утверждением, поэтому мы кратко рассмотрим его.
Но прежде чем мы это сделаем, мы должны кое-что предельно ясно уяснить: правила/грамматика языка (см. название этой серии книг Типы и грамматика) диктуют очень предсказуемое и надежное поведение для упорядочения операторов с точки зрения программы. Итак, то, что мы собираемся обсудить, — это не то, что вы когда-либо сможете наблюдать в своей JS-программе.
Предупреждение: Если вы когда-либо сможете наблюдать переупорядочивание операторов компилятора, как мы собираемся проиллюстрировать, это будет явным нарушением спецификации, и это, несомненно, будет связано с ошибкой в движке JS. в вопросе - тот, который должен быть немедленно сообщен и исправлен! Но гораздо чаще вы подозреваете что-то сумасшедшее происходит в движке JS, когда на самом деле это просто ошибка (вероятно, "состояние гонки"!) в вашем собственном коде - так что сначала смотрите туда, и снова и снова . Отладчик JS, использующий точки останова и последовательно выполняющий код, станет вашим самым мощным инструментом для обнаружения таких ошибок в вашем коде.
Рассмотреть возможность:
var a, b;
a = 10;
b = 30;
a = a + 1;
b = b + 1;
console.log( a + b ); // 42
Этот код не имеет выраженной асинхронности (за исключением редкого «консольного» асинхронного ввода-вывода, который обсуждался ранее!), поэтому наиболее вероятным предположением будет то, что он будет обрабатывать строку за строкой сверху вниз.
Но возможно, что движок JS после компиляции этого кода (да, JS скомпилирован — см. название Scope & Closures этой серии книг!) может найти возможности для более быстрого запуска вашего кода, перестраивая (безопасно) порядок этих выражений. По сути, пока вы не можете наблюдать за изменением порядка, все в порядке.
Например, движок может решить, что на самом деле быстрее выполнить такой код:
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 (см. заголовок ES6 & Beyond статьи эту серию книг).
Рассмотрим возможность:
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(); // 11
b += c.bar; // 31
console.log( a + b ); // 42
Если бы не операторы console.log(..)
в этом фрагменте (просто используемые как удобная форма наблюдаемого побочного эффекта для иллюстрации), движок JS, вероятно, мог бы свободно, если бы захотел (кто знает, захочет ли!?), изменить порядок кода:
// ...
a = 10 + foo();
b = 30 + c.bar;
// ...
В то время как семантика JS, к счастью, защищает нас от наблюдаемых кошмаров, которым может угрожать переупорядочивание операторов компилятора, по-прежнему важно понимать, насколько незначительна связь между способом создания исходного кода (сверху вниз) и как он работает после компиляции.
Переупорядочивание операторов компилятора — это почти микрометафора конкурентности и взаимодействия. В целом такая осведомленность может помочь вам лучше понять проблемы потока асинхронного кода JS.
Программа JavaScript (практически) всегда разбивается на две или более частей, где первая часть выполняется сейчас, а следующая часть выполняется позже, в ответ на событие. Несмотря на то, что программа выполняется по частям, все они имеют одинаковый доступ к области действия и состоянию программы, поэтому каждое изменение состояния выполняется поверх предыдущего состояния.
Всякий раз, когда есть события для запуска, цикл событий выполняется до тех пор, пока очередь не станет пустой. Каждая итерация цикла событий — это "такт". Взаимодействие с пользователем, ввод-вывод и таймеры помещают события в очередь событий.
В любой момент времени из очереди может быть обработано только одно событие. Во время выполнения события оно может прямо или косвенно вызывать одно или несколько последующих событий.
Конкурентность — это когда две или более цепочек событий чередуются во времени, так что с точки зрения высокого уровня кажется, что они выполняются одновременно (даже если в любой момент обрабатывается только одно событие).
Часто бывает необходимо выполнить какую-то форму координации взаимодействия между этими конкурирующими «процессами» (в отличие от процессов операционной системы), например, чтобы обеспечить упорядоченность или предотвратить «состояние гонки». Эти «процессы» также могут взаимодействовать, разбивая себя на более мелкие фрагменты и допуская чередование других «процессов».