对于网络操作,node.js 实际上是单线程的。然而,人们一直存在一个误解,即处理 I/O 需要持续的 CPU 资源。你的问题的核心归结为:
回调的实际处理是否取决于节点的单个线程是否空闲,或者回调是否与主线程并行处理?
答案是肯定和否定。是的,回调仅在主线程空闲时执行。不,线程空闲时不会完成“处理”。具体来说:没有“处理”——如果您所说的“进程”正在等待,那么节点“处理”数千个回调所需的 CPU 时间为零。
异步 I/O 的工作原理(在任何编程语言中)
Hardware
如果我们真的需要了解节点(或浏览器)内部如何工作,不幸的是我们必须首先了解计算机如何工作 - 从硬件到操作系统。是的,这将是一个深入的研究,所以请耐心等待。
这一切都始于中断的发明。
这是一项伟大的发明,也是潘多拉魔盒——埃兹格·迪杰斯特拉 (Edsger Dijkstra)
是的,上面的引文出自同一个“Goto 被认为有害”Dijkstra。从一开始,将异步操作引入计算机硬件就被认为是一个非常困难的话题,即使对于业内的一些传奇人物来说也是如此。
引入中断是为了加速 I/O 操作。硬件不需要用软件轮询某些输入(占用 CPU 时间进行有用的工作),而是向 CPU 发送信号,告诉它发生了事件。然后CPU将挂起当前正在运行的程序并执行另一个程序来处理中断——因此我们将这些函数称为中断处理程序。 “处理程序”这个词一直停留在 GUI 库的堆栈中,这些库将回调函数称为“事件处理程序”。
如果您一直注意的话,您会注意到中断处理程序的概念实际上是callback。您可以将 CPU 配置为在事件发生时稍后调用某个函数。因此,即使是回调也不是一个新概念——它比 C 语言还要古老。
OS
中断使现代操作系统成为可能。如果没有中断,CPU 就无法暂时停止程序运行操作系统(当然,存在协作式多任务处理,但我们暂时忽略它)。操作系统的工作原理是,它在CPU中设置一个硬件定时器来触发中断,然后告诉CPU执行您的程序。正是这个周期性的定时器中断运行着你的操作系统。除了定时器之外,操作系统(或者更确切地说是设备驱动程序)还为 I/O 设置中断。当 I/O 事件发生时,操作系统将接管您的 CPU(或多核系统中的一个 CPU)并检查其数据结构,接下来需要执行哪个进程来处理 I/O(这称为抢占式多任务处理)。
因此,处理网络连接甚至不是操作系统的工作 - 操作系统只是跟踪其数据结构(或更确切地说,网络堆栈)中的连接。真正处理网络 I/O 的是您的网卡、路由器、调制解调器、ISP 等。因此,等待 I/O 占用的 CPU 资源为零。它只是占用一些 RAM 来记住哪个程序拥有哪个套接字。
流程
现在我们已经清楚地了解了这一点,我们可以理解该节点的作用。各种操作系统有各种不同的 API 来提供异步 I/O - 从 Windows 上的重叠 I/O 到 Linux 上的 poll/epoll 到 BSD 上的 kqueue 到跨平台select()
。 Node 在内部使用 libuv 作为这些 API 的高级抽象。
这些 API 的工作原理相似,但细节有所不同。本质上,它们提供了一个函数,当调用该函数时,该函数将阻塞您的线程,直到操作系统向其发送事件。所以,是的,即使是非阻塞 I/O 也会阻塞你的线程。这里的关键是阻塞 I/O 会在多个地方阻塞你的线程,但非阻塞 I/O 只会在一处阻塞你的线程 - 等待事件的地方。
这允许您以面向事件的方式设计您的程序。这类似于中断让操作系统设计者实现多任务处理的方式。实际上,异步 I/O 之于框架就像中断之于操作系统。它允许节点花费恰好 0% 的 CPU 时间来处理(等待)I/O。这就是节点快速的原因——它并不是真正更快,但不会浪费时间等待。
回调处理
通过了解节点如何处理网络 I/O,我们可以了解回调如何影响性能。
-
有数千个回调等待,CPU 损失为零
当然,节点仍然需要在 RAM 中维护数据结构来跟踪所有回调,因此回调确实会带来内存损失。
-
处理回调的返回值是在单个线程中完成的
这有一些优点和一些缺点。这意味着节点不必担心竞争条件,因此节点内部不会使用任何信号量或互斥体来保护数据访问。缺点是任何 CPU 密集型 JavaScript 都会阻塞所有其他操作。
你提到:
我发现 20 个回调的性能从第一个回调到最后一个回调都在恶化
回调全部在主线程中顺序同步执行(只有等待实际上是并行完成的)。因此,您的回调可能正在执行一些 CPU 密集型计算,并且所有回调的总执行时间实际上是 4 秒。
但是,对于如此数量的回调,我很少看到此类问题。这仍然是可能的,我仍然不知道你在回调中做了什么。我只是觉得不太可能。
您还提到:
直到函数的回调返回值或错误
一种可能的解释是您的网络资源无法处理那么多同时连接。您可能认为这不算什么,因为只有 20 个连接,但我见过很多服务在每秒 10 个请求时会崩溃。问题是所有 20 个请求都是同时发生的。
您可以通过将节点排除在外并使用命令行工具同时发送 20 个请求来测试这一点。就像是curl
or wget
:
# assuming you're running bash:
for x in `seq 1 20`;do curl -o /dev/null -w "Connect: %{time_connect} Start: %{time_starttransfer} Total: %{time_total} \n" http://example.com & done
减轻
如果问题是同时执行 20 个请求给其他服务带来了压力,您可以做的就是限制同时请求的数量。
您可以通过批量请求来做到这一点:
async function () {
let input = [/* some values we need to process */];
let result = [];
while (input.length) {
let batch = input.splice(0,3); // make 3 requests in parallel
let batchResult = await Promise.all(batch.map(x => {
return fetchNetworkResource(x);
}));
result = result.concat(batchResult);
}
return result;
}