Не блокируйте цикл событий (или пул рабочих потоков)
Стоит ли вам читать это руководство?
Если вы пишете что-то более сложное, чем короткий скрипт командной строки, чтение этого должно помочь вам писать более производительные и безопасные приложения.
Этот документ написан с учетом серверов Node.js, но концепции применимы и к сложным приложениям Node.js. Там, где детали, специфичные для ОС, различаются, этот документ ориентирован на Linux.
Краткое содержание
Node.js запускает код JavaScript в цикле событий (инициализация и обратные вызовы) и предлагает пул рабочих потоков для обработки ресурсоемких задач, таких как файловый ввод-вывод. Node.js хорошо масштабируется, иногда даже лучше, чем более тяжеловесные подходы, такие как Apache. Секрет масштабируемости Node.js заключается в том, что он использует небольшое количество потоков для обработки множества клиентов. Если Node.js может обойтись меньшим количеством потоков, то он может тратить больше времени и памяти вашей системы на работу с клиентами, а не на оплату пространственных и временных издержек для потоков (память, переключение контекста). Но поскольку Node.js имеет лишь несколько потоков, вы должны структурировать свое приложение, чтобы использовать их с умом.
Вот хорошее эмпирическое правило для поддержания скорости вашего сервера Node.js: Node.js работает быстро, когда работа, связанная с каждым клиентом в любой момент времени, "небольшая".
Это относится к обратным вызовам в цикле событий и задачам в пуле рабочих потоков.
Почему я должен избегать блокировки цикла событий и пула рабочих потоков?
Node.js использует небольшое количество потоков для обработки множества клиентов. В Node.js есть два типа потоков: один цикл событий (также известный как главный цикл, главный поток, поток событий и т. д.) и пул из k
рабочих потоков в пуле рабочих потоков (также известный как пул потоков).
Если поток тратит много времени на выполнение обратного вызова (цикл событий) или задачи (рабочий поток), мы называем его "заблокированным". Пока поток заблокирован, работая от имени одного клиента, он не может обрабатывать запросы от других клиентов. Это дает две мотивации для блокировки ни цикла событий, ни пула рабочих потоков:
- Производительность: Если вы регулярно выполняете ресурсоемкие действия в любом из этих типов потоков, пропускная способность (запросов в секунду) вашего сервера пострадает.
- Безопасность: Если возможно, что для определенных входных данных один из ваших потоков может быть заблокирован, злонамеренный клиент может отправить эти "злые входные данные", заставить ваши потоки заблокироваться и помешать им работать с другими клиентами. Это будет атака типа "отказ в обслуживании".
Краткий обзор Node
Node.js использует архитектуру, управляемую событиями (Event-Driven Architecture): у нее есть Event Loop для оркестровки и Worker Pool для ресурсоемких задач.
Какой код выполняется в Event Loop?
При запуске приложения Node.js сначала завершают фазу инициализации, выполняя require
модулей и регистрируя обратные вызовы (callbacks) для событий. Затем приложения Node.js входят в Event Loop, реагируя на входящие клиентские запросы путем выполнения соответствующего обратного вызова. Этот обратный вызов выполняется синхронно и может регистрировать асинхронные запросы для продолжения обработки после его завершения. Обратные вызовы для этих асинхронных запросов также будут выполняться в Event Loop.
Event Loop также будет выполнять неблокирующие асинхронные запросы, сделанные его обратными вызовами, например, сетевой ввод/вывод (network I/O).
В итоге, Event Loop выполняет JavaScript-обратные вызовы, зарегистрированные для событий, а также отвечает за выполнение неблокирующих асинхронных запросов, таких как сетевой ввод/вывод.
Какой код выполняется в Worker Pool?
Worker Pool Node.js реализован в libuv (документация), который предоставляет общий API для отправки задач.
Node.js использует Worker Pool для обработки "ресурсоемких" задач. Сюда входит ввод/вывод, для которого операционная система не предоставляет неблокирующую версию, а также особенно ресурсоемкие с точки зрения CPU задачи.
Вот API модулей Node.js, которые используют Worker Pool:
- Интенсивный ввод/вывод (I/O-intensive)
- DNS:
dns.lookup()
,dns.lookupService()
. - Файловая система: Все API файловой системы, кроме
fs.FSWatcher()
и тех, которые явно синхронны, используют threadpool libuv.
- DNS:
- Интенсивный CPU (CPU-intensive)
Во многих приложениях 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.
Как Node.js решает, какой код запускать следующим?
В общих чертах, цикл событий (Event Loop) и пул рабочих потоков (Worker Pool) поддерживают очереди для ожидающих событий и ожидающих задач, соответственно.
По правде говоря, цикл событий на самом деле не поддерживает очередь. Вместо этого у него есть набор файловых дескрипторов, которые он просит операционную систему отслеживать, используя механизм, такой как epoll (Linux), kqueue (OSX), порты событий (Solaris) или IOCP (Windows). Эти файловые дескрипторы соответствуют сетевым сокетам, любым файлам, за которыми он наблюдает, и так далее. Когда операционная система говорит, что один из этих файловых дескрипторов готов, цикл событий преобразует его в соответствующее событие и вызывает обратный вызов (callback(s)), связанный с этим событием. Вы можете узнать больше об этом процессе здесь.
В отличие от этого, пул рабочих потоков использует реальную очередь, записи которой являются задачами для обработки. Рабочий поток извлекает задачу из этой очереди и работает над ней, а когда закончит, рабочий поток генерирует событие "По крайней мере, одна задача завершена" для цикла событий.
Что это значит для проектирования приложений?
В системе один поток на клиента, такой как Apache, каждому ожидающему клиенту назначается свой поток. Если поток, обрабатывающий одного клиента, блокируется, операционная система прервет его и предоставит ход другому клиенту. Таким образом, операционная система гарантирует, что клиенты, требующие небольшого объема работы, не будут наказаны клиентами, требующими больше работы.
Поскольку Node.js обрабатывает множество клиентов с небольшим количеством потоков, если поток блокируется при обработке запроса одного клиента, то ожидающие запросы клиентов могут не получить ход до тех пор, пока поток не завершит свой обратный вызов или задачу. Таким образом, справедливое отношение к клиентам является обязанностью вашего приложения. Это означает, что вам не следует делать слишком много работы для какого-либо клиента в каком-либо отдельном обратном вызове или задаче.
Это одна из причин, по которой Node.js может хорошо масштабироваться, но это также означает, что вы несете ответственность за обеспечение справедливого планирования. В следующих разделах рассказывается о том, как обеспечить справедливое планирование для цикла событий и для пула рабочих потоков.
Не блокируйте Event Loop
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) => {
let 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) => {
let 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 использует движок Google V8 для JavaScript, который довольно быстр для многих распространенных операций. Исключениями из этого правила являются регулярные выражения и операции JSON, о которых речь пойдет ниже.
Однако для сложных задач вам следует рассмотреть возможность ограничения ввода и отклонения слишком длинных входных данных. Таким образом, даже если ваш колбэк имеет большую сложность, ограничивая ввод, вы гарантируете, что колбэк не займет больше, чем время наихудшего случая для самого длинного приемлемого ввода. Затем вы можете оценить стоимость наихудшего случая этого колбэка и определить, является ли время его выполнения приемлемым в вашем контексте.
Блокировка цикла событий: REDOS
Один из распространенных способов катастрофической блокировки цикла событий — использование "уязвимого" регулярного выражения.
Избегание уязвимых регулярных выражений
Регулярное выражение (regexp) сопоставляет входную строку с шаблоном. Обычно мы представляем себе сопоставление регулярного выражения как требующее одного прохода по входной строке --- O(n)
времени, где n
— длина входной строки. Во многих случаях одного прохода действительно достаточно. К сожалению, в некоторых случаях сопоставление регулярного выражения может потребовать экспоненциального количества проходов по входной строке --- O(2^n)
времени. Экспоненциальное количество проходов означает, что если движку требуется x проходов для определения соответствия, то ему потребуется 2*x
проходов, если мы добавим всего один символ во входную строку. Поскольку количество проходов линейно связано с требуемым временем, эффект этой оценки будет заключаться в блокировке цикла событий.
Уязвимое регулярное выражение — это такое, на котором ваш движок регулярных выражений может потратить экспоненциальное время, подвергая вас REDOS на "злом вводе". Является ли ваш шаблон регулярного выражения уязвимым (т.е. может ли движок регулярных выражений потратить на него экспоненциальное время) — на самом деле сложный вопрос, и он варьируется в зависимости от того, используете ли вы Perl, Python, Ruby, Java, JavaScript и т. д., но вот несколько эмпирических правил, которые применимы ко всем этим языкам:
- Избегайте вложенных квантификаторов, таких как
(a+)*
. Движок регулярных выражений V8 может быстро обрабатывать некоторые из них, но другие уязвимы. - Избегайте ИЛИ с перекрывающимися предложениями, такими как
(a|a)*
. Опять же, они иногда бывают быстрыми. - Избегайте использования обратных ссылок, таких как
(a.*) \1
. Ни один движок регулярных выражений не может гарантировать оценку этих значений за линейное время. - Если вы делаете простое сопоставление строк, используйте
indexOf
или локальный эквивалент. Это будет дешевле и никогда не займет больше, чемO(n)
.
Если вы не уверены, уязвимо ли ваше регулярное выражение, помните, что Node.js обычно не испытывает проблем с сообщением о совпадении даже для уязвимого регулярного выражения и длинной входной строки. Экспоненциальное поведение вызывается, когда есть несоответствие, но Node.js не может быть уверен, пока не попробует множество путей через входную строку.
Пример REDOS
Вот пример уязвимого регулярного выражения, подвергающего сервер опасности REDOS:
app.get('/redos-me', (req, res) => {
let filePath = req.query.filePath;
// REDOS
if (filePath.match(/(\/.+)+$/)) {
console.log('valid path');
} else {
console.log('invalid path');
}
res.sendStatus(200);
});
Уязвимое регулярное выражение в этом примере является (плохим!) способом проверки допустимости пути в Linux. Оно соответствует строкам, которые представляют собой последовательность имен, разделенных "/"-ми, например "/a/b/c
". Это опасно, потому что нарушает правило 1: в нем есть дважды вложенный квантификатор.
Если клиент делает запрос с filePath ///.../\n
(100 символов "/" с последующим символом новой строки, которому не соответствует "." в регулярном выражении), то цикл событий будет выполняться бесконечно долго, блокируя цикл событий. Эта REDOS-атака клиента приводит к тому, что все остальные клиенты не получают очередь, пока не закончится сопоставление регулярного выражения.
По этой причине следует с опаской относиться к использованию сложных регулярных выражений для проверки пользовательского ввода.
Ресурсы для борьбы с REDOS
Существуют инструменты для проверки безопасности ваших регулярных выражений, например:
Однако ни один из них не обнаружит все уязвимые регулярные выражения.
Другой подход заключается в использовании другого движка регулярных выражений. Вы можете использовать модуль node-re2, который использует молниеносный движок регулярных выражений RE2 от Google. Но будьте осторожны, RE2 не на 100% совместим с регулярными выражениями V8, поэтому проверяйте наличие регрессий, если вы замените модуль node-re2 для обработки ваших регулярных выражений. И особенно сложные регулярные выражения не поддерживаются node-re2.
Если вы пытаетесь сопоставить что-то "очевидное", например URL-адрес или путь к файлу, найдите пример в библиотеке регулярных выражений или используйте модуль npm, например ip-regex.
Блокировка цикла событий: основные модули Node.js
Несколько основных модулей Node.js имеют синхронные дорогостоящие API, в том числе:
Эти API являются дорогостоящими, поскольку они включают в себя значительные вычисления (шифрование, сжатие), требуют операций ввода-вывода (файловый ввод-вывод) или, возможно, и то, и другое (дочерний процесс). Эти API предназначены для удобства написания скриптов, но не предназначены для использования в контексте сервера. Если вы выполняете их в цикле событий, они будут выполняться гораздо дольше, чем обычная инструкция JavaScript, блокируя цикл событий.
В сервере не следует использовать следующие синхронные API из этих модулей:
- Шифрование:
crypto.randomBytes
(синхронная версия)crypto.randomFillSync
crypto.pbkdf2Sync
- Также следует проявлять осторожность при предоставлении больших входных данных для процедур шифрования и дешифрования.
- Сжатие:
zlib.inflateSync
zlib.deflateSync
- Файловая система:
- Не используйте синхронные API файловой системы. Например, если файл, к которому вы обращаетесь, находится в распределенной файловой системе, такой как NFS, время доступа может сильно варьироваться.
- Дочерний процесс:
child_process.spawnSync
child_process.execSync
child_process.execFileSync
Этот список достаточно полон для Node.js v9.
Блокировка цикла событий: JSON DOS
JSON.parse
и JSON.stringify
являются другими потенциально ресурсоемкими операциями. Хотя они имеют сложность O(n) по длине входных данных, для больших n они могут занимать на удивление много времени.
Если ваш сервер манипулирует объектами JSON, особенно теми, которые поступают от клиента, вам следует проявлять осторожность в отношении размера объектов или строк, с которыми вы работаете в цикле событий.
Пример: Блокировка JSON. Мы создаем объект obj
размером 2^21 и JSON.stringify
его, запускаем indexOf для строки, а затем JSON.parse
ее. Строка, полученная после JSON.stringify
, имеет размер 50 МБ. Для преобразования объекта в строку требуется 0,7 секунды, для indexOf по строке размером 50 МБ - 0,03 секунды, а для синтаксического анализа строки - 1,3 секунды.
let obj = { a: 1 };
let niter = 20;
let before, str, pos, res, took;
for (let i = 0; i < niter; i++) {
obj = { obj1: obj, obj2: obj }; // Удваивается в размере при каждой итерации
}
before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);
before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);
before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);
Существуют npm модули, предлагающие асинхронные JSON API. Например:
- JSONStream, который имеет потоковые API.
- Big-Friendly JSON, который имеет потоковые API, а также асинхронные версии стандартных JSON API, использующие парадигму разделения в цикле событий, описанную ниже.
Сложные вычисления без блокировки цикла событий
Предположим, вы хотите выполнять сложные вычисления в JavaScript, не блокируя цикл событий. У вас есть два варианта: разделение или разгрузка.
Разделение
Вы можете разделить свои вычисления, чтобы каждое выполнялось в цикле событий, но регулярно уступало (давало очередь) другим ожидающим событиям. В JavaScript легко сохранить состояние текущей задачи в замыкании, как показано в примере 2 ниже.
В качестве простого примера предположим, что вы хотите вычислить среднее значение чисел от 1
до n
.
Пример 1: Неразделенное среднее, сложность O(n)
for (let i = 0; i < n; i++) sum += i;
let 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));
}
// Запускаем вспомогательную функцию с CB для вызова avgCB.
help(1, function (sum) {
let 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++ addon. В более старых версиях Node создайте свой C++ addon с использованием NAN, а в более новых версиях используйте N-API. node-webworker-threads предлагает способ доступа к Worker Pool Node.js только с использованием JavaScript.
- Вы можете создать и управлять своим собственным Worker Pool, предназначенным для вычислений, а не для Worker Pool Node.js, ориентированного на ввод-вывод. Самые простые способы сделать это - использовать Child Process или Cluster.
Не следует просто создавать Child Process для каждого клиента. Вы можете получать клиентские запросы быстрее, чем создавать и управлять дочерними процессами, и ваш сервер может стать fork bomb.
Недостаток передачи задач Недостатком подхода с передачей задач является то, что он влечет за собой накладные расходы в виде затрат на связь. Только Event Loop разрешено видеть "пространство имен" (состояние JavaScript) вашего приложения. Из Worker вы не можете манипулировать объектом JavaScript в пространстве имен Event Loop. Вместо этого вам нужно сериализовать и десериализовать любые объекты, которыми вы хотите поделиться. Затем Worker может работать со своей собственной копией этих объектов и возвращать измененный объект (или "патч") в Event Loop.
Что касается проблем сериализации, см. раздел о JSON DOS.
Некоторые предложения по передаче задач
Возможно, вам захочется различать задачи, интенсивно использующие ЦП, и задачи, интенсивно использующие ввод-вывод, поскольку они имеют заметно разные характеристики.
Задача, интенсивно использующая ЦП, прогрессирует только тогда, когда ее Worker запланирован, и Worker должен быть запланирован на одно из логических ядер вашей машины. Если у вас 4 логических ядра и 5 Worker, то один из этих Worker не может прогрессировать. В результате вы платите накладные расходы (память и затраты на планирование) за этот Worker и не получаете от него никакой отдачи.
Задачи, интенсивно использующие ввод-вывод, включают в себя запрос внешнего поставщика услуг (DNS, файловая система и т. д.) и ожидание его ответа. Пока Worker с задачей, интенсивно использующей ввод-вывод, ожидает своего ответа, ему больше нечего делать, и он может быть вытеснен операционной системой, давая другому Worker возможность отправить свой запрос. Таким образом, задачи, интенсивно использующие ввод-вывод, будут прогрессировать, даже если связанный поток не запущен. Внешние поставщики услуг, такие как базы данных и файловые системы, были в значительной степени оптимизированы для обработки множества ожидающих запросов одновременно. Например, файловая система будет изучать большой набор ожидающих запросов на запись и чтение, чтобы объединить конфликтующие обновления и получить файлы в оптимальном порядке.
Если вы полагаетесь только на один Worker Pool, например, Worker Pool Node.js, то различные характеристики работы, связанной с ЦП и работой, связанной с вводом-выводом, могут навредить производительности вашего приложения.
По этой причине вам может потребоваться поддерживать отдельный Computation Worker Pool.
Выгрузка задач: выводы
Для простых задач, таких как итерация по элементам произвольно длинного массива, разделение может быть хорошим вариантом. Если ваши вычисления более сложные, то выгрузка задач является лучшим подходом: затраты на связь, т.е. накладные расходы на передачу сериализованных объектов между Event Loop и Worker Pool, компенсируются преимуществом использования нескольких ядер.
Однако, если ваш сервер сильно зависит от сложных вычислений, вам следует подумать о том, действительно ли Node.js хорошо подходит для этого. Node.js отлично справляется с задачами, связанными с вводом-выводом, но для ресурсоемких вычислений это может быть не лучший вариант.
Если вы выбрали подход с выгрузкой задач, ознакомьтесь с разделом о том, как не блокировать Worker Pool.
Не блокируйте Worker Pool
Node.js имеет Worker Pool, состоящий из k Worker'ов. Если вы используете парадигму выгрузки задач, описанную выше, у вас может быть отдельный Computational Worker Pool, к которому применяются те же принципы. В любом случае, предположим, что k намного меньше, чем количество клиентов, которых вы можете обслуживать одновременно. Это соответствует философии Node.js "один поток для многих клиентов", секрету его масштабируемости.
Как обсуждалось выше, каждый Worker завершает свою текущую задачу, прежде чем перейти к следующей в очереди Worker Pool.
Теперь стоимость задач, необходимых для обработки запросов ваших клиентов, будет варьироваться. Некоторые задачи могут быть выполнены быстро (например, чтение коротких или кэшированных файлов или создание небольшого количества случайных байтов), а другие займут больше времени (например, чтение больших или некэшированных файлов или создание большего количества случайных байтов). Ваша цель должна состоять в том, чтобы минимизировать разброс времени выполнения задач, и для этого вам следует использовать разделение задач.
Минимизация разброса времени выполнения задач
Если текущая задача Worker'а намного дороже, чем другие задачи, то он будет недоступен для работы над другими ожидающими задачами. Другими словами, каждая относительно длинная задача эффективно уменьшает размер Worker Pool на единицу, пока она не будет завершена. Это нежелательно, потому что, до определенной степени, чем больше Worker'ов в Worker Pool, тем выше пропускная способность Worker Pool (задач/секунду) и, следовательно, тем выше пропускная способность сервера (запросов клиентов/секунду). Один клиент с относительно дорогостоящей задачей уменьшит пропускную способность Worker Pool, что, в свою очередь, уменьшит пропускную способность сервера.
Чтобы избежать этого, вы должны стараться минимизировать разброс длительности задач, которые вы отправляете в Worker Pool. Хотя целесообразно рассматривать внешние системы, к которым обращаются ваши запросы ввода-вывода (DB, FS и т. д.), как черные ящики, вы должны знать об относительной стоимости этих запросов ввода-вывода и избегать отправки запросов, которые, как вы ожидаете, будут особенно долгими.
Два примера должны проиллюстрировать возможный разброс времени выполнения задач.
Пример вариативности: Длительные операции чтения из файловой системы
Предположим, вашему серверу необходимо читать файлы для обработки клиентских запросов. Изучив API Node.js Файловой системы, вы решили использовать fs.readFile()
из соображений простоты. Однако, fs.readFile()
(в настоящее время) не разделяется: он отправляет одну fs.read()
задачу, охватывающую весь файл. Если вы читаете более короткие файлы для одних пользователей и более длинные для других, fs.readFile()
может вносить значительные различия в длительность задач, что отрицательно скажется на пропускной способности пула рабочих потоков.
В худшем случае, предположим, злоумышленник может убедить ваш сервер прочитать произвольный файл (это уязвимость обхода каталогов). Если ваш сервер работает под управлением Linux, злоумышленник может указать очень медленный файл: /dev/random
. Практически /dev/random
бесконечно медленный, и каждый рабочий поток, которому будет предложено чтение из /dev/random
, никогда не завершит эту задачу. Затем злоумышленник отправляет k запросов, по одному для каждого рабочего потока, и никакие другие клиентские запросы, использующие пул рабочих потоков, не будут выполнены.
Пример вариативности: Длительные криптографические операции
Предположим, ваш сервер генерирует криптографически безопасные случайные байты с использованием crypto.randomBytes()
. crypto.randomBytes()
не разделяется: он создает одну задачу randomBytes()
для генерации запрошенного количества байтов. Если вы создаете меньшее количество байтов для одних пользователей и большее для других, crypto.randomBytes()
является еще одним источником вариативности в длительности задач.
Разделение задач
Задачи с переменными временными затратами могут ухудшить пропускную способность пула рабочих потоков. Чтобы максимально минимизировать вариативность во времени выполнения задач, следует разделить каждую задачу на подзадачи с сопоставимой стоимостью. После завершения каждой подзадачи она должна отправлять следующую подзадачу, а после завершения последней подзадачи она должна уведомлять отправителя.
Чтобы продолжить пример с fs.readFile()
, вместо этого следует использовать fs.read()
(ручное разделение) или ReadStream
(автоматическое разделение).
Тот же принцип применим и к задачам, требующим интенсивных вычислений; пример asyncAvg
может быть неприемлемым для цикла событий, но хорошо подходит для пула рабочих потоков.
Когда вы разделяете задачу на подзадачи, более короткие задачи расширяются до небольшого количества подзадач, а более длинные задачи расширяются до большего количества подзадач. Между подзадачами более длинной задачи, рабочий поток, которому она была назначена, может работать над подзадачей из другой, более короткой, задачи, тем самым улучшая общую пропускную способность задач пула рабочих потоков.
Обратите внимание, что количество завершенных подзадач не является полезной метрикой для пропускной способности пула рабочих потоков. Вместо этого обращайте внимание на количество завершенных задач.
Избежание разделения задач
Напомним, что цель разделения задач - минимизировать разброс времени выполнения задач. Если вы можете различать более короткие и более длинные задачи (например, суммирование массива и сортировка массива), вы можете создать один пул работников для каждого класса задач. Маршрутизация более коротких и более длинных задач в отдельные пулы работников - это еще один способ минимизировать разброс времени выполнения задач.
В пользу этого подхода, разделение задач влечет за собой накладные расходы (затраты на создание представления задачи в пуле работников и на манипулирование очередью пула работников), а избежание разделения избавляет вас от затрат на дополнительные обращения к пулу работников. Это также предостерегает вас от ошибок при разделении ваших задач.
Недостатком этого подхода является то, что работники во всех этих пулах работников будут нести накладные расходы по месту и времени и будут конкурировать друг с другом за процессорное время. Помните, что каждая задача, связанная с ЦП, продвигается только во время ее планирования. В результате, вам следует рассматривать этот подход только после тщательного анализа.
Пул работников: выводы
Независимо от того, используете ли вы только пул работников Node.js или поддерживаете отдельные пулы работников, вы должны оптимизировать пропускную способность задач ваших пулов.
Чтобы сделать это, минимизируйте разброс времени выполнения задач, используя разделение задач.
Риски модулей npm
Хотя основные модули Node.js предлагают строительные блоки для широкого спектра приложений, иногда требуется что-то большее. Разработчики Node.js получают огромную выгоду от экосистемы npm, с сотнями тысяч модулей, предлагающих функциональность для ускорения процесса разработки.
Помните, однако, что большинство этих модулей написаны сторонними разработчиками и обычно выпускаются только с гарантиями наилучших усилий. Разработчик, использующий модуль npm, должен беспокоиться о двух вещах, хотя о последней часто забывают.
- Соблюдает ли он свои API?
- Могут ли его API блокировать цикл событий или работника? Многие модули не прилагают никаких усилий для указания стоимости своих API, что наносит ущерб сообществу.
Для простых API вы можете оценить стоимость API; стоимость манипулирования строками несложно понять. Но во многих случаях неясно, сколько может стоить API.
Если вы вызываете API, который может сделать что-то дорогостоящее, перепроверьте стоимость. Попросите разработчиков задокументировать это или изучите исходный код самостоятельно (и отправьте PR с документированием стоимости).
Помните, даже если API является асинхронным, вы не знаете, сколько времени он может потратить на работнике или в цикле событий в каждой из своих частей. Например, предположим, что в приведенном выше примере asyncAvg
каждый вызов вспомогательной функции суммировал половину чисел, а не одно из них. Тогда эта функция все равно была бы асинхронной, но стоимость каждой части была бы O(n)
, а не O(1)
, что делает ее гораздо менее безопасной для использования для произвольных значений n
.
Заключение
В Node.js есть два типа потоков: один Event Loop и k Workers. Event Loop отвечает за JavaScript-колбэки и неблокирующий ввод-вывод, а Worker выполняет задачи, соответствующие коду C++, который завершает асинхронный запрос, включая блокирующий ввод-вывод и интенсивные вычисления. Оба типа потоков работают не более чем над одной задачей одновременно. Если какой-либо колбэк или задача занимает много времени, поток, который ее выполняет, блокируется. Если ваше приложение делает блокирующие колбэки или задачи, это может привести в лучшем случае к снижению пропускной способности (клиентов в секунду), а в худшем - к полному отказу в обслуживании.
Чтобы написать высокопроизводительный веб-сервер, более устойчивый к DoS-атакам, вы должны убедиться, что ни Event Loop, ни ваши Workers не блокируются ни при обычном, ни при злонамеренном вводе.