Nic*_*ock 38 javascript http stream output-buffering node.js
在节点v8.1.4和v6.11.1上
我开始使用以下echo服务器实现,我将其称为pipe.js或pipe.
const http = require('http');
const handler = (req, res) => req.pipe(res);
http.createServer(handler).listen(3001);
Run Code Online (Sandbox Code Playgroud)
我用wrk和下面的lua脚本(为简洁起见缩短了)对它进行了基准测试,它将发送一个小体作为有效载荷.
wrk.method = "POST"
wrk.body = string.rep("a", 10)
Run Code Online (Sandbox Code Playgroud)
每秒2k请求和44ms的平均延迟,性能不是很好.
所以我编写了另一个使用中间缓冲区的实现,直到请求完成,然后将这些缓冲区写出来.我将其称为 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);
Run Code Online (Sandbox Code Playgroud)
性能发生了巨大变化,buffer.js每秒处理20k次请求,平均延迟为4ms.
在视觉上,下图描绘了超过5次运行和各种延迟百分位数的服务请求的平均数量(p50是中位数).
因此,缓冲区在所有类别中都要好一个数量级.我的问题是为什么?
接下来是我的调查笔记,希望它们至少具有教育意义.
这两个实现都是精心设计的,因此它们会给出与返回的完全相同的响应curl -D - --raw
.如果给出一个10 d's的主体,两者都将返回完全相同的响应(当然,修改时间):
HTTP/1.1 200 OK
Date: Thu, 20 Jul 2017 18:33:47 GMT
Connection: keep-alive
Transfer-Encoding: chunked
a
dddddddddd
0
Run Code Online (Sandbox Code Playgroud)
两者都输出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();
});
Run Code Online (Sandbox Code Playgroud)
经验:
因为只有一个块,如果我们删除缓冲并实现一个穷人的管道会发生什么:
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);
Run Code Online (Sandbox Code Playgroud)
事实证明,这与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
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)
我们看到,在这两种实现中,C++占主导地位; 然而,主导的功能是交换的.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
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)
管道(epoll_wait
)的顶级系统调用(44%的时间)仅为缓冲时间的0.6%(增加140 倍).虽然存在很大的时间差异,但是epoll_wait
调用的次数与管道调用的次数相比更少,
epoll_wait
大约是8倍.我们可以从该语句中获得一些有用的信息,例如管道调用epoll_wait
不断和平均值,这些调用比epoll_wait
for
缓冲区重.
对于缓冲区,顶级系统调用是writev
,考虑到大部分时间应该用于将数据写入套接字.
从逻辑上讲,下一步是epoll_wait
使用常规strace 查看这些语句,其中显示缓冲区始终包含epoll_wait
100个事件(表示使用的百个连接wrk
),并且管道
在大多数时间内少于100个.像这样:
pipe.js
epoll_wait(5, [.16 snip.], 1024, 0) = 16
Run Code Online (Sandbox Code Playgroud)
buffer.js
epoll_wait(5, [.100 snip.], 1024, 0) = 100
Run Code Online (Sandbox Code Playgroud)
图形:
这就解释了为什么有更多的是epoll_wait
在管,如epoll_wait
不服务于一体的事件循环的所有连接.该epoll_wait
零个事件,使它看起来像事件循环处于闲置状态!所有这些都无法解释为什么epoll_wait
占用更多的管道时间,因为它表示epoll_wait
应立即返回的手册页:
指定超时等于零会导致epoll_wait()立即返回,即使没有可用的事件.
手册页说明函数立即返回,我们可以确认一下吗?strace -T
救援:
除了支持缓冲区的呼叫次数减少外,我们还可以看到几乎所有呼叫都不到100ns.管道有一个更有趣的分布显示,虽然大多数呼叫需要不到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
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)
还记得当我说两个都输出128个字节?好吧,管道writev
返回123字节,缓冲区返回128 字节.管道的五个字节差异在随后的每个调用中进行协调.write
writev
write(44, "0\r\n\r\n", 5)
Run Code Online (Sandbox Code Playgroud)
如果我没弄错的话,write
系统调用就会阻塞.
如果我必须做出有根据的猜测,我会说在请求未完成时滚动导致write
调用.这些阻塞调用通过更频繁的epoll_wait
语句部分地显着降低了吞吐量.为什么
write
被调用而不是writev
在缓冲区中看到的单个超出我.有人可以解释为什么我看到的一切都在发生吗?
踢球者?在官方的Node.js指南中, 您可以看到指南如何从缓冲区实现开始,然后转移到管道!如果管道实施在官方指南中,那么应该没有这样的性能影响,对吗?
除此之外:这个问题的真实世界性能影响应该是最小的,因为问题是非常人为的,特别是在功能和身体方面,尽管这并不意味着这不是一个有用的问题.假设,答案可能看起来像"Node.js write
用于在x情况下获得更好的性能(其中x是更真实的世界用例)"
披露:从我的博客文章中复制并稍加修改的问题,希望这是一个更好的途径来回答这个问题
我最初的假设是,在请求流完成之后编写回显的主体增加了性能,@ robertklep用他的可读(或可读)实现反驳了:
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);
Run Code Online (Sandbox Code Playgroud)
可读在同一水平进行的缓冲,而之前写入数据end
的事件.如果有什么事情让我更加困惑,因为可读和我最初的穷人的管道实现之间的唯一区别是data
和readable
事件之间的差异,但这导致了10倍的性能提升.但我们知道data
事件本身并不慢,因为我们在缓冲区代码中使用它.
对于好奇的,可读的报告writev
输出,所有128字节输出像缓冲区
这很令人困惑!
war*_*gre 10
这是一个有趣的问题!
事实上,缓冲vs管道不是这里的问题.你有一小块; 它在一个事件中处理.要显示手头的问题,您可以像这样编写处理程序:
let chunk;
req.on('data', (dt) => {
chunk=dt
});
req.on('end', () => {
res.write(chunk);
res.end();
});
Run Code Online (Sandbox Code Playgroud)
要么
let chunk;
req.on('data', (dt) => {
chunk=dt;
res.write(chunk);
res.end();
});
req.on('end', () => {
});
Run Code Online (Sandbox Code Playgroud)
要么
let chunk;
req.on('data', (dt) => {
chunk=dt
res.write(chunk);
});
req.on('end', () => {
res.end();
});
Run Code Online (Sandbox Code Playgroud)
如果write
和end
是在同一个处理程序上,延迟会减少10倍.
如果检查write
功能代码,则该行周围有
msg.connection.cork();
process.nextTick(connectionCorkNT, msg.connection);
Run Code Online (Sandbox Code Playgroud)
cork
和uncork
下一个事件的连接.这意味着您对数据使用缓存,然后在处理其他事件之前强制在下一个事件上发送数据.
总而言之,如果您拥有write
和end
使用不同的处理程序,您将拥有:
如果它们位于同一个处理程序中,则在处理事件end
之前调用该函数uncork
,因此最终的块将位于缓存中.
此外,该end
功能运行cork
/ uncork
同步,这将更快一点.
现在为什么这很重要?因为在TCP端,如果您发送带有数据的数据包,并希望发送更多数据,则进程将在发送更多数据之前等待来自客户端的确认:
write
+ end
在不同的处理程序上:
POST
/ =>这是请求HTTP/1.1
=>第一块:标题+"aaaaaaaaa"0\r\n\r\n
)ack
发送第一个缓冲区之前约有40毫秒.
write
+ end
在同一个处理程序中:
数据在单个数据包中完成,ack
不需要.
为什么40ms开ACK
?这是操作系统中的内置功能,可提高整体性能.它在IETF RFC 1122的第4.2.3.2节中描述:何时发送ACK段'.Red Hat(Fedora/CentOS/RHEL)使用40ms:它是一个参数,可以修改.在Debian(包含Ubuntu)上,它似乎被硬编码为40ms,因此它不可修改(除非您使用该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);
}
});
Run Code Online (Sandbox Code Playgroud)
这迫使两次写入.这个过程将是:
readable
事件:读取数据.你得到五秒a
.readable
检测到它已完成=>创建end
事件end
); 你发送数据end
事件处理(uncork done)=>等待ACK
发送最终块 归档时间: |
|
查看次数: |
782 次 |
最近记录: |