Простое профилирование Node.js приложений
Для профилирования приложений Node.js доступно множество сторонних инструментов, но во многих случаях проще всего использовать встроенный профайлер Node.js. Встроенный профайлер использует профайлер V8, который делит стек выполняющейся программы на фрагменты через равные промежутки времени. Профайлер представляет результаты этих фрагментов с учетом оптимизаций, таких как Jit-компиляция, в виде ряда тиков:
code-creation,LazyCompile,0,0x2d5000a337a0,396,"bp native array.js:1153:16",0x289f644df68,~
code-creation,LazyCompile,0,0x2d5000a33940,716,"hasOwnProperty native v8natives.js:198:30",0x289f64438d0,~
code-creation,LazyCompile,0,0x2d5000a33c20,284,"ToName native runtime.js:549:16",0x289f643bb28,~
code-creation,Stub,2,0x2d5000a33d40,182,"DoubleToIStub"
code-creation,Stub,2,0x2d5000a33e00,507,"NumberToStringStub"
В прошлом требовался бы исходный код V8, чтобы иметь возможность анализировать тики. К счастью, начиная с Node.js 4.4.0 были представлены инструменты, которые облегчают использование этой информации без отдельной сборки V8. Давайте посмотрим, как встроенный профайлер дает представление о производительности приложений.
Возьмем простое приложением Express, чтобы проиллюстрировать использование профайлера. Приложение будет иметь два обработчика, один из которых будет использоваться для добавления новых пользователей в систему:
app.get('/newUser', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || users[username]) {
return res.sendStatus(400);
}
const salt = crypto.randomBytes(128).toString('base64');
const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
users[username] = { salt, hash };
res.sendStatus(200);
});
а другой - для проверки аутентификации пользователей:
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || !users[username]) {
return res.sendStatus(400);
}
const { salt, hash } = users[username];
const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
if (crypto.timingSafeEqual(hash, encryptHash)) {
res.sendStatus(200);
} else {
res.sendStatus(401);
}
});
Обратите внимание, что это НЕ рекомендуемые обработчики для аутентификации пользователей в приложениях Node.js. Они используются исключительно в качестве примера. В целом, не следует пытаться разработать свои собственные механизмы криптографической аутентификации. Гораздо лучше использовать готовые проверенные решения.
Теперь предположим, что мы развернули наше приложение, и пользователи жалуются на высокую задержку запросов. Мы можем легко запустить приложение с помощью встроенного профайлера:
NODE_ENV=production node --prof app.js
и добавить нагрузку на сервер с помощью ab
(ApacheBench):
curl -X GET "http://localhost:8080/newUser?username=matt&password=password"
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=matt&password=password"
и получить на выходе:
Concurrency Level: 20
Time taken for tests: 46.932 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 5.33 [#/sec] (mean)
Time per request: 3754.556 [ms] (mean)
Time per request: 187.728 [ms] (mean, across all concurrent requests)
Transfer rate: 1.05 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 3755
66% 3804
75% 3818
80% 3825
90% 3845
95% 3858
98% 3874
99% 3875
100% 4225 (longest request)
По выводу видно, что обрабатывается только около 5 запросов в секунду, и в среднем занимает чуть менее 4 секунд в обе стороны. В реальной ситуации могло бы последовать еще множество вычислений от имени пользовательского запроса, но даже в нашем простом примере могут возникать временные потери как при компиляции регулярных выражений, генерации случайных солей и хешей из паролей пользователей, так и внутри самого фреймворка Express.
Так как мы запустили приложение, используя опцию --prof
, тиковый файл был
сгенерирован в том же каталоге, откуда приложение было запущено. Он должен быть
вида isolate-0xnnnnnnnnnnnn-v8.log
(где n
- цифра).
Чтобы разобраться в этом файле, используйте тиковый процессор в комплекте с двоичным
файлом Node.js. Чтобы запустить процессор, используйте флаг --prof-process
:
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
Открыв обработанный текст в вашем любимом текстовом редакторе, вы увидите различного вида информацию. Файл разбит на секции, которые разбиты по языкам. Взглянем сначала на итоговый раздел:
[Summary]:
ticks total nonlib name
79 0.2% 0.2% JavaScript
36703 97.2% 99.2% C++
7 0.0% 0.0% GC
767 2.0% Shared libraries
215 0.6% Unaccounted
Это говорит нам о том, что 97% всех собранных замеров происходили в коде C++, и что при просмотре других разделов обработанного вывода мы должны уделять больше внимания работе, выполняемой именно в C++ (а не, к примеру, JavaScript). Имея это в виду, мы затем находим раздел [C++], который содержит информацию о том, какие функции C++ отнимают больше всего процессорного времени, и видим:
[C++]:
ticks total nonlib name
19557 51.8% 52.9% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
4510 11.9% 12.2% _sha1_block_data_order
3165 8.4% 8.6% _malloc_zone_malloc
Мы видим, что на первые три записи приходится 72,1% процессорного времени, используемого программой. Мы также сразу видим, что как минимум 51,8% процессорного времени занято функцией PBKDF2, которая соответствует нашей генерации хеш-кода из пароля пользователя. Тем не менее, может быть не сразу очевидно, как две нижние записи влияют на наше приложение (если же вы догадываетесь об этом, мы притворимся, что это не очевидно, в целях примера). Чтобы лучше понять взаимосвязь между этими функциями, мы обратимся затем к разделу [Bottom up (heavy) profile], который предоставляет информацию о том, где чаще всего вызывается каждая функция. Исследуя этот раздел, мы находим:
ticks parent name
19557 51.8% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
19557 100.0% v8::internal::Builtins::~Builtins()
19557 100.0% LazyCompile: ~pbkdf2 crypto.js:557:16
4510 11.9% _sha1_block_data_order
4510 100.0% LazyCompile: *pbkdf2 crypto.js:557:16
4510 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
3165 8.4% _malloc_zone_malloc
3161 99.9% LazyCompile: *pbkdf2 crypto.js:557:16
3161 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
Анализ этого раздела требует немного больше работы, чем простой подсчет тиков как выше.
В каждом из вышеприведенных стеков вызовов процент в родительском столбце показывает
процент выборок, для которых функция в строке выше была вызвана функцией в текущей строке.
Например, в среднем стеке для _sha1_block_data_order мы видим,
что вызов _sha1_block_data_order
встречался в 11,9% выборок, что мы и так знали
исходя из подсчетов выше. Однако теперь мы также можем сказать,
что он всегда вызывался функцией pbkdf2 внутри модуля crypto Node.js. Мы видим,
что аналогичным образом _malloc_zone_malloc
вызывалась практически всегда
той же функцией pbkdf2. Таким образом, используя информацию в этом представлении,
мы можем сказать, что наше вычисление хеша из пароля пользователя составляет
не только указанные 51,8%, а все процессорное время в 3 самых часто вызываемых функциях,
так как вызовы _sha1_block_data_order
и _malloc_zone_malloc
были сделаны
от имени функции pbkdf2.
Теперь становится ясно, что целью нашей оптимизации должна быть генерация хеша на основе пароля. К счастью, вы полностью усвоили преимущества асинхронного программирования и понимаете, что работа по генерации хеша выполняется синхронно и, таким образом, связывает цикл обработки событий. Это лишает нас возможности работать с другими входящими запросами во время вычисления хеша.
Чтобы устранить эту проблему, внесем небольшую модификацию в наши обработчики, используя асинхронную версию функции pbkdf2:
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || users[username]) {
return res.sendStatus(400);
}
crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => {
if (users[username].hash.toString() === hash.toString()) {
res.sendStatus(200);
} else {
res.sendStatus(401);
}
});
});
В результате нового запуска теста ab для асинхронной версии приложения получаем результат:
Concurrency Level: 20
Time taken for tests: 12.846 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 19.46 [#/sec] (mean)
Time per request: 1027.689 [ms] (mean)
Time per request: 51.384 [ms] (mean, across all concurrent requests)
Transfer rate: 3.82 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 1018
66% 1035
75% 1041
80% 1043
90% 1049
95% 1063
98% 1070
99% 1071
100% 1079 (longest request)
Ура! Наше приложение теперь обрабатывает около 20 запросов в секунду, что примерно в 4 раза больше, чем при синхронном генерировании хешей. Кроме того, средняя задержка снизилась с 4 секунд до чуть более 1 секунды.
Надеемся, что благодаря разбору производительности этого (заведомо надуманного) примера вы увидели, как тиковый процессор V8 может дать вам лучшее понимание производительности ваших приложений Node.js.