От переводчика: Это вторая статья из цикла о Node.js от команды Mozilla Identity, которая занимается проектом Persona. Эта статья написана по мотивам выступления Ллойда Хилайеля на конференции Node Philly 2012 в Филадельфии.
Процесс Node.js выполняется на единственном ядре процессора, так что построение масштабируемого сервера на Node требует особой заботы. Благодаря возможности писать нативные расширения и продуманному набору API для управления процессами, есть несколько разных способов заставить Node выполнять код параллельно. Мы рассмотрим их в этой статье.
Кроме того, мы представим модуль compute-cluster — маленькую библиотеку, которая облегчает управление коллекцией процессов для выполнения распределённых вычислений.
Для Persona нам было необходимо создать сервер, который справился бы с обработкой множества запросов со смешанными характеристиками. Мы выбрали для этой цели Node.js. Нам надо было обрабатывать два основных типа запросов: «интерактивные», которые не требовали сложных вычислений и должны были выполняться быстро, чтобы интерфейс приложения был отзывчивым, и «пакетные», которые отнимали примерно пол-секунды процессорного времени и могли быть ненадолго отложены без ущерба для удобства пользователя.
В поисках наилучшей архитектуры приложения мы долго и тщательно обдумывали способы обработки этих типов запросов с учётом юзабилити и стоимости масштабирования и в конце концов сформулировали четыре основных требования:
Вооружившись этими требованиями, мы можем осмысленно сравнивать разные подходы.
Когда тяжёлые вычисления делаются в основном потоке, результат ужасен. Нет ни насыщенности — загружено только одно ядро, ни отзывчивости, ни отказоустойчивости — пока идут вычисления, приложение не реагирует ни на какие запросы. Единственное достоинство этого подхода — простота.
Синхронные вычисления в приложении Node.js, которое должно обрабатывать больше чем один запрос одновременно — плохая идея.
Асинхронные функции, которые выполняются в фоновом режиме, решат наши проблемы, правильно?
Ну, это зависит от того, что на самом деле значит «в фоновом режиме». Если функция, выполняющая вычисления, реализована так, что она на самом деле работает в основном потоке, то производительность будет ничуть не лучше, чем при синхронном подходе. Взгляните:
Одно лишь использование асинхронных API в Node не гарантирует, что вы получите приложение, которое выполняется на нескольких ядрах.
Имея библиотеку, грамотно написанную с использованием нативного кода, вполне возможно задействовать несколько потоков из приложения на Node.js. Есть много таких библиотек, например, node.bcrypt.js, написанная Ником Кэмпбеллом.
На машине с четырьмя ядрами результат выглядит прекрасно. Производительность увеличивается в четыре раза, задействуя все доступные ресурсы. Однако, если вы запустите приложение на сервере с 24 ядрами, картина уже не так волшебна — работают всё те же четыре ядра, а остальные простаивают.
Проблема в том, что эта библиотека использует внутренний пул потоков Node.js, вовсе не предназначенный для этой цели, и жёстко ограниченный всего 4 потоками.
И это не единственная проблема:
Библиотеки, которые используют такую многопоточность, не могут насытить множество ядер, плохо влияют на отзывчивость, и ограничивают возможность приложения корректно реагировать на перегрузку, то есть вредят отказоустойчивости.
Node.js версии 0.6.x и выше имеет встроенный модуль кластеризации, которые позволяет создавать несколько процессов, слушающих один и тот же сокет, чтобы сбалансировать нагрузку. Что если скомбинировать эту возможность с одним из предыдущих подходов?
Такая архитектура унаследует недостатки предыдущих подходов, мы точно так же не сможем обеспечить отзывчивость и отказоустойчивость.
Просто запускать несколько дополнительных экземпляров приложения — не всегда правильный вариант.
Для Persona мы решили проблему распараллеливания вычислений путём создания кластера процессов, специально предназначенных для вычислительных работ. В результате появилась библиотека compute-cluster.
compute-cluster порождает процессы и управляет ими, предоставляя вам удобное средство распределения работы по дочерним процессам. Вот как её использовать:
Файл
compute-cluster можно интегрировать в уже существующие асинхронные API без переписывания вызывающего кода и запускать по-настоящему быстрые параллельные вычисления с минимальными изменениями в программе.
Насколько этот подход соответствует нашим четырём требованиям?
Насыщение: множество рабочих процессов использует все доступные ядра.
Отзывчивость: Так как управляющий процесс не делает ничего, кроме создания дочерних процессов и передачи им сообщений, он может большую часть времени заниматься обработкой интерактивных запросов. Даже если машина загружена на 100%, в планировщике задач уровня операционной системы можно задать управляющему процессу более высокий приоритет.
Простота: это решение легко интегрировать в существующий проект. Скрывая подробности за простым асинхронным API, compute-cluster оставляет вызывающий процесс в счастливом неведении относительно деталей реализации.
Что насчёт отказоустойчивости во время резких наплывов посещаемости? Ведь наша цель — работать максимально эффективно и при этом суметь обслужить максимальное число клиентов.
compute-cluster умеет делать нечто большее, чем создание процессов и передача сообщений. Он следит за тем, сколько задач уже выполняется, и сколько времени в среднем требует одна задача. Благодаря этой информации можно достоверно предсказать, сколько времени займёт выполнение запроса ещё до того, как он поставлен в очередь.
Параметр
Например, требование вида «пользователь не должен ждать завершения авторизации больше 10 секунд» можно задать, установив
Нагрузочное тестирование compute-cluster показало многообещающие результаты. Даже под экстремальной нагрузкой авторизованные пользователи могли продолжать пользоваться системой, а часть тех, кто пытался войти на перегруженный сервер, сразу получали сообщение об ошибке.
Распараллеливание на уровне приложений с использованием процессов хорошо работает только в однослойной архитектуре, когда есть только один тип узлов, и масштабирование состоит в простом увеличении их числа. Но когда приложение становится сложнее, архитектура развивается в направлении выделения нескольких слоёв из соображений производительности или безопасности.
В дополнение к многослойности, высоконагруженные приложения часто требуют размещения в нескольких географически отдалённых датацентрах. И, наконец, масштабирование приложения может осуществляться путем добавления облачных ресурсов по требованию. Многослойная архитектура, географическая разнесённость и динамически подключаемые облачные ресурсы заметно меняют параметры задачи масштабирование, в то время как цель остаётся неизменной.
Возможные направления развития compute-cluster могут включать в себя и распределение задач по разным слоям сложного приложения, и координацию между разными датацентрами для обработки локальных пиков нагрузки, и способность задействовать облачные ресурсы по требованию.
Если у вас есть идеи и предложения по улучшению compute-cluster, я буду рад их услышать. Присоединяйтесь к обсуждению Persona в нашем списке рассылки. Спасибо за чтение!
Все статьи цикла:
- "Охотимся за утечками памяти в Node.js"
- "Нагружаем Node под завязку"
- "Храним сессии на клиенте, чтобы упростить масштабирование приложения"
- "Производительность фронтэнда. Часть 1 — конкатенация, компрессия, кэширование"
- "Пишем сервер, который не падает под нагрузкой"
- "Производительность фронтэнда. Часть 2 — кешируем динамический контент с помощью etagify"
- "Приручаем конфигурации веб-приложений с помощью node-convict"
- "Производительность фронтенда. Часть 3 — оптимизация шрифтов"
- "Локализация приложений Node.js. Часть 1"
- "Локализация приложений Node.js. Часть 2: инструментарий и процесс"
- "Локализация приложений Node.js. Часть 3: локализация в действии"
- "Awsbox — PaaS-инфраструктура для развёртывания приложений Node.js в облаке Amazon"
Процесс Node.js выполняется на единственном ядре процессора, так что построение масштабируемого сервера на Node требует особой заботы. Благодаря возможности писать нативные расширения и продуманному набору API для управления процессами, есть несколько разных способов заставить Node выполнять код параллельно. Мы рассмотрим их в этой статье.
Кроме того, мы представим модуль compute-cluster — маленькую библиотеку, которая облегчает управление коллекцией процессов для выполнения распределённых вычислений.
Постановка задачи
Для Persona нам было необходимо создать сервер, который справился бы с обработкой множества запросов со смешанными характеристиками. Мы выбрали для этой цели Node.js. Нам надо было обрабатывать два основных типа запросов: «интерактивные», которые не требовали сложных вычислений и должны были выполняться быстро, чтобы интерфейс приложения был отзывчивым, и «пакетные», которые отнимали примерно пол-секунды процессорного времени и могли быть ненадолго отложены без ущерба для удобства пользователя.
В поисках наилучшей архитектуры приложения мы долго и тщательно обдумывали способы обработки этих типов запросов с учётом юзабилити и стоимости масштабирования и в конце концов сформулировали четыре основных требования:
- Насыщение. Наше решение должно было использовать все доступные ядра процессора.
- Отзывчивость. Пользовательский интерфейс должен оставаться отзывчивым. Всегда.
- Отказоустойчивость. Когда нагрузка зашкаливает, мы должны нормально обслужить столько клиентов, сколько сможем, а остальным показать сообщение об ошибке.
- Простота. Решение должно легко и постепенно интегрироваться в уже работающий сервер.
Вооружившись этими требованиями, мы можем осмысленно сравнивать разные подходы.
Подход №1. Просто делаем всё в основном потоке
Когда тяжёлые вычисления делаются в основном потоке, результат ужасен. Нет ни насыщенности — загружено только одно ядро, ни отзывчивости, ни отказоустойчивости — пока идут вычисления, приложение не реагирует ни на какие запросы. Единственное достоинство этого подхода — простота.
function myRequestHandler(request, response) {
// Подвесим всё приложение на секунду-другую.
var results = doComputationWorkSync(request.somesuch);
}
Синхронные вычисления в приложении Node.js, которое должно обрабатывать больше чем один запрос одновременно — плохая идея.
Подход №2. Делаем всё асинхронно
Асинхронные функции, которые выполняются в фоновом режиме, решат наши проблемы, правильно?
Ну, это зависит от того, что на самом деле значит «в фоновом режиме». Если функция, выполняющая вычисления, реализована так, что она на самом деле работает в основном потоке, то производительность будет ничуть не лучше, чем при синхронном подходе. Взгляните:
function doComputationWork(input, callback) {
// Так как внутренняя реализация этой асинхронной
// функции на самом деле работает синхронно, в основном потоке,
// вы всё равно заблокируете процесс целиком.
var output = doComputationWorkSync(input);
process.nextTick(function() {
callback(null, output);
});
}
function myRequestHandler(request, response) {
// Несмотря на то, что этот код *выглядит* лучше,
// мы всё равно подвесим всё приложение.
doComputationWork(request.somesuch, function(err, results) {
// ... сделать что-то с резульатом ...
});
}
Одно лишь использование асинхронных API в Node не гарантирует, что вы получите приложение, которое выполняется на нескольких ядрах.
Подход №3. Делаем всё асинхронно с многопоточными библиотеками
Имея библиотеку, грамотно написанную с использованием нативного кода, вполне возможно задействовать несколько потоков из приложения на Node.js. Есть много таких библиотек, например, node.bcrypt.js, написанная Ником Кэмпбеллом.
На машине с четырьмя ядрами результат выглядит прекрасно. Производительность увеличивается в четыре раза, задействуя все доступные ресурсы. Однако, если вы запустите приложение на сервере с 24 ядрами, картина уже не так волшебна — работают всё те же четыре ядра, а остальные простаивают.
Проблема в том, что эта библиотека использует внутренний пул потоков Node.js, вовсе не предназначенный для этой цели, и жёстко ограниченный всего 4 потоками.
И это не единственная проблема:
- Заполнение системного пула потоков Node вычислительными задачами может замедлить операции с файлами или сетью, тем самым ухудшая отзывчивость.
- Нет никакого способа контролировать очередь задач. Если сервер уже загружен работой на 5 минут вперед, захотите ли вы нагружать его ещё больше?
Библиотеки, которые используют такую многопоточность, не могут насытить множество ядер, плохо влияют на отзывчивость, и ограничивают возможность приложения корректно реагировать на перегрузку, то есть вредят отказоустойчивости.
Подход №4. Используем встроенную кластеризацию
Node.js версии 0.6.x и выше имеет встроенный модуль кластеризации, которые позволяет создавать несколько процессов, слушающих один и тот же сокет, чтобы сбалансировать нагрузку. Что если скомбинировать эту возможность с одним из предыдущих подходов?
Такая архитектура унаследует недостатки предыдущих подходов, мы точно так же не сможем обеспечить отзывчивость и отказоустойчивость.
Просто запускать несколько дополнительных экземпляров приложения — не всегда правильный вариант.
Подход №5. Представляем compute-cluster
Для Persona мы решили проблему распараллеливания вычислений путём создания кластера процессов, специально предназначенных для вычислительных работ. В результате появилась библиотека compute-cluster.
compute-cluster порождает процессы и управляет ими, предоставляя вам удобное средство распределения работы по дочерним процессам. Вот как её использовать:
const computecluster = require('compute-cluster');
// создаём вычислительный кластер
var cc = new computecluster({ module: './worker.js' });
// запускаем параллельные вычисления
cc.enqueue({ input: "foo" }, function (error, result) {
console.log("foo done", result);
});
cc.enqueue({ input: "bar" }, function (error, result) {
console.log("bar done", result);
});
Файл
worker.js
должен содержать обработчик события message
для получения входных данных.process.on('message', function(m) {
var output;
// здесь делаем все тяжёлые вычисления, не заботясь о том, что можем заблокировать
// основной поток, ведь это процесс специально предназначен для выполнения одной большой задачи
var output = doComputationWorkSync(m.input);
process.send(output);
});
compute-cluster можно интегрировать в уже существующие асинхронные API без переписывания вызывающего кода и запускать по-настоящему быстрые параллельные вычисления с минимальными изменениями в программе.
Насколько этот подход соответствует нашим четырём требованиям?
Насыщение: множество рабочих процессов использует все доступные ядра.
Отзывчивость: Так как управляющий процесс не делает ничего, кроме создания дочерних процессов и передачи им сообщений, он может большую часть времени заниматься обработкой интерактивных запросов. Даже если машина загружена на 100%, в планировщике задач уровня операционной системы можно задать управляющему процессу более высокий приоритет.
Простота: это решение легко интегрировать в существующий проект. Скрывая подробности за простым асинхронным API, compute-cluster оставляет вызывающий процесс в счастливом неведении относительно деталей реализации.
Что насчёт отказоустойчивости во время резких наплывов посещаемости? Ведь наша цель — работать максимально эффективно и при этом суметь обслужить максимальное число клиентов.
compute-cluster умеет делать нечто большее, чем создание процессов и передача сообщений. Он следит за тем, сколько задач уже выполняется, и сколько времени в среднем требует одна задача. Благодаря этой информации можно достоверно предсказать, сколько времени займёт выполнение запроса ещё до того, как он поставлен в очередь.
Параметр
max_request_time
позволяет задать максимально приемлемое время для выполнения запроса. Попытка поставить в очередь запрос приведёт к ошибке, если ожидаемое время его выполнения превысит максимально разрешённое.Например, требование вида «пользователь не должен ждать завершения авторизации больше 10 секунд» можно задать, установив
max_request_time
в 7 секунд (оставим запас в 3 секунды на возможные сетевые задержки).Нагрузочное тестирование compute-cluster показало многообещающие результаты. Даже под экстремальной нагрузкой авторизованные пользователи могли продолжать пользоваться системой, а часть тех, кто пытался войти на перегруженный сервер, сразу получали сообщение об ошибке.
Что дальше?
Распараллеливание на уровне приложений с использованием процессов хорошо работает только в однослойной архитектуре, когда есть только один тип узлов, и масштабирование состоит в простом увеличении их числа. Но когда приложение становится сложнее, архитектура развивается в направлении выделения нескольких слоёв из соображений производительности или безопасности.
В дополнение к многослойности, высоконагруженные приложения часто требуют размещения в нескольких географически отдалённых датацентрах. И, наконец, масштабирование приложения может осуществляться путем добавления облачных ресурсов по требованию. Многослойная архитектура, географическая разнесённость и динамически подключаемые облачные ресурсы заметно меняют параметры задачи масштабирование, в то время как цель остаётся неизменной.
Возможные направления развития compute-cluster могут включать в себя и распределение задач по разным слоям сложного приложения, и координацию между разными датацентрами для обработки локальных пиков нагрузки, и способность задействовать облачные ресурсы по требованию.
Если у вас есть идеи и предложения по улучшению compute-cluster, я буду рад их услышать. Присоединяйтесь к обсуждению Persona в нашем списке рассылки. Спасибо за чтение!
Все статьи цикла:
- "Охотимся за утечками памяти в Node.js"
- "Нагружаем Node под завязку"
- "Храним сессии на клиенте, чтобы упростить масштабирование приложения"
- "Производительность фронтэнда. Часть 1 — конкатенация, компрессия, кэширование"
- "Пишем сервер, который не падает под нагрузкой"
- "Производительность фронтэнда. Часть 2 — кешируем динамический контент с помощью etagify"
- "Приручаем конфигурации веб-приложений с помощью node-convict"
- "Производительность фронтенда. Часть 3 — оптимизация шрифтов"
- "Локализация приложений Node.js. Часть 1"
- "Локализация приложений Node.js. Часть 2: инструментарий и процесс"
- "Локализация приложений Node.js. Часть 3: локализация в действии"
- "Awsbox — PaaS-инфраструктура для развёртывания приложений Node.js в облаке Amazon"