На JavaScript легко писать. Достаточно взять пару библиотек или модный фреймворк, прочитать несложный туториал и все — через пару часов у вас простой работающий интерфейс.
Проблемы начинаются, когда интерфейс становится сложнее. Вот тут без глубокого понимания JavaScript не обойтись. Важно, чтобы даже большой и сложный интерфейс оставался быстрым и отзывчивым. Отзывчивость, как правило, достигается за счет использования асинхронных функций. Попробуем разобраться, как устроена асинхронность в JavaScript.
В JavaScript нет многопоточности. Несмотря на то, что мы уже можем полноценно использовать вебворкеры, из них нельзя менять DOM или вызывать методы объекта window. Одним словом, не многопоточность, а сплошное разочарование.
Причины таких ограничений понятны. Представьте себе, что два параллельных потока пытаются наперегонки поменять один и тот же узел в DOM с непредсказуемым результатом. Представили? Мне тоже стало не по себе.
С DOM-деревом работают в одном потоке, чтобы гарантировать целостность и непротиворечивость данных, но как программировать интерфейс с одним потоком? Ведь сама суть интерфейса — в асинхронности. Именно для этого придуманы асинхронные функции. Они выполняются не сразу, а после наступления события. Интересно, что эти функции — не часть JavaScript-движков. Вызов setTimeout на чистом V8 приводит к ошибке, так как в V8 нет такой функции. Тогда откуда же появляется setTimeout или requestAnimationFrame или addEventListener?
Асинхронность внутри
Движок JavaScript похож на мясорубку, бесконечно перемалывающую операции, которые последовательно берутся из стека вызовов (1). Код выполняется линейно и последовательно. Удалить операцию из стека нельзя, можно только прервать поток выполнения. Поток выполнения прерывается, если вызвать что-то типа alert или «исключение».
Каждая операция содержит контекст — некую область памяти, из которой доступны данные. Контексты расположены в памяти в виде дерева. Каждому листу в дереве доступны области видимости, которые определены в родительских ветках и в корне (глобальной области видимости). Функции в JavaScript — это данные, они хранятся в памяти именно как данные и поэтому передаются как переменные или возвращаются из других функций.
Асинхронные операции выполняются не в движке, а в окружении (5,6). (Как подсказал forgotten это не совсем так: мы можем из стека вызовов сразу же положить функцию в очередь вызовов и таким образом чистый движок тоже будет работать асинхронно)
Окружение — надстройка на движком. NodeJS и Chrome для движка V8 и Firefox для Gecko. Иногда окружение еще называют web API.
Чтобы создать асинхронный вызов, в web API передается ссылка на функцию, которая выполнится позже или не выполнится вовсе.
У функции есть свой контекст или своя область памяти (3), в которой она определена. Функция имеет доступ к этой области памяти и ко всем родителям этой области памяти. Такие функции называются замыканиями. С этой точки зрения, все функции в JavaScript — замыкания, так как все они имеют контекст.
Web API и JavaScrtipt движок работают независимо. Web API решает, в какой момент функция двигается дальше, в очередь вызовов (2).
Функции в очереди вызовов попадают в JavaScript-движок, где выполняются по одной. Выполнение происходит в том же порядке, в котором функции попадают в очередь.
Окружение самостоятельно решает, когда добавить переданный ей код в очередь вызовов. Функции из очереди добавляются в стек выполнения (выполняются) не раньше, чем стек вызовов закончит работу над текущей функцией.
Таким образом, стек вызовов работает синхронно, а web API асинхронно.
Это очень важно! Разработчику не нужно самому контролировать параллельный доступ к ресурсам, асинхронную работу за него выполняет окружение. Окружения определяют различия между браузером и node.js, ведь на node.js мы пишем сетевые приложения или обращаемся напрямую к жесткому диску, а из Chrome перехватываем клики по кнопкам, используя один и тот же движок.
В очереди вызовов нельзя отменять отдельные операции. Это делается в окружении (removeEventListener — в качестве примера).
Примеры
Можно загрузить стек вызовов так, чтобы он работал бесконечно и следующая функция из очереди вызовов не вызвалась. Попробуйте, например, запустить вот такой код.
document.addEventListener(‘click’, function(){
console.log(‘clicked’)
});
while(true){
console.log(‘wait’);
}
Обработчик клика не сработает, а бесконечный цикл загрузит процессор компьютера. Вкладка зависнет ;)
А вот другой пример.
Клик вызовет «тяжелую» для расчета функцию. После клика в консоль пишется start, в конце выполнения функции — end. Выполнение функции на моем ноутбуке занимает несколько секунд. Все время, пока выполняется функция, квадратик мигает. Это значит, что анимации в CSS выполняются асинхронно JavaScript-коду.
Но что будет, если вместо opacity менять размер?
Квадратик зависнет на время выполнения функции. Дело в том, что CSS-свойство height обращается к DOM. Как мы помним, к DOM можно обращаться только из одного потока, чтобы не было проблем с параллельным доступом.
Делаем вывод, что для анимации лучше пользоваться свойствами, которые не меняют DOM (transform, opacity и т.д.). А всю тяжелую работу в JavaScript лучше делать асинхронно. Например вот так.
Код написан для наглядности и на коленке, в бою применять не рекомендуется. Мы делим большой кусок работы на маленькие и выполняем асинхронно. При этом интерфейс не блокируется. Для таких расчетов можно пользоваться веб-воркерами.
Вывод
Благодаря JavaScript мы пишем асинхронные приложения, не задумываясь о многопоточности: о целостности и непротиворечивости данных. За эти преимущества мы платим огромным числом обратных вызовов, блокированием основного потока и постоянными потерями контекста.
О том, как бороться с последней проблемой, я расскажу в следующий раз.
Комментарии (37)
Aquahawk
09.06.2016 13:38+9Ребят, это детский сад, а не пособие для тех кто хочет разобраться. Это супер базовое введение коих сотни. Хорошее пособие для тех кто хочет разобраться это например рассказать о внутреннем устройстве промисов, асинков, авайтов, генераторов. Рассказать как работает yield, как это на самом деле внутри работает, какие плюсы и минусы по перфомансу.
zolotyh
09.06.2016 13:43+6Да. Это базовые знания. Но многие про это не знают. Это видно по собеседованиям. В любом случае, спасибо за обратную связь. Постараемся в следующий раз написать про что-то более хардкорное.
Aquahawk
09.06.2016 16:00+3Я может резковато высказался, вполне возможно просто несовпадение ожиданий вызванных заголовком и тела статьи. Иначе говоря слишком общий и громкий заголовок. В вообще, хорошо написано, продолжайте.
Nookie-Grey
12.06.2016 13:58Асинхронность в JavaScript: Пособие для тех, кто хочет
запутатьсяразобраться
mrsum
09.06.2016 16:01+4В действительности, к даже таким базовым вещам – разработчик приходит спустя пару лет опыта. Когда начинает оптмизировать свои велосипеды.
За отличные иллюстрации отдельный +
niksonk
09.06.2016 16:33Мне кажется тут понятнее www.youtube.com/watch?v=8aGhZQkoFbQ&list=PLswsjU_X520TEQmEnQHD90-XHURSQpjSr
forgotten
10.06.2016 08:27+2> Асинхронные операции выполняются не в движке, а в окружении (5,6). Окружение — надстройка на движком. NodeJS и Chrome для движка V8 и Firefox для Gecko. Иногда окружение еще называют web API.
ШТА?
Дорогой автор, вся «асинхронность», т.е. порядок выполнения job-ов, описаны непосредственно в стандарте ECMAScript
http://www.ecma-international.org/ecma-262/6.0/index.html#sec-executable-code-and-execution-contexts
Почитайте на досуге. Например, промисы принципиально по стандарту работают асинхронно.
WebAPI — это просто все API, определённые в браузере, а не в стандарте ECMA.zolotyh
10.06.2016 09:56Все так, но я сходу не могу придумать как запустить что-то асинхронно на чистом v8. Если вы подскажите, будет очень круто, я обязательно добавлю это в статью.
forgotten
10.06.2016 10:01+1Promise.resolve().then(() => console.log('async'))
tenbits
13.06.2016 12:21Ну какая же это асинхронность, это всего лишь отложенный вызов функции, который достигается перекладыванием на верх ивэнт лупа. Так же как и nextTick. Есть ещё setImmediate, который кладёт вызов функции вниз ивэнт лупа. Поэтому манипуляции с ивэнт лупом можно назвать "отложенностью", но никак не "асинхронностью".
forgotten
13.06.2016 12:28Во-первых, nextTick и setImmediate определены в Nodejs и в браузере соответственно, а не в стандарте ECMAScript
Во-вторых, а дайте определение «асинхронности» и «отложенности», пожалуйста.tenbits
13.06.2016 13:23Мой комментарий был лишь к тому, что бы читатель понимал из-за чего достигается подобное поведение. И я например не считаю, что это можно назвать асинхронностью, но и в дискуссию также не хочу вступать, если считаете иначе, то пускай так и будет. Это всего лишь мое мнение. Но изменение очереди вызова функций, лично мне, сложно назвать асинхронностью. Ведь в конце концов, вызов функций остаётся синхронным, лишь порядок изменяется. То есть здесь нет никого, кто бы выполнял какой либо
таск
вне потокаивэнт лупа
. Но и вы тоже отчасти будете правы если скажете, что функция не синхронная, так как в данном случае мы не можем использовать, напримерreturn x
, так как функция возвращает свой результат вколлбэке
, который в свою очередь будет вызван хоть и синхронно, но после актуального вызова.
zolotyh
10.06.2016 10:49console в v8 нет. А в общем и целом вы правы. Можно в движке запускать что-то асинхронно.
Promise.resolve().then(function(){print(1)}); print(2);
На выходе:
2
1
Другое дело, что смыла в этом особого нет, кроме тех случаев, когда нужно решить проблему с сильной загруженностью стека вызовов.
caballero
10.06.2016 11:58как уже было отмечено, технически вызовы не совсем асинхронны. Правильнее наверно говорить о паралельном (фоновом) выполнении. Речь именно о ожидании ответа а не просто запуске некоего паралельного обработчика
Аналогичная ситуация с await async в .NET.
Асинхронность — это когда функция возвращает управление.а вызываемая сторона потом по своей инициативе дергает некий callback или типа того.
Реальная асинхронка, например, медицинский стандарт DICOM где и SCU и SCP вставляют TCP порты. И когда SCU делает запрос на передачу файла, SCP говорит — команду понял, закрывает соединение, ищет файл. потом открывает свое соединение по TCP и начинает передавать.
мы платим огромным числом обратных вызовов, блокированием основного потока и постоянными потерями контекста
а еще запутаный, нечитаемый и неотлаживаемый код — фиг его знает кто в каком месте повесил какой евент или биндинг.
По своей шкуре знаю как жертва проекта на Flex
i360u
Всегда думал что асинхронный вызов создает что-то типа потока внутри общего контейнера (который снаружи выглядит как один процесс). Как в единую очередь вызовов попадают асинхронные вызовы? Просто подмешиваются по мере исполнения? А если окружение реализует всю асинхронную кухню, то почему это не вид многопоточности?
zolotyh
Это неуправляемая многопоточность. Ей не дают управлять, чтобы не выстрелить себе в ногу. Тут нужно различать, многопоточность и асинхронность.
i360u
Я понимаю различия многопоточности и асинхронности. Но если я через асинхронные вызовы могу создавать и удалять некие условные "потоки", значит эта "условная многопоточность" все-таки не совсем неуправляемая? Конечно это не горутины, но… Так в итоге, как именно обрабатываются "окружением" асинхронные вызовы? Конкурентно? Зависит от движка?
zolotyh
Управлять конечно можно. В некотором смысле. Но суть от этого не меняется. Программист на javascript не может полноценно управлять потоками. Он может предполагать, что где-то кто-то создаст за него поток или 5 потоков или 10. Он не знает сколько. Весь код, который пишет программист на JS выполнится в одном потоке (если не принимать во внимание веб-воркеры).
i360u
Мне навскидку приходит несколько вариантов реализации управления потоками (отдельными контекстами исполнения, созданными через асинхронные вызовы). По управлением я понимаю, как минимум, создание/удаление, блокировку, обмен данными. Помимо этого, существуют уже реализованные библиотеки и тулзы для "чего-то типа" мультитридинга в JS. Кроме того, не вижу причин не принимать во внимание веб-воркеры. Во многом ответ на вопрос о многопоточности в JS лежит именно в тонкостях реализации того, что вы назвали окружением. Но вы старательно избегаете этой темы, хотя изначально взялись объяснить ("зависит от реализации" — это, извините, не ответ). Надеюсь на следующую статью.
zolotyh
Рискну предположить, что окружение само решает как обрабатывать вызовы. То есть ответ на ваш вопрос: зависит от реализации.
webkumo
Согласен с вами,
имхо устоявшийся термин js асинхронность — это и есть многопоточность. Многопоточность, разделяющая процессорное время (в данном случае — очередь вызовов).
Кто помнит *nix-подобные системы и Windows 9x как раз и стали теми ОС (надеюсь никого не забыл?), кто привнесли многозадачность на единственном процессоре за счёт разделения процессорного времени. И именно в такой ситуации и зародилась «классическая» многопоточность.
Единственной отличие js — не вижу никакого способа внешнего управления потоками (проритет, возможность остановки, ...)
Zibx
Но можно написать внутренний планировщик, переопределить setTimeout\interval\requestAnimationFrame, HTMLElement.prototype.addEventListener и всякие xhr эвенты. Соответственно в этих функциях заворачивать исходную функцию в функцию и отдавать непереопределённой функции. И вот тогда можно начинать управлять порядком исполнения коллбэков и навертеть любую другую логику. Если бы было можно переопределить у всех объектов valueOf (в том числе у Number) и toString, то стало бы возможно использовать такие вызовы вместо прерываний и устроить параллельное выполнение разных функций. Это бы убило всю производительность, но я бы определённо написал библиотеку реализующую это и вылил в openSource с предупреждением о возможном вреде производительности и мозгу при попытках понять происходящее.
bromzh
https://github.com/angular/zone.js ?
zolotyh
Следующая статья как раз про это.
bromzh
Отлично, как раз хотелось больше инфы про неё.