В прошлых частях цикла мы: рассмотрели базовые концепты работы с многопоточностью в JavaScript на примере среды Node.js; научились формировать общую очередь и каналы обмена данными и сигналами, чтобы более эффективно управлять загрузкой потоков; использовали разделяемую память и Atomics-операции как самое быстрое средство обмена большими блоками данных; и создали отдельный поток-координатор, чтобы устранить негативное влияние синхронного кода в основном потоке исполнения на загрузку потоков вспомогательных. В сегодняшней, заключительной, части я продемонстрирую, как все эти механики вместе позволяют сделать эффективный микросервис, автоматически подстраивающийся под изменения входящей нагрузки. В данном случае эффективность - это не про максимально возможную скорость обработки каждой отдельной задачи, а про сбалансированное использование аппаратных ресурсов с учетом тех ограничений, на которые мы готовы пойти. Особенно актуально это для различных "облачных" размещений, где оплата идет за фактически потребленные CPU и RAM. В предыдущей части мы научились быстро передавать блоки двоичных данных через разделяемую память в обрабатывающие их потоки с помощью потока-координатора, избавившего нас от ожидания исполнения синхронного кода в основном потоке: Но, как правило, обрабатываемые сообщения состоят не только из двоичного контента, но и из некоторого объекта, который хочется иметь в доступе по итогу процессинга, а вот передавать его туда целиком вовсе незачем. Доработаем схему из прошлой статьи, вынеся функционал промежуточного хранения сообщений и формирования единого блока двоичных данных в класс Coordinator, а управления обрабатывающими потоками и очередью - в WorkersPool: В коде эта схема может выглядеть примерно так: Для "увязки" обрабатываемых двоичных данные и исходного сообщения мы можем использовать автоинкрементный id. Но только лишь его - мало, чтобы корректно передать данные в поток. Создавая блок разделяемой памяти SharedArrayBuffer, мы должны заранее предусмотреть максимальный размер возможного контента. Но работать-то мы можем с разными данными - то есть получается, что передавать размер нам надо тоже, иначе мы не сможем их правильно "выцепить" из разделяемой памяти. Получается, что каждый отправляемый в поток блок данных должен иметь префикс, состоящий из служебного id и длины самих данных - для этого нам достаточно иметь Uint32Array из  двух ячеек. Но для "склейки" этих данных с двоичными данными из сообщения нам понадобится его Uint8Array-проекция и функция преобразования dataFunc, которая может быть примерно такой: В свою очередь, на стороне обрабатывающего потока позиция id воспринимается не только как Int32-значение (уже знаковое!), но и как ячейка для ожидания блокировки: Давайте детализируем схему выше с точки зрения путей обмена этими данными: в основном потоке приложения у нас появляется сообщение message, содержащее некоторый набор двоичных данных мы передаем его в Coordinator, где оно по автоинкрементному id заносится в промежуточное Map-хранилище messages с помощью вспомогательной функции dataFunc мы из сообщения извлекаем все необходимые двоичные данные и "склеиваем" их с префиксом, содержащим id и размер данных результирующий data-буфер мы по ссылке передаем в поток-координатор в координаторе данные должны или пройти через очередь (если потоки заняты), или сразу копируются в разделяемую память свободного потока поток, получая уведомление о новых данных, обрабатывает их и отправляет прямо в основной поток, ... ... где они по id связываются с исходным сообщением и передаются в .emit  На что тут стоит обратить внимание: для связи между объектом Coordinator в основном потоке и потоком-координатором мы подняли служебный MessageChannel, состоящий из пары портов, как это было описано во второй части серии; именно по этому каналу поток-координатор передает нам порты для порождаемых им обрабатывающих потоков; по этим-то портам рабочие потоки и сбрасывают нам результаты своей деятельности прямо в основной поток. Так, с передачей данных в обрабатывающие потоки и обратно в основной поток - разобрались. Но сколько таких потоков нам необходимо иметь вообще? Сначала обратим внимание на тот факт, что некоторых ресурсов стоит уже само создание потока. Если же оно включает в себя какую-то "тяжелую" инициализационную подготовку вроде прегенерации кэша внутри потока, то его старт может кратковременно занимать логическое ядро CPU даже на все 100%. Отсюда следует четыре достаточно простых вывода: потоки стоит порождать/убивать как можно реже; не стоит это делать одновременно - то есть допустима некоторая задержка на создание/уничтожение потока; как минимум, один поток обработки должен существовать всегда; потоков должно быть не больше, чем CPU-ядер. Раз уж мы упомянули тот факт, что внутри потока могут формироваться собственные данные или кэш, то становится выгодно иметь лишь минимально необходимое число потоков, насколько это вообще возможно, чтобы занимать как можно меньше памяти и улучшать долюcache hit. Как упоминалось выше, для решения наших задач будет логично выделить класс WorkersPool, работающий внутри потока-координатора, который и будет осуществлять все управление потоками и очередью. Но как понять, что существующего количества потоков уже стало недостаточно, и все-таки необходимо создать еще один, несмотря на все сопутствующие издержки? Фактически, нас интересует лишь один параметр работы нашего сервиса - чтобы все задачи обрабатывались достаточно быстро. А любая поступившая задача может быть либо сразу отдана на исполнение какому-то из потоков, либо поставлена в очередь. Очередь, кстати, возьмем все ту же, из прошлой статьи, на основе кольцевого буфера: Сама длина очереди "в штуках" нам не сильно о чем-то говорит, ведь мы не оцениваем ни скорость обработки, ни "вес" каждой отдельной задачи. Но если мы задумаемся, что нахождение задачи в очереди в течение 1 миллисекунды нас совсем не напрягает, а "зависание" в течение 1 секунды не устраивает совсем, то где-то на этом интервале  найдем комфортное значение, сколько мы готовы достаточно безболезненно позволить задаче находиться в очереди - скажем, это будет 100ms. То есть пока время пребывания самой старой задачи в очереди не перевалило за это значение, выгоднее еще немного подождать, чем стартовать еще один поток. Чтобы не заниматься этой оценкой на каждой операции, повесим эту проверку на таймер прямо в конструкторе класса. Тут мы использовали #workersRemain одновременно и как счетчик доступных к запуску, и как признак (отрицательное значение) наличия запускающегося в текущий момент потока. Второе замечание касается использования для пула свободных именно массива, а не Set, поскольку нам важен приоритет потоков при раздаче им заданий на обработку.  Старшему досталась мельница, среднему – осел, ну а младшему пришлось взять себе кота.  С учетом возможности существования собственного кэша в каждом из потоков, нам выгоднее использовать его как можно эффективнее - то есть отправлять задачи в поток, который существует дольше. В идеале, таким потоком должен всегда оказываться главный рабочий поток, который, как мы договорились выше, существует всегда. Но он может быть занят выполнением текущей задачи - тогда задача должна быть направлена в очередь, либо первому свободному потоку, если "длина" очереди уже заведомо превосходит возможности первого потока. Если же ни одного такого потока не нашлось, и общая длина очереди превышает возможности всех потоков за нормативное время порождения потока, то пора задуматься о порождении нового потока: Такая конструкция не только заставляет ждать intervalCheck для попытки запуска потока, но и блокирует все остальные попытки на этом интервале. На самом деле, когда мы говорим про очередь, надо не забывать, что их, фактически, две. Когда у нас случается parentPort.on('message', message => ...), это означает, что нам отправили не только конкретное сообщение, но и что-то еще за ним могли успеть положить в очередь порта, которую мы можем сразу извлечь через receiveMessageOnPort, избегнув повторных вызов обработчика, как рассказывалось во второй части. Получается вот такая нетривиальная схема доставки данных до процессинга в потоке:  Сама схема передачи в поток "по блокировке" та же самая, что мы использовали в прошлой части. Осталось только свести все части воедино: Для простоты изложения выше опущена часть существенных деталей: ячеек для ожидания нотификации должно быть две - "туда" и "обратно", иначе они иначе ожидание в самом потоке координаторе может быть прервано нотификацией из него же одну из ячеек мы используем в качестве сигнала об окончании записи буфера, что не является атомарной операцией само по себе, поэтому в потоке можно начать читать начало уже нового буфера, а "хвост" - еще от старого Если ваша обработка в том или ином виде требует копирования данных из разделяемого буфера (например, преобразование в строку), то вы можете освободить блокировку сразу после него. В этом случае поток продолжит обработку строки, а координатор уже сможет положить в буфер свежие данные. Если вы все сделали правильно, то должны увидеть примерно такой вывод: В данном случае было запущено одновременно до 3 рабочих потоков: главный #2 и два вспомогательных #3 и #4. Один из них успел завершиться даже раньше, чем мы получили все результаты, а второй - чуть погодя. На этом цикл статей про многопоточность в JavaScript/Node.js я завершаю, а вы прочитайте предыдущие части - не пожалеете! часть 1: базовые концепты часть 2: очередь, каналы и координатор часть 3: разделяемая память, атомарные операции и блокировки часть 4: координатор против синхронного кода часть 5: автомасштабирование под нагрузку  Архитектура ИС: PostgreSQL, Node.js и highload