当缓冲使用streampipe道时,节点回应服务器的性能下降10倍

在节点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脚本(简写为简称)进行了基准testing,将发送一个小的身体作为有效载荷。

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

在每秒2k个请求和平均延迟为44ms的情况下,性能不是很好。

所以我写了另一个使用中间缓冲区的实现,直到请求完成,然后写出这些缓冲区。 我将这个称为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个请求,平均等待时间为4ms。

从视觉上看,下面的图表描述了5次运行的平均请求数和不同的延迟百分比(p50是中值)。

所以, 缓冲在所有类别中都要好一个数量级。 我的问题是为什么?

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

响应行为

这两个实现都是精心制作的,以便它们可以得到与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

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

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

事实certificate,这与pipe.js一样糟糕。 我觉得这很有趣,因为res.writeres.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 

我们看到在这两个实现中,C ++主宰了时间; 然而,支配的function是交换的。 Syscalls占了将近一半的时间,但只有1%的缓冲区 (原谅我的四舍五入)。 下一步,哪个系统调用是罪魁祸首?

我们来吧

调用strace像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 

对于pipe道epoll_wait )的最高系统调用(44%的时间)仅为缓冲区的 0.6%(增加了epoll_wait )。 虽然有很大的时间差异,但调用epoll_wait的次数更less, pipe道调用epoll_wait的次数会更less。 我们可以从这个语句中派生出几个有用的信息,比如pipe道调用epoll_wait和平均值,这些调用比缓冲区epoll_wait重。

对于缓冲区来说 ,最上面的系统调用是writev ,预计考虑大部分时间应该花在写入数据到套接字上。

在逻辑上,下一步是用常规strace来看看这些epoll_wait语句,它显示了缓冲区总是包含100个事件(代表使用epoll_wait的百个连接)的epoll_wait ,并且pipe道大部分时间都less于100个。 像这样:

pipe.js

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

buffer.js

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

graphics:

这就解释了为什么epoll_wait中有更多的epoll_wait ,因为epoll_wait并不服务于一个事件循环中的所有连接。 零事件的epoll_wait使它看起来像事件循环闲置! 所有这些都不能解释为什么epoll_wait需要更多的时间用于pipe道 ,从手册页来说, epoll_wait应该立即返回:

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

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

除了支持这个缓冲区的通话数量更less之外,我们还可以看到几乎所有的通话都不到100ns。 pipe道有一个更有趣的分布表明,虽然大多数呼吁采取100ns以下,一个不可忽视的数额需要更长的时间,并进入微秒的土地。

Strace确实find了另一个怪异的地方,那就是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道和128个缓冲区pipe道的五个字节差异在每个writev的后续write调用中被调和。

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

如果我没有弄错, write系统调用是阻塞的。

结论

如果我不得不做出有根据的猜测,那么当请求没有完成时,我会说pipe道会导致write调用。 这些阻塞调用通过更频繁的epoll_wait语句部分地降低吞吐量。 为什么write被调用,而不是在缓冲区中看到的单个writev超出了我的意思。 有人能解释为什么我看到的一切都在发生吗?

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

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


信息披露:从我的博客文章复制和稍加修改的问题,希望这是一个更好的途径得到这个问题的答案


2017年7月31日编辑

我最初的假设是,在请求stream完成之后编写回显的主体已经完成了性能的提高,已经被@robertklep用其可读的.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事件之前写入数据时, 可执行程序在与缓冲区相同的级别执行。 如果有的话,这使我更加困惑,因为可读性和我最初的穷人的pipe道实现之间的唯一区别是datareadable事件之间的差异,但却导致了10倍的性能提升。 但是我们知道data事件并不是天生的慢,因为我们在缓冲区代码中使用了它。

好奇的是, 可读报告的writev输出像缓冲区一样输出所有128个字节的输出

这是令人困惑的!

这是一个有趣的问题!

事实上,缓冲与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在同一个处理器上,则延迟是十倍以下。

如果您检查writefunction代码 ,则在此行附近

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

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

总而言之,如果你在不同的处理器上writeend ,你将会拥有:

  1. 软木塞连接(+创build一个打勾)
  2. 用数据创build缓冲区
  3. 取消另一个事件的连接(发送数据)
  4. 呼叫结束进程(用最后一个块发送另一个包并closures)

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

  1. 软木连接
  2. 用数据创build缓冲区
  3. 在缓冲区中添加“结束”块
  4. uncork连接发送一切

此外, endfunction同步运行cork / uncork ,这会快一点。

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

在不同的处理器上write + end

大约40ms的确认

  • 0.044961s: POST / =>这是请求
  • 0.045322s: HTTP/1.1 =>第一个块:头+“aaaaaaaaa”
  • 0.088522s:确认数据包
  • 0.088567s:Continuation => 2nd chunk(结束部分, 0\r\n\r\n

在发送第一个缓冲区之后,在ack之前ack ms。

在相同的处理程序中write + end

没有必要

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

为什么ACK上的40ms? 这是操作系统中的一项内置function,可全面提高性能。 它在IETF RFC 1122的4.2.3.2节:何时发送ACK段中有描述 。 红帽(Fedora / CentOS / RHEL)使用40ms:这是一个参数, 可以修改 。 在Debian(包括Ubuntu)上,它似乎被硬编码为40ms,所以它是不可修改的(除非你使用TCP_NO_DELAY选项创build一个连接)。

我希望这是足够的细节,以更多地了解这个过程。 这个答案已经很大了,所以我想在这里停下来。

可读

我查了一下关于readable 。 疯狂的猜测:如果readable检测到一个空的input,它会closures同一滴答的stream。

编辑:我读了可读的代码。 正如我怀疑的:

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检测到它已经完成=>创buildend事件
  3. 你写数据,以便创build一个事件来解锁
  4. end事件处理(完成)
  5. uncork处理(但没有做任何事情已经完成)

如果你减less缓冲区:

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

这迫使两个写道。 这个过程将是:

  1. readable事件:读取数据。 你得到五个。
  2. 你写数据,创build一个uncork事件
  3. 你读数据。 readable检测到它已经完成=>创buildend事件
  4. 你写数据,它被添加到缓冲的数据
  5. uncork处理(因为它是在end前发布); 你发送数据
  6. end事件处理(uncork done)=>等待ACK发送最终块
  7. 进程会很慢(这是我检查)

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

粗略地估计每秒2K的10字节请求和〜50ms的延迟,我认为实际传输“数据”花费的时间可以忽略不计。 这意味着服务器在任何给定时间处理约100个同时请求。

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

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

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

尽pipe如此,我们还是可以在任何时候同时处理大约100个请求。

我不知道服务器的内部,但是如果你的服务器正在触发和这样的上限,它可能会引入延迟让“数据”事件处理程序也处理数据写入响应。

通过缓冲和立即返回,处理程序可以更快地释放,从而大大减less读取端的延迟。 我脑海中的一个悬而未决的问题是:处理程序真的需要几乎50ms的开销来编写响应吗? 我想不会,但是如果有100个请求竞争资源来写入他们的数据,那么这个数据可能会开始累加起来。 再加上res.end()仍然需要调用(在不同的处理程序中),并且您可能已经发现了您的延迟猪。

在“结束”处理中,20K的10字节响应很难被称为数据负载,所以这是一个资源pipe理的问题,即响应完成处理程序。 如果res.write()和res.end()发生在同一个处理程序上,那么比在另一个上写入10个字节和结束响应更有效率。 无论哪种方式,我都无法想象,响应完成代码引入了任何滞后。 更有可能是工作要做(甚至在缓冲方法中)。

编辑

你也可以在你的缓冲方法中尝试res.end(data) ,而不是调用res.write(data)res.end() ,然后看看是否在分析中增加了明确的数据点。

编辑

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

Interesting Posts