Skip to content

Не блокируйте цикл обработки событий (или пул рабочих потоков)

Стоит ли читать это руководство?

Если вы пишете что-то более сложное, чем короткий скрипт командной строки, чтение этого руководства поможет вам создавать высокопроизводительные и более безопасные приложения.

Этот документ написан с учетом серверов 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 рабочих потоков в пуле рабочих потоков (также известный как пул потоков).

Если поток занимает много времени для выполнения обратного вызова (цикл обработки событий) или задачи (рабочий поток), мы называем его "заблокированным". Пока поток заблокирован, работая от имени одного клиента, он не может обрабатывать запросы от других клиентов. Это дает две причины для того, чтобы не блокировать ни цикл обработки событий, ни пул рабочих потоков:

  1. Производительность: Если вы регулярно выполняете ресурсоемкую активность в любом из типов потоков, пропускная способность (запросов/секунду) вашего сервера пострадает.
  2. Безопасность: Если возможно, что для определенного ввода один из ваших потоков может заблокироваться, злоумышленник может отправить этот "вредоносный ввод", заблокировать ваши потоки и помешать им работать с другими клиентами. Это будет атака типа "отказ в обслуживании".

Краткий обзор Node

Node.js использует архитектуру, управляемую событиями: он имеет цикл обработки событий (Event Loop) для оркестровки и пул рабочих потоков (Worker Pool) для ресурсоемких задач.

Какой код выполняется в цикле обработки событий?

При запуске приложения Node.js сначала проходят фазу инициализации, require'я модули и регистрируя обратные вызовы для событий. Затем приложения Node.js переходят в цикл обработки событий, отвечая на входящие запросы клиентов путем выполнения соответствующего обратного вызова. Этот обратный вызов выполняется синхронно и может регистрировать асинхронные запросы для продолжения обработки после его завершения. Обратные вызовы для этих асинхронных запросов также будут выполнены в цикле обработки событий.

Цикл обработки событий также будет выполнять неблокирующие асинхронные запросы, сделанные его обратными вызовами, например, сетевой ввод/вывод.

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

Какой код выполняется в пуле рабочих потоков?

Пул рабочих потоков Node.js реализован в libuv (документация), который предоставляет общий API для отправки задач.

Node.js использует пул рабочих потоков для обработки "ресурсоемких" задач. Это включает в себя ввод/вывод, для которого операционная система не предоставляет неблокирующую версию, а также особенно ресурсоемкие для процессора задачи.

Это API модулей Node.js, которые используют этот пул рабочих потоков:

  1. Ресурсоемкие для ввода/вывода
    1. DNS: dns.lookup(), dns.lookupService().
    2. [Файловая система][/api/fs]: Все API файловой системы, за исключением fs.FSWatcher() и тех, которые явно синхронны, используют пул потоков libuv.
  2. Ресурсоемкие для процессора
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: Все API zlib, за исключением тех, которые явно синхронны, используют пул потоков libuv.

Во многих приложениях Node.js эти API являются единственными источниками задач для пула рабочих потоков. Приложения и модули, использующие дополнение C++, могут отправлять другие задачи в пул рабочих потоков.

Для полноты картины отметим, что при вызове одного из этих API из обратного вызова в цикле обработки событий, цикл обработки событий несет некоторые незначительные затраты на настройку, когда он входит в привязки Node.js C++ для этого API и отправляет задачу в пул рабочих потоков. Эти затраты незначительны по сравнению с общей стоимостью задачи, поэтому цикл обработки событий делегирует ее выполнение. При отправке одной из этих задач в пул рабочих потоков Node.js предоставляет указатель на соответствующую функцию C++ в привязках Node.js C++.

Как Node.js решает, какой код запускать следующим?

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

На самом деле цикл обработки событий не поддерживает очередь. Вместо этого он имеет набор файловых дескрипторов, которые он запрашивает у операционной системы для мониторинга, используя механизм, подобный epoll (Linux), kqueue (OSX), портам событий (Solaris) или IOCP (Windows). Эти файловые дескрипторы соответствуют сетевым сокетам, любым файлам, за которыми он следит, и так далее. Когда операционная система сообщает, что один из этих файловых дескрипторов готов, цикл обработки событий преобразует его в соответствующее событие и вызывает обратный вызов(ы), связанный(ые) с этим событием. Вы можете узнать больше об этом процессе здесь.

В отличие от этого, пул рабочих процессов использует реальную очередь, записи которой являются задачами для обработки. Рабочий извлекает задачу из этой очереди и работает над ней, а по завершении Рабочий генерирует событие «По крайней мере одна задача завершена» для цикла обработки событий.

Что это значит для проектирования приложений?

В системе «один поток на клиента», такой как Apache, каждому ожидающему клиенту назначается свой собственный поток. Если поток, обрабатывающий одного клиента, блокируется, операционная система прервет его и предоставит другому клиенту возможность. Таким образом, операционная система гарантирует, что клиенты, которым требуется небольшое количество работы, не будут наказаны клиентами, которым требуется больше работы.

Поскольку Node.js обрабатывает множество клиентов с помощью нескольких потоков, если поток блокируется при обработке запроса одного клиента, то ожидающие запросы клиентов могут не получить ход до тех пор, пока поток не завершит свой обратный вызов или задачу. Таким образом, справедливое отношение к клиентам является обязанностью вашего приложения. Это означает, что вы не должны выполнять слишком много работы для любого клиента в любом отдельном обратном вызове или задаче.

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

Не блокируйте цикл событий

Цикл событий замечает каждое новое клиентское подключение и организует генерацию ответа. Все входящие запросы и исходящие ответы проходят через цикл событий. Это означает, что если цикл событий тратит слишком много времени в какой-либо точке, все текущие и новые клиенты не получат свою очередь.

Вы должны убедиться, что никогда не блокируете цикл событий. Другими словами, каждый ваш обратный вызов JavaScript должен завершаться быстро. Это, конечно, также относится к вашим await, вашим Promise.then и так далее.

Хороший способ обеспечить это — рассуждать о "вычислительной сложности" ваших обратных вызовов. Если ваш обратный вызов занимает постоянное количество шагов независимо от его аргументов, то вы всегда будете предоставлять каждому ожидающему клиенту справедливую очередь. Если ваш обратный вызов занимает разное количество шагов в зависимости от его аргументов, то вы должны подумать о том, насколько длинными могут быть аргументы.

Пример 1: Обратный вызов с постоянным временем.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200)
})

Пример 2: Обратный вызов O(n). Этот обратный вызов будет выполняться быстро для малых n и медленнее для больших n.

js
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).

js
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) сопоставляет входную строку с шаблоном. Обычно мы считаем, что сопоставление regexp требует одного прохода по входной строке --- O(n) времени, где n — длина входной строки. Во многих случаях одного прохода действительно достаточно. К сожалению, в некоторых случаях сопоставление regexp может потребовать экспоненциального числа проходов по входной строке --- O(2^n) времени. Экспоненциальное число проходов означает, что если движку требуется x проходов для определения совпадения, то ему потребуется 2*x проходов, если мы добавим только один символ к входной строке. Поскольку число проходов линейно связано с необходимым временем, результатом такой оценки будет блокировка цикла обработки событий.

Уязвимое регулярное выражение — это такое регулярное выражение, для которого движок регулярных выражений может потребовать экспоненциального времени, подвергая вас REDOS на «вредоносном вводе». Вопрос о том, является ли ваш шаблон регулярного выражения уязвимым (т.е. движок regexp может затратить на него экспоненциальное время), на самом деле сложен и зависит от того, используете ли вы Perl, Python, Ruby, Java, JavaScript и т. д., но вот несколько практических правил, которые применимы ко всем этим языкам:

  1. Избегайте вложенных квантификаторов, таких как (a+)*. Движок regexp V8 может быстро обрабатывать некоторые из них, но другие уязвимы.
  2. Избегайте операторов OR с перекрывающимися предложениями, такими как (a|a)*. Опять же, они иногда быстрые.
  3. Избегайте использования обратных ссылок, таких как (a.*) \1. Ни один движок regexp не может гарантировать вычисление этих выражений за линейное время.
  4. Если вы выполняете простое сопоставление строк, используйте indexOf или локальный эквивалент. Это будет дешевле и никогда не займет больше, чем O(n).

Если вы не уверены, является ли ваше регулярное выражение уязвимым, помните, что Node.js обычно не испытывает проблем с сообщением о совпадении даже для уязвимого regexp и длинной входной строки. Экспоненциальное поведение возникает, когда происходит несоответствие, но Node.js не может быть уверенным, пока не попробует множество путей в входной строке.

Пример REDOS

Вот пример уязвимого регулярного выражения, подвергающего сервер атаке REDOS:

js
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 секунды.

js
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 легко сохранить состояние текущей задачи в замыкании, как показано во втором примере ниже.

Для простого примера предположим, что вы хотите вычислить среднее значение чисел от 1 до n.

Пример 1: Неразделенное среднее, сложность O(n)

js
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)

Пример 2: Разделенное среднее, каждый из n асинхронных шагов имеет сложность O(1).

js
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) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg)
})

Вы можете применить этот принцип к итерациям массивов и так далее.

Передача задач

Если вам нужно выполнить что-то более сложное, разбиение на части — не лучший вариант. Это связано с тем, что разбиение использует только цикл событий, и вы почти наверняка не сможете воспользоваться преимуществами многоядерности вашего компьютера. Помните, цикл событий должен управлять запросами клиентов, а не выполнять их сам. Для сложной задачи перенесите работу из цикла событий в пул рабочих процессов.

Как передать задачу

У вас есть два варианта для целевого пула рабочих процессов, в который можно передать работу.

  1. Вы можете использовать встроенный пул рабочих процессов Node.js, разработав дополнение C++. В старых версиях Node создайте своё дополнение C++ с помощью NAN, а в новых версиях используйте N-API. node-webworker-threads предлагает способ доступа к пулу рабочих процессов Node.js, используя только JavaScript.
  2. Вы можете создать и управлять собственным пулом рабочих процессов, предназначенным для вычислений, а не для ввода-вывода в стиле Node.js. Наиболее простой способ сделать это — использовать дочерние процессы или кластеризацию.

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

Недостатки передачи задач Недостатком подхода с передачей задач является то, что он приводит к дополнительным затратам в виде затрат на связь. Только цикл событий может видеть «пространство имён» (состояние JavaScript) вашего приложения. Из рабочего процесса вы не можете манипулировать объектом JavaScript в пространстве имён цикла событий. Вместо этого вам необходимо сериализовать и десериализовать любые объекты, которыми вы хотите поделиться. Затем рабочий процесс может работать со своей собственной копией этого(-их) объекта(-ов) и возвращать изменённый объект (или «патч») в цикл событий.

Что касается проблем сериализации, см. раздел о JSON DOS.

Некоторые предложения по передаче задач

Вы можете захотеть различать задачи, связанные с использованием ЦП, и задачи, связанные с вводом-выводом, поскольку они имеют заметно разные характеристики.

Задача, связанная с использованием ЦП, делает прогресс только тогда, когда её рабочий процесс запланирован, и рабочий процесс должен быть запланирован на одном из логических ядер вашей машины. Если у вас 4 логических ядра и 5 рабочих процессов, один из этих рабочих процессов не может сделать прогресс. В результате вы платите за накладные расходы (затраты памяти и планирования) для этого рабочего процесса и ничего не получаете взамен.

Задачи, связанные с вводом-выводом, включают запрос внешнего поставщика услуг (DNS, файловая система и т. д.) и ожидание его ответа. Пока рабочий процесс с задачей, связанной с вводом-выводом, ожидает ответа, ему больше нечего делать, и операционная система может отменить его планирование, дав другому рабочему процессу возможность отправить свой запрос. Таким образом, задачи, связанные с вводом-выводом, будут делать прогресс даже тогда, когда связанный поток не работает. Внешние поставщики услуг, такие как базы данных и файловые системы, были высоко оптимизированы для обработки большого количества ожидающих запросов одновременно. Например, файловая система будет проверять большой набор ожидающих запросов на запись и чтение, чтобы объединить конфликтующие обновления и извлекать файлы в оптимальном порядке.

Если вы полагаетесь только на один пул рабочих процессов, например, пул рабочих процессов Node.js, то различные характеристики работы, связанной с ЦП, и работы, связанной с вводом-выводом, могут снизить производительность вашего приложения.

По этой причине вы можете захотеть поддерживать отдельный пул вычислительных рабочих процессов.

Разгрузка: выводы

Для простых задач, таких как итерация по элементам массива произвольной длины, разделение может быть хорошим вариантом. Если ваши вычисления более сложны, разгрузка — лучший подход: затраты на коммуникацию, то есть накладные расходы на передачу сериализованных объектов между циклом событий и пулом рабочих процессов, компенсируются преимуществом использования нескольких ядер.

Однако, если ваш сервер сильно зависит от сложных вычислений, вам следует подумать, действительно ли Node.js подходит. Node.js превосходно подходит для задач, связанных с вводом-выводом, но для дорогостоящих вычислений он может быть не лучшим вариантом.

Если вы используете подход разгрузки, см. раздел о том, как не блокировать пул рабочих процессов.

Не блокируйте пул рабочих процессов

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

Как обсуждалось выше, каждый рабочий процесс завершает свою текущую задачу, прежде чем переходить к следующей в очереди пула рабочих процессов.

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

Минимизация вариации времени выполнения задач

Если текущая задача рабочего процесса намного дороже других задач, то она будет недоступна для работы над другими ожидающими задачами. Другими словами, каждая относительно длительная задача эффективно уменьшает размер пула рабочих процессов на один до тех пор, пока она не будет завершена. Это нежелательно, потому что, до определенного момента, чем больше рабочих процессов в пуле рабочих процессов, тем больше пропускная способность пула рабочих процессов (задач/секунду) и, следовательно, тем больше пропускная способность сервера (клиентских запросов/секунду). Один клиент с относительно дорогой задачей уменьшит пропускную способность пула рабочих процессов, в свою очередь уменьшая пропускную способность сервера.

Чтобы избежать этого, вы должны попытаться минимизировать вариацию длительности задач, которые вы отправляете в пул рабочих процессов. Хотя уместно рассматривать внешние системы, к которым обращаются ваши запросы ввода-вывода (БД, ФС и т. д.), как черные ящики, вы должны знать об относительной стоимости этих запросов ввода-вывода и избегать отправки запросов, которые, как вы ожидаете, будут особенно длительными.

Два примера должны проиллюстрировать возможную вариацию времени выполнения задач.

Пример вариации: длительные операции чтения файловой системы

Предположим, ваш сервер должен читать файлы для обработки некоторых запросов клиентов. После ознакомления с API файловой системы Node.js File system вы решили использовать 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, должен беспокоиться о двух вещах, хотя последняя часто забывается.

  1. Соблюдает ли он свои API?
  2. Могут ли его API блокировать цикл событий или рабочего? Многие модули не делают никаких попыток указать стоимость своих API, что вредит сообществу.

Для простых API вы можете оценить стоимость API; стоимость обработки строк несложно понять. Но во многих случаях неясно, сколько может стоить API.

Если вы вызываете API, которое может делать что-то дорогостоящее, дважды проверьте стоимость. Попросите разработчиков задокументировать её или самостоятельно изучите исходный код (и отправьте PR с документированием стоимости).

Помните, даже если API асинхронно, вы не знаете, сколько времени оно может потратить на рабочего или на цикл событий в каждом из его разделов. Например, предположим, что в примере asyncAvg, приведённом выше, каждый вызов вспомогательной функции суммировал половину чисел, а не одно из них. Тогда эта функция оставалась бы асинхронной, но стоимость каждого раздела была бы O(n), а не O(1), что делает её гораздо менее безопасной для использования для произвольных значений n.

Заключение

В Node.js есть два типа потоков: один цикл событий (Event Loop) и k рабочих потоков (Workers). Цикл событий отвечает за обратные вызовы JavaScript и неблокирующий ввод-вывод, а рабочий поток выполняет задачи, соответствующие коду C++, которые завершают асинхронный запрос, включая блокирующий ввод-вывод и ресурсоемкие вычисления. Оба типа потоков работают не более чем над одной задачей одновременно. Если какой-либо обратный вызов или задача занимает много времени, поток, его выполняющий, блокируется. Если ваше приложение делает блокирующие обратные вызовы или задачи, это может привести, в лучшем случае, к снижению пропускной способности (клиентов/секунду), а в худшем — к полному отказу в обслуживании.

Для написания высокопроизводительного, более защищенного от DoS веб-сервера необходимо обеспечить, чтобы ни цикл событий, ни рабочие потоки не блокировались ни на доброкачественном, ни на вредоносном вводе.