Все мы слышали про ajax и node.js. Они прочно обосновались уже не просто в словарном запасе, но и в наборе инструментов веб-разработчика. Ajax — асинхронное подтягивание данных с сервера на страницу, node — фреймворк с асинхронным IO. Но как в таком однопоточном языке, как Javascript, реализуется та самая асинхронность?
Вы, наверное, уже догадались из заголовка, речь пойдет об основном цикле («main loop»).
Как в спецификации
Наченем издалека. Говорим «Javascript», подразумеваем «ECMAScript», говорим «ES», подразумеваем «JS» (если, конечно, Вы не работаете в Mozilla и Вас не зовут Брендан Айх). Логично будет начать со спецификации ECMAScript.
Открываем ECMA-262 и видим… что в ней вообще ничего не сказано про то, что скрипт должен делать после того, как отработает, как его поставить на паузу или остановить. Значит, это отдается на откуп окружению, в котором он выполняется.
Как это сделано в WSH
Самое, пожалуй, простое окружение — это Windows Script Host, он же WSH. Нагрузил процессор на 100%, отработал до конца, умер — вот такой нехитрый жизненный цикл. Из функций управления выполнением только старый добрый sleep()
, который останавливает интерпретатор на n миллисекунд.
WScript.echo(new Date() + ': Hello world!');
WScript.sleep(1000);
WScript.echo(new Date() + ': Goodbye, cruel world!');
Для несложных задач этого вполне хватает, задачи же посложнее могут превратиться в бег с препятствиями.
Пример с потолка: в библиотеке стоит терминал на Windows 98, без клавиатуры, но зато с Internet Explorer 6, который в фуллскрине показывает каталог книг. Юные хулиганы повадились IE6 закрывать, а запустить его обратно библиотекарям нелегко. Как быть?
Создаем функцию для запуска IE, создаем в глобальном скопе коллбек с хитрым именем и запускаем.
function startIE () {
var ie = WScript.CreateObject("InternetExplorer.Application", "ieEvent_");
ie.Navigate("http://127.0.0.1/");
ie.Visible = true;
ie.FullScreen = true;
}
var ieEvent_OnQuit = startIE;
startIE();
Не работает. Точнее, работает, IE запускается, но интерпретатор дорабатывает до конца и тихо умрает. Нужно отправить его в вечный сон. В лоб, sleep(Infinity)
WSH не умеет, поэтому делаем бесконечный цикл со слипами по 60 секунд. Получается как-то так:
function startIE () {
var ie = WScript.CreateObject("InternetExplorer.Application", "ieEvent_");
ie.Navigate("http://127.0.0.1/");
ie.Visible = true;
ie.FullScreen = true;
}
var ieEvent_OnQuit = startIE;
startIE();
while (true){
WScript.sleep(60000);
}
Вот, теперь работает, библиотекари счастливы. Даже лучше, чем ожидалось — IE перезапускается сразу же, а не спустя произвольное количество времени от 0 до 59 секунд. Выходит, что «событие» OnQuit прерывает сон интерпретатора. При этом если sleep
вырезать, оставив только busy loop, эксплорер даже не сможет запустить OnQuit.
Что же, у нас получился примитивный основной цикл. Кратко его можно охарактеризовать так:
- Работа интерпретатора не завершается с завершением программы.
- Код коллбека может отработать только тогда, когда интерпретатор ничего не делает. Одновременно два куска кода работать не могут.
Как это сделано в браузерах
Подход «выполнил и умер» в условиях браузера не подходит. Иначе как же использовать JS по его основному назначению — открывать назойливые всплывающие окна при клике в любом месте? У браузеров (и в node.js) основная петля вшита. Причем так глубоко, что при зациклившемся скрипте даже порой невозможно пользоваться интерфейсом.
Принцип прост: есть очередь, и в нее становится любой код, который желает выполниться. Если очередь не пуста, интерпретатор выкусывает из нее первый элемент и выполняет его. Если очередь пуста — ждет, пока в нее что-нибудь не попадет.
Такими «кусками кода» в очереди может быть все, что угодно: внедренные и слинкованные скрипты на странице, события интерфейса (onclick
, onmouseover
, …), коллбеки таймеров (setTimeout
, setInterval
) или браузерных объектов (xhr.onreadystatechange
). В каком-то смысле между таймерами и событиями в браузерном JS нет разницы.
Ну, а теперь по-порядку.
alert
Три функции: alert
, prompt
и confirm
— стоят особняком во всем браузерном джаваскрипте. Возможно, у Вас, как и у меня, знакомство с JS началось с одной из них. Так или иначе, каждая из них создает модальное окно, и интерпретатор засыпает пока оно не будет закрыто. Это единственный способ приостановить основной цикл в браузере (без использования дебаггера).
Раньше в браузерах alert
в какой-нибудь фоновой вкладке мог заблокировать весь интерфейс, а Google Chrome и до сих пор этим страдает. Впрочем, в современных интернетах встретить эти использование этих функций удается нечасто. Вот и Вы тоже их не используйте.
setTimeout, setInterval
В браузерном JS нет функции sleep
— интерпретатор никогда не останавливается (кроме alert
и ему подобных). Зато можно отложить выполнение функции «напотом» при помощи функции setTimeout
.
Синтаксис прост и лаконичен: setTimeout(fn, timeout)
. Функция fn
будет запущена не раньше, чем через timeout
миллисекунд. Почему именно не раньше, а не ровно через? Заглянем под капот.
Вызов setTimeout
регистрирует новый таймер (кстати, его идентификатор и возвращает эта функция при вызове). Когда его время истечет, и окажется, что интерпретатор в этот момент не занят выполнением никакого кода, функция fn
вызовется сразу, это тривиальный случай.
Если же не повезет, и движок JS еще будет к тому моменту дожевывать куски очереди, то сначала придется дождаться опустошения очереди. При всем желании запустить коллбек «прямо сейчас» не получится, Javascript однопоточный! Затем, когда очередь оказывается пустой, интерпретатор пробегается по всем таймерам и проверяет, какие из них истекли. Из всех истекших таймеров выбирается тот, который был поставлен на меньший таймаут и, если таких несколько, то выбирается тот, который был установлен раньше всех. Теперь такой «самый просроченный» таймер генерирует новый элемент очереди на исполнение, и — вуаля — интерпретатору снова есть, чем заняться — разбирать ставшую непустой очередь.
После того, как таймер setTimeout
отработает, он удаляется. То есть, дважды setTimeout
не стреляет.
Ну, и немного иллюстративного кода:
console.log('script started');
setTimeout(function(){
console.log('timed out function');
}, 5);
var endDate = +new Date() + 10;
while (+new Date() < endDate){
// busy loop for 10 ms
}
console.log('script finished');
В консоль будет выведено:
script started script finished timed out function
Именно в таком порядке. Несмотря на то, что таймер закончился уже через 5 миллисекунд, движок на тот момент обрабатывал сверхважную задачу постоянного сравнивания даты с эталонной. Поэтому отложенной функции пришлось подождать еще 5 миллисекунд, пока тот не закончит. Вот. Это, пожалуй, самое важное.
В любой момент можно отменить таймер функцией clearTimeout(timeoutId)
. Если таймер уже стрельнул, то отменять его, в общем-то, уже бессмысленно, но ошибкой это не считается.
А успеем ли мы отменить таймер?
var timeoutId;
setTimeout(function(){
console.log('timed out function');
clearTimeout(timeoutId);
}, 5);
timeoutId = setTimeout(function(){
console.log('timed out function 2');
}, 5);
var endDate = +new Date() + 10;
while (+new Date() < endDate){
// busy loop for 10 ms
}
Такие случаи как в этом примере случаются на практике редко, но все-таки. Оба таймера были установены на 5 мс и оба уже истекли когда while
закончил свою бессмысленную и беспощадную деятельность. Но первым «стреляет» первый таймер, потому что был поставлен раньше, хотя количество миллисекунд отсрочки у обоих таймеров было одинаковым. И успешно удаляет уже просроченный второй таймер, не дав ему стрельнуть.
setInterval
отличается от setTimeout
тем, что его таймер не удаляется после того, как он сгенерирует элемент для очереди исполнения. Вместо этого его значение сбрасывается на исходное. Таким образом можно вызывать функцию периодически, не вызывая setTimeout
внутри setTimeout
.
Обратите внимание, счетчик таймера setInterval
сбрасывается не в момент срабатывания таймера, а только когда очередь основного цикла опустошается. Всвязи с этим он может со временем «проскальзывать».
setInterval(function(){
console.log(+new Date());
}, 1000);
setTimeout(function(){
var endDate = +new Date() + 2000;
while (+new Date() < endDate){
// busy loop for 10 ms
}
}, 1500);
В остальном все то же самое. Только интервалы отменяются другой функцией, clearInterval
.
Ну, и напоследок, если setTimeout
или setInterval
был передано значение таймаута меньше 4 миллисекунд, вместо него использует 4 мс ровно. Для меня это было неприятным сюрпризом. Но, видимо, оно и к лучшему — случайно установленный интервал в 0 миллисекунд быстро и эффективно заглушил бы основной цикл любого браузера. Чтобы выполнить функцию с действительно нулевым таймаутом, используйте setImmediate
. Эта функция пока не очень широко доступна «из коробки», но для нее есть полифиллы.
<script>
Все скрипты попадают в очередь основного цикла. С одной стороны, это позволяет использовать async
и defer
. А с другой, и без этих атрибутов это может привести к неожиданным результатам.
Что, если в страницу встроено два скрипта, и первый ставит таймаут на 4 миллисекунды, а потом тормозит 10 миллисекунд. Когда отработает коллбек таймаута — перед вторым скриптом, или после?
<!DOCTYPE html>
<script>
console.log('script 1');
setTimeout(function(){
console.log('setTimeout from script 1');
}, 5);
var endDate = +new Date() + 10;
while (+new Date() < endDate){
// busy loop for 10 ms
}
console.log('script 1 finished');
</script>
<script>
console.log('script 2');
</script>
Firefox, Chrome и IE10 выполнят таймаут после второго скрипта. Opera — перед вторым скриптом.
А если второй скрипт будет не внедренным, инлайновым, а внешним?
<!DOCTYPE html>
<script>
console.log('script 1');
setTimeout(function(){
console.log('setTimeout from script 1');
}, 5);
var endDate = +new Date() + 10;
while (+new Date() < endDate){
// busy loop for 10 ms
}
console.log('script 1 finished');
</script>
<script src="http://127.0.0.1/script.js"></script>
А вот тут все браузеры скорее всего выполнят таймаут перед вторым скриптом. Какой прок, спросите Вы, от этого знания, кроме академической значимости? Для уменьшения количества запросов часто несколько скриптов склеивают в один. И получившийся скрипт может работать иначе, чем исходные.
События
Стоит рассмотреть отдельно события пользовательского интерфейса и события изменения DOM (onDOMNodeInsertedIntoDocument
и ему подобные).
События, происходящие при изменении DOM'овского дерева не попадают в основной цикл. Вместо этого они отрабатывают сразу после изменения дерева.
var i = 0;
document.body.addEventListener('DOMSubtreeModified', function(e){
i = 42;
}, false);
console.log('i = ' + i); // i = 0;
document.body.appendChild(document.createElement('div'))
console.log('i = ' + i); // i = 42;
Напороться на такое спонтанное, казалось бы, изменение значения переменной в реальной жизни трудно. Но я верю, где-нибудь в интеретах ждет своего часа какой-нибудь особо хитрый плагин для jQuery, активно использующий MutationEvent и протекающий в глобальный скоп. К тому же, события изменения DOM не рекомендуются к использованию (deprecated) и очень затормаживают работу с деревом, так что не используйте их.
Вернемся к событиям мышино-клавишно-тачевым. У каждого такого события есть действия по умолчанию. Например действие по умолчанию для кнопки по событию mousedown
— приобрести вдавленный вид. А для ссылки по click
— переход по адресу. Некоторые действия по умолчанию можно отменить, вызвав в функции-обрабочике метод preventDefault
у первого аргумента.
А это значит две вещи. Первое, сначала обработчик, потом действие по умолчанию, и кнопка не «нажмется», пока не пройдет обработчик Javascript. Второе, обработчик не стартанет пока не подойдет его очередь в главном цикле. Таким образом, массивные вычисления js'ного движка крайне негативно отражаются на том, что называют responsibility. Иными словами, всё начинает ощутимо тормозить и раздражать пользователя. Казалось бы, логично не ждать очереди для события, на которое не повешен ни один обработчик, но фактически во всех браузерах кнопка с onclick
тормозит так же как и без него.
Разработчики браузеров, конечно, пытаются что-то сделать с этим. Opera, например, меняет визуальное представление, не дожидаясь обработчика, что позволяет немного сгладить тормоза. Кроме того, на страницу может повлиять тяжелый процесс в соседней вкладке, но это не касается Chrome.
Из последовательности «сначала обработчик, потом действие по умолчанию» ещё следует каноничный случай, когда по событию keypress
нужно определить длину текста в поле ввода, а получается, что она не совпадает с настоящей. Решение этой задачи я оставлю читателю.
WebWorkers
WebWorker'ы — это способ разгрузить основной цикл, прежде всего с целью избавится от «проседаний» в отзывчивости пользовательского интерфейса. Фактически, это отдельный процесс, со своим собственным основным основным циклом, не завязанный на интерфейсе и DOM (и не имеющий к них прямого доступа).
Со своим «основным» процессом WebWorker общается исключительно при помощи сообщений. Причем, когда сообщение от вёркера приходит в основной процесс, он становится — да-да — в ту же самую очередь, что и всё остальное.
В общем, если Вам нужно сгенерировать на клиенте wav из xm или распаковать многомегабайтный bzip, и браузер поддерживает WebWorker'ы, используйте их.
Как это сделано в node.js
Этот раздел будет очень небольшим, потому как в node используется очень похожая на браузерную модель. Такие же setTimeout
и setInterval
, а события файловой системы и прочего IO похожи на браузерные события, разве что аргументы другие.
Из особенностей можно отметить функцию process.nextTick
, она работает подобно setTimeout(…, 0)
, но не создает никаких таймеров за ненадобностью. Идентификатор в node.js таймера не целое число, как в браузерах, а объект. Ну, и нода полностью игнорирует ограничение в 4 миллисекунды.
Вместо заключения
Резюмируя все вышесказанное: понимайте, как работает основной цикл и старайтесь не задерживать его надолго.
Для дальнейшего чтения/просмотра:
Автор: subzey