9 июня 2016 в 11:45

Асинхронность в JavaScript: Пособие для тех, кто хочет разобраться

image


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


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


В JavaScript нет многопоточности. Несмотря на то, что мы уже можем полноценно использовать вебворкеры, из них нельзя менять DOM или вызывать методы объекта window. Одним словом, не многопоточность, а сплошное разочарование.


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


image


С DOM-деревом работают в одном потоке, чтобы гарантировать целостность и непротиворечивость данных, но как программировать интерфейс с одним потоком? Ведь сама суть интерфейса — в асинхронности. Именно для этого придуманы асинхронные функции. Они выполняются не сразу, а после наступления события. Интересно, что эти функции — не часть JavaScript-движков. Вызов setTimeout на чистом V8 приводит к ошибке, так как в V8 нет такой функции. Тогда откуда же появляется setTimeout или requestAnimationFrame или addEventListener?


Асинхронность внутри


Движок JavaScript похож на мясорубку, бесконечно перемалывающую операции, которые последовательно берутся из стека вызовов (1). Код выполняется линейно и последовательно. Удалить операцию из стека нельзя, можно только прервать поток выполнения. Поток выполнения прерывается, если вызвать что-то типа alert или «исключение».


image


Каждая операция содержит контекст — некую область памяти, из которой доступны данные. Контексты расположены в памяти в виде дерева. Каждому листу в дереве доступны области видимости, которые определены в родительских ветках и в корне (глобальной области видимости). Функции в 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 мы пишем асинхронные приложения, не задумываясь о многопоточности: о целостности и непротиворечивости данных. За эти преимущества мы платим огромным числом обратных вызовов, блокированием основного потока и постоянными потерями контекста.


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

Автор: @zolotyh
Wrike
рейтинг 89,95
Wrike делает совместную работу над проектами проще

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

  • +2

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

    • 0
      Это неуправляемая многопоточность. Ей не дают управлять, чтобы не выстрелить себе в ногу. Тут нужно различать, многопоточность и асинхронность.
      • 0

        Я понимаю различия многопоточности и асинхронности. Но если я через асинхронные вызовы могу создавать и удалять некие условные "потоки", значит эта "условная многопоточность" все-таки не совсем неуправляемая? Конечно это не горутины, но… Так в итоге, как именно обрабатываются "окружением" асинхронные вызовы? Конкурентно? Зависит от движка?

        • +1
          Управлять конечно можно. В некотором смысле. Но суть от этого не меняется. Программист на javascript не может полноценно управлять потоками. Он может предполагать, что где-то кто-то создаст за него поток или 5 потоков или 10. Он не знает сколько. Весь код, который пишет программист на JS выполнится в одном потоке (если не принимать во внимание веб-воркеры).
          • 0

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

        • 0
          Рискну предположить, что окружение само решает как обрабатывать вызовы. То есть ответ на ваш вопрос: зависит от реализации.
    • 0
      Согласен с вами,
      имхо устоявшийся термин js асинхронность — это и есть многопоточность. Многопоточность, разделяющая процессорное время (в данном случае — очередь вызовов).
      Кто помнит *nix-подобные системы и Windows 9x как раз и стали теми ОС (надеюсь никого не забыл?), кто привнесли многозадачность на единственном процессоре за счёт разделения процессорного времени. И именно в такой ситуации и зародилась «классическая» многопоточность.

      Единственной отличие js — не вижу никакого способа внешнего управления потоками (проритет, возможность остановки, ...)
      • +1
        Но можно написать внутренний планировщик, переопределить setTimeout\interval\requestAnimationFrame, HTMLElement.prototype.addEventListener и всякие xhr эвенты. Соответственно в этих функциях заворачивать исходную функцию в функцию и отдавать непереопределённой функции. И вот тогда можно начинать управлять порядком исполнения коллбэков и навертеть любую другую логику. Если бы было можно переопределить у всех объектов valueOf (в том числе у Number) и toString, то стало бы возможно использовать такие вызовы вместо прерываний и устроить параллельное выполнение разных функций. Это бы убило всю производительность, но я бы определённо написал библиотеку реализующую это и вылил в openSource с предупреждением о возможном вреде производительности и мозгу при попытках понять происходящее.
        • +1
          • 0
            Следующая статья как раз про это.
            • 0

              Отлично, как раз хотелось больше инфы про неё.

  • +3
    Вот тут дядька даже емулятор визуальный написал: loupe.
  • +9
    Ребят, это детский сад, а не пособие для тех кто хочет разобраться. Это супер базовое введение коих сотни. Хорошее пособие для тех кто хочет разобраться это например рассказать о внутреннем устройстве промисов, асинков, авайтов, генераторов. Рассказать как работает yield, как это на самом деле внутри работает, какие плюсы и минусы по перфомансу.
    • +6
      Да. Это базовые знания. Но многие про это не знают. Это видно по собеседованиям. В любом случае, спасибо за обратную связь. Постараемся в следующий раз написать про что-то более хардкорное.
      • 0
        Пишите что-то новое.
      • +3
        Я может резковато высказался, вполне возможно просто несовпадение ожиданий вызванных заголовком и тела статьи. Иначе говоря слишком общий и громкий заголовок. В вообще, хорошо написано, продолжайте.
        • 0
          Асинхронность в JavaScript: Пособие для тех, кто хочет запутаться разобраться
  • +2
    Все циклы от большого числа до нуля, можно было написать как while (i--); Во втором примере
    • 0
      Спасибо, как-то не подумал об этом ))
  • –1
    Либо я трудный, либо картинка-схема(
  • +4
    В действительности, к даже таким базовым вещам – разработчик приходит спустя пару лет опыта. Когда начинает оптмизировать свои велосипеды.
    За отличные иллюстрации отдельный +
  • 0
  • +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.
    • 0
      Все так, но я сходу не могу придумать как запустить что-то асинхронно на чистом v8. Если вы подскажите, будет очень круто, я обязательно добавлю это в статью.
      • +1
        Promise.resolve().then(() => console.log('async'))
        • 0
          Исправил статью. Спасибо за уточнение.
        • 0

          Ну какая же это асинхронность, это всего лишь отложенный вызов функции, который достигается перекладыванием на верх ивэнт лупа. Так же как и nextTick. Есть ещё setImmediate, который кладёт вызов функции вниз ивэнт лупа. Поэтому манипуляции с ивэнт лупом можно назвать "отложенностью", но никак не "асинхронностью".

          • 0
            Во-первых, nextTick и setImmediate определены в Nodejs и в браузере соответственно, а не в стандарте ECMAScript
            Во-вторых, а дайте определение «асинхронности» и «отложенности», пожалуйста.
            • 0

              Мой комментарий был лишь к тому, что бы читатель понимал из-за чего достигается подобное поведение. И я например не считаю, что это можно назвать асинхронностью, но и в дискуссию также не хочу вступать, если считаете иначе, то пускай так и будет. Это всего лишь мое мнение. Но изменение очереди вызова функций, лично мне, сложно назвать асинхронностью. Ведь в конце концов, вызов функций остаётся синхронным, лишь порядок изменяется. То есть здесь нет никого, кто бы выполнял какой либо таск вне потока ивэнт лупа. Но и вы тоже отчасти будете правы если скажете, что функция не синхронная, так как в данном случае мы не можем использовать, например return x, так как функция возвращает свой результат в коллбэке, который в свою очередь будет вызван хоть и синхронно, но после актуального вызова.

              • 0
                К чему словоблудие — просто поясните, что такое, по-вашему, асинхронность.
                • 0
                  Умеющий читать — да прочтёт:
                  … здесь нет никого, кто бы выполнял какой либо таск вне потока ивэнт лупа..
                  • 0
                    Ясно. Понятно.
    • 0
      Полезно… разобраться.
  • +1
    Incredible Machine. Спасибо. Полезно.
  • 0
    console в v8 нет. А в общем и целом вы правы. Можно в движке запускать что-то асинхронно.

       Promise.resolve().then(function(){print(1)});
       print(2);
    

    На выходе:
    2
    1

    Другое дело, что смыла в этом особого нет, кроме тех случаев, когда нужно решить проблему с сильной загруженностью стека вызовов.
  • 0
    как уже было отмечено, технически вызовы не совсем асинхронны. Правильнее наверно говорить о паралельном (фоновом) выполнении. Речь именно о ожидании ответа а не просто запуске некоего паралельного обработчика
    Аналогичная ситуация с await async в .NET.
    Асинхронность — это когда функция возвращает управление.а вызываемая сторона потом по своей инициативе дергает некий callback или типа того.
    Реальная асинхронка, например, медицинский стандарт DICOM где и SCU и SCP вставляют TCP порты. И когда SCU делает запрос на передачу файла, SCP говорит — команду понял, закрывает соединение, ищет файл. потом открывает свое соединение по TCP и начинает передавать.
    мы платим огромным числом обратных вызовов, блокированием основного потока и постоянными потерями контекста

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

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

Самое читаемое Разработка