Node эхо-сервер деградирует 10 раз, когда потоковые каналы используются для буферизации
В node v8.1.4 и v6.11.1
Я начал со следующей реализации эхо-сервера, которую я буду называть pipe.js или pipe.
const http = require('http');
const handler = (req, res) => req.pipe(res);
http.createServer(handler).listen(3001);
И я сравнил его с wrk и следующим
lua script (сокращенный для краткости), который отправит небольшое тело в качестве полезной нагрузки.
wrk.method = "POST"
wrk.body = string.rep("a", 10)
При 2k запросах в секунду и средней задержке 44 мс производительность невелика.
Итак, я написал другую реализацию, которая использует промежуточные буферы, пока
запрос завершен, а затем записывает эти буферы. Я буду называть это
buffer.js или buffer.
const http = require('http');
const handler = (req, res) => {
let buffs = [];
req.on('data', (chunk) => {
buffs.push(chunk);
});
req.on('end', () => {
res.write(Buffer.concat(buffs));
res.end();
});
};
http.createServer(handler).listen(3001);
Производительность резко изменилась с помощью buffer.js, обслуживающего 20 тыс. запросов на
второй при 4 мс средней латентности.
Визуально на приведенном ниже графике изображено среднее число
запросов, обслуживаемых более чем 5 тиражами и различными процентилями латентности (p50 -
медиана).
![]()
Таким образом, буфер на порядок лучше во всех категориях. Мой вопрос: почему?
Далее следуют мои заметки о расследовании, надеюсь, они, по крайней мере, образовательны.
Реакция
Обе реализации были созданы так, что они будут давать точные точные
ответ, возвращаемый curl -D - --raw
. Если дано тело 10 д'ев, то оба будут
верните тот же самый ответ (с измененным временем, конечно):
HTTP/1.1 200 OK
Date: Thu, 20 Jul 2017 18:33:47 GMT
Connection: keep-alive
Transfer-Encoding: chunked
a
dddddddddd
0
Оба выводят 128 байтов (помните об этом).
Простой факт буферизации
Семантически, единственное различие между двумя реализациями состоит в том, что
pipe.js записывает данные, пока запрос не закончился. Это может сделать один
что в buffer.js может быть несколько событий data
. Это не
правда.
req.on('data', (chunk) => {
console.log(`chunk length: ${chunk.length}`);
buffs.push(chunk);
});
req.on('end', () => {
console.log(`buffs length: ${buffs.length}`);
res.write(Buffer.concat(buffs));
res.end();
});
Эмпирически:
- Длина фрагмента всегда будет 10
- Длина буфера всегда будет 1
Поскольку будет только один кусок, что произойдет, если мы удалим буферизацию и реализуем трубу бедного человека:
const http = require('http');
const handler = (req, res) => {
req.on('data', (chunk) => res.write(chunk));
req.on('end', () => res.end());
};
http.createServer(handler).listen(3001);
Оказывается, это имеет ужасную производительность, как pipe.js. Я нахожу это
Интересно, потому что выполнено столько же вызовов res.write
и res.end
с теми же параметрами. До сих пор я догадываюсь, что производительность
различия связаны с отправкой данных ответа после завершения данных запроса.
Профилирование
Я профилировал оба приложения с помощью простого руководства по профилированию
(--prof).
Я включил только соответствующие строки:
pipe.js
[Summary]:
ticks total nonlib name
2043 11.3% 14.1% JavaScript
11656 64.7% 80.7% C++
77 0.4% 0.5% GC
3568 19.8% Shared libraries
740 4.1% Unaccounted
[C++]:
ticks total nonlib name
6374 35.4% 44.1% syscall
2589 14.4% 17.9% writev
buffer.js
[Summary]:
ticks total nonlib name
2512 9.0% 16.0% JavaScript
11989 42.7% 76.2% C++
419 1.5% 2.7% GC
12319 43.9% Shared libraries
1228 4.4% Unaccounted
[C++]:
ticks total nonlib name
8293 29.6% 52.7% writev
253 0.9% 1.6% syscall
Мы видим, что в обеих реализациях С++ доминирует во времени; однако функции
которые доминируют, меняются местами. Почти два раза на
труба, но только 1% для буфера (простите мое округление). Следующий шаг, который
syscalls являются виновниками?
Strace Here We Come
Вызов strace like strace -c node pipe.js
даст нам краткое изложение системных вызовов. Вот главные системные вызовы:
pipe.js
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
43.91 0.014974 2 9492 epoll_wait
25.57 0.008720 0 405693 clock_gettime
20.09 0.006851 0 61748 writev
6.11 0.002082 0 61803 106 write
buffer.js
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
42.56 0.007379 0 121374 writev
32.73 0.005674 0 617056 clock_gettime
12.26 0.002125 0 121579 epoll_ctl
11.72 0.002032 0 121492 read
0.62 0.000108 0 1217 epoll_wait
Верхний столбец для трубы (epoll_wait
) с 44% времени составляет всего 0,6%
время для буфера (увеличение 140x). Хотя существует большое время
несоответствие, количество раз epoll_wait
вызывается менее однообразно с
трубкой, вызывающей epoll_wait
~ 8x чаще. Мы можем получить пару бит
полезную информацию из этого утверждения, так что pipe вызывает epoll_wait
постоянно и в среднем, эти вызовы тяжелее, чем epoll_wait
для
буфер.
Для буфера верхний syscall - writev
, который ожидается с учетом большинства
времени должно быть потрачено на запись данных в сокет.
Логически следующий шаг - взглянуть на эти операторы epoll_wait
с регулярным strace, который показал буфер всегда содержал epoll_wait
с
100 событий (представляющих сто соединений, используемых с wrk
) и трубкой
было менее 100 в большинстве случаев. Например:
pipe.js
epoll_wait(5, [.16 snip.], 1024, 0) = 16
buffer.js
epoll_wait(5, [.100 snip.], 1024, 0) = 100
Графически:
![]()
Это объясняет, почему в трубе больше epoll_wait
, так как epoll_wait
не обслуживает все соединения в одном цикле событий. epoll_wait
для
нулевые события делают его похожим на то, что цикл событий простаивает! Все это не объясняет
почему epoll_wait
занимает больше времени для трубы, так как с man-страницы она заявляет
что epoll_wait
должен немедленно вернуться:
с указанием тайм-аута, равного нулю, вызывает epoll_wait() для немедленного возврата, даже если события не доступны.
Пока справочная страница говорит, что функция немедленно возвращается, можем ли мы подтвердить это? strace -T
на помощь:
![]()
Помимо поддержки того, что буфер имеет меньше вызовов, мы также видим, что почти
все звонки заняли менее 100 нс. Труба имеет гораздо более интересное распределение
показывая, что, хотя большинство вызовов занимает менее 100 нс,
дольше и приземляться в микросекунду.
Strace нашел другую странность, а с помощью writev
. Возвращаемое значение
количество записанных байтов.
pipe.js
writev(11, [{"HTTP/1.1 200 OK\r\nDate: Thu, 20 J"..., 109},
{"\r\n", 2}, {"dddddddddd", 10}, {"\r\n", 2}], 4) = 123
buffer.js
writev(11, [{"HTTP/1.1 200 OK\r\nDate: Thu, 20 J"..., 109},
{"\r\n", 2}, {"dddddddddd", 10}, {"\r\n", 2}, {"0\r\n\r\n", 5}], 5) = 128
Помните, когда я сказал, что оба выхода 128 байтов? Ну, writev
вернулся 123
байтов для канала и 128 для буфера. Разница в пяти байтах для трубы
сверяется в следующем вызове write
для каждого writev
.
write(44, "0\r\n\r\n", 5)
И если я не ошибаюсь, блокировки write
блокируются.
Заключение
Если мне нужно сделать обоснованное предположение, я бы сказал, что трубопровод, когда запрос
не завершено вызывает вызовы write
. Эти блокирующие вызовы значительно сокращают
пропускная способность частично за счет более частых операторов epoll_wait
. Зачем
write
вызывается вместо одного writev
, который отображается в буфере,
за мной. Может кто-нибудь объяснить, почему все, что я видел, происходит?
Кикер? В официальном Node.js
руководство
вы можете увидеть, как руководство начинается с реализации буфера, а затем перемещается
трубить! Если реализация трубы в официальном руководстве не должна быть
такой удар производительности, правильно?
Кроме того: последствия реального мира в этом вопросе должны быть минимальными, поскольку вопрос достаточно надуман, особенно в отношении функциональности и стороны тела, хотя это не означает, что это менее полезный вопрос. Гипотетически, ответ может выглядеть так: "Node.js использует write
, чтобы обеспечить лучшую производительность в ситуациях x (где x - более реальный случай использования в мире)"
Раскрытие: вопрос скопирован и слегка изменен из мой пост в блоге в надежде, что это лучший способ ответить на этот вопрос
31 июля 2017 года EDIT
Моя первоначальная гипотеза о том, что запись эхо-объекта после завершения потока запросов завершилась, повышает производительность, была опровергнута @robertklep с его readable.js(или читаемой) реализацией:
const http = require('http');
const BUFSIZ = 2048;
const handler = (req, res) => {
req.on('readable', _ => {
let chunk;
while (null !== (chunk = req.read(BUFSIZ))) {
res.write(chunk);
}
});
req.on('end', () => {
res.end();
});
};
http.createServer(handler).listen(3001);
Чтение выполняется на том же уровне, что и буфер при записи данных перед событием end
. Если что-нибудь, это меня смущает, потому что единственная разница между прочитанной и моей начальной бедной трубой - это разница между событиями data
и readable
, и все же это привело к увеличению производительности 10 раз. Но мы знаем, что событие data
не является по сути медленным, потому что мы использовали его в нашем буферном коде.
Для любознательных, strace на читаемых сообщениях writev
выводит все выходные данные из 128 байтов, такие как buffer
Это вызывает недоумение!
Ответы
Ответ 1
Это забавный вопрос!
На самом деле, буферизованный vs piped не является вопросом здесь. У вас небольшой кусок; он обрабатывается в одном событии. Чтобы показать проблему, вы можете написать свой обработчик следующим образом:
let chunk;
req.on('data', (dt) => {
chunk=dt
});
req.on('end', () => {
res.write(chunk);
res.end();
});
или
let chunk;
req.on('data', (dt) => {
chunk=dt;
res.write(chunk);
res.end();
});
req.on('end', () => {
});
или
let chunk;
req.on('data', (dt) => {
chunk=dt
res.write(chunk);
});
req.on('end', () => {
res.end();
});
Если write
и end
находятся на одном и том же обработчике, латентность в 10 раз меньше.
Если вы проверите write
код функции, вокруг этой строки будет
msg.connection.cork();
process.nextTick(connectionCorkNT, msg.connection);
cork
и uncork
подключение к следующему событию. Это означает, что вы используете кеш для данных, а затем принудительно отправляете данные для следующего события до того, как будут обработаны другие события.
Подводя итог, если у вас есть write
и end
для разных обработчиков, у вас будет:
- пробковое соединение (+ создать галочку для откупоривания)
- создать буфер с данными
- Отключить соединение от другого события (отправить данные)
- процесс завершения вызова (который отправляет другой пакет с последним куском и закрывается)
Если они находятся на одном и том же обработчике, функция end
вызывается перед обработкой события uncork
, поэтому последний фрагмент будет находиться в кеше.
- пробковое соединение
- создать буфер с данными
- добавить "конец" в буфере
- Отключить подключение для отправки всего
Кроме того, функция end
выполняет cork
/uncork
синхронно, что будет немного быстрее.
Теперь почему это имеет значение? Поскольку на стороне TCP, если вы отправляете пакет с данными и хотите отправить больше, процесс будет ожидать подтверждения от клиента, прежде чем отправлять больше:
write
+ end
для разных обработчиков:
![Около 40 мс для ack]()
- 0.044961s:
POST
/= > это запрос
- 0.045322s:
HTTP/1.1
= > 1st chunk: header + "aaaaaaaaa"
- 0.088522s: подтверждение пакета
- 0.088567s: Продолжение = > 2-я часть (конечная часть,
0\r\n\r\n
)
Время до ~ 100 мс до ack
после отправки первого буфера.
write
+ end
в том же обработчике:
![Нет необходимости]()
Данные завершены в одном пакете, не требуется ack
.
Почему 40 мс на ack
? Это встроенная функция ОС для повышения производительности в целом. Он описан в разделе раздел 4.2.3.2 IETF RFC 1122: Когда отправлять сегмент ACK '.
Red Hat (Fedora/CentOS/RHEL) использует 40ms: это параметр и может быть изменен. В Debian (включен Ubuntu) он, кажется, жестко запрограммирован до 40 мс, поэтому он не модифицируется (кроме случаев, когда вы создаете соединение с опцией TCP_NO_DELAY
).
Надеюсь, этого достаточно, чтобы понять немного больше об этом процессе. Этот ответ уже большой, поэтому я остановлюсь здесь, я думаю.
Читаемые
Я проверил ваше примечание о readable
. Дикая догадка: если readable
обнаруживает пустой вход, он закрывает поток на том же тике.
Изменить: я читаю код для чтения. Как я подозревал:
https://github.com/nodejs/node/blob/master/lib/_stream_readable.js#L371
https://github.com/nodejs/node/blob/master/lib/_stream_readable.js#L1036
Если чтение завершает событие, end
немедленно испускается для последующей обработки.
Итак, обработка событий:
-
readable
событие: считывает данные
-
readable
обнаруживает, что он закончил = > создает событие end
- Вы записываете данные так, чтобы они создавали событие для откоса
-
end
обработанное событие (отложенный)
- uncork обработано (но ничего не делает, поскольку все уже сделано)
Если вы уменьшите буфер:
req.on('readable',()=> {
let chunk2;
while (null !== (chunk2 = req.read(5))) {
res.write(chunk2);
}
});
Это заставляет две записи. Этот процесс будет:
-
readable
событие: считывает данные. Вы получаете пять a
s.
- Вы пишете данные, которые создают событие uncork
- Вы читаете данные.
readable
обнаруживает, что он закончил = > создать end
событие
- Вы записываете данные и добавляются в буферизованные данные
- uncork обработано (потому что оно было запущено до
end
); вы отправляете данные
-
end
обработанное событие (uncork done) = > подождать ack
для отправки окончательного фрагмента
- Процесс будет медленным (это, я проверил)
Ответ 2
Ключ находится в задержке, задержки - примерно 10 раз. Я думаю, что, поскольку метод буферизации перемещает вызов записи на req.on('end', ...)
, сервер может оптимизировать ответ. Несмотря на то, что только один буфер из десяти байтов считывается и записывается в любом заданном запросе, выполняется много одновременных запросов.
Грубо оценивая с помощью 2K 10 байтовых запросов в секунду и задержку ~ 50 мс, я полагаю, что время, затрачиваемое на фактическую передачу "данных", незначительно. Это предполагает, что сервер обрабатывает около 100 одновременных запросов в любой момент времени.
1 / .05 = 20. 2000/20 = 100
Теперь переключитесь на задержку ~ 5 мс, и снова, учитывая, что фактическое время tx данных равно 0.
1 / .005 = 200. 20000/200 = 100.
Тем не менее, сервер обрабатывает примерно 100 запросов, поступающих одновременно в любой момент времени.
Я не знаю внутренности сервера, но если ваш сервер бьет и верхний предел подобным образом, вероятно, возникает латентность, чтобы обработчик события "data" также обрабатывал запись данных в ответ.
Буферизацией и возвратом немедленно, обработчик может быть освобожден раньше и, таким образом, резко сократить латентность на стороне чтения. Открытый вопрос, на мой взгляд, должен: должен ли обработчик действительно нуждаться почти в 50 мс для написания ответа? Я бы не подумал, но если 100 запросов конкурируют за ресурсы, чтобы писать свои данные, это может начать складываться. Соедините это с тем фактом, что res.end() еще нужно вызвать (в другом обработчике), и вы потенциально нашли своего латентного свиньи.
В обработке "конец" ответы 20K 10 байт вряд ли могут быть вызваны большой нагрузкой данных, поэтому возникает вопрос об управлении ресурсами, то есть обработчик завершения ответа. Если res.write() и res.end() происходят на одном и том же обработчике, это, по-видимому, более эффективно, чем запись 10 байтов на один и завершение ответа на другой. В любом случае, я не могу представить, что код завершения ответа вводит любое отставание. Скорее всего, он голоден для работы (даже в буферизованном подходе).
ИЗМЕНИТЬ
Вы также можете попробовать res.end(data)
в своем методе буферизации вместо вызова res.write(data)
, а затем res.end()
, и посмотреть, добавляет ли это какие-либо уточняющие данные в ваш анализ.
ИЗМЕНИТЬ
Я просто попробовал один и тот же тест на своих системах. Я использовал Ubuntu Linux VM на другой физической машине, как клиент, wrk как тестовый стенд, lua script как ваш, и настройки по умолчанию. Я использовал рабочий стол Windows 8 для запуска nodejs, такой же script, за исключением использования порта 8080. Моя производительность для pipe() и буферизации была намного ниже вашей, но по сравнению друг с другом, буферы были примерно в 9 раз быстрее, чем pipe(). Таким образом, это просто независимое подтверждение.