Worker threads что это
Многопоточность в Node.js
Авторизуйтесь
Многопоточность в Node.js
Некоторые разработчики удивляются, как однопоточный Node.js может конкурировать с многопоточным серверным софтом. Кажется нелогичным, что компании выбирают его в качестве backend. Для начала надо разобраться в том, что на самом деле подразумевается под однопоточностью Node.
JavaScript был создан для реализации простых web-задач вроде проверки формы или создания следа у курсора. Только в 2009 году Райан Дал (создатель Node.js) сделал возможным использование этого языка для написания backend-софта.
Backend-языки, поддерживающие многопоточность, имеют необходимые механизмы для синхронизации значений между потоками и другими поточно-ориентированными функциями. Для поддержки этого в JavaScript потребовалось бы изменить весь язык, что не входило в планы Дала. Пришлось создать обходной путь, чтобы простой JavaScript мог поддерживать многопоточность.
Как на самом деле работает Node.js
Node.js использует два вида потоков:
Цикл обработки событий — это механизм, который принимает callback-функции и регистрирует их для выполнения в определённый момент в будущем. Он работает в том же потоке, что и сам код JavaScript. Когда операция блокирует поток, цикл событий также блокируется.
Пул воркеров — модель исполнения, вызывающая и обрабатывающая отдельные потоки. Затем они синхронно выполняют задачу и возвращают результат в цикл обработки событий. После цикл вызывает callback-функцию с указанным результатом.
Если коротко, то пул воркеров может заниматься асинхронными операциями ввода-вывода — прежде всего, взаимодействем с системным диском и сетью. Эта модель исполнения в основном используется модулями вроде fs (требовательного к скорости ввода-вывода) или crypto (требовательного к CPU). Пул воркеров реализован в libuv, что приводит к небольшой задержке всякий раз, когда Node требует связи между JavaScript и C ++, но эта задержка едва ощутима.
Используя оба эти механизма, можно написать следующий код:
Модуль fs указывает пулу воркеров использовать один из его потоков для чтения содержимого файла и уведомления цикла обработки событий, когда это будет сделано. Цикл принимает предоставленную callback-функцию и выполняет её с содержимым файла.
Выше приведён пример неблокирующего кода. Пул воркеров прочитает файл и вызовет предоставленную функцию с результатом. Поскольку пул имеет собственные потоки, цикл обработки событий может продолжать исполнение в обычном режиме во время чтения файла.
Всё работает, пока нет необходимости синхронно выполнять какую-то сложную операцию. Любая функция, выполнение которой занимает слишком много времени, блокирует поток. Если в приложении много таких функций, оно может значительно снизить производительность сервера или вообще заморозить его работу в целом. В этом случае нет способа делегировать работу пулу воркеров.
Области, требующие сложных вычислений, — искусственный интеллект, машинное обучение или большие данные— не могли эффективно использовать Node.js из-за операций, блокирующих основной (и единственный) поток, что делало сервер неотзывчивым. Так было до появления Node.js v10.5.0, в котором была добавлена поддержка нескольких потоков.
Знакомство с worker_threads
Модуль worker_threads — это пакет, который позволяет создавать полнофункциональные многопоточные приложения Node.js.
Потоковый воркер (thread worker) — фрагмент кода (обычно извлекаемый из файла), созданный в отдельном потоке.
Связь между потоками основана на событиях. Это означает, что надо настроить обработчики, которые будут вызываться после отправки потоком данного события.
Рассмотрим наиболее распространённые события.
Событие error генерируется, когда внутри воркера возникает необработанное исключение. Затем поток завершается, а ошибка становится первым аргументом в callback.
Online генерируется, когда воркер прекращает парсинг кода JavaScript и начинает его выполнение. Это событие используется нечасто, но в определённых случаях оно может быть информативным.
Message генерируется, когда воркер отправляет данные в родительский поток.
Обмен данными между потоками
Аргумент data
Первый аргумент данных — назовём его data — это объект, который копируется в другой поток. Он может содержать всё, что поддерживает алгоритм копирования.
Алгоритм не копирует функции, ошибки, дескрипторы свойств или цепочки прототипов. Следует также отметить, что копирование объектов таким способом отличается от JSON, потому что он может содержать циклические ссылки и типизированные массивы, а JSON не может.
Поддерживая копирование типизированных массивов, алгоритм позволяет разделять память между потоками.
Разделение памяти между потоками
Считается, что модули вроде cluster или child_process используют потоки уже давно. Это одновременно и верно и нет.
Потоковые воркеры являются более лёгкими и имеют тот же идентификатор процесса, что и их родительские потоки. Ещё они могут использовать память совместно со своими родительскими потоками. Это позволяет воркерам избежать сериализации больших входных данных и, как следствие, отправлять данные вперёд и назад более эффективно.
Рассмотрим пример разделения памяти между потоками. Чтобы память была разделена, экземпляры ArrayBuffer или SharedArrayBuffer должны быть отправлены другому потоку в качестве аргумента data или внутри него.
Пример воркера, который разделяет память со своим родительским потоком:
В родительском потоке:
Меняя значение arr[0] на 5, фактически изменяем его в обоих потоках.
При разделении памяти есть риск изменить значение в одном потоке, изменив его в другом. Но вместе с этим появляется хорошая особенность: значение не нужно сериализовывать, чтобы оно было доступно в другом потоке. Это значительно повышает эффективность. Просто не забывайте правильно управлять ссылками на данные, чтобы те в свою очередь не оставляли за собой мусор после завершения работы с ними.
Зачастую гораздо удобнее передавать между потоками не массив, а объект. Но, к сожалению, не существует SharedObjectBuffer или чего-либо подобного, но можно самим создать похожую структуру.
Аргумент TransferList
Создание канала связи
Второй способ связи между потоками — создать MessageChannel и отправить его воркеру. Вот как можно создать новый MessagePort и поделиться им с потоковым воркером:
Теперь внутри воркера:
Таким образом, используется порт, который был отправлен родительским потоком.
Использование parentPort тоже является правильным подходом, но лучше создать новый MessagePort с экземпляром MessageChannel, а затем поделиться им с созданным воркером.
Два способа использования воркеров
Первый — создать воркер, выполнить его код и отправить результат в родительский поток. При таком подходе каждый раз, когда появляется новая задача, надо заново создавать воркер.
Документация Node.js рекомендует второй подход, поскольку много усилий необходимо для создания потокового воркера, который требует создания виртуальной машины, парсинга и выполнения кода. Этот метод также намного эффективнее, чем постоянно создающиеся воркеры.
Пример файла, содержащего воркер, который создаётся, выполняется, а затем закрывается:
После отправки collection в родительский поток, воркер просто завершается.
А вот пример воркера, который может ждать в течение длительного периода времени, прежде чем ему будет дано задание:
Полезные свойства модуля worker_threads
isMainThread
workerData
Несёт в себе данные, включённые в конструктор воркера созданным потоком.
parentPort
threadId
Уникальный идентификатор, присвоенный воркеру.
Реализация setTimeout
setTimeout — это бесконечный цикл, который прерывает выполнение приложения. На практике он проверяет на каждой итерации, меньше ли сумма начальной даты и заданного количества миллисекунд, чем фактическая дата.
Эта конкретная реализация создаёт поток, выполняет его код и затем завершает работу.
Реализуем код, который будет использовать этот воркер. Создадим стейт, в котором будут отслеживаться созданные воркеры:
Функция, которая отвечает за создание потоковых воркеров и хранит их в стейт:
Видно, что в setTimeout() есть небольшая задержка — около 40 мс — из-за создаваемого воркера. Средняя стоимость процессора также немного выше, но ничего страшного в этом нет (стоимость процессора — это среднее значение загрузки процессора за всё время процесса).
Если бы можно было повторно использовать воркеры, задержка и загрузка ЦП снизилась бы. Поэтому рассмотрим, как реализовать собственный пул воркеров.
Реализация пула воркеров
Вот как можно создать пул воркеров из восьми рабочих потоков:
Если вы знакомы с ограничением параллельных операций, то знаете, что логика здесь почти одинакова.
Из фрагмента выше видно, конструктору WorkerPool передаётся количество воркеров и путь для их появления.
Создаём messageCallback() и errorCallback() для вызова событий message и error соответственно. Регистрируем указанные функции для обработки события и отправки данных воркеру.
Создадим воркер, который выполняет некоторые вычисления после получения данных в событии message :
Потоковый воркер создаёт массив из 1 миллиона случайных чисел, а затем сортирует их.
Пример простого использования пула воркеров:
Всё начиналось с создания пула из восьми воркеров. Затем был создан массив из 100 элементов и для каждого элемента запускалась задача в пуле воркеров. Первые восемь задач были выполнены немедленно, а остальные помещены в очередь и выполнены постепенно. Благодаря использованию пула воркеров не нужно каждый раз создавать воркер, что значительно повышает эффективность.
Многопоточность в Node.js: модуль worker_threads
Жизнь в однопоточном мире
JavaScript был задуман как однопоточный язык программирования, который работает в браузере. «Однопоточность» означает, что в одном и том же процессе (в современных браузерах речь идёт об отдельных вкладках браузера) одновременно может выполняться лишь один набор инструкций.
Это упрощает разработку приложений, облегчает работу программистов. Изначально JavaScript был языком, подходящим лишь для добавления некоторых интерактивных возможностей на веб-страницы, например — чего-то вроде проверки форм. Среди задач, на которые был рассчитан JS, не было чего-то особенно сложного, нуждающегося в многопоточности.
Райан Даль, создатель Node.js, увидел в этом ограничении языка интересную возможность. Ему хотелось реализовать серверную платформу, основанную на асинхронной подсистеме ввода/вывода. Это означало, что программисту не нужно работать с потоками, что значительно упрощает разработку под подобную платформу. При разработке программ, рассчитанных на параллельное выполнение кода, могут возникать проблемы, решать которые очень непросто. Скажем, если несколько потоков пытаются обратиться к одной и той же области памяти, это может привести к так называемому «состоянию гонки процессов», нарушающему работу программы. Подобные ошибки сложно воспроизводить и исправлять.
Является ли платформа Node.js однопоточной?
Являются ли Node.js-приложения однопоточными. Да, в некотором роде так оно и есть. На самом деле, Node.js позволяет выполнять некие действия параллельно, но для этого программисту не нужно создавать потоки или синхронизировать их. Платформа Node.js и операционная система выполняют параллельные операции ввода/вывода своими средствами, а когда приходит время обработки данных средствами нашего JavaScript-кода, он работает в однопоточном режиме.
Другими словами, всё, кроме нашего JS-кода работает параллельно. В синхронных блоках JavaScript-кода команды всегда выполняются по одной, в том порядке, в котором они представлены в исходном коде:
Всё это замечательно — в том случае, если всё, чем занят наш код — выполнение асинхронных операций ввода/вывода. Программа состоит из небольших блоков синхронного кода, которые быстро оперируют данными, например, отправляемыми в файлы и потоки. Код фрагментов программы работает так быстро, что не блокирует выполнение кода других его фрагментов. Гораздо больше времени, чем на выполнение кода, уходит на ожидание результатов выполнения асинхронных операций ввода/вывода. Рассмотрим небольшой пример:
Возможно, показанный здесь запрос к базе данных будет выполняться около минуты, но сообщение Running query попадёт в консоль сразу же после того, как будет инициирован этот запрос. При этом сообщение Hey there будет выведено через секунду после выполнения запроса, независимо от того, завершилось ли его выполнение или ещё нет. Наше Node.js-приложение просто вызывает функцию, инициирующую запрос, при этом выполнение другого его кода не блокируется. После того, как запрос завершится, приложению будет сообщено об этом с помощью функции обратного вызова, и тут же оно получит ответ на этот запрос.
Задачи, интенсивно использующие ресурсы процессора
Что произойдёт, если нам, средствами JavaScript, нужно выполнять тяжёлые вычисления? Например — обрабатывать большой набор данных, хранящихся в памяти? Это может привести к тому, что в программе будет присутствовать фрагмент синхронного кода, выполнение которого занимает много времени и блокирует выполнение другого кода. Представьте себе, что эти вычисления занимают 10 секунд. Если речь идёт о веб-сервере, который обрабатывает некий запрос, это будет означать, что другие запросы, по меньшей мере, в течение 10 секунд, он обрабатывать не сможет. Это — большая проблема. На самом деле, вычисления, длительность которых превышает 100 миллисекунд, уже могут стать причиной подобной проблемы.
JavaScript и платформа Node.js изначально не были предназначены для решения задач, интенсивно использующих ресурсы процессора. В случае с JS, работающим в браузере, выполнение таких задач означает «тормоза» пользовательского интерфейса. В Node.js подобное способно ограничить возможность запрашивать у платформы выполнение новых асинхронных задач ввода/вывода и возможность реагировать на события, связанные с их завершением.
Вернёмся к нашему предыдущему примеру. Представим, что в ответ на запрос к базе данных пришло несколько тысяч неких зашифрованных записей, которые, в синхронном JS-коде, надо расшифровать:
Результаты, после их получения, оказываются в функции обратного вызова. После этого, до окончания их обработки, никакой другой JS-код выполняться не сможет. Обычно, как уже было сказано, нагрузка на систему, создаваемая подобным кодом, минимальна, он достаточно быстро выполняет возлагаемые на него задачи. Но в данном случае в программу пришли результаты запроса, имеющие немалый объём, и нам ещё нужно их обработать. Нечто подобное может занять несколько секунд. Если речь идёт о сервере, с которым работает множество пользователей, это будет означать, что они смогут продолжить работу только после завершения ресурсоёмкой операции.
Почему в JavaScript никогда не будет потоков?
Учитывая вышесказанное, может показаться, что для решения тяжёлых вычислительных задач в Node.js нужно добавить новый модуль, который позволит создавать потоки и управлять ими. Как вообще можно обходиться без чего-то подобного? Весьма печально то, что у тех, кто пользуется зрелой серверной платформой, такой, как Node.js, нет средств для красивого решения задач, связанных с обработкой больших объёмов данных.
Всё это так, но если в JavaScript добавить возможность работы с потоками, это приведёт к изменению самой природы этого языка. В JS нельзя просто добавить возможность работы с потоками, скажем, в виде некоего нового набора классов или функций. Для этого понадобится изменить сам язык. В языках, которые поддерживают многопоточность, широко используется такое понятие, как «синхронизация». Например, в Java даже некоторые числовые типы не являются атомарными. Это значит, что если для работы с ними из разных потоков не использовать механизмы синхронизации, всё это может закончиться тем, что, например, после того, как пара потоков одновременно попытается изменить значение одной и той же переменной, несколько байт такой переменной будут установлены одним потоком, а несколько — другим. Как результат, такая переменная будет содержать нечто несовместимое с нормальной работой программы.
Примитивное решение задачи: итерации цикла событий
Node.js не будет заниматься выполнением следующего блока кода в очереди событий до тех пор, пока не завершится работа предыдущего блока. Это значит, что для решения нашей задачи мы можем разбить её на части, представленные синхронными фрагментами кода, после чего пользоваться конструкцией вида setImmediate(callback) для того, чтобы планировать выполнение этих фрагментов. Код, задаваемый функцией callback в этой конструкции, будет выполнен после того, как будут завершены задачи текущей итерации (тика) цикла событий. После этого такая же конструкция используется для постановки в очередь очередной порции вычислений. Это позволяет не блокировать цикл событий и, в то же время, решать объёмные задачи.
Представим себе, что у нас есть большой массив, который надо обработать, при этом в ходе обработки каждого элемента такого массива требуются сложные вычисления:
Как уже было сказано, если мы решим за один заход обработать весь массив, это займёт слишком много времени и не даст выполняться другому коду приложения. Поэтому разобьём эту большую задачу на части и воспользуемся конструкцией setImmediate(callback) :
Фоновые процессы
Возможно, вышерассмотренный подход с setImmediate() нормально подойдёт для простых случаев, но до идеала ему далеко. Кроме того, тут не используются потоки (по понятным причинам) и менять ради этого язык мы тоже не намерены. Можно ли выполнять параллельную обработку данных без использования потоков? Да, это возможно, и нам для этого нужен какой-то механизм для фоновой обработки данных. Речь идёт о том, чтобы запустить некую задачу, передав ей данные, и чтобы эта задача, не мешая основному коду, пользовалась бы всем, что ей нужно, тратила бы на работу столько времени, сколько ей понадобится, а после этого возвратила бы результаты в основной код. Нам нужно нечто подобное следующему фрагменту кода:
Реальность такова, что в Node.js возможно использование фоновых процессов. Речь идёт о том, что можно создать форк процесса и реализовать вышеописанную схему работы с помощью механизма обмена сообщениями между дочерним и родительским процессами. Главный процесс может взаимодействовать с процессом-потомком, отправляя ему события и получая их от него. Разделяемая память при таком подходе не используется. Все данные, которыми обмениваются процессы, «клонируются», то есть, при внесении изменений в экземпляр этих данных одним процессом, эти изменения другому процессу не видны. Это похоже на HTTP-запрос — когда клиент отправляет его серверу, сервер получает лишь его копию. Если процессы не пользуются общей памятью — это означает, что при их одновременной работе невозможно возникновения «состояния гонки», и то, что нам не нужно обременять себя работой с потоками. Похоже, наша проблема решена.
Правда, на самом деле это не так. Да — перед нами одно из решений задачи выполнения интенсивных вычислений, но оно, снова, неидеально. Создание форка процесса — это ресурсозатратная операция. На её выполнение нужно время. Фактически, речь идёт о создании новой виртуальной машины с нуля и об увеличении объёма памяти, потребляемой программой, что происходит из-за того, что процессы не пользуются разделяемой памятью. Учитывая вышесказанное, уместно задаться вопросом о том, можно ли, после выполнения некоей задачи, повторно использовать форк процесса. На этот вопрос можно дать положительный ответ, но тут надо вспомнить о том, что форку процесса планируется передавать различные ресурсозатратные задания, которые будут выполняться в нём синхронно. Тут можно увидеть две проблемы:
Модуль worker_threads
Потоки воркеров выполняются в изолированном контексте. Они обмениваются информацией с главным процессом, используя сообщения. Это избавляет нас от проблемы «состояния гонок», которой подвержены многопоточные среды. При этом потоки воркеров существуют в том же процессе, в котором находится основная программа, то есть, при таком подходе, в сравнении с применением форков процессов, используется гораздо меньше памяти.
Работа с потоками воркеров
Кроме того, тут стоит помнить о том, что создание воркера (как и создание потока в любом языке), хотя и требует гораздо меньших затрат ресурсов чем создание форка процесса, тоже создаёт определённую нагрузку на систему. Возможно, в вашем случае даже эта нагрузка может оказаться слишком большой. В подобных случаях документация рекомендует создавать пул воркеров. Если вам это нужно, то вы, конечно, можете создать свою реализацию подобного механизма, но, возможно, вам стоит поискать что-нибудь подходящее в реестре NPM.
Тут приведён очень простой пример, но с использованием тех же механизмов можно строить куда более сложные конструкции. Например, из потока воркера можно отправлять в главный поток множество сообщений, несущих сведения о состоянии обработки данных в том случае, если наше приложение нуждается в подобном механизме. Ещё из воркера результаты обработки данных можно возвращать по частям. Например, нечто подобное может пригодиться в ситуации, когда воркер занят, например, обработкой тысяч изображений, и вы, не дожидаясь обработки их всех, хотите уведомлять основное приложение о завершении обработки каждого из них.
Подробности о модуле worker_threads можно найти здесь.
Практика работы с потоками в Node.js 10.5.0
Совсем недавно вышла версия 10.5.0 платформы Node.js. Одной из её главных возможностей стала впервые добавленная в Node.js поддержка работы с потоками, пока носящая статус экспериментальной. Этот факт особенно интересен в свете того, что данная возможность теперь есть у платформы, адепты которой всегда гордились тем, что потоки ей, благодаря фантастической асинхронной подсистеме ввода-вывода, не нужны. Однако, поддержка потоков в Node.js всё же появилась. С чего бы это? Кому и зачем они могут пригодиться?
Если в двух словах, то нужно это для того, чтобы платформа Node.js могла бы достигнуть новых высот в тех областях, в которых раньше она показывала не самые замечательные результаты. Речь идёт о выполнении вычислений, интенсивно использующих ресурсы процессора. Это, в основном, является причиной того, что Node.js не отличается сильными позициями в таких сферах, как искусственный интеллект, машинное обучение, обработка больших объёмов данных. На то, чтобы позволить Node.js хорошо показать себя в решении подобных задач, направлено немало усилий, но тут эта платформа пока выглядит куда скромнее, чем, например, в деле разработки микросервисов.
Автор материала, перевод которого мы сегодня публикуем, говорит, что решил свести техническую документацию, которую можно найти в исходном пулл-запросе и в официальных источниках, к набору простых практических примеров. Он надеется, что, любой, кто разберёт эти примеры, узнает достаточно для того, чтобы приступить к работе с потоками в Node.js.
Обратите внимание на то, что флаг включает в себя слово «worker» (воркер), а не «thread» (поток). Именно так то, о чём мы говорим, упоминается в документации, в которой используются термины «worker thread» (поток воркера) или просто «worker» (воркер). В дальнейшем и мы будем придерживаться такого же подхода.
Если вы уже писали многопоточный код, то, исследуя новые возможности Node.js, вы увидите много такого, с чем уже знакомы. Если же раньше вы ни с чем таким не работали — просто продолжайте читать дальше, так как здесь будут даны соответствующие пояснения, рассчитанные на новичков.
О задачах, которые можно решать с помощью воркеров в Node.js
Потоки воркеров предназначены, как уже было сказано, для решения задач, интенсивно использующих возможности процессора. Надо отметить, что применение их для решения задач ввода-вывода — это пустая трата ресурсов, так как, в соответствии с официальной документацией, внутренние механизмы Node.js, направленные на организацию асинхронного ввода-вывода, сами по себе гораздо эффективнее, чем использование для решения той же задачи потоков воркеров. Поэтому сразу решим, что вводом-выводом данных с помощью воркеров мы заниматься не будем.
Начнём с простого примера, демонстрирующего порядок создания и использования воркеров.
Пример №1
Вывод этого кода будет выглядеть как набор строк, демонстрирующих счётчики, значения которых увеличиваются с разной скоростью.
Результаты работы первого примера
Разберёмся с тем, что тут происходит:
Пример №2
Рассмотрим пример, в котором, во-первых, будем выполнять некие «тяжёлые» вычисления, а во-вторых, делать нечто асинхронное в главном потоке.
В этот раз мы параллельно решаем две задачи. Во-первых — загружаем домашнюю страницу google.com, во-вторых — сортируем случайно сгенерированный массив из миллиона чисел. Это может занять несколько секунд, что даёт нам прекрасную возможность увидеть новые механизмы Node.js в деле. Кроме того, тут мы измеряем время, которое требуется потоку воркера для сортировки чисел, после чего отправляем результат измерения (вместе с первым элементом отсортированного массива) главному потоку, который выводит результаты в консоль.
Результат работы второго примера
Теперь рассмотрим ещё один пример, очень похожий на то, что мы уже видели, но на этот раз уделим особое внимание структуре проекта.
Пример №3
В качестве последнего примера предлагаем рассмотреть реализацию того же функционала, что и в предыдущем примере, но на этот раз улучшим структуру кода, сделаем его чище, приведём его к виду, который повышает удобство поддержки программного проекта.
Вот код основной программы.
А вот код, описывающий поведение потока воркера (в вышеприведённой программе путь к файлу с этим кодом формируется с помощью конструкции __dirname + ‘/workerCode.js’ ):
Вот особенности этого примера: