当 Node.js 在内部仍然依赖线程时,它如何本质上更快?

IT技术 javascript architecture concurrency node.js
2021-03-16 15:34:01

我刚刚看了下面的视频:Node.js 简介,但仍然不明白你是如何获得速度优势的。

主要是,Ryan Dahl(Node.js 的创建者)曾说 Node.js 是基于事件循环而不是基于线程的。线程很昂贵,只能留给并发编程专家使用。

随后,他展示了 Node.js 的架构栈,它有一个底层的 C 实现,内部有自己的线程池。所以很明显,Node.js 开发人员永远不会启动他们自己的线程或直接使用线程池……他们使用异步回调。我明白的就这么多。

我不明白的是,Node.js 仍然在使用线程......它只是隐藏了实现,所以如果 50 人请求 50 个文件(当前不在内存中)那么这会更快,然后不需要 50 个线程?

唯一的区别在于,由于它是在内部管理的,Node.js 开发人员不必编写线程细节代码,但在其底层仍然使用线程来处理 IO(阻塞)文件请求。

因此,您真的不只是解决一个问题(线程)并在该问题仍然存在时隐藏它:主要是多线程、上下文切换、死锁……等等?

这里一定有一些细节我仍然不明白。

6个回答

实际上有一些不同的东西在这里被混为一谈。但它始于线程真的很难的模因。因此,如果它们很难,您更有可能在使用线程时 1) 由于错误而中断和 2) 没有尽可能有效地使用它们。(2) 是你要问的那个。

想想他给出的一个例子,一个请求进来,你运行一些查询,然后对结果做一些事情。如果您以标准程序方式编写它,代码可能如下所示:

result = query( "select smurfs from some_mushroom" );
// twiddle fingers
go_do_something_with_result( result );

如果传入的请求导致您创建一个运行上述代码的新线程,那么您将有一个线程坐在那里,在query()运行时什么也不做(根据 Ryan 的说法,Apache 使用单个线程来满足原始请求,而 nginx 在他所谈论的情况下表现优于它,因为事实并非如此。)

现在,如果你真的很聪明,你会用一种方式来表达上面的代码,当你运行查询时,环境可以关闭并做其他事情:

query( statement: "select smurfs from some_mushroom", callback: go_do_something_with_result() );

这基本上就是 node.js 正在做的事情。你基本上是在装饰——以一种方便的方式,因为语言和环境,因此是关于闭包的要点——你的代码使环境可以聪明地知道什么运行,什么时候运行。这样一来,node.js 就其发明了异步 I/O 的意义而言并不新鲜(并不是任何人都声称有类似的东西),但它的新意在于它的表达方式略有不同。

注意:当我说环境可以很聪明地决定运行什么以及何时运行时,具体来说我的意思是它用于启动某些 I/O 的线程现在可以用于处理某些其他请求,或某些可以完成的计算并行,或启动其他一些并行 I/O。(我不确定 node 是否足够复杂,可以为相同的请求开始更多的工作,但你明白了。)

是的,我要说的一件事是他并没有找到一种方法来缩小差距:这不是一种新模式。不同的是,他使用 Javascript 来让程序员以一种更方便的方式来表达他们的程序,以实现这种异步。可能是一个挑剔的细节,但仍然......
2021-04-21 15:34:01
还值得指出的是,对于许多 I/O 任务,Node 使用任何可用的内核级异步 I/O api(epoll、kqueue、/dev/poll 等)
2021-05-11 15:34:01
我仍然不确定我是否完全理解它。如果我们认为在 Web 请求中 IO 操作是处理请求所需的大部分时间的操作,并且如果为每个 IO 操作创建一个新线程,那么对于 50 个非常快的连续请求,我们将可能有 50 个线程并行运行并执行它们的 IO 部分。与标准 Web 服务器的不同之处在于,在那里整个请求都在线程上执行,而在 node.js 中只是它的 IO 部分,但这是占用大部分时间并使线程等待的部分。
2021-05-12 15:34:01
好的,我绝对可以看到这如何提高性能,因为在我看来,您可以最大限度地利用 CPU,因为没有任何线程或执行堆栈只是在等待 IO 返回,因此可以有效地找到 Ryan 所做的事情一种弥合所有差距的方法。
2021-05-15 15:34:01
@SystemParadox 感谢您指出这一点。我最近实际上对这个主题进行了一些研究,确实发现异步 I/O 在内核级别正确实现时,在执行异步 I/O 操作时不使用线程。相反,一旦 I/O 操作开始,调用线程就会被释放,并在 I/O 操作完成并且有线程可用时执行回调。因此,如果正确实现了对 I/O 操作的异步支持,node.js 可以使用一个线程(几乎)并行运行 50 个并发请求和 50 个 I/O 操作。
2021-05-17 15:34:01

笔记!这是一个旧的答案。虽然在粗略的轮廓上仍然如此,但由于 Node 在过去几年的快速发展,一些细节可能已经发生了变化。

它使用线程是因为:

  1. open()O_NONBLOCK 选项对 files 不起作用
  2. 有些第三方库不提供非阻塞 IO。

要伪造非阻塞 IO,线程是必要的:在单独的线程中进行阻塞 IO。这是一个丑陋的解决方案,会导致很多开销。

在硬件层面上更糟:

  • 使用DMA,CPU 异步卸载 IO。
  • 数据直接在 IO 设备和内存之间传输。
  • 内核将其封装在一个同步的、阻塞的系统调用中。
  • Node.js 将阻塞系统调用包装在一个线程中。

这简直是​​愚蠢且低效的。但它至少有效!我们可以享受 Node.js,因为它在事件驱动的异步架构背后隐藏了丑陋和繁琐的细节。

也许将来有人会为文件实现 O_NONBLOCK ?...

编辑:我和一个朋友讨论过这个问题,他告诉我线程的替代方法是使用select轮询:指定超时为 0 并对返回的文件描述符执行 IO(现在保证它们不会阻塞)。

对不起,不知道。我只知道 libuv 是做异步工作的平台中立层。在 Node 的早期没有 libuv。然后决定拆分 libuv,这使得特定于平台的代码更容易。换句话说,Windows 有自己的异步故事,这可能与 Linux 完全不同,但对我们来说无关紧要,因为 libuv 为我们做了艰苦的工作。
2021-05-01 15:34:01
窗户呢?
2021-05-20 15:34:01

我担心我在这里“做错了事”,如果是这样,请删除我并道歉。特别是,我看不到我是如何创建一些人创建的整洁的小注释的。但是,我对这个线程有很多担忧/观察。

1) 流行答案之一的伪代码中的注释元素

result = query( "select smurfs from some_mushroom" );
// twiddle fingers
go_do_something_with_result( result );

本质上是假的。如果线程正在计算,那么它不是在摆弄拇指,而是在做必要的工作。另一方面,如果它只是在等待 IO 的完成,那么它并没有使用 CPU 时间,内核中线程控制基础结构的全部意义在于 CPU 会找到一些有用的事情去做。正如这里所建议的那样,“摆弄你的拇指”的唯一方法是创建一个轮询循环,并且没有人编写过真正的网络服务器,没有人能够做到这一点。

2)“线程很难”,只有在数据共享的上下文中才有意义。如果您有本质上独立的线程,例如处理独立的 Web 请求时的情况,那么线程化非常简单,您只需编写如何处理一项工作的线性流程,然后坐下来就知道它将处理多个请求,并且每个将有效地独立。就我个人而言,我敢说,对于大多数程序员来说,学习闭包/回调机制比简单地编写自上而下的线程版本要复杂得多。(但是,是的,如果你必须在线程之间进行通信,生活会变得非常艰难,但我不相信闭包/回调机制真的改变了这一点,它只是限制了你的选择,因为这种方法仍然可以通过线程实现. 无论如何,那个'

3) 到目前为止,没有人提出任何真实的证据来说明为什么一种特定类型的上下文切换会比任何其他类型更耗时或更短。我在创建多任务内核方面的经验(嵌入式控制器的小规模,没有什么比“真正的”操作系统更花哨)表明情况并非如此。

4) 迄今为止,我所看到的所有插图都旨在表明 Node 比其他网络服务器快多少,但它们存在严重缺陷,但它们确实间接地说明了我肯定会接受 Node 的一个优势(和这绝不是微不足道的)。Node 看起来不需要(实际上甚至不允许)调整。如果您有线程模型,则需要创建足够的线程来处理预期的负载。做得不好,最终会导致性能不佳。如果线程太少,那么CPU是空闲的,但是无法接受更多的请求,创建的线程太多,会浪费内核内存,而在Java环境下,也会浪费主堆内存. 现在,对于 Java,浪费堆是破坏系统性能的第一个也是最好的方法,因为高效的垃圾收集(目前,这可能会随着 G1 的变化而改变,但至少在 2013 年初,陪审团似乎仍然在这一点上)取决于有大量的备用堆。所以,有一个问题,用太少的线程来调整它,你有空闲的 CPU 和低吞吐量,用太多来调整它,它会以其他方式陷入困境。

5) 还有另一种方式,我接受 Node 的方法“设计速度更快”这一说法的逻辑,就是这样。大多数线程模型使用时间切片上下文切换模型,分层在更合适(value判断警报:)和更高效(不是value判断)的抢占模型之上。发生这种情况有两个原因,第一,大多数程序员似乎不理解优先级抢占,第二,如果你在 windows 环境中学习线程,无论你喜欢与否,时间片都在那里(当然,这加强了第一点; 值得注意的是,Java 的第一个版本在 Solaris 实现中使用了优先级抢占,在 Windows 中使用了时间片。因为大多数程序员不理解并抱怨“线程在 Solaris 中不起作用” 他们到处都将模型更改为时间片)。无论如何,底线是时间切片会创建额外的(并且可能是不必要的)上下文切换。每次上下文切换都会占用 CPU 时间,而这些时间会从手头实际工作中可以完成的工作中有效地消除。但是,由于时间切片而在上下文切换上所花费的时间量不应超过总时间的很小一部分,除非发生了非常奇怪的事情,而且我没有理由认为情况会如此简单的网络服务器)。所以,是的,时间片中涉及的过多上下文切换是低效的(这些不会发生在 并且从手头的实际工作中可以完成的工作有效地消除了时间。但是,由于时间切片而在上下文切换上所花费的时间量不应超过总时间的很小一部分,除非发生了非常奇怪的事情,而且我没有理由认为情况会如此简单的网络服务器)。所以,是的,时间片中涉及的过多上下文切换是低效的(这些不会发生在 并且从手头的实际工作中可以完成的工作有效地消除了时间。但是,由于时间切片而在上下文切换上所花费的时间量不应超过总时间的很小一部分,除非发生了非常奇怪的事情,而且我没有理由认为情况会如此简单的网络服务器)。所以,是的,时间片中涉及的过多上下文切换是低效的(这些不会发生在内核线程作为一项规则,顺便说一句),但差异将是吞吐量的百分之几,而不是通常隐含在 Node.js 中的性能声明中隐含的那种整数因素。

无论如何,为所有冗长而漫不经心的事情道歉,但我真的觉得到目前为止,讨论还没有证明任何事情,我很高兴听到有人在这两种情况下的意见:

a) 对为什么 Node 应该更好的真正解释(除了我上面概述的两个场景,我认为第一个(调整不佳)是迄今为止我看到的所有测试的真实解释。([编辑] ], 实际上, 我越想, 我就越想知道这里大量堆栈使用的内存是否很重要. 现代线程的默认堆栈大小往往非常大, 但由 a 分配的内存基于闭包的事件系统只是需要的)

b) 一个真正的基准测试,它实际上为选择的线程服务器提供了公平的机会。至少那样,我不得不停止相信这些声明本质上是错误的;>([编辑]这可能比我预期的要强得多,但我确实觉得对性能优势的解释充其量是不完整的,并且显示的基准是不合理的)。

干杯,托比

@levi 但是 nodejs 不使用“每个请求一个线程”之类的东西。它使用 IO 线程池,可能是为了避免使用异步 IO API 的复杂性(也许 POSIXopen()不能成为非阻塞的?)。通过这种方式,它可以分摊传统fork()/ pthread_create()-on-request 模型必须创建和销毁线程的任何性能损失。而且,如后记 a) 中所述,这也摊销了堆栈空间问题。您可能可以使用 16 个 IO 线程来处理数千个请求,就好了。
2021-04-24 15:34:01
线程的问题:它们需要 RAM。一个非常繁忙的服务器可以运行多达几千个线程。Node.js 避免了线程,因此效率更高。效率不是通过更快地运行代码。代码是在线程中还是在事件循环中运行都没有关系。对于 CPU,它是相同的。但是通过取消线程我们节省了 RAM:只有一个堆栈而不是几千个堆栈。我们还保存上下文切换。
2021-04-28 15:34:01
节点也将回调的闭包存储在 RAM 中,所以我看不到它在哪里获胜。
2021-05-01 15:34:01
但是节点并没有取消线程。它仍然在内部将它们用于 IO 任务,这是大多数 Web 请求所需要的。
2021-05-06 15:34:01
“现代线程的默认堆栈大小往往非常大,但基于闭包的事件系统分配的内存只是需要的内存”我的印象是这些应该是相同的顺序。闭包并不便宜,运行时必须将单线程应用程序的整个调用树保存在内存中(可以说是“模拟堆栈”),并且能够在树的叶子作为关联的闭包被释放时进行清理得到“解决”。这将包括对无法被垃圾收集的堆上内容的大量引用,并且会在清理时影响性能。
2021-05-14 15:34:01

我不明白的是 Node.js 仍然在使用线程。

Ryan 使用线程来处理阻塞的部分(大多数 node.js 使用非阻塞 IO),因为有些部分非常难以编写非阻塞。但我相信 Ryan 的愿望是让一切都无阻塞。幻灯片 63(内部设计)上,您会看到 Ryan 使用libev(抽象异步事件通知的库)作为非阻塞eventloop由于事件循环 node.js 需要较少的线程,从而减少上下文切换、内存消耗等。

线程仅用于处理没有异步功能的函数,例如stat().

stat()函数始终处于阻塞状态,因此node.js 需要使用线程来执行实际调用,而不阻塞主线程(事件循环)。如果您不需要调用这些类型的函数,则可能永远不会使用线程池中的任何线程。