Если вы пишете что-то сложнее короткого скрипта командной строки, это чтение поможет вам создавать более производительные и более безопасные приложения.
Этот документ написан с прицелом на серверы Node.js, но концепции применимы и к сложным приложениям на Node.js. Там, где детали зависят от ОС, документ ориентирован на Linux.
Node.js выполняет JavaScript-код в Event Loop (инициализация и колбэки) и предлагает Worker Pool для обработки дорогих задач вроде файлового I/O. Node.js хорошо масштабируется — иногда лучше, чем более тяжеловесные подходы вроде Apache. Секрет масштабируемости Node.js в том, что он использует небольшое число потоков для обслуживания множества клиентов. Если Node.js может обойтись меньшим числом потоков, то он может тратить больше времени и памяти системы на работу с клиентами, а не на оплату накладных расходов по пространству и времени на потоки (память, переключение контекста). Но поскольку у Node.js всего несколько потоков, вы должны структурировать своё приложение так, чтобы использовать их с умом.
Вот хорошее эмпирическое правило, чтобы держать ваш сервер Node.js быстрым: Node.js быстр, когда работа, связанная с каждым клиентом в любой момент времени, «мала».
Это относится и к колбэкам на Event Loop, и к задачам в Worker Pool.
Node.js использует небольшое число потоков для обслуживания множества клиентов.
В Node.js есть два типа потоков: один Event Loop (он же главный цикл, главный поток, поток событий и т. д.) и пул из k воркеров в Worker Pool (он же threadpool).
Если поток долго выполняет колбэк (Event Loop) или задачу (Worker), мы называем его «заблокированным». Пока поток заблокирован работой в интересах одного клиента, он не может обрабатывать запросы других клиентов. Отсюда две причины не блокировать ни Event Loop, ни Worker Pool:
- Производительность: если вы регулярно выполняете тяжеловесную деятельность на любом из типов потоков, пропускная способность (запросов/секунду) вашего сервера пострадает.
- Безопасность: если при определённом вводе один из ваших потоков может заблокироваться, злоумышленник может подать этот «злой ввод», заставить ваши потоки блокироваться и не давать им работать с другими клиентами. Это была бы атака отказа в обслуживании (Denial of Service).
Node.js использует событийно-ориентированную архитектуру (Event-Driven Architecture): у него есть Event Loop для оркестрации и Worker Pool для дорогих задач.
При старте приложения на Node.js сначала проходят фазу инициализации: подключают модули через require и регистрируют колбэки на события.
Затем приложения на Node.js входят в Event Loop, отвечая на входящие клиентские запросы выполнением соответствующего колбэка.
Этот колбэк выполняется синхронно и может регистрировать асинхронные запросы, чтобы продолжить обработку после своего завершения.
Колбэки для этих асинхронных запросов тоже будут выполняться на Event Loop.
Event Loop также выполняет неблокирующие асинхронные запросы, сделанные его колбэками, например сетевой I/O.
Итого, Event Loop выполняет JavaScript-колбэки, зарегистрированные на события, а также отвечает за выполнение неблокирующих асинхронных запросов вроде сетевого I/O.
Worker Pool в Node.js реализован в libuv (документация), которая предоставляет общий API для отправки задач.
Node.js использует Worker Pool для обработки «дорогих» задач. Сюда входит I/O, для которого операционная система не предоставляет неблокирующей версии, а также особенно нагружающие CPU задачи.
Вот API модулей Node.js, которые используют этот Worker Pool:
- Интенсивные по I/O
- DNS:
dns.lookup(),dns.lookupService(). - Файловая система: все API файловой системы, кроме
fs.FSWatcher()и явно синхронных, используют threadpool libuv.
- DNS:
- Интенсивные по CPU
Во многих приложениях на Node.js эти API — единственные источники задач для Worker Pool. Приложения и модули, использующие C++-аддон, могут отправлять в Worker Pool и другие задачи.
Для полноты отметим, что когда вы вызываете один из этих API из колбэка на Event Loop, Event Loop платит некоторые небольшие расходы на подготовку, входя в C++-биндинги Node.js для этого API и отправляя задачу в Worker Pool. Эти расходы пренебрежимо малы по сравнению с общей стоимостью задачи — поэтому Event Loop и разгружает её. Отправляя одну из таких задач в Worker Pool, Node.js предоставляет указатель на соответствующую C++-функцию в C++-биндингах Node.js.
Абстрактно Event Loop и Worker Pool поддерживают очереди для ожидающих событий и ожидающих задач соответственно.
На самом деле Event Loop не поддерживает очередь как таковую. Вместо этого у него есть набор файловых дескрипторов, за которыми он просит следить операционную систему, используя механизм вроде epoll (Linux), kqueue (OSX), event ports (Solaris) или IOCP (Windows). Эти файловые дескрипторы соответствуют сетевым сокетам, любым отслеживаемым файлам и так далее. Когда операционная система сообщает, что один из этих файловых дескрипторов готов, Event Loop переводит это в соответствующее событие и вызывает колбэк(и), связанные с этим событием. Подробнее об этом процессе можно узнать здесь.
В отличие от этого, Worker Pool использует настоящую очередь, записи которой — задачи для обработки. Воркер снимает задачу из этой очереди и работает над ней, а по завершении генерирует для Event Loop событие «Как минимум одна задача завершена».
В системе «один поток на клиента», как Apache, каждому ожидающему клиенту выделяется собственный поток. Если поток, обслуживающий одного клиента, блокируется, операционная система прервёт его и даст ход другому клиенту. Таким образом операционная система гарантирует, что клиенты, требующие небольшого объёма работы, не пострадают из-за клиентов, требующих больше работы.
Поскольку Node.js обслуживает множество клиентов немногими потоками, если поток блокируется, обрабатывая запрос одного клиента, то ожидающие клиентские запросы могут не получить хода, пока поток не завершит свой колбэк или задачу. Таким образом, справедливое обращение с клиентами — ответственность вашего приложения. Это значит, что вам не следует делать слишком много работы для какого-либо клиента в одном колбэке или задаче.
Отчасти поэтому Node.js хорошо масштабируется, но это также означает, что за справедливое планирование отвечаете вы. В следующих разделах рассказывается, как обеспечить справедливое планирование для Event Loop и для Worker Pool.
Event Loop замечает каждое новое клиентское соединение и оркестрирует формирование ответа. Все входящие запросы и исходящие ответы проходят через Event Loop. Это значит, что если Event Loop где-то задерживается слишком надолго, все текущие и новые клиенты не получат хода.
Вы должны убедиться, что никогда не блокируете Event Loop.
Иными словами, каждый ваш JavaScript-колбэк должен завершаться быстро.
Это, разумеется, относится и к вашим await, вашим Promise.then и так далее.
Хороший способ этого добиться — рассуждать о «вычислительной сложности» ваших колбэков. Если ваш колбэк выполняет постоянное число шагов независимо от своих аргументов, то вы всегда дадите каждому ожидающему клиенту справедливый ход. Если ваш колбэк выполняет разное число шагов в зависимости от аргументов, то вам стоит подумать о том, насколько длинными могут быть аргументы.
Пример 1: колбэк с постоянным временем.
app.get('/constant-time', (req, res) => {
res.sendStatus(200);
});Пример 2: колбэк O(n). Этот колбэк отработает быстро при малом n и медленнее при большом n.
app.get('/countToN', (req, res) => {
const n = req.query.n;
// n итераций, прежде чем дать ход кому-то ещё
for (let i = 0; i < n; i++) {
console.log(`Iter ${i}`);
}
res.sendStatus(200);
});Пример 3: колбэк O(n^2). Этот колбэк всё ещё отработает быстро при малом n, но при большом n он будет работать намного медленнее, чем предыдущий пример O(n).
app.get('/countToN2', (req, res) => {
const n = req.query.n;
// n^2 итераций, прежде чем дать ход кому-то ещё
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}.${j}`);
}
}
res.sendStatus(200);
});Node.js использует для JavaScript движок Google V8, который довольно быстр для многих распространённых операций. Исключения из этого правила — операции с регулярными выражениями и JSON, обсуждаемые ниже.
Однако для сложных задач вам стоит подумать об ограничении ввода и отклонении слишком длинного ввода. Так, даже если ваш колбэк имеет большую сложность, ограничивая ввод, вы гарантируете, что колбэк не займёт больше времени, чем в худшем случае на самом длинном допустимом вводе. Затем вы можете оценить стоимость этого колбэка в худшем случае и определить, приемлемо ли его время выполнения в вашем контексте.
Один распространённый способ катастрофически заблокировать Event Loop — использовать «уязвимое» регулярное выражение.
Регулярное выражение (regexp) сопоставляет входную строку с шаблоном.
Обычно мы думаем о сопоставлении regexp как о требующем одного прохода по входной строке — время O(n), где n — длина входной строки.
Во многих случаях одного прохода действительно достаточно.
К сожалению, в некоторых случаях сопоставление regexp может потребовать экспоненциального числа проходов по входной строке — время O(2^n).
Экспоненциальное число проходов означает, что если движку требуется x проходов, чтобы определить совпадение, ему понадобится 2*x проходов, если добавить во входную строку всего один символ.
Поскольку число проходов линейно связано с требуемым временем, эффектом такой оценки будет блокировка Event Loop.
Уязвимое регулярное выражение — это выражение, на котором ваш движок регулярных выражений может занять экспоненциальное время, подвергая вас REDOS на «злом вводе». Уязвим ли ваш шаблон регулярного выражения (то есть может ли движок regexp занять на нём экспоненциальное время) — на деле трудный вопрос, и ответ зависит от того, используете ли вы Perl, Python, Ruby, Java, JavaScript и т. д., но вот несколько эмпирических правил, применимых во всех этих языках:
- Избегайте вложенных квантификаторов вроде
(a+)*. Движок regexp в V8 некоторые из них обрабатывает быстро, но другие уязвимы. - Избегайте «ИЛИ» с пересекающимися ветвями, вроде
(a|a)*. Опять же, они иногда быстры. - Избегайте использования обратных ссылок (backreferences), вроде
(a.*) \1. Ни один движок regexp не может гарантировать их вычисление за линейное время. - Если вы делаете простое сопоставление строк, используйте
indexOfили местный эквивалент. Это дешевле и никогда не займёт больше, чемO(n).
Если вы не уверены, уязвимо ли ваше регулярное выражение, помните, что Node.js обычно без труда сообщает о совпадении даже для уязвимого regexp и длинной входной строки. Экспоненциальное поведение срабатывает при несовпадении, когда Node.js не может быть уверен, пока не перепробует множество путей по входной строке.
Вот пример уязвимого regexp, подставляющего свой сервер под REDOS:
app.get('/redos-me', (req, res) => {
const filePath = req.query.filePath;
// REDOS
if (filePath.match(/(\/.+)+$/)) {
console.log('valid path');
} else {
console.log('invalid path');
}
res.sendStatus(200);
});Уязвимый regexp в этом примере — это (плохой!) способ проверить корректность пути на Linux. Он совпадает со строками, представляющими собой последовательность имён, разделённых «/», вроде «/a/b/c». Он опасен, потому что нарушает правило 1: у него дважды вложенный квантификатор.
Если клиент сделает запрос с filePath ///.../\n (100 «/», за которыми идёт символ новой строки, который «.» в regexp не совпадёт), то Event Loop будет работать фактически бесконечно, блокируя Event Loop.
Эта REDOS-атака клиента приводит к тому, что все остальные клиенты не получают хода, пока не завершится сопоставление regexp.
По этой причине вам стоит с опаской относиться к использованию сложных регулярных выражений для валидации пользовательского ввода.
Есть инструменты для проверки ваших regexp на безопасность, например
Однако ни один из них не поймает все уязвимые regexp.
Другой подход — использовать другой движок regexp. Можно использовать модуль node-re2, который использует молниеносно быстрый движок regexp от Google — RE2. Но учтите: RE2 не на 100% совместим с regexp из V8, так что проверяйте на регрессии, если подключаете модуль node-re2 для обработки ваших regexp. А особо сложные regexp node-re2 не поддерживает.
Если вы пытаетесь сопоставить что-то «очевидное», вроде URL или пути к файлу, найдите пример в библиотеке regexp или используйте npm-модуль, например ip-regex.
У некоторых ключевых модулей Node.js есть синхронные дорогие API, в том числе:
Эти API дорогие, потому что связаны со значительными вычислениями (шифрование, сжатие), требуют I/O (файловый I/O) или потенциально и того, и другого (дочерний процесс). Эти API предназначены для удобства скриптинга, но не для использования в серверном контексте. Если выполнять их на Event Loop, они займут гораздо больше времени, чем типичная JavaScript-инструкция, блокируя Event Loop.
На сервере вам не следует использовать следующие синхронные API из этих модулей:
- Шифрование:
crypto.randomBytes(синхронная версия)crypto.randomFillSynccrypto.pbkdf2Sync- Также стоит быть осторожным с передачей большого ввода в процедуры шифрования и расшифрования.
- Сжатие:
zlib.inflateSynczlib.deflateSync
- Файловая система:
- Не используйте синхронные API файловой системы. Например, если файл, к которому вы обращаетесь, находится в распределённой файловой системе вроде NFS, время доступа может сильно варьироваться.
- Дочерние процессы:
child_process.spawnSyncchild_process.execSyncchild_process.execFileSync
Этот список достаточно полон по состоянию на Node.js v9.
JSON.parse и JSON.stringify — другие потенциально дорогие операции.
Хотя они O(n) по длине ввода, для больших n они могут занимать на удивление долго.
Если ваш сервер манипулирует JSON-объектами, особенно приходящими от клиента, вам стоит с осторожностью относиться к размеру объектов или строк, с которыми вы работаете на Event Loop.
Пример: блокировка на JSON. Мы создаём объект obj размера 2^21 и вызываем на нём JSON.stringify, запускаем indexOf по строке, а затем JSON.parse. Строка после JSON.stringify — 50 МБ. Преобразование объекта в строку занимает 0,7 секунды, indexOf по 50-МБ строке — 0,03 секунды, а парсинг строки — 1,3 секунды.
let obj = { a: 1 };
const iterations = 20;
// Экспоненциально раздуваем объект вложением
for (let i = 0; i < iterations; i++) {
obj = { obj1: obj, obj2: obj };
}
// Измеряем время преобразования объекта в строку
let start = process.hrtime();
const jsonString = JSON.stringify(obj);
let duration = process.hrtime(start);
console.log('JSON.stringify took', duration);
// Измеряем время поиска строки внутри JSON
start = process.hrtime();
const index = jsonString.indexOf('nomatch'); // Всегда -1
duration = process.hrtime(start);
console.log('String.indexOf took', duration);
// Измеряем время парсинга JSON обратно в объект
start = process.hrtime();
const parsed = JSON.parse(jsonString);
duration = process.hrtime(start);
console.log('JSON.parse took', duration);Есть npm-модули, предлагающие асинхронные JSON-API. См., например:
- JSONStream, у которого потоковые (stream) API.
- Big-Friendly JSON, у которого есть и потоковые API, и асинхронные версии стандартных JSON-API, использующие описанную ниже парадигму разбиения на Event Loop.
Предположим, вы хотите выполнять сложные вычисления в JavaScript без блокировки Event Loop. У вас два варианта: разбиение (partitioning) или разгрузка (offloading).
Вы можете разбить свои вычисления так, чтобы каждое выполнялось на Event Loop, но регулярно уступало (давало ход) другим ожидающим событиям. В JavaScript легко сохранить состояние текущей задачи в замыкании, как показано в примере 2 ниже.
Для простого примера предположим, что вы хотите вычислить среднее чисел от 1 до n.
Пример 1: неразбитое среднее, стоит O(n)
for (let i = 0; i < n; i++) {
sum += i;
}
const avg = sum / n;
console.log('avg: ' + avg);Пример 2: разбитое среднее, каждый из n асинхронных шагов стоит O(1).
function asyncAvg(n, avgCB) {
// Сохраняем текущую сумму в замыкании JS.
let sum = 0;
function help(i, cb) {
sum += i;
if (i == n) {
cb(sum);
return;
}
// «Асинхронная рекурсия».
// Планируем следующую операцию асинхронно.
setImmediate(help.bind(null, i + 1, cb));
}
// Запускаем помощника с колбэком, вызывающим avgCB.
help(1, function (sum) {
const avg = sum / n;
avgCB(avg);
});
}
asyncAvg(n, function (avg) {
console.log('avg of 1-n: ' + avg);
});Этот принцип можно применять к итерациям по массивам и так далее.
Если вам нужно сделать что-то более сложное, разбиение — не лучший вариант. Это потому, что разбиение использует только Event Loop, и вы не выиграете от нескольких ядер, почти наверняка доступных на вашей машине. Помните, Event Loop должен оркестрировать клиентские запросы, а не выполнять их сам. Для сложной задачи перенесите работу с Event Loop в Worker Pool.
У вас два варианта целевого Worker Pool, в который можно разгрузить работу.
- Можно использовать встроенный Worker Pool Node.js, разработав C++-аддон. На старых версиях Node собирайте свой C++-аддон с помощью NAN, а на новых — с помощью N-API. node-webworker-threads предлагает способ доступа к Worker Pool Node.js исключительно из JavaScript.
- Можно создать и управлять собственным Worker Pool, выделенным под вычисления, а не под Worker Pool Node.js, ориентированный на I/O. Самые прямолинейные способы сделать это — использовать Child Process или Cluster.
Вам не следует просто создавать дочерний процесс (Child Process) на каждого клиента. Вы можете получать клиентские запросы быстрее, чем создавать и управлять дочерними процессами, и ваш сервер может превратиться в fork bomb.
Обратная сторона подхода с разгрузкой в том, что он влечёт накладные расходы в виде стоимости коммуникации. Только Event Loop имеет право видеть «пространство имён» (состояние JavaScript) вашего приложения. Из воркера вы не можете манипулировать JavaScript-объектом в пространстве имён Event Loop. Вместо этого вам нужно сериализовать и десериализовать любые объекты, которыми вы хотите поделиться. Затем воркер может работать над собственной копией этих объектов и вернуть модифицированный объект (или «патч») в Event Loop.
О вопросах сериализации см. раздел про JSON DOS.
Возможно, вам захочется различать интенсивные по CPU и интенсивные по I/O задачи, потому что у них заметно разные характеристики.
Интенсивная по CPU задача продвигается вперёд только когда её воркер запланирован, а воркер должен быть запланирован на одно из логических ядер вашей машины. Если у вас 4 логических ядра и 5 воркеров, один из этих воркеров не может продвигаться. В результате вы платите накладные расходы (память и стоимость планирования) за этот воркер, не получая от него отдачи.
Интенсивные по I/O задачи связаны с запросом к внешнему поставщику услуг (DNS, файловая система и т. д.) и ожиданием его ответа. Пока воркер с интенсивной по I/O задачей ждёт ответа, ему больше нечем заняться, и операционная система может снять его с планирования, давая другому воркеру шанс отправить свой запрос. Таким образом, интенсивные по I/O задачи будут продвигаться, даже пока связанный поток не выполняется. Внешние поставщики услуг вроде баз данных и файловых систем сильно оптимизированы под конкурентную обработку множества ожидающих запросов. Например, файловая система изучит большой набор ожидающих запросов на запись и чтение, чтобы объединить конфликтующие обновления и получить файлы в оптимальном порядке.
Если вы полагаетесь только на один Worker Pool, например Worker Pool Node.js, то разные характеристики CPU-bound и I/O-bound работы могут навредить производительности вашего приложения.
По этой причине вам может захотеться поддерживать отдельный вычислительный Worker Pool.
Для простых задач, вроде перебора элементов произвольно длинного массива, разбиение может быть хорошим вариантом. Если ваши вычисления сложнее, лучше подходит разгрузка: стоимость коммуникации, то есть накладные расходы на передачу сериализованных объектов между Event Loop и Worker Pool, компенсируется выгодой от использования нескольких ядер.
Однако если ваш сервер сильно полагается на сложные вычисления, вам стоит подумать, действительно ли Node.js хорошо подходит. Node.js великолепен для I/O-bound работы, но для дорогих вычислений он может оказаться не лучшим вариантом.
Если вы выбираете подход с разгрузкой, см. раздел о том, как не блокировать Worker Pool.
У Node.js есть Worker Pool из k воркеров.
Если вы используете описанную выше парадигму разгрузки, у вас может быть отдельный вычислительный Worker Pool, к которому применимы те же принципы.
В любом случае будем считать, что k намного меньше числа клиентов, которых вы можете обслуживать одновременно.
Это соответствует философии Node.js «один поток на многих клиентов» — секрету его масштабируемости.
Как обсуждалось выше, каждый воркер завершает свою текущую задачу, прежде чем перейти к следующей в очереди Worker Pool.
Стоимость задач, необходимых для обработки запросов ваших клиентов, будет варьироваться. Некоторые задачи можно завершить быстро (например, чтение коротких или закэшированных файлов либо генерация небольшого числа случайных байтов), а другие займут дольше (например, чтение более крупных или незакэшированных файлов либо генерация большего числа случайных байтов). Вашей целью должно быть минимизировать разброс во времени выполнения задач, и для этого вам следует использовать разбиение задач.
Если текущая задача воркера намного дороже других задач, то он будет недоступен для работы над другими ожидающими задачами. Иными словами, каждая относительно долгая задача фактически уменьшает размер Worker Pool на единицу, пока не завершится. Это нежелательно, потому что, до определённого предела, чем больше воркеров в Worker Pool, тем выше пропускная способность Worker Pool (задач/секунду) и, следовательно, тем выше пропускная способность сервера (клиентских запросов/секунду). Один клиент с относительно дорогой задачей уменьшит пропускную способность Worker Pool, что, в свою очередь, уменьшит пропускную способность сервера.
Чтобы этого избежать, вам стоит стараться минимизировать разброс длительности задач, которые вы отправляете в Worker Pool. Хотя уместно относиться к внешним системам, к которым обращаются ваши I/O-запросы (БД, ФС и т. д.), как к чёрным ящикам, вам следует быть в курсе относительной стоимости этих I/O-запросов и избегать отправки запросов, которые, как вы ожидаете, будут особенно долгими.
Два примера должны проиллюстрировать возможный разброс во времени задач.
Предположим, ваш сервер должен читать файлы для обработки некоторых клиентских запросов.
Посмотрев API файловой системы Node.js, вы для простоты выбрали fs.readFile().
Однако fs.readFile() до v10 не был разбит: он отправлял одну задачу fs.read(), охватывающую весь файл.
Если вы читаете более короткие файлы для одних пользователей и более длинные для других, fs.readFile() может привнести значительный разброс в длительность задач в ущерб пропускной способности Worker Pool.
Для сценария худшего случая предположим, что злоумышленник может убедить ваш сервер прочитать произвольный файл (это уязвимость обхода директорий (directory traversal)).
Если ваш сервер работает на Linux, злоумышленник может назвать крайне медленный файл: /dev/random.
Со всех практических точек зрения /dev/random бесконечно медленен, и каждый воркер, которого попросили читать из /dev/random, никогда не завершит эту задачу.
Затем злоумышленник отправляет k запросов, по одному на каждого воркера, и никакие другие клиентские запросы, использующие Worker Pool, не будут продвигаться.
Предположим, ваш сервер генерирует криптографически безопасные случайные байты с помощью crypto.randomBytes().
crypto.randomBytes() не разбит: он создаёт одну задачу randomBytes(), чтобы сгенерировать столько байтов, сколько вы запросили.
Если вы создаёте меньше байтов для одних пользователей и больше для других, crypto.randomBytes() — ещё один источник разброса в длительности задач.
Задачи с переменной стоимостью по времени могут навредить пропускной способности Worker Pool. Чтобы минимизировать разброс во времени задач, вам, насколько возможно, следует разбивать каждую задачу на подзадачи сопоставимой стоимости. Когда каждая подзадача завершается, она должна отправлять следующую подзадачу, а когда завершается последняя подзадача, она должна уведомить отправителя.
Продолжая пример fs.readFile(), вам вместо этого стоит использовать fs.read() (ручное разбиение) или ReadStream (автоматически разбитый).
Тот же принцип применим к CPU-bound задачам; пример asyncAvg может быть неуместен для Event Loop, но хорошо подходит для Worker Pool.
Когда вы разбиваете задачу на подзадачи, более короткие задачи раскрываются в небольшое число подзадач, а более длинные — в большее число подзадач. Между подзадачами более длинной задачи воркер, которому она назначена, может поработать над подзадачей другой, более короткой задачи, тем самым улучшая общую пропускную способность задач в Worker Pool.
Обратите внимание, что число завершённых подзадач не является полезной метрикой пропускной способности Worker Pool. Вместо этого следите за числом завершённых задач.
Напомним, что цель разбиения задач — минимизировать разброс во времени задач. Если вы можете различать более короткие и более длинные задачи (например, суммирование массива против сортировки массива), вы могли бы создать по одному Worker Pool на каждый класс задач. Маршрутизация более коротких и более длинных задач в отдельные Worker Pool — ещё один способ минимизировать разброс во времени задач.
В пользу этого подхода: разбиение задач влечёт накладные расходы (стоимость создания представления задачи Worker Pool и манипуляций с очередью Worker Pool), а отказ от разбиения избавляет вас от расходов на дополнительные обращения к Worker Pool. Он также убережёт вас от ошибок в разбиении ваших задач.
Обратная сторона этого подхода в том, что воркеры во всех этих Worker Pool будут нести накладные расходы по пространству и времени и будут конкурировать друг с другом за процессорное время. Помните, что каждая CPU-bound задача продвигается только пока она запланирована. В результате рассматривать этот подход стоит только после тщательного анализа.
Используете ли вы только Worker Pool Node.js или поддерживаете отдельные Worker Pool, вам следует оптимизировать пропускную способность задач вашего пула (или пулов).
Для этого минимизируйте разброс во времени задач, используя разбиение задач.
Хотя ключевые модули Node.js предлагают строительные блоки для самых разных приложений, иногда нужно что-то большее. Разработчики на Node.js колоссально выигрывают от экосистемы npm с сотнями тысяч модулей, предлагающих функциональность, чтобы ускорить процесс разработки.
Однако помните, что большинство этих модулей написаны сторонними разработчиками и, как правило, выпускаются лишь с гарантиями «по мере возможности» (best-effort). Разработчику, использующему npm-модуль, стоит беспокоиться о двух вещах, хотя о второй часто забывают.
- Соблюдает ли он свои API?
- Могут ли его API заблокировать Event Loop или воркер? Многие модули не прилагают усилий, чтобы указать стоимость своих API, в ущерб сообществу.
Для простых API вы можете оценить их стоимость; стоимость манипуляций со строками несложно осознать. Но во многих случаях неясно, во сколько может обойтись API.
Если вы вызываете API, который может сделать что-то дорогое, перепроверьте стоимость. Попросите разработчиков задокументировать её или изучите исходный код сами (и отправьте PR, документирующий стоимость).
Помните, даже если API асинхронный, вы не знаете, сколько времени он может провести на воркере или на Event Loop в каждой из своих частей.
Например, предположим, что в приведённом выше примере asyncAvg каждый вызов функции-помощника суммировал половину чисел, а не одно из них.
Тогда эта функция всё ещё была бы асинхронной, но стоимость каждой части была бы O(n), а не O(1), что делает её гораздо менее безопасной для произвольных значений n.
У Node.js два типа потоков: один Event Loop и k воркеров.
Event Loop отвечает за JavaScript-колбэки и неблокирующий I/O, а воркер выполняет задачи, соответствующие C++-коду, который завершает асинхронный запрос, включая блокирующий I/O и интенсивную по CPU работу.
Оба типа потоков работают не более чем над одной активностью за раз.
Если какой-либо колбэк или задача занимают много времени, поток, выполняющий их, становится заблокированным.
Если ваше приложение делает блокирующие колбэки или задачи, это может привести к деградации пропускной способности (клиентов/секунду) в лучшем случае и к полному отказу в обслуживании в худшем.
Чтобы написать веб-сервер с высокой пропускной способностью и большей устойчивостью к DoS, вы должны гарантировать, что и на безобидном, и на вредоносном вводе не заблокируются ни ваш Event Loop, ни ваши воркеры.