Как эмулировать многопоточность в JavaScript

макрозадачи

Изучая языки, подобные 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. Далее мы подробнее рассмотрим очередь задач.

Полезные книги и статьи по теме (на английском языке):

Микрозадачи и макрозадачи

Мы увидели, что в очереди задач хранятся запланированные обратные вызовы, которые выполняются, когда закончена обработка главного потока.

Однако работа очереди задач несколько сложнее. Запланированные действия разбиты на микрозадачи и макрозадачи.

В одной итерации цикла событий ровно одна макрозадача обрабатывается из очереди (очередь задач предназначена для макрозадач) :

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

Обратите внимание, что макрозадачи запланированы с помощью setTimeoutsetIntervalsetImmediate, а микрозадачи — process.nextTickPromisesMutationObserver. Мы видим, что script start обрабатывается первым, затем script endpromise1promise2 и setTimeout. Несмотря на то, что для setTimeout установлена задержка в 0 секунд, он обрабатывается последним.

Как уже упоминалось, в одной итерации цикла событий обрабатываются макрозадачи, а затем очередь всех микрозадач. Можно возразить, что setTimeout должен быть обработан первым, так как макрозадача выполняется до очистки очереди микрозадач. А в приведённом скрипте до вызова setTimeout не запланировано никаких макрозадач.

Это действительно так. Однако в JS код не запускается до наступления события. Это событие запланировано в очереди как макрозадача.

При исполнении любого файла JS-движок конвертирует содержимое в функцию и ассоциирует её с событием start или launch. Движок инициализирует стартовое событие и добавляет события в очередь как макрозадачи.

Начиная обработку, движок JS выбирает первую макрозадачу из очереди и выполняет обработчик обратного вызова:

  1. Получает содержимое исходного файла.
  2. Преобразует его в функцию.
  3. Ассоциирует эту функцию с обработчиком событий, ориентированным на событие “start” или “launch”.
  4. Выполняет остальные процедуры инициализации.
  5. Запускает событие, начинающее работу программы.
  6. Событие добавляется в очередь событий.
  7. Движок Javascript извлекает это событие из очереди и выполняет обработчик.
  8. Запускает программу.

— “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

Хинт для программистов: если зарегистрироваться на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании. Перейти к регистрации.