Подтвердить что ты не робот

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

Это вызывает недоумение!

4b9b3361

Ответ 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(). Таким образом, это просто независимое подтверждение.