当流管道用于缓冲时,节点回显服务器性能下降 10 倍

Node echo server degrades 10x when stream pipes are used over buffering

在节点 v8.1.4 和 v6.11.1 上

我从以下回显服务器实现开始,我将其称为 pipe.jspipe

const http = require('http');

const handler = (req, res) => req.pipe(res);
http.createServer(handler).listen(3001);

我用 wrk 和以下内容对其进行了基准测试 lua 脚本(为简洁起见缩短)将发送一个小的 body 作为有效负载。

wrk.method = "POST"
wrk.body   = string.rep("a", 10)

每秒 2k 个请求和 44 毫秒的平均延迟,性能不是很好。

所以我写了另一个使用中间缓冲区的实现,直到 请求完成,然后将这些缓冲区写出。我将其称为 buffer.js缓冲区.

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 每次服务 20k 个请求 平均延迟为 4 毫秒。

从视觉上看,下图描述了平均数 超过 5 次运行和各种延迟百分位数(p50 是 中位数)。

因此,buffer 在所有类别中都好一个数量级。我的问题是为什么?

接下来是我的调查笔记,希望它们至少具有教育意义。

响应行为

两种实现都经过精心设计,因此它们将提供相同的确切信息 return 由 curl -D - --raw 编辑的响应。如果给出 10 d 的 body,两者都会 return 完全相同的响应(当然有修改时间):

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();
});

根据经验:

因为永远只有一个块,如果我们移除缓冲并实现一个穷人的管道会发生什么:

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.writeres.end 调用 具有相同的参数。到目前为止,我最好的猜测是性能 差异是由于在请求数据结束后才发送响应数据。

分析

我使用 简单分析指南分析了这两个应用程序 (--教授).

我只包含了相关行:

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

我们看到,在这两种实现中,C++ 支配着时间;然而,功能 占主导地位的被交换。系统调用占了将近一半的时间 pipe,但 buffer 只有 1%(请原谅我的四舍五入)。下一步,哪个 系统调用是罪魁祸首?

Strace 我们来了

strace -c node pipe.js 这样调用 strace 会给我们一个系统调用的总结。以下是最重要的系统调用:

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

pipe (epoll_wait) 的顶级系统调用占 44% 的时间仅为 0.6% buffer 的时间(增加了 140 倍)。虽然有很多时间 差异,调用 epoll_wait 的次数不那么不平衡 pipe 调用 epoll_wait 的频率提高了 ~8 倍。我们可以得出一些 该语句中的有用信息,例如 pipe 调用 epoll_wait 持续且平均而言,这些呼叫比 epoll_wait 更重 缓冲区.

对于buffer,最上面的系统调用是writev,考虑到大多数 的时间应该花在将数据写入套接字上。

从逻辑上讲,下一步是查看这些 epoll_wait 语句 使用常规 strace,显示 buffer 始终包含 epoll_wait 100 个事件(代表与 wrk 一起使用的一百个连接)和 pipe 大部分时间少于100。像这样:

pipe.js

epoll_wait(5, [.16 snip.], 1024, 0) = 16

buffer.js

epoll_wait(5, [.100 snip.], 1024, 0) = 100

图形化:

这解释了为什么 pipe 中有更多的 epoll_wait,因为 epoll_wait 不会在一个事件循环中为所有连接提供服务。 epoll_wait 为 零事件使事件循环看起来像是空闲的!这一切都不解释 为什么 epoll_wait 占用 pipe 的更多时间,从手册页中可以看出 epoll_wait 应该 return 立即:

specifying a timeout equal to zero cause epoll_wait() to return immediately, even if no events are available.

虽然手册页立即说明函数 returns,但我们可以确认这一点吗? strace -T 救援:

除了支持buffer调用次数较少外,我们还可以看到几乎 所有调用都不到 100ns。 Pipe 有一个更有趣的分布 嘘一边说虽然大多数调用都在 100ns 以下,但 non-negligible 数量 更长并降落到微秒级。

Strace 确实发现了另一个奇怪的地方,那就是 writev。 return 值为 写入的字节数。

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 returned 123 pipe 的字节和 buffer 的 128 个字节。 pipe 的五个字节差异是 在每个 writev.

的后续 write 调用中协调
write(44, "0\r\n\r\n", 5)

如果我没记错的话,write 系统调用正在阻塞。

结论

如果我必须做出有根据的猜测,我会说请求时管道 未完成导致 write 次调用。这些阻塞调用显着减少 吞吐量部分地通过更频繁的 epoll_wait 语句。为什么 write 被调用而不是在 buffer 中看到的单个 writev 是 超越我。有人可以解释为什么我看到的一切正在发生吗?

踢球者?在官方Node.js 指南 您可以看到指南如何从缓冲区实现开始,然后移动 管!如果管道实现在官方指南中,则不应该 这样的性能打击,对吧?

旁白:这个问题对现实世界的性能影响应该是最小的,因为这个问题非常人为设计,尤其是在功能和 body 方面,尽管这并不意味着它是一个有用的问题。假设地,答案可能看起来像 "Node.js uses write to allow for better performance under x situations (where x is a more real world use case)"


披露:从 my blog post 复制并稍作修改的问题,希望这是回答此问题的更好途径


2017 年 7 月 31 日编辑

我最初的假设是在请求流完成后写入回显 body 可以提高性能,但 @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 事件之前写入数据时,

Readable 的执行级别与 buffer 相同。如果有什么让我更困惑的话,因为 readable 和我最初的穷人的管道实现之间的唯一区别是 datareadable 事件之间的区别,然而导致 10 倍的性能提升。但我们知道 data 事件本身并不慢,因为我们在 缓冲区 代码中使用了它。

出于好奇,strace on readable reported writev outputs all 128 bytes output like buffer

这令人费解!

线索在于延迟,延迟大约相差 10 倍。我认为因为缓冲方法将写入调用移至 req.on('end', ...),服务器可以优化响应。即使在任何给定请求中只读取和写入一个十字节的缓冲区,也会同时发出许多请求。

粗略估计每秒 2K 个 10 字节请求和约 50 毫秒的延迟,我认为实际传输 'data' 所花费的时间可以忽略不计。这表明服务器在任何给定时间处理大约 100 个并发请求。

1 / .05 = 20.  2000/20 = 100

现在切换到 ~5ms 延迟,并再次考虑实际数据 tx 时间为 0。

1 / .005 = 200.  20000/200 = 100.

我们仍然让服务器处理大约 100 个在任何时间点同时传入的请求。

我不知道服务器的内部结构,但是如果你的服务器像这样达到上限,它可能会引入延迟,让 'data' 事件处理程序也处理将数据写入响应.

通过缓冲并立即返回,可以更快地释放处理程序,从而大大减少读取端的延迟。我脑海中悬而未决的问题是:处理程序真的需要将近 50 毫秒的开销来编写响应吗?我不这么认为,但如果 100 个请求正在争夺资源来写入他们的数据,那可能会开始加起来。再加上 res.end() 仍然需要调用(在不同的处理程序中)这一事实,您可能会发现延迟问题。

在'end'处理中,20K 10byte 的响应几乎不能称为数据负载,因此这是一个资源管理问题,即响应完成处理程序。如果 res.write() 和 res.end() 发生在同一个处理程序上,它可能比在一个处理程序上写入 10 个字节并在另一个处理程序上结束响应更有效。无论哪种方式,我都无法想象响应完成代码会引入任何滞后。更有可能是它急需工作(即使在缓冲方法中)。

编辑

您也可以在缓冲方法中尝试 res.end(data),而不是先调用 res.write(data) 再调用 res.end(),然后看看这是否会为您的分析添加任何澄清数据点。

编辑

我刚刚在我的系统上尝试了相同的测试。我在另一台物理机上使用了 Ubuntu Linux VM 作为客户端,wrk 作为测试台,像你一样的 lua 脚本和默认设置。我使用 Windows 8 桌面到 运行 nodejs,相同的脚本,除了使用端口 8080。我的 pipe() 和缓冲的性能都比你的低得多,但相互比较,缓冲区大约是 9 倍比 pipe() 快。所以,这只是一个独立的确认。

你的问题真有趣!

事实上,缓冲与管道不是这里的问题。你有一小块;它在一个事件中处理。要显示手头的问题,您可以这样编写您的处理程序:

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();
});

如果 writeend 在同一个处理程序上,延迟会减少 10 倍。

如果勾选write function code,这条线附近有

msg.connection.cork();
process.nextTick(connectionCorkNT, msg.connection);

corkuncork 连接下一个事件。这意味着您为数据使用缓存,然后在处理其他事件之前强制在下一个事件上发送数据。

总而言之,如果您在不同的处理程序上有 writeend,您将有:

  1. 软木塞连接(+ 创建一个勾号来打开软木塞)
  2. 用数据创建缓冲区
  3. 断开与另一个事件的连接(发送数据)
  4. 调用结束进程(发送另一个包含最终块的数据包并关闭)

如果它们在同一个处理程序上,end 函数在处理 uncork 事件之前被调用,因此最终块将在缓存中。

  1. 软木连接
  2. 用数据创建缓冲区
  3. 在缓冲区上添加“结束”块
  4. 打开连接发送所有内容

还有end函数runs cork / uncork synchronously,会快一点

为什么这很重要?因为在 TCP 端,如果您发送一个包含数据的数据包,并希望发送更多,进程将在发送更多之前等待来自客户端的确认:

write + end 在不同的处理程序上:

  • 0.044961s: POST / => 是请求
  • 0.045322s:HTTP/1.1 => 第一个块:header + "aaaaaaaaa"
  • 0.088522s:确认数据包
  • 0.088567s:继续 => 第二块(结束部分,0\r\n\r\n

在发送第一个缓冲区后 ack 前约 40 毫秒。

write + end 在同一处理程序中:

数据在一个数据包中完整,不需要ack

为什么 ACK 是 40 毫秒?这是 OS 中的一项 built-in 功能,可提高整体性能。 section 4.2.3.2 of IETF RFC 1122: When to Send an ACK Segment' 中对其进行了描述。 Red Hat (Fedora/CentOS/RHEL) 使用 40ms:它是一个参数并且 can be modified。在 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 会立即发出以供下一步处理。

所以事件处理是:

  1. readable事件:读取数据
  2. readable 检测到它已完成 => 创建 end 事件
  3. 您写入数据,以便它创建一个事件来开瓶
  4. end 事件已处理(开瓶完成)
  5. 开瓶已处理(但什么都不做,因为一切都已经完成)

如果减少缓冲区:

req.on('readable',()=> {
    let chunk2;
    while (null !== (chunk2 = req.read(5))) {
        res.write(chunk2);
    }
});

这会强制写入两次。该过程将是:

  1. readable事件:读取数据。你得到五个 as.
  2. 您写入了创建开瓶器事件的数据
  3. 您读取了数据。 readable 检测到它已完成 => 创建 end 事件
  4. 你写入数据,它被添加到缓冲数据中
  5. uncork 已处理(因为它是在 end 之前启动的);你发送数据
  6. end 事件已处理(解开完成)=> 等待 ACK 发送最终块
  7. 过程会很慢(确实如此;我检查过)