Как эмулировать многопоточность в 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К показов



