Share This
Связаться со мной
Крути в низ
Categories
//Как эмулировать многопоточность в JavaScript

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

Перевод статьи Microtask and Macrotask: A Hands-on Approach

Изучая языки, подобные Java, мы часто сталкиваемся с потоками. Они предназначены для исполнения кода за пределами основной программы. Многие языки, например семейство .NET, имеют реализации параллельного программирования. Однако JavaScript — однопоточный язык.

Как создать иллюзию многопоточности, используя JavaScript? Работая одновременно с двумя программами, операционная система резервирует для каждой отдельный участок памяти и виртуальное пространство адресов, определённое в BDT. ОС может переключаться между двумя исполняемыми процессами, обрабатывая каждый определённое количество времени. Система ставит на паузу один процесс, сохраняя его адреса, и продолжает работу с другим с точки сохранения.

Посмотрим, как можно создать в JavaScript несколько потоков, подобно тому, как это делают в Java.

Вебинар «Разработка библиотеки компонентов на React + Storybook»

22 сентября в 20:00, Онлайн, Беcплатно

tproger.ru События и курсы на tproger.ru

Для этого мы используем 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

Обратите внимание, что макрозадачи запланированы с помощью 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 происходит после исполнения микрозадач.

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

Перейти к регистрации

  • 28 views
  • 0 Comment

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Связаться со мной
Close