当流管道用于缓冲时,节点回显服务器降级 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.js 或 pipe。
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.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 每处理 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.write
和res.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
事件之前写入数据时,可读 执行与缓冲区 相同的级别。如果有什么让我更加困惑的话,因为 可读 和我最初的穷人管道实现之间的唯一区别是 data
和 readable
事件之间的区别,但这导致了 10 倍的性能提升。但是我们知道data
事件本身并不慢,因为我们在 buffer 代码中使用了它。
为了好奇,可读上的 strace 报告了 writev
输出所有 128 字节输出,如 buffer
这令人困惑!
【问题讨论】:
你挖得比我想得更远,谢谢你。但是您是否考虑过这样一个事实,即如果您回显 MB 的数据,管道将大大减少响应时间?此外,在回显之前必须缓冲所有内容会使 RAM 饱和并比高并发的管道慢。 这也是我的想法,但我坚持的是,当我执行穷人的管道实现时,我看到相同的性能下降,即使write
和 end
被称为相同的号码具有相同参数的次数。我意识到这个例子是人为的,但是为什么节点在管道时会选择write
而不是writev
?
我希望我能投票两次。不幸的是,在这个网站上很少有这样的格式良好、经过充分研究的问题。
如果你喜欢,试试这个:gist.github.com/robertklep/1682ae054ebac9e822a9f0c30ac2ab8f 这和你的穷人的管道很相似,但在我的 MBP 上是三个中最快的(pipe.js: 13.8K/s, buffer.js: 19K/s, readable.js: 21K/s)
@robertklep 感谢您提供更多信息!我测试并发现 buffer 和 readable 在统计上没有太大差异,所以我最初关于阅读时写作速度变慢的假设现在被推翻了!
【参考方案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();
);
如果write
和end
在同一个处理程序上,延迟会减少10 倍。
如果你检查write
function code,这条线周围有
msg.connection.cork();
process.nextTick(connectionCorkNT, msg.connection);
cork
和 uncork
连接下一个事件。这意味着您对数据使用缓存,然后在处理其他事件之前强制在下一个事件上发送数据。
总而言之,如果您在不同的处理程序上有 write
和 end
,您将拥有:
-
软木连接(+ 创建一个刻度以打开软木塞)
用数据创建缓冲区
从另一个事件断开连接(发送数据)
调用结束进程(发送另一个带有最终块的数据包并关闭)
如果它们在同一个处理程序上,则在处理uncork
事件之前调用end
函数,因此最终块将在缓存中。
-
软木连接
用数据创建缓冲区
在缓冲区中添加“结束”块
断开连接以发送所有内容
还有end
函数runs cork
/ uncork
synchronously,会快一点。
现在为什么这很重要?因为在 TCP 端,如果您发送一个带有数据的数据包,并希望发送更多数据,进程将在发送更多数据之前等待客户端的确认:
write
+ end
在不同的处理程序上:
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
事件:读取数据。你会得到五个a
s。
您编写的数据会创建一个 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 倍的主要内容,如果未能解决你的问题,请参考以下文章