Параллельная модель и цикл событий.
Параллелизм/Многопоточность в JavaScript работает за счёт цикла событий (event loop), который отвечает за выполнение кода, сбора и обработки событий и выполнения под-задач из очереди (queued sub-tasks). Эта модель весьма отличается от других языков программирования, таких как C и Java.
Концепция жизненного цикла
В следующей секции объясняется теоретическая модель. Современные JavaScript движки внедряют/имплементируют и существенно оптимизируют этот процесс.
Визуальное представление
Для лучшего визуального представления работы Event loop, Вы можете ознакомиться с данным видео: https://www.youtube.com/watch?v=8aGhZQkoFbQ&t=389s
Стек
Вызов любой функции создаёт контекст выполнения (Execution Context (en-US) ). При вызове вложенной функции создаётся новый контекст, а старый сохраняется в специальной структуре данных — стеке вызовов (Call Stack).
function f(b) var a = 12; return a + b + 35; > function g(x) var m = 4; return f(m * x); > g(21);
Когда вызывается функция g , создаётся первый контекст выполнения, содержащий аргументы функции g и локальные переменные. Когда g вызывает f , создаётся второй контекст с аргументами f и её локальными переменными. И этот контекст выполнения f помещается в стек вызовов выше первого. Когда f возвращает результат, верхний элемент из стека удаляется. Когда g возвращает результат, её контекст также удалится, и стек становится пустым.
Куча
Объекты размещаются в куче. Куча — это просто имя для обозначения большой неструктурированной области памяти.
Очередь
Среда выполнения JavaScript содержит очередь задач. Эта очередь — список задач, подлежащих обработке. Каждая задача ассоциируется с некоторой функцией, которая будет вызвана, чтобы обработать эту задачу.
Когда стек полностью освобождается, самая первая задача извлекается из очереди и обрабатывается. Обработка задачи состоит в вызове ассоциированной с ней функции с параметрами, записанными в этой задаче. Как обычно, вызов функции создаёт новый контекст выполнения и заносится в стек вызовов.
Обработка задачи заканчивается, когда стек снова становится пустым. Следующая задача извлекается из очереди и начинается её обработка.
Цикл событий
Модель событийного цикла ( event loop ) называется так потому, что отслеживает новые события в цикле:
while (queue.waitForMessage()) queue.processNextMessage(); >
queue.waitForMessage ожидает поступления задач, если очередь пуста.
Запуск до завершения
Каждая задача выполняется полностью, прежде чем начнёт обрабатываться следующая. Благодаря этому мы точно знаем: когда выполняется текущая функция – она не может быть приостановлена и будет целиком завершена до начала выполнения другого кода (который может изменить данные, с которыми работает текущая функция). Это отличает JavaScript от такого языка программирования как C. Поскольку в С функция, запущенная в отдельном потоке, в любой момент может быть остановлена, чтобы выполнить какой-то другой код в другом потоке.
У данного подхода есть и минусы. Если задача занимает слишком много времени, то веб-приложение не может обрабатывать действия пользователя в это время (например, скролл или клик). Браузер старается смягчить проблему и выводит сообщение «скрипт выполняется слишком долго» («a script is taking too long to run») и предлагает остановить его. Хорошей практикой является создание задач, которые исполняются быстро, и если возможно, разбиение одной задачи на несколько мелких.
Добавление событий в очередь
В браузерах события добавляются в очередь в любое время, если событие произошло, а так же если у него есть обработчик. В случае, если обработчика нет – событие потеряно. Так, клик по элементу, имеющему обработчик события по событию click , добавит событие в очередь, а если обработчика нет – то и событие в очередь не попадёт.
Вызов setTimeout добавит событие в очередь по прошествии времени, указанного во втором аргументе вызова. Если очередь событий на тот момент будет пуста, то событие обработается сразу же, в противном случае событию функции setTimeout придётся ожидать завершения обработки остальных событий в очереди. Именно поэтому второй аргумент setTimeout корректно считать не временем, через которое выполнится функция из первого аргумента, а минимальное время, через которое она сможет выполниться.
Нулевые задержки
Нулевая задержка не даёт гарантии, что обработчик выполнится через ноль миллисекунд. Вызов setTimeout с аргументом 0 (ноль) не завершится за указанное время. Выполнение зависит от количества ожидающих задач в очереди. Например, сообщение »this is just a message» из примера ниже будет выведено на консоль раньше, чем произойдёт выполнение обработчика cb1. Это произойдёт, потому что задержка – это минимальное время, которое требуется среде выполнения на обработку запроса.
(function () console.log("this is the start"); setTimeout(function cb() console.log("this is a msg from call back"); >); console.log("this is just a message"); setTimeout(function cb1() console.log("this is a msg from call back1"); >, 0); console.log("this is the end"); >)(); // "this is the start" // "this is just a message" // "this is the end" // "this is a msg from call back" // "this is a msg from call back1"
Связь нескольких потоков между собой
Web Worker или кросс-доменный фрейм имеют свой собственный стек, кучу и очередь событий. Два отдельных событийных потока могут связываться друг с другом, только через отправку сообщений с помощью метода postMessage . Этот метод добавляет сообщение в очередь другого, если он конечно принимает их.
Никогда не блокируется
Очень интересное свойство цикла событий в JavaScript, что в отличие от множества других языков, поток выполнения никогда не блокируется. Обработка I/O обычно осуществляется с помощью событий и колбэк-функций, поэтому даже когда приложение ожидает запрос от IndexedDB или ответ от XHR, оно может обрабатывать другие процессы, например пользовательский ввод.
Существуют хорошо известные исключения как alert или синхронный XHR, но считается хорошей практикой избегать их использования.
Found a content problem with this page?
- Edit the page on GitHub.
- Report the content issue.
- View the source on GitHub.
This page was last modified on 7 авг. 2023 г. by MDN contributors.
Однопоточный JavaScript и многопоточная Java: что быстрее?
При необходимости в JavaScript можно запускать дополнительные потоки. Но обычно в Node.js или в браузерах весь код на JavaScript выполняется в одном потоке. В браузерах один и тот же поток рендерит содержимое веб-страницы на экран. По сути, один поток выполнения занимается всеми задачами, потому что приложения JavaScript пользуются преимуществами асинхронного выполнения. Для асинхронного выполнения задача помещается в очередь задач. Задачи из очереди одна за другой выполняются единственным потоком. Например, вторая строка кода выполняет планирование асинхронной задачи, которая запускается после завершения текущей задачи:
console.log("1"); setTimeout(()=>console.log("2")); console.log("3");
Результатом работы кода будет 1 3 2 .
В Java API под асинхронным выполнением обычно подразумевается, что задача выполняется в новом выделенном потоке. Например, представленный ниже код при помощи метода supplyAsync() планирует асинхронную задачу:
System.out.println("current thread: " + Thread.currentThread().getName()); var future = CompletableFuture.supplyAsync(() -> Thread.currentThread().getName()); System.out.println("current thread: " + Thread.currentThread().getName()); System.out.println("task thread: " + future.get());
Результат работы программы показывает, что текущий поток создал новый поток для выполнения задачи:
current thread: main current thread: main task thread: ForkJoinPool.commonPool-worker-1
Проблема множественных потоков заключается в том, что Java runtime не может создавать бесконечное их количество. Когда все запущенные потоки ожидают, а новые потоки создать нельзя, приложение тоже ничего не будет делать. Чуть ниже я проиллюстрирую этот случай, но сначала мне бы хотелось упомянуть менее серьёзный, но более распространённый пример.
Сравнение производительности многопоточных и однопоточных приложений
Теоретически многопоточные приложения должны быть более производительными, чем однопоточные, но на практике это не всегда так. Возьмём в качестве примера основной способ применения Java — серверы приложений Java. В логе видно, что HTTP-запросы обрабатывает множество параллельных потоков с собственными именами. Но если развёрнутое веб-приложение выполняет операции ввода-вывода, то многопоточность по большей мере теряет смысл, поскольку доступ к файловой системе — это узкое «бутылочное горлышко». Десять потоков не могут быть производительнее одного потока, вынужденного ждать содержимого от файловой системы. Например, Java-сервер Tomcat при передаче статичных файлов проявляет себя не лучше, чем один инстанс Node.js.
Когда многопоточная Java работает медленнее, чем однопоточный JavaScript
Давайте попробуем скачать содержимое примерно ста случайных URL. При этом воспользуемся возможностью и сравним производительность древнего HttpURLConnection и современного HttpClient .
Представленный ниже код извлекает все абсолютные ссылки с https://www.bbc.com/news/world (около 100 URL), загружает их содержимое, а затем выводит общее время, потраченное на параллельное получение содержимого:
public abstract class Runner < abstract CompletableFuture> requestManyUrls(List urls) throws Exception; void run() throws Exception < var urls = getUrlsFromUrl("https://www.bbc.com/news/world"); var start = System.currentTimeMillis(); var contents = requestManyUrls(urls).get(); var time = System.currentTimeMillis() - start; var totalLength = contents.stream() .mapToInt(o ->o.txt().length()) .reduce((a, b) -> a + b).getAsInt(); System.out.println("fetched " + totalLength + " bytes from " + urls.size() + " urls in " + time + " ms"); > >
Также код выводит общий размер загруженного содержимого, чтобы убедиться, что разные способы загружают один и тот же контент. Самое важное для нас в коде — это измерение времени, необходимого для параллельного выполнения множества HTTP-запросов.
UrlTxt — это просто запись с двумя полями:
public record UrlTxt(String url,String txt) <>
Метод getUrlsFromUrl() извлекает абсолютные URL из содержимого https://www.bbc.com/news/world:
public static List getUrlsFromUrl(String url) throws Exception < return Pattern.compile("href=\"(https:[^\"]+)\"") .matcher(get(url)) .results() .map(r ->r.group(1)) .collect(Collectors.toList()); >
Параллельные HTTP-запросы при помощи древнего HttpURLConnection
Для получения содержимого URL используется обычный код:
public static String get(String url) throws Exception < var con = (HttpURLConnection) new URL(url).openConnection(); con.setInstanceFollowRedirects(false); if (con.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) < return ""; // 404 throws FileNotFoundException >try ( BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()))) < var response = new StringBuilder(); String line; while ((line = in.readLine()) != null) < response.append(line); >return response.toString(); > >
get() используется в подклассе общего родителя Runner . Чтобы использовать get() асинхронным образом, я применяю метод-адаптер load() . Кстати, обратите внимание на раздражающее ограничение стандартных функциональных интерфейсов — они не выдают исключений и реализующий их код часто необходимо оборачивать в некрасивые блоки try catch .
public class URLRequests extends Runner < CompletableFutureload(String url) < return CompletableFuture.supplyAsync(() -> < try < return new UrlTxt(url, get(url)); >catch (Exception e) < throw new IllegalStateException(e); >>); > @Override CompletableFuture requestManyUrls(List urls) throws InterruptedException, ExecutionException < CompletableFuture[] requests = urls .stream().map(url -> load(url)).toArray(i -> new CompletableFuture[i]); return CompletableFuture.allOf(requests) .thenApply(v -> < return Stream.of(requests) .map(future ->future.join()) .collect(Collectors.toList()); >); > public static void main(String[] args) Exception < new URLRequests().run(); >>
Функциональный код в requestManyUrls() адаптирован из самого современного рецепта по созданию параллельных запросов.
Результат работы кода:
fetched 39517285 bytes from 105 urls in 6211 ms
Если повторно запустить тот же код, общий размер будет близким, но не точно таким же. Предполагаю, что содержимое некоторых ссылок динамично.
Параллельные HTTP-запросы при помощи современного HttpClient
Похоже, в настоящее время HttpClient — это лучший класс Java для создания HTTP-запросов. Кажется, он даже поддерживает HTTP/2, потому что иногда выдаёт ошибку HTTP/2 GOAWAY .
public class HttpClientRequests extends Runner < @Override public CompletableFuture> requestManyUrls(List urls) throws InterruptedException, ExecutionException < HttpClient client = HttpClient.newHttpClient(); CompletableFuture[] requests = urls.stream() .map(url -> URI.create(url)) .map(uri -> HttpRequest.newBuilder(uri)) .map(reqBuilder -> reqBuilder.build()) .map(request -> client.sendAsync(request, BodyHandlers.ofString())) .toArray(i -> new CompletableFuture[i]); return CompletableFuture.allOf(requests) .thenApply(v -> < return Stream.of(requests) .map(future ->future.join()) .map(response -> new UrlTxt(response.uri().toString(), response.body())) .collect(Collectors.toList()); >); > public static void main(String[] args) throws Exception < new HttpClientRequests().run(); >>
Огромный код с современным HttpClient выглядит пугающе, но по сравнению с предыдущим результатом в 6211 мс его работа радует:
fetched 39983157 bytes from 105 urls in 4910 ms
Параллельные HTTP-запросы на Node.js
В браузере JavaScript не может скачивать содержимое с других хостов, если целевой хост этого не разрешил. Это мера безопасности. Сайт bbc.com не разрешает другим хостам получать его содержимое. Поэтому я использую только Node.js.
Посмотрите, насколько прост полный аналог предыдущего кода на JavaScript:
import fetch from 'node-fetch'; const re = /href=\"(https:[^\"]+)\"/g; function extractLinks(txt) < return Array.from(txt.matchAll(re), ar =>ar[1]); > function load(url) < return fetch(url,) .then(res => res.text().then(txt => (< url, txt >))); > load("https://www.bbc.com/news/world") .then((< txt >) => extractLinks(txt)) .then(urls => < const start = Date.now(); Promise.all(urls.map(url =>load(url))) .then(contents => < const time= Date.now() - start ; const totalLength = contents.reduce((total, < url, txt >) => total + txt.length , 0); console.log("fetched " + totalLength + " bytes from " + urls.length + " urls in " + time + " ms"); >); >);
Что бы вы ни писали на JavaScript, преимущество очевидно — чем меньше клавиш мы нажимаете, тем меньше тратите времени и тем меньше вероятность внести баги. Однако так думают не все. Многие любят преобразовывать JavaScript в Java-подобный код под названием TypeScript.
Результат работы файла на JavaScript:
fetched 39492499 bytes from 105 urls in 1744 ms
Почему разница между Java и JavaScript почти трёхкратная?
Код на JavaScript сначала выполняет один за другим 105 HTTP-запросов. Когда приходит ответ, движок JavaScript помещает в очередь задач небольшой обратный вызов. После получения всех ответов единственный поток по очереди обрабатывает их.
В Java это работает совершенно иначе. Создаётся множество потоков, каждый из которых отправляет один HTTP-запрос. После создания некого оптимального количества потоков стандартный оптимальный внутренний пул потоков больше не может создавать потоки. Несколько созданных потоков ждут ответов. Код ничего не делает. После поступления ответов создаются новые потоки для отправки новых запросов. И этот процесс повторяется, пока не будут отправлены все запросы. По сути, мой пример кода на Java (4910–1744)/4910=64% от общего времени не делает ничего, кроме как ждёт HTTP-откликов. Ситуация такая же, как и с вводом-выводом в серверах приложений Java, но для Интернет-содержимого время ожидания больше.
Если вы знаете, как реализовать более эффективные параллельные HTTP-запросы на Java, то напишите комментарий.
Многопоточность в Node.js
Рассказываем самое необходимое о многопоточности в Node.js v10.5.0. Потоковые воркеры, пул воркеров и worker_threads своими словами.
Некоторые разработчики удивляются, как однопоточный Node.js может конкурировать с многопоточным серверным софтом. Кажется нелогичным, что компании выбирают его в качестве backend. Для начала надо разобраться в том, что на самом деле подразумевается под однопоточностью Node.
JavaScript был создан для реализации простых web-задач вроде проверки формы или создания следа у курсора. Только в 2009 году Райан Дал (создатель Node.js) сделал возможным использование этого языка для написания backend-софта.
Backend-языки, поддерживающие многопоточность, имеют необходимые механизмы для синхронизации значений между потоками и другими поточно-ориентированными функциями. Для поддержки этого в JavaScript потребовалось бы изменить весь язык, что не входило в планы Дала. Пришлось создать обходной путь, чтобы простой JavaScript мог поддерживать многопоточность.
Как на самом деле работает Node.js
Node.js использует два вида потоков:
- основной поток, обрабатываемый циклом событий (Event Loop),
- несколько вспомогательных потоков в пуле воркеров.
Цикл обработки событий — это механизм, который принимает callback-функции и регистрирует их для выполнения в определённый момент в будущем. Он работает в том же потоке, что и сам код JavaScript. Когда операция блокирует поток, цикл событий также блокируется.
Пул воркеров — модель исполнения, вызывающая и обрабатывающая отдельные потоки. Затем они синхронно выполняют задачу и возвращают результат в цикл обработки событий. После цикл вызывает callback-функцию с указанным результатом.
Если коротко, то пул воркеров может заниматься асинхронными операциями ввода-вывода — прежде всего, взаимодействем с системным диском и сетью. Эта модель исполнения в основном используется модулями вроде fs (требовательного к скорости ввода-вывода) или crypto (требовательного к CPU). Пул воркеров реализован в libuv, что приводит к небольшой задержке всякий раз, когда Node требует связи между JavaScript и C ++, но эта задержка едва ощутима.
Используя оба эти механизма, можно написать следующий код:
fs.readFile(path.join(__dirname, './package.json'), (err, content) => < if (err) < return null; >console.log(content.toString()); >);
Модуль fs указывает пулу воркеров использовать один из его потоков для чтения содержимого файла и уведомления цикла обработки событий, когда это будет сделано. Цикл принимает предоставленную callback-функцию и выполняет её с содержимым файла.
Выше приведён пример неблокирующего кода. Пул воркеров прочитает файл и вызовет предоставленную функцию с результатом. Поскольку пул имеет собственные потоки, цикл обработки событий может продолжать исполнение в обычном режиме во время чтения файла.
Всё работает, пока нет необходимости синхронно выполнять какую-то сложную операцию. Любая функция, выполнение которой занимает слишком много времени, блокирует поток. Если в приложении много таких функций, оно может значительно снизить производительность сервера или вообще заморозить его работу в целом. В этом случае нет способа делегировать работу пулу воркеров.
Области, требующие сложных вычислений, — искусственный интеллект, машинное обучение или большие данные— не могли эффективно использовать Node.js из-за операций, блокирующих основной (и единственный) поток, что делало сервер неотзывчивым. Так было до появления Node.js v10.5.0, в котором была добавлена поддержка нескольких потоков.
Знакомство с worker_threads
Модуль worker_threads — это пакет, который позволяет создавать полнофункциональные многопоточные приложения Node.js.
Потоковый воркер (thread worker) — фрагмент кода (обычно извлекаемый из файла), созданный в отдельном потоке.
Для использования потоковых воркеров нужно импортировать модуль worker_threads . Начнём с создания функции, которая поможет создавать эти воркеры, а также рассмотрим их свойства.
type WorkerCallback = (err: any, result?: any) => any; export function runWorker(path: string, cb: WorkerCallback, workerData: object | null = null) < const worker = new Worker(path, < workerData >); worker.on('message', cb.bind(null, null)); worker.on('error', cb); worker.on('exit', (exitCode) => < if (exitCode === 0) < return null; >return cb(new Error(`Worker has stopped with code $`)); >); return worker; >
Для создания потокового воркера необходимо создать экземпляр класса Worker . В первом аргументе указываем путь к файлу, который содержит код воркера; во втором предоставляем объект, содержащий свойство с именем workerData . Это те данные, к которым поток будет иметь доступ при запуске, если того хочет разработчик.
Обратите внимание: независимо от того, используете ли вы сам JavaScript или что-то, что в него транспилируется (например TypeScript), путь всегда должен ссылаться на файлы с расширениями .js или .mjs .
Также стоит указать, почему используется callback-функция вместо возвращения промиса (promise), который будет передавать результат в resolve при запуске события message . Это связано с возможностью потоковых воркеров отправлять много событий message , а не только одно.
Связь между потоками основана на событиях. Это означает, что надо настроить обработчики, которые будут вызываться после отправки потоком данного события.
Рассмотрим наиболее распространённые события.
worker.on('error', (error) => <>);
Событие error генерируется, когда внутри воркера возникает необработанное исключение. Затем поток завершается, а ошибка становится первым аргументом в callback.
worker.on('exit', (exitCode) => <>);
Exit генерируется, когда воркер заканчивает своё выполнение. Если process.exit() вызывается внутри потока, exitCode будет предоставлен в callback. Если поток прерывается с помощью worker.terminate() , код будет 1 .
worker.on('online', () => <>);
Online генерируется, когда воркер прекращает парсинг кода JavaScript и начинает его выполнение. Это событие используется нечасто, но в определённых случаях оно может быть информативным.
worker.on('message', (data) => <>);
Message генерируется, когда воркер отправляет данные в родительский поток.
Обмен данными между потоками
Для отправки данных другому потоку используется метод port.postMessage() . Он имеет следующую сигнатуру:
port.postMessage(data[, transferList])
Объект port может быть или экземпляром parentPort , или экземпляром MessagePort — подробнее об этом позже.
Аргумент data
Первый аргумент данных — назовём его data — это объект, который копируется в другой поток. Он может содержать всё, что поддерживает алгоритм копирования.
Алгоритм не копирует функции, ошибки, дескрипторы свойств или цепочки прототипов. Следует также отметить, что копирование объектов таким способом отличается от JSON, потому что он может содержать циклические ссылки и типизированные массивы, а JSON не может.
Поддерживая копирование типизированных массивов, алгоритм позволяет разделять память между потоками.
Разделение памяти между потоками
Считается, что модули вроде cluster или child_process используют потоки уже давно. Это одновременно и верно и нет.
Cluster может создавать несколько процессов Node.js с одним главным процессом, маршрутизирующим запросы между ними. Кластеризация приложения позволяет эффективно увеличить пропускную способность сервера. Однако нельзя создать отдельный поток с модулем cluster .
Модуль child_process может создавать любой исполняемый файл независимо от типа файла. В этом модуле отсутствуют некоторые важные функции, которые есть у worker_threads .
Потоковые воркеры являются более лёгкими и имеют тот же идентификатор процесса, что и их родительские потоки. Ещё они могут использовать память совместно со своими родительскими потоками. Это позволяет воркерам избежать сериализации больших входных данных и, как следствие, отправлять данные вперёд и назад более эффективно.
Рассмотрим пример разделения памяти между потоками. Чтобы память была разделена, экземпляры ArrayBuffer или SharedArrayBuffer должны быть отправлены другому потоку в качестве аргумента data или внутри него.
Пример воркера, который разделяет память со своим родительским потоком:
import < parentPort >from 'worker_threads'; parentPort.on('message', () => < const numberOfElements = 100; const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * numberOfElements); const arr = new Int32Array(sharedBuffer); for (let i = 0; i < numberOfElements; i += 1) < arr[i] = Math.round(Math.random() * 30); >parentPort.postMessage(< arr >); >);
Создаётся экземпляр SharedArrayBuffer с размером памяти, необходимым для хранения ста 32-битных целых чисел. Затем создаётся экземпляр Int32Array , который будет использовать буфер для хранения его структуры. После массив заполняется некоторыми случайными числами и отправляется в родительский поток.
В родительском потоке:
import path from 'path'; import < runWorker >from '../run-worker'; const worker = runWorker(path.join(__dirname, 'worker.js'), (err, < arr >) => < if (err) < return null; >arr[0] = 5; >); worker.postMessage(<>);
Меняя значение arr[0] на 5, фактически изменяем его в обоих потоках.
При разделении памяти есть риск изменить значение в одном потоке, изменив его в другом. Но вместе с этим появляется хорошая особенность: значение не нужно сериализовывать, чтобы оно было доступно в другом потоке. Это значительно повышает эффективность. Просто не забывайте правильно управлять ссылками на данные, чтобы те в свою очередь не оставляли за собой мусор после завершения работы с ними.
Зачастую гораздо удобнее передавать между потоками не массив, а объект. Но, к сожалению, не существует SharedObjectBuffer или чего-либо подобного, но можно самим создать похожую структуру.
Аргумент TransferList
TransferList может содержать только ArrayBuffer и MessagePort . После передачи в другой поток их больше нельзя использовать в отправляющем потоке. Память перемещается в другой поток и, следовательно, недоступна в отправляющем.
Пока нет канала связи, нельзя передавать сетевые сокеты, включая их в TransferList .
Создание канала связи
Связь между потоками осуществляется через порты, которые являются экземплярами класса MessagePort . Они обеспечивают эту связь на основе событий.
Есть два способа использования портов для связи между потоками. Первый используется по умолчанию и проще, чем второй. В код воркера импортируется объект с именем parentPort из модуля worker_threads и используется .postMessage() для отправки сообщений в родительский поток.
import < parentPort >from 'worker_threads'; const data = < // . >; parentPort.postMessage(data);
parentPort — это экземпляр MessagePort , который Node.js создал “за кулисами”, чтобы обеспечить связь с родительским потоком. Таким образом, можно общаться между потоками, используя объекты parentPort и worker .
Второй способ связи между потоками — создать MessageChannel и отправить его воркеру. Вот как можно создать новый MessagePort и поделиться им с потоковым воркером:
import path from 'path'; import < Worker, MessageChannel >from 'worker_threads'; const worker = new Worker(path.join(__dirname, 'worker.js')); const < port1, port2 >= new MessageChannel(); port1.on('message', (message) => < console.log('message from worker:', message); >); worker.postMessage(< port: port2 >, [port2]);
После создания port1 и port2 настраиваем обработчики событий на port1 и отправляем port2 воркеру. Необходимо включить его в файл TransferList , чтобы он был передан рабочей стороне.
Теперь внутри воркера:
import < parentPort, MessagePort >from 'worker_threads'; parentPort.on('message', (data) => < const < port >: < port: MessagePort >= data; port.postMessage('heres your message!'); >);
Таким образом, используется порт, который был отправлен родительским потоком.
Использование parentPort тоже является правильным подходом, но лучше создать новый MessagePort с экземпляром MessageChannel, а затем поделиться им с созданным воркером.
Обратите внимание, в примерах ниже для простоты используется parentPort .
Два способа использования воркеров
Первый — создать воркер, выполнить его код и отправить результат в родительский поток. При таком подходе каждый раз, когда появляется новая задача, надо заново создавать воркер.
Второй способ — создать воркер и настроить обработчики для события message . Каждый раз при запуске это событие выполняет свою работу и отправляет результат обратно в родительский поток, который сохраняет воркер для последующего использования.
Документация Node.js рекомендует второй подход, поскольку много усилий необходимо для создания потокового воркера, который требует создания виртуальной машины, парсинга и выполнения кода. Этот метод также намного эффективнее, чем постоянно создающиеся воркеры.
Такой подход называется пулом воркеров. Создаётся пул и воркеры находятся в ожидании события message , которое нужно для выполнения задания.
Пример файла, содержащего воркер, который создаётся, выполняется, а затем закрывается:
import < parentPort >from 'worker_threads'; const collection = []; for (let i = 0; i < 10; i += 1) < collection[i] = i; >parentPort.postMessage(collection);
После отправки collection в родительский поток, воркер просто завершается.
А вот пример воркера, который может ждать в течение длительного периода времени, прежде чем ему будет дано задание:
import < parentPort >from 'worker_threads'; parentPort.on('message', (data: any) => < const result = doSomething(data); parentPort.postMessage(result); >);
Полезные свойства модуля worker_threads
Свойство имеет значение true , когда не работает внутри потока воркера. Если есть необходимость, можно включить простой оператор if в начале файла, чтобы убедиться, что он запускается только как воркер.
Несёт в себе данные, включённые в конструктор воркера созданным потоком.
const worker = new Worker(path, < workerData >);
В потоке воркера:
import < workerData >from 'worker_threads'; console.log(workerData.property);
Экземпляр MessagePort , используется для связи с родительским потоком.
Уникальный идентификатор, присвоенный воркеру.
Реализация setTimeout
setTimeout — это бесконечный цикл, который прерывает выполнение приложения. На практике он проверяет на каждой итерации, меньше ли сумма начальной даты и заданного количества миллисекунд, чем фактическая дата.
import < parentPort, workerData >from 'worker_threads'; const time = Date.now(); while (true) < if (time + workerData.time ); break; > >
Эта конкретная реализация создаёт поток, выполняет его код и затем завершает работу.
Реализуем код, который будет использовать этот воркер. Создадим стейт, в котором будут отслеживаться созданные воркеры:
const timeoutState: < [key: string]: Worker >= <>;
Функция, которая отвечает за создание потоковых воркеров и хранит их в стейт:
export function setTimeout(callback: (err: any) => any, time: number) < const const worker = runWorker( path.join(__dirname, './timeout-worker.js'), (err) => < if (!timeoutState[id]) < return null; >timeoutState[id] = null; if (err) < return callback(err); >callback(null); >, < time, >, ); timeoutState[id] = worker; return id; >
Используем пакет UUID для создания уникального идентификатора воркера, затем задействуем определённую ранее вспомогательную функцию runWorker() , чтобы получить воркер. Передаём ему callback-функцию, которая запускается после отправки воркером некоторых данных. Сохраняем воркер в стейт и возвращаем id .
Внутри callback-функции нужно проверить, существует ли воркер в стейте, потому что есть возможность отменить его с помощью cancelTimeout() . Если он существует, удаляем его из стейта и вызываем callback, переданный в функцию setTimeout() .
Функция cancelTimeout() использует метод .terminate() , чтобы принудительно остановить воркер и удалить его из стейта:
export function cancelTimeout(id: string) < if (timeoutState[id]) < timeoutState[id].terminate(); timeoutState[id] = undefined; return true; >return false; >
Прим. если вам интересно, есть реализация метода setInterval() . Но он не имеет ничего общего с потоками (повторно используется код setTimeout() ). Кроме того, существует небольшой тестовый код для проверки, насколько такой подход отличается от исходного. Вы можете просмотреть код здесь. Результаты:
native setTimeout < ms: 7004, averageCPUCost: 0.1416 >worker setTimeout
Видно, что в setTimeout() есть небольшая задержка — около 40 мс — из-за создаваемого воркера. Средняя стоимость процессора также немного выше, но ничего страшного в этом нет (стоимость процессора — это среднее значение загрузки процессора за всё время процесса).
Если бы можно было повторно использовать воркеры, задержка и загрузка ЦП снизилась бы. Поэтому рассмотрим, как реализовать собственный пул воркеров.
Реализация пула воркеров
Пул воркеров — это заданное количество ранее созданных воркеров, которые ожидают событие message . Как только событие происходит, воркеры выполняют работу и отправляют результат обратно.
Вот как можно создать пул воркеров из восьми рабочих потоков:
const pool = new WorkerPool(path.join(__dirname, './test-worker.js'), 8);
Если вы знакомы с ограничением параллельных операций, то знаете, что логика здесь почти одинакова.
Из фрагмента выше видно, конструктору WorkerPool передаётся количество воркеров и путь для их появления.
export class WorkerPool < private queue: QueueItem[] = []; private workersById: < [key: number]: Worker >= <>; private activeWorkersById: < [key: number]: boolean >= <>; public constructor(public workerPath: string, public numberOfThreads: number) < this.init(); >>
Здесь есть дополнительные свойства вроде workerById и activeWorkersById , в которых можно сохранить существующие воркеры и их идентификаторы соответственно. Также есть queue (очередь), в которой можно сохранять объекты со следующей структурой:
type QueueCallback = (err: any, result?: N) => void; interface QueueItem < callback: QueueCallback; getData: () =>T; >
callback — callback-функция в Node по умолчанию с ошибкой в качестве первого аргумента и возможным результатом в качестве второго. getData — это функция, передаваемая методу .run() пула воркеров (поясняется ниже), которая вызывается после начала обработки элемента. Данные, возвращаемые функцией getData() , будут переданы в рабочий поток.
Внутри метода .init() создаём воркеры и сохраняем их в стейтах:
private init() < if (this.numberOfThreads < 1) < return null; >for (let i = 0; i < this.numberOfThreads; i += 1) < const worker = new Worker(this.workerPath); this.workersById[i] = worker; this.activeWorkersById[i] = false; >>
Для избежания бесконечных циклов нужно убедиться, что количество потоков больше 1. Создаём необходимое число воркеров и сохраняем их по индексу в стейте workerById . Также сохраняем информацию, работают ли они в настоящее время, в стейте activeWorkersById , который всегда по умолчанию имеет значение false .
Реализуем метод .run() для настройки задачи, которая будет запущена, как только воркер станет доступен.
public run(getData: () => T) < return new Promise((resolve, reject) => < const availableWorkerId = this.getInactiveWorkerId(); const queueItem: QueueItem= < getData, callback: (error, result) => < if (error) < return reject(error); >return resolve(result); >, >; if (availableWorkerId === -1) < this.queue.push(queueItem); return null; >this.runWorker(availableWorkerId, queueItem); >); >
Внутри функции, переданной в промис, проверяем, есть ли доступный для обработки данных воркер, вызывая .getInactiveWorkerId() :
private getInactiveWorkerId(): number < for (let i = 0; i < this.numberOfThreads; i += 1) < if (!this.activeWorkersById[i]) < return i; >> return -1; >
Создаём queueItem , в котором сохраняем переданную методу .run() функцию getData() в качестве callback. В этом callback разрешаем ( resolve ) или отклоняем ( reject ) промис в зависимости от того, передал ли воркер callback.
Если значение availableWorkerId равно -1, доступного воркера нет. В этом случае добавляем queueItem в queue . Если есть доступный воркер, вызываем метод .runWorker() для его выполнения.
В методе .runWorker() в стейте activeWorkersById необходимо установить, что воркер в данный момент используется. Также нужно настроить обработчики для событий message и error (после очистить их). И, наконец, отправить данные воркеру.
const messageCallback = (result: N) => < queueItem.callback(null, result); cleanUp(); >; const errorCallback = (error: any) => < queueItem.callback(error); cleanUp(); >; const cleanUp = () => < worker.removeAllListeners('message'); worker.removeAllListeners('error'); this.activeWorkersById[workerId] = false; if (!this.queue.length) < return null; >this.runWorker(workerId, this.queue.shift()); >; worker.once('message', messageCallback); worker.once('error', errorCallback); worker.postMessage(await queueItem.getData()); >
Используя переданный workerId , получаем ссылку на воркер из стейта workerById . Внутри activeWorkersById устанавливаем в свойстве [workerId] значение true . Таким образом будет известно, что больше ничего не нужно запускать, пока воркер занят.
Создаём messageCallback() и errorCallback() для вызова событий message и error соответственно. Регистрируем указанные функции для обработки события и отправки данных воркеру.
Внутри функций вызываем callback в queueItem , а затем вызываем функцию cleanUp() . Убеждаемся, что обработчики событий удаляются, т. к. один и тот же воркер используется многократно. Если не удалить обработчики, произойдёт утечка памяти (память медленно исчерпается).
В стейте activeWorkersById устанавливаем для свойства [workerId] значение false и проверяем, пуста ли очередь. Если это не так, удаляем первый элемент из queue и снова вызываем воркер с другим queueItem .
Создадим воркер, который выполняет некоторые вычисления после получения данных в событии message :
import < isMainThread, parentPort >from 'worker_threads'; if (isMainThread) < throw new Error('Its not a worker'); >const doCalcs = (data: any) => < const collection = []; for (let i = 0; i < 1000000; i += 1) < collection[i] = Math.round(Math.random() * 100000); >return collection.sort((a, b) => < if (a >b) < return 1; >return -1; >); >; parentPort.on('message', (data: any) => < const result = doCalcs(data); parentPort.postMessage(result); >);
Потоковый воркер создаёт массив из 1 миллиона случайных чисел, а затем сортирует их.
Пример простого использования пула воркеров:
const pool = new WorkerPool, number>(path.join(__dirname, './test-worker.js'), 8); const items = [. new Array(100)].fill(null); Promise.all( items.map(async (_, i) => < await pool.run(() =>(< i >)); console.log('finished', i); >), ).then(() => < console.log('finished all'); >);
Всё начиналось с создания пула из восьми воркеров. Затем был создан массив из 100 элементов и для каждого элемента запускалась задача в пуле воркеров. Первые восемь задач были выполнены немедленно, а остальные помещены в очередь и выполнены постепенно. Благодаря использованию пула воркеров не нужно каждый раз создавать воркер, что значительно повышает эффективность.
Заключение
worker_threads предоставляет простой способ добавить поддержку многопоточности в приложения. Передавая тяжёлые CPU-вычисления другим потокам, можно значительно увеличить пропускную способность сервера. Благодаря официальной поддержке потоков можно ожидать, что всё больше разработчиков и инженеров из различных областей (ИИ, машинное обучение и большие данные) начнут использовать Node.js.
Как эмулировать многопоточность в JavaScript
Статья рассказывает о том, как работает очередь задач движка 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. Далее мы подробнее рассмотрим очередь задач.
Полезные книги и статьи по теме (на английском языке):
- “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())
После этого в том же цикле обрабатываются все микрозадачи, запланированные в соответствующую очередь. Эти микрозадачи могут добавлять в очередь другие микрозадачи, и процесс будет продолжаться, пока очередь не опустеет.
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');
Запустив его, мы получим следующее.
Обратите внимание, что макрозадачи запланированы с помощью setTimeout , setInterval , setImmediate , а микрозадачи — process.nextTick , Promises , MutationObserver . Мы видим, что script start обрабатывается первым, затем script end , promise1 , promise2 и setTimeout . Несмотря на то, что для setTimeout установлена задержка в 0 секунд, он обрабатывается последним.
Как уже упоминалось, в одной итерации цикла событий обрабатываются макрозадачи, а затем очередь всех микрозадач. Можно возразить, что setTimeout должен быть обработан первым, так как макрозадача выполняется до очистки очереди микрозадач. А в приведённом скрипте до вызова setTimeout не запланировано никаких макрозадач.
Это действительно так. Однако в JS код не запускается до наступления события. Это событие запланировано в очереди как макрозадача.
При исполнении любого файла JS-движок конвертирует содержимое в функцию и ассоциирует её с событием start или launch . Движок инициализирует стартовое событие и добавляет события в очередь как макрозадачи.
Начиная обработку, движок JS выбирает первую макрозадачу из очереди и выполняет обработчик обратного вызова:
Мы видим, что выполняется первая из поставленных в очередь макрозадач. Обратный вызов запускает код. По мере дальнейшего исполнения с помощью вызова 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 , то увидим следующее:
Теперь давайте используем наш код 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 .
Таким образом, мы ожидаем следующее:
Чтобы запустить код в нашем собственном движке 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 , мы получим:
Точно такой же вывод покажет настоящий движок, поэтому мы смогли верно реализовать принципы его работы в собственном коде.
runScript помечает наш код в качестве макрозадачи и на выходе обратный вызов исполняет код, который выводит script start . setTimeout устанавливает макрозадачу, а micro1 (элемент setMicro ) устанавливает микрозадачу. script end выводится последним. После исполнения макрозадачи обрабатываются все микрозадачи в соответствующей очереди. Обратный вызов micro1 выводит micro1 и помещает в очередь микрозадачу micro2 . При завершении micro1 запускается micro2 с собственным выводом. По завершении в очереди не остаётся других микрозадач, и запускается следующая макрозадача. setTimeout выводит надпись setTimeout . Поскольку других макрозадач нет, цикл завершается и движок прекращает работу.
Ключевые моменты
- Задачи берутся из очереди задач.
- Задача из очереди задач — макрозадача != микрозадача.
- Все микрозадачи обрабатываются, пока не очистится очередь, и только после этого начинается следующий цикл макрозадачи.
- Микрозадачи могут ставить в очередь другие микрозадачи, и все они должны быть исполнены в пределах одного цикла.
- Рендеринг UI происходит после исполнения микрозадач.
