Профилирование Node.js-приложений
С чего начнем?
Иногда нам сложно понять, какого черта приложение работает медленно, хотя не должно. А иногда оно вроде бы и работает ОК, но нужно, чтобы оно работало быстрее. Можно, конечно, долго и вдумчиво читать код, рандомно что-то менять, замерять скорость и надеяться только на себя.
К счастью, в Node.js встроен помощник на такие случаи — профайлер. Точнее, он встроен в V8 (что это такое, можно почитать здесь или даже здесь). Профайлер V8 — это такая штука, которая делит стек выполняющейся программы (о стеке можно почитать в моей статье об управлении памятью в JS) на фрагменты через равные промежутки времени. А представляет он нам результаты этих фрагментов с учетом оптимизаций (к примеру, 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, чтобы иметь возможность анализировать эти тики. Но, к счастью, в 2016 году у Node.js появились опции --prof
и --prof-process
, которые позволяют делать это сильно проще.
Давайте представим, что пишем сверх-хайлоад-микросервис по созданию и аутентификации пользователей на Express, а потом отпрофилируем его, чтобы понять, как его можно ускорить.
import express from 'express';
import crypto from 'crypto';
const app = express();
const users = [];
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);
}
});
app.listen(3000, () => {
console.log('Example app listening on port 3000')
});
Профилирование
Вот мы написали приложение, выкатили, а пользователи жалуются, что все медленно. Мы запустим приложение с опцией --prof
и сымитируем нагрузку на приложение с помощью ApacheBench:
node --prof express-app.js # запускаем приложение с опцией --prof
# создаем пользователя
curl -X GET "http://localhost:3000/newUser?username=user&password=password"
# имитируем множество авторизаций
ab -k -c 20 -n 250 "http://localhost:3000/auth?username=user&password=password"
# получаем такой результат:
Concurrency Level: 20
Time taken for tests: 6.620 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 57250 bytes
HTML transferred: 500 bytes
Requests per second: 37.77 [#/sec] (mean)
Time per request: 529.579 [ms] (mean)
Time per request: 26.479 [ms] (mean, across all concurrent requests)
Transfer rate: 8.45 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 2.0 0 9
Processing: 29 506 129.5 502 1038
Waiting: 29 506 129.6 502 1038
Total: 29 507 128.8 502 1038
Percentage of the requests served within a certain time (ms)
50% 502
66% 503
75% 507
80% 529
90% 535
95% 551
98% 1001
99% 1005
100% 1038 (longest request)
Получаем, что наше приложение может обработать только 37.77 запросов в секунду, и запрос в среднем занимает около полусекунды, что не очень-то для хайлоад-приложения. Поскольку мы запустили наше приложение с опцией --prof
, в каталоге с приложением появится файл вида isolate-0x<много цифр>-v8.log
. Разберемся в этом файле с помощью тикового процессора, встроенного в Node.js (флаг --prof-process
):
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
Откроем файл processed.txt
, увидим кучу секций, разбитых по языкам. Глянем в Summary:
[Summary]:
ticks total nonlib name
11 0.2% 0.2% JavaScript
5454 99.1% 99.7% C++
18 0.3% 0.3% GC
36 0.7% Shared libraries
4 0.1% Unaccounted
Целых 99 с чем-то процентов заняли задачи, выполняемые C++. Выглядит не очень и само по себе, но посмотрим в раздел C++, чтобы понять, какие функции заняли больше процессорного времени:
[C++]:
ticks total nonlib name
5357 97.3% 98.0% T node::contextify::ContextifyScript::~ContextifyScript()
42 0.8% 0.8% T node::builtins::BuiltinLoader::CompileFunction(v8::FunctionCallbackInfo<v8::Value> const&)
20 0.4% 0.4% T node::contextify::ContextifyContext::CompileFunction(v8::FunctionCallbackInfo<v8::Value> const&)
16 0.3% 0.3% T _semaphore_destroy
9 0.2% 0.2% T _posix_spawnattr_setmacpolicyinfo_np
3 0.1% 0.1% t std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::__put_character_sequence<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*, unsigned long)
2 0.0% 0.0% t std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> > std::__1::__pad_and_output<char, std::__1::char_traits<char> >(std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> >, char const*, char const*, char const*, std::__1::ios_base&, char)
2 0.0% 0.0% T _mach_get_times
1 0.0% 0.0% T _mig_dealloc_reply_port
1 0.0% 0.0% T __simple_putline
1 0.0% 0.0% T ___psynch_cvbroad
Все еще не очень понятно :( на самом деле, иногда все становится понятно и из этой секции — но не в этот раз. Чтобы лучшве понять, что это за node::contextify::ContextifyScript::~ContextifyScript()
, отбирающий 98% процессорного времени, обратимся к секции Bottom up (heavy) profile
, которая предоставляет информацию о том,где чаще всего вызывается каждая функция. Там мы видим это:
[Bottom up (heavy) profile]:
Note: percentage shows a share of a particular caller in the total
amount of its parent calls.
Callers occupying less than 1.0% are not shown.
ticks parent name
5357 97.3% T node::contextify::ContextifyScript::~ContextifyScript()
5036 94.0% Function: ^pbkdf2Sync node:internal/crypto/pbkdf2:68:20
4993 99.1% Function: ^<anonymous> file:///Users/sptm/Documents/Development/test/fastify/express-app.js:28:18
4993 100.0% Function: ^handle /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/layer.js:86:49
4888 97.9% Function: ^next /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:116:16
4888 100.0% Function: ^dispatch /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:98:45
105 2.1% LazyCompile: ~next /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:116:16
105 100.0% Function: ^dispatch /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:98:45
194 3.6% LazyCompile: ~pbkdf2Sync node:internal/crypto/pbkdf2:68:20
173 89.2% LazyCompile: ~<anonymous> file:///Users/sptm/Documents/Development/test/fastify/express-app.js:28:18
107 61.8% Function: ^handle /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/layer.js:86:49
107 100.0% LazyCompile: ~next /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:116:16
107 100.0% LazyCompile: ~dispatch /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:98:45
66 38.2% LazyCompile: ~handle /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/layer.js:86:49
66 100.0% LazyCompile: ~next /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:116:16
66 100.0% LazyCompile: ~dispatch /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:98:45
21 10.8% LazyCompile: ~<anonymous> file:///Users/sptm/Documents/Development/test/fastify/express-app.js:9:21
21 100.0% LazyCompile: ~handle /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/layer.js:86:49
21 100.0% LazyCompile: ~next /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:116:16
21 100.0% LazyCompile: ~dispatch /Users/sptm/Documents/Development/test/fastify/node_modules/express/lib/router/route.js:98:45
Оптимизация
А вот отсюда очевидно, что практически все процессорное время отнимает функция pbkdf2Sync()
, которой мы генерируем хэш на основе пароля. К счастью, мы понимаем, что эта функция работает синхронно и блокирует event loop (если не понимаем, о чем речь, читаем вот эту статью об асинхронности). Попробуем использовать асинхронную версию функции pbkdf2Sync()
(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);
}
});
});
И снова сымитируем нагрузку. Получим такие результаты:
Concurrency Level: 20
Time taken for tests: 0.073 seconds
Complete requests: 250
Failed requests: 0
Non-2xx responses: 250
Keep-Alive requests: 250
Total transferred: 62000 bytes
HTML transferred: 2750 bytes
Requests per second: 3444.38 [#/sec] (mean)
Time per request: 5.807 [ms] (mean)
Time per request: 0.290 [ms] (mean, across all concurrent requests)
Transfer rate: 834.19 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 3.2 0 14
Processing: 1 4 2.7 3 18
Waiting: 1 3 2.6 3 16
Total: 1 4 5.4 3 24
Percentage of the requests served within a certain time (ms)
50% 3
66% 3
75% 3
80% 4
90% 7
95% 22
98% 23
99% 23
100% 24 (longest request)
Сильно лучше, правда? От 38 запросов в секунду мы выросли практически на два порядка, до 3444 запросов. А один запрос теперь занимает около 5 миллисекунд вместо 529.
Итоги
Надеюсь, на этом полностью синтетическом и выдуманном примере мы научились профилировать наши приложения. Ну, и поняли, насколько вредны синхронные блокирующие операции :)
Интересный пост?
Вот еще похожие:
- Событийно-ориентированная архитектура и Node.js Events
- Реактивное программирование: теория и практика
- Как и зачем писать тесты?
- Функциональное программирование. Что это и зачем?
- Управление памятью в JS