Как эмулировать многопоточность в JavaScript
t.me/we_use_jsИзучая языки, подобные Java, мы часто сталкиваемся с потоками. Они предназначены для исполнения кода за пределами основной программы. Многие языки, например семейство .NET, имеют реализации параллельного программирования. Однако JavaScript — однопоточный язык.
Как создать иллюзию многопоточности, используя JavaScript? Работая одновременно с двумя программами, операционная система резервирует для каждой отдельный участок памяти и виртуальное пространство адресов, определённое в BDT. ОС может переключаться между двумя исполняемыми процессами, обрабатывая каждый определённое количество времени. Система ставит на паузу один процесс, сохраняя его адреса, и продолжает работу с другим с точки сохранения.
Посмотрим, как можно создать в JavaScript несколько потоков, подобно тому, как это делают в Java.
Для этого мы используем events
— планирование исполнения разных участков кода на определённое время. Этот метод применения асинхронности в JavaScript называется цикл событий. В этой статье вы узнаете принципы работы этой системы, написав собственный движок JS. Практика — лучший способ понять, как язык обрабатывает очередь задач с помощью циклов.
Под капотом: циклы событий, стек вызовов и асинхронный код в JavaScript
JS использует для асинхронной обработки задач концепцию циклов событий. Этот подход требует прикрепления к событиям обработчиков таким образом, чтобы при наступлении событий исполнялся прикреплённый к ним код. Прежде чем двинуться дальше, давайте рассмотрим, как работает движок JS.
Движок JS состоит из стека, кучи и очереди задач.
Стек
Это структура, похожая по строению на массив, отслеживающая исполняемые функции.
function m() { a() b() } m()
В данном случае функция m()
обращается к функциям a()
и b()
. Во время исполнения программы адрес функции m помещается в стек вызова. Чтобы лучше понять концепцию адресации памяти, стоит изучить принципы работы операционной системы.
Прежде чем обработать код функции, движок JS помещает её адрес в стек вызова. На самом низком уровне существуют регистры EAX, EBX, ECX, ESP, EIP. Они используются центральным процессором для временного хранения переменных и исполнения загруженных в память программ. EAX и EBX используются для вычислений, ECX обрабатывает счётчики (например в цикле for). ESP (указатель стека) содержит текущий адрес стека, EIP (указатель инструкции) — адрес исполняемой программы.
RAM EIP = 10 0 | | ESP = 21 1 |a(){}| 2 | | Call Stack 3 |b(){}| 14| | 4 | | 15| | 5 | | 16| | 6 |m(){ | 17| | 7 | a() | 18| | 8 | b() | 19| | 9 |} | 20| | 10|m() | 21| |
Это грубый набросок того, как выглядит память во время исполнения программы.
Сначала загружается наша программа, затем стек вызова, ESP и EIP. Точка входа программы — функция m()
, поэтому EIP указывает на соответствующий адрес в памяти. Когда процессор начинает исполнять программу, он обращается к EIP и получает точку старта. В нашем случае он начинает с адреса 10 и исполняет m()
.
В Ассемблере это выражение call m
. Когда происходит вызов функции, система обращается к соответствующему адресу и начинает исполнение команд оттуда. Выполнив функцию, система продолжает исполнять код с того места, с которого был осуществлён вызов. Стек вызова содержит адрес возврата точки исполнения. При каждом вызове функции текущее значение EIP помещается в этот стек. В нашем примере при вызове a()
память будет выглядеть следующим образом:
RAM EIP = 1 0 | | ESP = 20 ➥1 |a(){}| 2 | | Call Stack 3 |b(){}| 14| | 4 | | 15| | 5 | | 16| | 6 |m(){ | 17| | 7 | a() | 18| | 8 | b() | 19| | 9 |} | 20| | 10|m() | 21| 7 |
Когда работа функции a
завершается, адрес (7) выталкивается из стека в EIP, и исполнение программы продолжается с этого адреса.
Параметры также помещаются в стек вызова. При выполнении функции с параметрами используется регистр EBP, чтобы получить значения из стека. Эти значения и есть параметры. Прежде чем обратиться к функции, требуется обеспечить доступ к ним, а уже после этого обработать адреса в регистрах EIP и ESP.
Куча
Объекты располагаются в так называемой куче. В отличие от стека, куча не упорядочена. Новые объекты создаются с помощью ключевого слова new.
const lion = new Animal('lion', 'very_aggresive')
Эта строка создаёт объект класса 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.
Микрозадачи и макрозадачи
Мы увидели, что в очереди задач хранятся запланированные обратные вызовы, которые выполняются, когда закончена обработка главного потока.
Однако работа очереди задач несколько сложнее. Запланированные действия разбиты на микрозадачи и макрозадачи.
В одной итерации цикла событий ровно одна макрозадача обрабатывается из очереди (очередь задач предназначена для макрозадач) :
while (eventLoop.waitForTask()) { eventLoop.processNextTask() }
После этого в том же цикле обрабатываются все микрозадачи, запланированные в соответствующую очередь. Эти микрозадачи могут добавлять в очередь другие микрозадачи, и процесс будет продолжаться, пока очередь не опустеет.
while (eventLoop.waitForTask()) { const taskQueue = eventLoop.selectTaskQueue() if (taskQueue.hasNextTask()) { taskQueue.processNextTask() } const microtaskQueue = eventLoop.microTaskQueue while (microtaskQueue.hasNextMicrotask()) { microtaskQueue.processNextMicrotask() } }
До запуска следующей макрозадачи может пройти довольно много времени. Это может привести к зависанию интерфейса пользователя или простою приложения.
Из этого кода видно, что микрозадачи выполняются раньше макрозадач:
// example.js console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end');
Запустив его, мы получим следующее.
script start script end promise1 promise2 setTimeout
Обратите внимание, что макрозадачи запланированы с помощью 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-кода.
// js_engine.js 1.➥ let macrotask = [] 2.➥ let microtask = [] 3.➥ let js_stack = [] // микрозадача 4.➥ function setMicro(fn) { microtask.push(fn) } // макрозадача 5.➥ function setMacro(fn) { macrotask.push(fn) } // макрозадача 6.➥ function runScript(fn) { macrotask.push(fn) } 7.➥ global.setTimeout = function setTimeout(fn, milli) { macrotask.push(fn) } // ваш скрипт 8.➥ function runScriptHandler() { 8I.➥for (var index = 0; index < js_stack.length; index++) { 8II.➥eval(js_stack[index]) } } // начало исполнения скрипта 9.➥runScript(runScriptHandler) // запуск макрозадачи 10.➥for (let ii = 0; ii < macrotask.length; ii++) { 11.➥ eval(macrotask[ii])() if (microtask.length != 0) { // обработка микрозадач 12.➥ for (let __i = 0; __i < microtask.length; __i++) { eval(microtask[__i])() } // очистка микрозадач microtask = [] } }
Сначала мы инициализируем очереди 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-код.
console.log('start') console.log(`Hi, I'm running in a custom JS engine`) console.log('end')
Берём каждый оператор и помещаем в виде строки в js_stack
.
... // ваш скрипт js_stack.push(`console.log('start')`) js_stack.push("console.log(`Hi, I'm running in a custom JS engine`)") js_stack.push(`console.log('end')`) ...
Как видите, js_stack
похож на код нашего файла JS. Движок вычитывает его и выполняет каждый оператор. Это то действие, которое мы заложили в функцию runScriptHandler
(8) Мы проходим с помощью цикла (8I) через js_stack
и исполняем каждый оператор (ln. 8II) используя функцию eval
.
Если мы запустим программу node js_engine.js
, то увидим следующее:
start Hi, I'm running in a custom JS engine end
Теперь давайте используем наш код example.js, с помощью которого мы демонстрировали макро- и микрозадачи, но с некоторыми изменениями:
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); setMicro(()=> { console.log('micro1') setMicro(()=> { console.log('micro2') }) }) console.log('script end');
Мы удалили Promises, заменив их функцией setMicro
, также обращающейся к очереди микрозадач. Мы можем увидеть, что при исполнении обратного вызова micro1
, функция добавляет другую микрозадачу, micro2
, так же, как это делали Promises
.
Таким образом, мы ожидаем следующее:
script start script end micro1 micro2 setTimeout
Чтобы запустить код в нашем собственном движке JS, мы транслируем код следующим образом:
// js_engine.js ... js_stack.push(`console.log('script start');`) js_stack.push(`setTimeout(function() { console.log('setTimeout'); }, 0);`) js_stack.push(`setMicro(()=> { console.log('micro1') setMicro(()=> { console.log('micro2') }) })`) js_stack.push(`console.log('script end');`) ...
Затем, запустив node js_engine.js
, мы получим:
$ node js_engine script start script end micro1 micro2 setTimeout
Точно такой же вывод покажет настоящий движок, поэтому мы смогли верно реализовать принципы его работы в собственном коде.
runScript
помечает наш код в качестве макрозадачи и на выходе обратный вызов исполняет код, который выводит script start
. setTimeout
устанавливает макрозадачу, а micro1
(элемент setMicro
) устанавливает микрозадачу. script end
выводится последним. После исполнения макрозадачи обрабатываются все микрозадачи в соответствующей очереди. Обратный вызов micro1
выводит micro1
и помещает в очередь микрозадачу micro2
. При завершении micro1
запускается micro2
с собственным выводом. По завершении в очереди не остаётся других микрозадач, и запускается следующая макрозадача. setTimeout
выводит надпись setTimeout
. Поскольку других макрозадач нет, цикл завершается и движок прекращает работу.
Ключевые моменты
- Задачи берутся из очереди задач.
- Задача из очереди задач — макрозадача != микрозадача.
- Все микрозадачи обрабатываются, пока не очистится очередь, и только после этого начинается следующий цикл макрозадачи.
- Микрозадачи могут ставить в очередь другие микрозадачи, и все они должны быть исполнены в пределах одного цикла.
- Рендеринг UI происходит после исполнения микрозадач.
Перевод статьи Microtask and Macrotask: A Hands-on Approach