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

Posted

技术标签:

【中文标题】当流管道用于缓冲时,节点回显服务器降级 10 倍【英文标题】:Node echo server degrades 10x when stream pipes are used over buffering 【发布时间】:2017-12-31 12:17:21 【问题描述】:

在节点 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 脚本(为简洁起见而缩写),它将发送一个小主体作为有效负载。

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

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

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

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 在所有类别中都要好一个数量级。我的问题是为什么?

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

响应行为

这两种实现都经过精心设计,因此它们将提供相同的确切信息 curl -D - --raw 返回的响应。如果给定一个 10 d 的主体,两者都会 返回完全相同的响应(当然是修改时间):

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

分析

我使用simple profiling guide (--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

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

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 一起使用的一百个连接)和 管道 大部分时间少于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 应该立即返回:

指定一个等于零的超时时间会导致 epoll_wait() 立即返回, 即使没有可用的事件。

虽然手册页说函数立即返回,但我们可以确认一下吗? strace -T救援:

除了支持 buffer 有更少的调用,我们还可以看到几乎 所有通话时间都不到 100ns。 Pipe 有一个更有趣的分布 表明虽然大多数通话时间都在 100ns 以下,但也有不可忽略的时间 更长,降落到微秒级。

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 pipe 为字节,buffer 为 128 个字节。 pipe 的五个字节差异是 在随后的 write 调用中对每个 writev 进行协调。

write(44, "0\r\n\r\n", 5)

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

结论

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

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

除此之外:这个问题对现实世界的性能影响应该是最小的,因为这个问题非常人为,特别是在功能和身体方面,尽管这并不意味着这不是一个有用的问题。假设,答案可能看起来像“Node.js 使用write 以在 x 情况下(其中 x 是更真实的用例)提供更好的性能”


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


2017 年 7 月 31 日编辑

我最初的假设是,在请求流完成后编写回显正文会提高性能,但 @robertklep 用他的 readable.js(或 readable)实现证明了这一点:

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 事件之前写入数据时,

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

为了好奇,可读上的 strace 报告了 writev 输出所有 128 字节输出,如 buffer

这令人困惑!

【问题讨论】:

你挖得比我想得更远,谢谢你。但是您是否考虑过这样一个事实,即如果您回显 MB 的数据,管道将大大减少响应时间?此外,在回显之前必须缓冲所有内容会使 RAM 饱和并比高并发的管道慢。 这也是我的想法,但我坚持的是,当我执行穷人的管道实现时,我看到相同的性能下降,即使 writeend 被称为相同的号码具有相同参数的次数。我意识到这个例子是人为的,但是为什么节点在管道时会选择write 而不是writev 我希望我能投票两次。不幸的是,在这个网站上很少有这样的格式良好、经过充分研究的问题。 如果你喜欢,试试这个:gist.github.com/robertklep/1682ae054ebac9e822a9f0c30ac2ab8f 这和你的穷人的管道很相似,但在我的 MBP 上是三个中最快的(pipe.js: 13.8K/s, buffer.js: 19K/s, readable.js: 21K/s) @robertklep 感谢您提供更多信息!我测试并发现 bufferreadable 在统计上没有太大差异,所以我最初关于阅读时写作速度变慢的假设现在被推翻了! 【参考方案1】:

这是一个有趣的问题!

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

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,您将拥有:

    软木连接(+ 创建一个刻度以打开软木塞) 用数据创建缓冲区 从另一个事件断开连接(发送数据) 调用结束进程(发送另一个带有最终块的数据包并关闭)

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

    软木连接 用数据创建缓冲区 在缓冲区中添加“结束”块 断开连接以发送所有内容

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

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

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

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

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

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

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

为什么ACK 上的 40 毫秒?这是操作系统中的一项内置功能,可提高整体性能。它在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 会立即发出以进行下一步处理。

所以事件处理是:

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

如果减少缓冲区:

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

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

    readable 事件:读取数据。你会得到五个as。 您编写的数据会创建一个 uncork 事件 您读取数据。 readable 检测到它已经完成 => 创建 end 事件 您写入数据并将其添加到缓冲数据中 uncork 已处理(因为它是在end 之前启动的);你发送数据 end 事件已处理(解锁完成)=> 等待 ACK 发送最终块 进程会很慢(确实如此;我检查过)

【讨论】:

哇,感谢破案!我会接受这个答案并给你赏金。在你的带领下,我将继续调查,特别是考虑到节点 v8.2.0 出现了与软木相关的变化【参考方案2】:

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

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

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

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

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

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

我不知道服务器内部情况,但如果您的服务器达到这样的上限,它可能会引入延迟,让“数据”事件处理程序同时处理将数据写入响应。

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

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

编辑

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

编辑

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

【讨论】:

Node.js 不使用事件循环中的线程进行套接字连接。 @MatteoCollina - 是的,选词不当,我在概念上使用了线程这个词,但是在重新阅读之后,尽管我将整个过程总结为无线程,但我看到了具体的阅读方式。我已经更新了我的答案,并且已经运行了与 OP 相同的测试。

以上是关于当流管道用于缓冲时,节点回显服务器降级 10 倍的主要内容,如果未能解决你的问题,请参考以下文章

10.3 进程间通信--管道

C中的管道,用于读取标准输入的缓冲区

OTL翻译(10) -- OTL的流缓冲池

四十进程间通信——管道的分类与读写

10张图带你彻底搞懂限流熔断服务降级

10张图带你彻底搞懂限流熔断服务降级