Как эмулировать многопоточность в JavaScript
Статья рассказывает о том, как работает очередь задач движка JavaScript, о циклах событий, обрабатывающих макрозадачи и микрозадачи.
31К открытий32К показов
Изучая языки, подобные Java, мы часто сталкиваемся с потоками. Они предназначены для исполнения кода за пределами основной программы. Многие языки, например семейство .NET, имеют реализации параллельного программирования. Однако JavaScript — однопоточный язык.
Как создать иллюзию многопоточности, используя JavaScript? Работая одновременно с двумя программами, операционная система резервирует для каждой отдельный участок памяти и виртуальное пространство адресов, определённое в BDT. ОС может переключаться между двумя исполняемыми процессами, обрабатывая каждый определённое количество времени. Система ставит на паузу один процесс, сохраняя его адреса, и продолжает работу с другим с точки сохранения.
Посмотрим, как можно создать в JavaScript несколько потоков, подобно тому, как это делают в Java.
Для этого мы используем events
— планирование исполнения разных участков кода на определённое время. Этот метод применения асинхронности в JavaScript называется цикл событий. В этой статье вы узнаете принципы работы этой системы, написав собственный движок JS. Практика — лучший способ понять, как язык обрабатывает очередь задач с помощью циклов.
Под капотом: циклы событий, стек вызовов и асинхронный код в JavaScript
JS использует для асинхронной обработки задач концепцию циклов событий. Этот подход требует прикрепления к событиям обработчиков таким образом, чтобы при наступлении событий исполнялся прикреплённый к ним код. Прежде чем двинуться дальше, давайте рассмотрим, как работает движок JS.
Движок JS состоит из стека, кучи и очереди задач.
Стек
Это структура, похожая по строению на массив, отслеживающая исполняемые функции.
В данном случае функция m()
обращается к функциям a()
и b()
. Во время исполнения программы адрес функции m помещается в стек вызова. Чтобы лучше понять концепцию адресации памяти, стоит изучить принципы работы операционной системы.
Прежде чем обработать код функции, движок JS помещает её адрес в стек вызова. На самом низком уровне существуют регистры EAX, EBX, ECX, ESP, EIP. Они используются центральным процессором для временного хранения переменных и исполнения загруженных в память программ. EAX и EBX используются для вычислений, ECX обрабатывает счётчики (например в цикле for). ESP (указатель стека) содержит текущий адрес стека, EIP (указатель инструкции) — адрес исполняемой программы.
Это грубый набросок того, как выглядит память во время исполнения программы.
Сначала загружается наша программа, затем стек вызова, ESP и EIP. Точка входа программы — функция m()
, поэтому EIP указывает на соответствующий адрес в памяти. Когда процессор начинает исполнять программу, он обращается к EIP и получает точку старта. В нашем случае он начинает с адреса 10 и исполняет m()
.
В Ассемблере это выражение call m
. Когда происходит вызов функции, система обращается к соответствующему адресу и начинает исполнение команд оттуда. Выполнив функцию, система продолжает исполнять код с того места, с которого был осуществлён вызов. Стек вызова содержит адрес возврата точки исполнения. При каждом вызове функции текущее значение EIP помещается в этот стек. В нашем примере при вызове a()
память будет выглядеть следующим образом:
Когда работа функции a
завершается, адрес (7) выталкивается из стека в EIP, и исполнение программы продолжается с этого адреса.
Параметры также помещаются в стек вызова. При выполнении функции с параметрами используется регистр EBP, чтобы получить значения из стека. Эти значения и есть параметры. Прежде чем обратиться к функции, требуется обеспечить доступ к ним, а уже после этого обработать адреса в регистрах EIP и ESP.
Куча
Объекты располагаются в так называемой куче. В отличие от стека, куча не упорядочена. Новые объекты создаются с помощью ключевого слова new.
Эта строка создаёт объект класса Animal
, размещает его в куче и возвращает адрес переменной lion
. Поскольку объекты в куче не упорядочены, менеджер памяти ОС должен контролировать распределение адресов таким образом, чтобы не допускать появления неиспользуемого пространства.
Очередь задач
Здесь размещаются задачи, которые движок должен обработать.
Цикл событий — это постоянный процесс, который проверяет стек вызова, и если стек пуст, переходит к исполнению инструкций из очереди задач.
Как мы убедились, события вполне возможно использовать для достижения асинхронности в JS. Далее мы подробнее рассмотрим очередь задач.
Полезные книги и статьи по теме (на английском языке):
- “Assembly Language: Function Calls” by Jennifer Rexford;
- Writing a JavaScript framework — Execution timing, beyond setTimeout by Bertalan Miklos;
- Concurrency model and Event Loop — Mozilla Web Docs.
Микрозадачи и макрозадачи
Мы увидели, что в очереди задач хранятся запланированные обратные вызовы, которые выполняются, когда закончена обработка главного потока.
Однако работа очереди задач несколько сложнее. Запланированные действия разбиты на микрозадачи и макрозадачи.
В одной итерации цикла событий ровно одна макрозадача обрабатывается из очереди (очередь задач предназначена для макрозадач) :
После этого в том же цикле обрабатываются все микрозадачи, запланированные в соответствующую очередь. Эти микрозадачи могут добавлять в очередь другие микрозадачи, и процесс будет продолжаться, пока очередь не опустеет.
До запуска следующей макрозадачи может пройти довольно много времени. Это может привести к зависанию интерфейса пользователя или простою приложения.
Из этого кода видно, что микрозадачи выполняются раньше макрозадач:
Запустив его, мы получим следующее.
Обратите внимание, что макрозадачи запланированы с помощью setTimeout
, setInterval
, setImmediate
, а микрозадачи — process.nextTick
, Promises
, MutationObserver
. Мы видим, что script start
обрабатывается первым, затем script end
, promise1
, promise2
и setTimeout
. Несмотря на то, что для setTimeout
установлена задержка в 0 секунд, он обрабатывается последним.
Как уже упоминалось, в одной итерации цикла событий обрабатываются макрозадачи, а затем очередь всех микрозадач. Можно возразить, что setTimeout
должен быть обработан первым, так как макрозадача выполняется до очистки очереди микрозадач. А в приведённом скрипте до вызова setTimeout
не запланировано никаких макрозадач.
Это действительно так. Однако в JS код не запускается до наступления события. Это событие запланировано в очереди как макрозадача.
При исполнении любого файла JS-движок конвертирует содержимое в функцию и ассоциирует её с событием start
или launch
. Движок инициализирует стартовое событие и добавляет события в очередь как макрозадачи.
Начиная обработку, движок JS выбирает первую макрозадачу из очереди и выполняет обработчик обратного вызова:
Получает содержимое исходного файла.Преобразует его в функцию.Ассоциирует эту функцию с обработчиком событий, ориентированным на событие “start” или “launch”.Выполняет остальные процедуры инициализации.Запускает событие, начинающее работу программы.Событие добавляется в очередь событий.Движок Javascript извлекает это событие из очереди и выполняет обработчик.Запускает программу.— “Asynchronous Programming in Javascript CSCI 5828: Foundations of Software Engineering Lectures 18–10/20/2016” by Kenneth M. Anderson.
Мы видим, что выполняется первая из поставленных в очередь макрозадач. Обратный вызов запускает код. По мере дальнейшего исполнения с помощью вызова console.log
выводится script start
. Затем вызывается функция setTimeout
, размещённая в очереди обработчиком. После этого вызов Promise размещает в очереди микрозадачу, а далее console.log
выводит script end
, и начальный вызов завершается.
После макрозадачи начинается обработка микрозадач. Запускается обратный вызов Promise, который обращается к promise1
. Тот выполняет свой участок кода и завершается, при этом добавляя в очередь другую микрозадачу с помощью функции then()
. Эта операция обрабатывается (как мы помним, микрозадачи могут добавлять другие микрозадачи в очередь в пределах одной итерации цикла макрозадачи), что приводит в выводу promise2
. Другие микрозадачи в очередь не попадают, и она опустошается. Стартовая макрозадача выполнена, что оставляет макрозадачу функции setTimeout
.
В этот момент запускается рендеринг UI (если он представлен в программе). Далее обрабатывается макрозадача setTimeout
, выполняется её код и задача удаляется из очереди. Если задач больше нет и стек пуст, работа движка останавливается.
Следуя по стопам Джейка Арчибальда, эмулируем цикл событий. В данном случае это будет разделение на макро- и микрокоманды, реализованное посредством JS-кода.
Сначала мы инициализируем очереди macrotask
(1) и microtask
(2). При исполнении макрозадачной функции, подобной setTimeout
, её функция обратного вызова помещается в очередь macrotask
(1), таким же образом (2) обрабатываются вызовы микрозадач.
Стек js_stack
(3) содержит функции и выражения, которые мы намереваемся исполнить. По сути, он содержит наш JS-код. Чтобы его выполнить, мы циклично проходим через код, вызывая его содержимое с помощью функции eval
.
Затем мы определяем функции, транслирующие макро- и микрозадачи: setMicro
(4), setMacro
(5), runScript
(6) и setTimeout
(7). Эти функции принимают в качестве параметра обратный вызов fn
и помещают fn
в соответствующую очередь.
Ранее мы рассмотрели примеры макро- и микрозадач. Упомянутые функции определённым образом определяют макро- и микрозадачи при вызове. В нашем случае мы просто помещаем обратный вызов fn
в соответствующую очередь. setMicro
является функцией микрозадачи, поэтому её обратный вызов помещается в очередь микрозадач. Функцию setTimeout
мы переопределили, поэтому при исполнении кода будет обработана наша версия.
Поскольку setTimeout
— функция макрозадачи, мы помещаем обратный вызов в очередь макрозадач. setMacro
также относится к макрозадачам, поэтому её вызов регистрируется в соответствующей очереди. У нас есть функция runScript
, эмулирующая глобальное событие «start» в движке JS во время инициализации. Поскольку глобальное событие относится к области макрозадач, мы помещаем обратный вызов fn
в эту очередь. Параметр fn
функции runScript
(8) заключает код в js_stack
(например код в нашем файле JS), поэтому при запуске обратный вызов fn
загружает код в js_stack
.
Сначала мы выполняем функцию runScript
, которая, как мы выяснили, содержит весь код из js_stack. Когда стек очищен, запускается очередь макрозадач (10). Для каждой итерации выполнения макрозадач (11) обрабатываются все обратные вызовы микрозадач (12).
Мы прошли через массив макрозадач с помощью цикла for
и исполнили текущую функцию по индексу. Внутри цикла мы таким же образом прошли через массив микрозадач и исполнили все. Некоторые микрозадачи могут добавлять в очередь собственные элементы. Цикл обрабатывает очередь, пока она не опустеет, а затем переходит к следующей макрозадаче.
Чтобы посмотреть, как это работает на практике, попробуем запустить наш JS-код.
Берём каждый оператор и помещаем в виде строки в js_stack
.
Как видите, js_stack
похож на код нашего файла JS. Движок вычитывает его и выполняет каждый оператор. Это то действие, которое мы заложили в функцию runScriptHandler
(8) Мы проходим с помощью цикла (8I) через js_stack
и исполняем каждый оператор (ln. 8II) используя функцию eval
.
Если мы запустим программу node js_engine.js
, то увидим следующее:
Теперь давайте используем наш код example.js, с помощью которого мы демонстрировали макро- и микрозадачи, но с некоторыми изменениями:
Мы удалили Promises, заменив их функцией setMicro
, также обращающейся к очереди микрозадач. Мы можем увидеть, что при исполнении обратного вызова micro1
, функция добавляет другую микрозадачу, micro2
, так же, как это делали Promises
.
Таким образом, мы ожидаем следующее:
Чтобы запустить код в нашем собственном движке JS, мы транслируем код следующим образом:
Затем, запустив node js_engine.js
, мы получим:
Точно такой же вывод покажет настоящий движок, поэтому мы смогли верно реализовать принципы его работы в собственном коде.
runScript
помечает наш код в качестве макрозадачи и на выходе обратный вызов исполняет код, который выводит script start
. setTimeout
устанавливает макрозадачу, а micro1
(элемент setMicro
) устанавливает микрозадачу. script end
выводится последним. После исполнения макрозадачи обрабатываются все микрозадачи в соответствующей очереди. Обратный вызов micro1
выводит micro1
и помещает в очередь микрозадачу micro2
. При завершении micro1
запускается micro2
с собственным выводом. По завершении в очереди не остаётся других микрозадач, и запускается следующая макрозадача. setTimeout
выводит надпись setTimeout
. Поскольку других макрозадач нет, цикл завершается и движок прекращает работу.
Ключевые моменты
- Задачи берутся из очереди задач.
- Задача из очереди задач — макрозадача != микрозадача.
- Все микрозадачи обрабатываются, пока не очистится очередь, и только после этого начинается следующий цикл макрозадачи.
- Микрозадачи могут ставить в очередь другие микрозадачи, и все они должны быть исполнены в пределах одного цикла.
- Рендеринг UI происходит после исполнения микрозадач.
31К открытий32К показов