Релиз Node.js 10.5: мультипоточность из коробки


    На прошлой неделе состоялся релиз Node.js версии 10.5.0, содержащий нововведение, чью значимость трудно переоценить, – поддержку многопоточности в виде модуля worker_threads. Сразу оговорюсь API находится в экспериментальной стадии и поэтому может измениться, но уже сейчас можно составить первое впечатление и получить представление о заложенных в его основу принципах и технологиях. А если у вас есть желание, то и поучаствовать в финализации интерфейса, написании кода или исправлении багов (список issues).


    История появления


    На протяжении всей жизни Node.js единственным способом распараллелить вычисления был запуск нового процесса, например с использованим модуля cluster. По многим причинам такой подход не устраивает разработчиков, в частности потому, что это приводит к повтроной загрузке в память компьютера исполняемого кода Node.js со всеми встроенными модулями, что является неэффективным способом расходования ресурсов.


    Тем неменее обсуждение внедрения многопоточности в Node.js всегда упиралось в сложность V8 и огромное количество неизвестных: как подключать нативные модули, разделять память, осуществлять коммуникацию между потоками и прочее. И пока разработчики искали с какой стороны подступиться к теме в вебе успешно был внедрен Worker API, который и стал ориентиром на начальных этапах. Разработка началась усилиями addaleax и была подхвачена сообществом.


    В течение года велась активная работа, в процессе которой требования к дизайну были конкретизированы и API обрел собственные специфичные для Node.js черты, а сам модуль получил название worker_threads. Ниже я кратко опишу worker_threads в общих чертах, а для подробного изучения советую посетить страницу официальной документации.


    Описание


    Как уже было сказано выше, цель данной разработки – улучшение производительности при помощи распределенния нагрузки по раздельным потокам в рамках одного процесса, вместо запуска нескольких процессов. Поэтому потоки будут поддерживать подключение всех доступных основному процессу модулей (на данный момент нативные модули не поддерживаются).


    Так же как и в Worker API взаимодействие между главным и дочерним потоком осуществляется посредством передачи передаваемых (Transferrable) объектов посредством postMessage, что позволяет избежать проблем одновременного доступа, хоть и требует дополнительных обращений к памяти для копирования данных. При этом объекты вроде SharedArrayBuffer сохраняют свое поведение и не вызывают переаллокации.


    Из WebAPI был взят MessageChannel и MessagePort, что позволяет создавать изолированные каналы обмена сообщениями и передавать их между потоками.


    Для того чтобы попробовать worker_threads в деле при запуске процесса необходимо указать специальный флаг:


    node --experimental-worker main.js

    Пример


    Так как API еще может меняться я не буду его описывать, но приведу пример обмена сообщениями между родительским и дочерним потоком, в котором дочерний поток сообщает свой threadId, через MessagePort и завершает свою работу.


    Главный поток


    Пример кода основного потока:


    // main.js
    const {Worker} = require('worker_threads');
    
    const worker = new Worker(__dirname + '/worker.js');
    
    worker.on('online', () => {
      console.log('Worker ready');
    });
    
    worker.on('message', (msg) => {
      console.log('Worker message:', msg);
    });
    
    worker.on('error', (err) => {
      console.error('Worker error:', err);
    });
    
    worker.on('exit', (code) => {
      console.log('Worker exit code:', code);
    });

    Дочерний поток


    Дочерний поток живет пока его очередь событий (event loop) не опустеет. Таким образом сразу после выполнения кода из worker.js поток будет автоматически закрыт. Для связи с родителем используется parentPort:


    // worker.js
    const {threadId, parentPort} = require('worker_threads');
    
    parentPort.postMessage(`Hello from thread #${threadId}.`);
    // Exit happens here

    В дочернем потоке объект process переопределен, а его поведение несколько отличается от поведения process в родительском потоке. В частности нет возможности отреагировать на сигналы SIGNINT, изменить значения process.env, а вызов process.exit остановит только worker, но не весь процесс.


    Заключение


    Воркеры позволят сильно упростить создание приложений требующих взаимодействия между параллельно исполняемыми участками кода и, что особенно важно, делает коммуникацию и управление потоками наиболее очевидным способом. А так же позволят избежать платформозависимых ограничений вызванных различием Windows и Unix. Уверен, что открывающиеся возможности привлекут новых разработчиков, которые еще не сделали выбор в пользу Node.js. А пока продолжайте следить за изменениями и подключайтесь к процессу разработки API в репозитории.


    Ссылки


    Поделиться публикацией
    Комментарии 45
      0

      Всё бы хорошо — но пока нет возможности передавать объекты. А без этого фича не очень полезная. Но есть надежда, что допилят.

        0

        А где про это написано?

          0
          В пулл реквесте:

          The super-high-level description of the implementation here is that Workers can share and transfer memory, but not JS objects (they have to be cloned for transferring), and not yet handles like network sockets.
          0
          Если это простые объекты, то JSON вам в помощь.
            0
            Ага, вместе с оверхедом на сериализацию и десериализацию. Но даже так не выйдет — увы, обычно есть какие-то сложные объекты, которые хочется унести на обработку в другой поток.

            Уже который год жду, пока в ноду завезут сериализацию для полноценных объектов. Но увы — кроме редко работающего node-serialize от luin пока ничего нет, и это меня который год удивляет.
              +6

              Если не копировать объекты, то получите состояние гонки. Данное решение необходимо чтобы избежать проблем одновременного доступа. Тот же Erlang использует копирование всегда, при этом считается очень производительным. При этом дочерний поток – это новый контекст, который ничего не знает о том как выглядит прототип объекта из другого контекста.


              Этот же подход используется в WebWorker API и скорее всего будет применяться и в Node.js.

                +7

                В Эрланге копирование всегда, но с важной оговоркой: бинарные данные размером больше 64 байт не копируются, а хранятся в общей куче и управляются по счётчику ссылок. А в виде бинарных данных в Эрланге принято обрабатывать почти все "данные". Для примера посмотрел на своем приложении:


                • binary: 709Мб
                • process: 124Мб
                • ets (ещё один способ шарить данные между процессами): 118Мб

                Т.е. из 1Гб памяти потенциально может быть скопировано при передаче между процессами только 124Мб.


                Ну и для копирования данных в Erlang под капотом используется memcpy а не сериализация в строку / json. Плюс собственная сложная система аллокаторов памяти заточенная на такое обращение.
                Сокеты между erlang процессами можно передавать, но нужно для этого использовать отдельное API либо "обернуть" сокет в процесс. Тогда вообще никаких ограничений.

                  +1

                  Спасибо за детальный ответ. Про IPC в статье указано, что будет использоваться механизм Worker API для передачи так называемых transferrable объектов, этот механизм оптимизирован в V8 (насколько это возможно) и хотя вряд ли сравнится с Erlang по производительности, как минимум из-за большей вариативности, это все же не обычная сериализация, так как передаваться будут и инстансы некоторых объектов.

                    0
                    Ну так SharedArrayBuffer тоже не копируется…
                      +1

                      Так эрланг — это функциональщина, значит неизменяемость и возможность безболезненно шарить данные. Зато изменение через создание нового объекта, так что в одном месте копирования избежали, зато в другом получили

                    0

                    Есть блобы и blob-URL. Возможно ли их как то применить для этих целей?

                  0

                  Передавайте легкие обьекты в которых только данные нужные для вычисления. Зачем тянуть весь объект?

                    0
                    Это прекрасный совет, только одна беда — он из серии «нормально делай — нормально будет». В реальной жизни всё работает несколько по-другому — хотя бы потому что в большинстве случаев идёт работа с объектами, а не простыми данными. Хотя бы для того, чтобы использовать ссылки, которые позволяют не раздуть объект в 10 раз.
                      +1

                      Я понимаю ваше разочарование но думаю возможнось передавать объекты добавила бы больше проблем и сложностей чем полезностей

                        –1
                        одна из фич шарпа перед явой — данные по значению.
                        нестоит недооценивать
                        это круто не только в плане всякого кадра памяти, но ещё и это автоматом неизменяемая структура — что передали то и осталось
                          0

                          Хотя возможно стоило бы разрешить передавать фрозен объекты только на чтение.

                            0

                            А все изменения в объект можно сделать асинхронными и выполнять их в главном потоке через event loop. Но пока есть что есть.

                          0
                          Этот подход сильно ограничен в применении. Например с таким подходом нельзя написать самую главную часть бекенда — базу данных. А база данных либо полностью либо часть данных в виде кеша хранит в некой структуре состоящую из объектов в оперативной памяти. И если на запись из-за race-condition можно разрешить обновлять данные только одному потоку то нет никаких причин не распараллелить чтение на все ядра процессора. Но только вот проблема — в ноде нет возможности читать к одну и ту же структуру объектов из разных потоков а хранить копию структуры в каждом потоке (а потом еще и синхронизировать изменения) никакой оперативки не хватит
                            0

                            Ну частично это можно обойти используя нод кластер и ращные библиотеки типо memored для общего доступа к структурам.

                        +1
                        а это точно потоки?
                        как же шаред и прочее?
                        память получается необщая?
                          0

                          Да это точно потоки, а не что-то иное. Просто совместного доступа к памяти не будет, за исключением SharedArrayBuffer.

                          +2

                          А в чем принципиальное отличие от child process api?

                            0

                            Различие API или плюсы перед child process?

                              +1
                              Плюсы. Зачем нужен child process и как его готовить у меня представление есть. Про Worker API совсем ничего не читал. Хочется чуть более подробного введения, для чего и как его использовать, и в чем его преимущество над уже существующими решениями.
                                +1

                                Некоторые плюсы я описал в статье (меньшее количество аллокаций памяти и более удобная и быстрая коммуникация между потоками), подробнее опишу, когда API финализируется и список получится более точным. Но для меня плюс в большем контроле: child_process может быть завершен другим процессом, а worker – нет.

                                  0
                                  А в чем проблема того, что другие процессы имеют теоретическую возможность убить дочерний процесс? К тому же они все равно могут kill / TerminateProcess по отношению к главному процессу.
                                    +1

                                    Могу ответить встречным вопросом: зачем нужен полноценный процесс, когда потребность только в вычислительной среде, запущенной в отдельном потоке.

                            +2
                            Вот еще одна возможность многопоточности в Node.js — Napa.js
                              +2
                              Как это все выглядит со стороны:
                              В мире java: многопоточность сложна, возьмем netty и будем писать в один поток!
                              В мире node: подержи мое саке, бака гайдзин!..
                                0
                                Node.JS появился и развивался как альтернатива системам построенным на концепции мультипоточного программирования. Теперь в node.js появляется возможность мультипоточного программирования. Может я чего не понимаю, но похоже что разработчики рубят сук на котором сидят. Нет?
                                  +1

                                  Эта фича — она как child_process, только на потоках. Принципиально ничего нового.

                                    +1
                                    Нет, это вполне себе улучшение.

                                    С чем сравниваем ноду: с CGI/FastCGI, и пулом потоков(как в ASP.NET)

                                    Юзкейс 1 — приложение в котором практически нет сложной вычислительной логики, а только операции ввода/вывода:
                                    1) В CGI на каждый запрос создается и убивается отдельный процесс
                                    Проигрываем на времени создания процесса, на памяти под отдельные процессы, и на переключении контекста между процессами
                                    При этом большую часть времени каждый процесс находится в ожидании одной операции чтения/записи
                                    2) В FastCGI процессы переиспользуются, и достаются/возвращаются из пула по мере необходимости.
                                    Пропали потери на создание/уничтожение процесса, но память и переключение контекста остались
                                    3) Пул потоков. Примерно тоже, что и в предыдущем пункте, только меньше накладных расходов на память и переключение контекста
                                    4) Однопоточная асинхронная архитектура(Node.js, Twisted, ...)
                                    Процесс и поток один. Нету накладных расходов на память и переключение контекста(если не считать соседние и системные процессы).
                                    При условии что нету блокирующих вычислительных операций, все очень хорошо в сравнении с предыдущими пунктами

                                    Юзкейс 2 — в приложении есть блокирующая вычислительная логика
                                    Допустим время одного запроса 1 секунда, из них 0.1 — вычисления, и 0.9 — ожидание ввода вывода. И на сервер приходит в среднем 100 запросов в секунду
                                    1-3) в среднем будет 100 одновременно активных процессов/потоков(с сопутствующими накладными расходами)
                                    4.1) child_process/cluster. В среднем должно хватить 10 воркер-процессов, каждый из которых будет на 100% занят вычислениями(в перерывах между ними получая результаты ввода/вывода от других запросов)
                                    Хотя возможна проблема когда приходят два одновременных запроса в один воркер, и тогда второму придется дождаться завершения блокирующей операции от первого запроса(+100мс для конкретного запроса).
                                    Но можно увеличить количество воркеров(не столь значительно как для 1-3 подходов), и уменьшить потенциальный разброс
                                    4.2) То же самое, только уменьшаются накладные расходы на процессы, и обмен сообщениями между ними
                                    Таким образом Node.js и в данном юзкейсе хорошо справляется(используя меньшее количество процессов/потоков)
                                      0
                                      Уточнение: в ASP.NET давно уже можно использовать асинхронную архитектуру.
                                        0
                                        В том смысле что можно обрабатывать одновременные операции ввода/вывода в одном потоке, в событийном цикле, как это происходит в ноде?
                                        Если да, звучит здорово.
                                        Если же под отдельную операцию ввода/вывода нужно взять отдельный поток из пула, и работать с этой задачей асинхронно, то в плане нагрузки вроде ничего не меняется.
                                          0
                                          Асинхронные операции ввода/вывода вообще не привязываются ни к каким потокам. А вот их продолжения исполняются в пуле потоков.

                                          Если возможность загнать все продолжения в один поток — но не в ASP.NET.

                                          Кстати, для справки: механизм async/await в C# появился раньше чем в javascript (собственно, из C# его и стянули).
                                    0
                                    Дочерний поток живет пока его очередь событий (event loop) не опустеет.

                                    А я правильно понимаю, что в воркерах например не получится установить сервер и слушать входящие соединения? Или можно?

                                    Могу я например в воркерах запустить н серверов параллельно?
                                      +2
                                      Можно, никаких проблем
                                      0
                                      У веб-воркеров есть недостаток что невозможно как-то прервать вычисления (если задача вдруг стала неактуальной) не убив воркер полностью (это могло бы выглядеть как например бросание исключение работающему воркеру через worker.throw() чтобы прервать синхронные вычисления). Можно конечно прервать через worker.terminate() но создавать на каждую задачу новый воркер слишком медленно и нерационально а нужно иметь пулл потоков равный количеству ядер и распределять задачи между ними. В итоге в воркере нужно разбивать вычисления на асинхронные части и проверять проверять пришло ли сообщение на остановку задачи что сильно усложняет и замедляет вычисления в целом. Будут ли иметь этот недостаток веб-воркеры для ноды?
                                        +1
                                        Судя по API — такой возможности пока нет. И, как мне кажеться, врядли будет.
                                        А как бы вы эту задачу решали при использовании полноценных потоков в других языках :-)?
                                          0
                                          Насколько я знаю используя си на есть возможность послать сигнал процессу или потоку ( а потоки в линуксе тоже процессы просто разделяют адресное пространство) и это прервет синхронно вычисляемые задачи даже если там бесконечный цикл и управление сразу передастся обработчику сигнала и дальше уже он сможет либо вернутся к вычислениям либо прервать если задача стала неактуальной.
                                          0

                                          Не могу придумать, где это может пригодиться. Исключение – это исключение, оно сообщает об ошибке и более ни для чего использоваться не должно. Сигнализировать воркеру, что в нем что-то пошло не так извне грозит сложным дебагом. В любом случае такое поведение не является частью стандарта, поэтому не стоит расчитывать на его появление, но лучше написать об этом в обсуждении PR на гитхабе.

                                            +1
                                            Не могу придумать, где это может пригодиться.

                                            Например пришел запрос на вычисление cpu-bound задачи — мы отправили эту задачу в отдельный воркер (не создавая каждый раз новый и не убивая после завершения вычисления). Но тут вдруг запрос прерывается или сообщает что задача уже неактуальна а значить нет смысла ждать пока этот воркер закончит работу над этой задачей и надо как-то сообщить ему чтобы он прекратил вычисления. А исключение это просто как способ прервать какие-то синхронно вычисляемые задачи как например стек вызываемых функций или просто цикл.
                                              0

                                              Для таких вещей есть генераторы. Собственно делают то, что вам нужно.

                                            0
                                            Так вроде нигде нет такого функционала, например в c# реализован cancelation token, которые останавливает именно так как сказали.
                                              0
                                              Ну, кооперативное-то прерывание можно сделать без особых проблем через SharedArrayBuffer. А чужой код всегда может отловить исключение и сказать что его не было, так что тут только прибивать воркер и остается.

                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                            Самое читаемое