На прошлой неделе состоялся релиз 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 в репозитории.


Ссылки


Комментарии (40)


  1. jehy
    25.06.2018 19:28

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


    1. PYXRU
      25.06.2018 19:41

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


      1. jehy
        25.06.2018 19:50

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

        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.


    1. Akuma
      25.06.2018 19:48

      Если это простые объекты, то JSON вам в помощь.


      1. jehy
        25.06.2018 19:54
        +1

        Ага, вместе с оверхедом на сериализацию и десериализацию. Но даже так не выйдет — увы, обычно есть какие-то сложные объекты, которые хочется унести на обработку в другой поток.

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


        1. rumkin Автор
          25.06.2018 23:20
          +1

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


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


          1. seriyPS
            26.06.2018 00:11
            +1

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


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

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


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


            1. rumkin Автор
              26.06.2018 02:28

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


            1. mayorovp
              26.06.2018 06:53

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


            1. khrundel
              26.06.2018 16:38
              +1

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


        1. jMas
          26.06.2018 12:32

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


    1. zim32
      26.06.2018 10:57

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


      1. jehy
        26.06.2018 11:02

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


        1. zim32
          26.06.2018 16:57
          +1

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


  1. kalininmr
    25.06.2018 19:38

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


    1. rumkin Автор
      25.06.2018 23:21

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


  1. GreedyIvan
    25.06.2018 22:48

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


    1. rumkin Автор
      25.06.2018 23:26

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


      1. GreedyIvan
        25.06.2018 23:50

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


        1. rumkin Автор
          26.06.2018 02:45

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


          1. MikailBag
            26.06.2018 18:14

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


            1. rumkin Автор
              26.06.2018 20:32
              +1

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


  1. Alternator
    26.06.2018 00:24
    +1

    Вот еще одна возможность многопоточности в Node.js — Napa.js


  1. AstarothAst
    26.06.2018 11:10

    Как это все выглядит со стороны:
    В мире java: многопоточность сложна, возьмем netty и будем писать в один поток!
    В мире node: подержи мое саке, бака гайдзин!..


  1. andreylartsev
    26.06.2018 13:30

    Node.JS появился и развивался как альтернатива системам построенным на концепции мультипоточного программирования. Теперь в node.js появляется возможность мультипоточного программирования. Может я чего не понимаю, но похоже что разработчики рубят сук на котором сидят. Нет?


    1. Strate
      26.06.2018 15:18
      +1

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


    1. Alternator
      27.06.2018 00:04
      +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 и в данном юзкейсе хорошо справляется(используя меньшее количество процессов/потоков)


      1. mayorovp
        27.06.2018 10:27

        Уточнение: в ASP.NET давно уже можно использовать асинхронную архитектуру.


        1. Alternator
          27.06.2018 11:38

          В том смысле что можно обрабатывать одновременные операции ввода/вывода в одном потоке, в событийном цикле, как это происходит в ноде?
          Если да, звучит здорово.
          Если же под отдельную операцию ввода/вывода нужно взять отдельный поток из пула, и работать с этой задачей асинхронно, то в плане нагрузки вроде ничего не меняется.


          1. mayorovp
            27.06.2018 11:42

            Асинхронные операции ввода/вывода вообще не привязываются ни к каким потокам. А вот их продолжения исполняются в пуле потоков.

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

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


  1. SilentImp
    26.06.2018 15:19

    Дочерний поток живет пока его очередь событий (event loop) не опустеет.

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

    Могу я например в воркерах запустить н серверов параллельно?


    1. legos
      27.06.2018 10:16
      +2

      Можно, никаких проблем


  1. bgnx
    26.06.2018 18:21

    У веб-воркеров есть недостаток что невозможно как-то прервать вычисления (если задача вдруг стала неактуальной) не убив воркер полностью (это могло бы выглядеть как например бросание исключение работающему воркеру через worker.throw() чтобы прервать синхронные вычисления). Можно конечно прервать через worker.terminate() но создавать на каждую задачу новый воркер слишком медленно и нерационально а нужно иметь пулл потоков равный количеству ядер и распределять задачи между ними. В итоге в воркере нужно разбивать вычисления на асинхронные части и проверять проверять пришло ли сообщение на остановку задачи что сильно усложняет и замедляет вычисления в целом. Будут ли иметь этот недостаток веб-воркеры для ноды?


    1. RidgeA
      26.06.2018 20:35
      +1

      Судя по API — такой возможности пока нет. И, как мне кажеться, врядли будет.
      А как бы вы эту задачу решали при использовании полноценных потоков в других языках :-)?


      1. bgnx
        27.06.2018 10:31

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


    1. rumkin Автор
      26.06.2018 20:38

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


      1. bgnx
        27.06.2018 10:38
        +1

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

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


        1. rumkin Автор
          27.06.2018 23:52

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


    1. akass
      26.06.2018 20:50

      Так вроде нигде нет такого функционала, например в c# реализован cancelation token, которые останавливает именно так как сказали.


    1. mayorovp
      27.06.2018 10:42

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