一般来说,Node.js 如何处理 10,000 个并发请求?

701

我了解Node.js使用单线程和事件循环来处理请求,一次只处理一个请求(这是非阻塞的)。但是,比如说有10,000个并发请求,那么事件循环会如何处理所有这些请求呢?难道这不需要太长时间吗?

我还不能理解它如何比多线程的Web服务器更快。我知道多线程的Web服务器在资源(内存、CPU)方面会更昂贵,但它是否仍然会更快呢?我可能是错的,请解释一下为什么这个单线程在处理大量请求时更快,以及通常在服务很多请求(比如10,000个请求)时都要做些什么(高层次上)。

此外,这个单线程能否很好地扩展到如此大的数量?请记住,我刚开始学习Node.js。


15
由于大部分工作(数据传输)不涉及CPU。 - OrangeDog
16
请注意,仅因为只有一个线程执行JavaScript,并不意味着没有许多其他线程在执行工作。 - OrangeDog
此问题可能过于广泛,或者是其他多个问题的重复。 - OrangeDog
https://dev59.com/UXA65IYBdhLWcg3wyh57 - Shanoor
https://dev59.com/6VYM5IYBdhLWcg3w3jI4#70161215 - mercury
显示剩余3条评论
9个回答

1260
如果你需要问这个问题,那么你可能不熟悉大多数Web应用/服务所做的事情。你可能认为所有软件都是这样做的:
user do an action
       │
       v
 application start processing action
   └──> loop ...
          └──> busy processing
 end loop
   └──> send result to user

然而,这并不是 Web 应用程序,或者任何以数据库作为后端的应用程序的工作方式。Web 应用程序会这样做:
user do an action
       │
       v
 application start processing action
   └──> make database request
          └──> do nothing until request completes
 request complete
   └──> send result to user

在这种情况下,软件大部分运行时间都在使用0%的CPU时间等待数据库返回。
多线程网络应用程序会像这样处理上述工作负载:
request ──> spawn thread
              └──> wait for database request
                     └──> answer request
request ──> spawn thread
              └──> wait for database request
                     └──> answer request
request ──> spawn thread
              └──> wait for database request
                     └──> answer request

因此,线程大部分时间都在使用0%的CPU等待数据库返回数据。在这样做的同时,它们必须为每个线程分配所需的内存,包括每个线程的完全独立的程序堆栈等。此外,他们将不得不启动一个线程,虽然不像启动完整进程那么昂贵,但仍然不是很便宜。

单线程事件循环

既然我们大部分时间都在使用0%的CPU,为什么不在我们不使用CPU时运行一些代码呢?这样,每个请求仍然会获得与多线程应用程序相同数量的CPU时间,但我们不需要启动线程。所以我们这样做:

request ──> make database request
request ──> make database request
request ──> make database request
database request complete ──> send response
database request complete ──> send response
database request complete ──> send response

实际上,这两种方法返回的数据延迟差不多,因为数据库响应时间占主导地位。
这里的主要优点是我们不需要启动一个新线程,因此我们不需要做很多malloc操作,这会减慢速度。
神奇的、看不见的线程
以上两种方法似乎神秘的地方在于如何同时运行工作负载?答案是数据库是多线程的。因此,我们的单线程应用程序实际上利用了另一个进程的多线程行为:数据库。
单线程方法失败的地方
如果需要在返回数据之前进行大量的CPU计算,则单线程应用程序将失败。这里我指的不是处理数据库结果的for循环,那仍然基本上是O(n)。我的意思是像进行傅里叶变换(例如mp3编码)、光线追踪(3D渲染)等操作。
单线程应用程序的另一个缺点是它只能利用单个CPU核心。因此,如果您有一个四核服务器(现在很常见),您没有使用其他3个核心。
多线程方法失败的地方
一个多线程应用如果需要为每个线程分配大量的RAM,那么它很容易失败。首先,内存使用本身意味着您无法处理与单线程应用程序相同数量的请求。更糟糕的是,malloc很慢。分配大量对象(这在现代Web框架中很常见)意味着我们可能最终比单线程应用程序更慢。这就是node.js通常获胜的地方。
其中一种使多线程变得更糟的用例是当您需要在线程中运行另一种脚本语言时。首先,您通常需要为该语言分配整个运行时,然后需要为脚本使用的变量分配内存。
因此,如果您正在使用C、Go或Java编写网络应用程序,则线程的开销通常不会太大。如果您正在编写一个C Web服务器以提供PHP或Ruby,则很容易使用JavaScript、Ruby或Python编写更快的服务器。
混合方法
一些Web服务器使用混合方法。例如,Nginx和Apache2将其网络处理代码实现为事件循环的线程池。每个线程同时运行事件循环,以单线程方式处理请求,但请求在多个线程之间进行负载平衡。
一些单线程架构也采用混合方法。而不是从单个进程启动多个线程,您可以启动多个应用程序 - 例如,在四核机器上启动4个node.js服务器。然后,您可以使用负载均衡器将工作量分配到这些进程中。node.js中的cluster模块就是这样做的。
实际上,这两种方法在技术上是完全相同的镜像。

195
到目前为止我读过的最好的有关Node的解释。"单线程的应用实际上正在利用另一个进程的多线程行为:数据库",这才是关键。 - voucher_wolves
6
这句话的意思是:“@CaspainCaldion,这取决于你对‘非常快’和‘很多客户端’的理解。就目前而言,Node.js可以处理每秒1000个请求,速度仅受限于您的网络卡速度。请注意,它每秒处理的是1000个请求而不是同时连接的客户端数量。它可以轻松处理10,000个同时连接的客户端。真正的瓶颈在于网络卡。” - slebetman
3
@slebetman ,最好的解释了。不过,如果我有一个机器学习算法,它处理一些信息并相应地提供结果,我应该使用多线程方法还是单线程方法? - Ganesh Karewad
10
算法使用CPU,服务(数据库、REST API等)使用I/O。如果AI是用JS编写的算法,则应在另一个线程或进程中运行它。如果AI是在另一台计算机上运行的服务(如Amazon、Google或IBM AI服务),则应该使用单线程架构。 - slebetman
5
请注意,视频流根本不会被节点服务器处理。99%的处理过程发生在浏览器/媒体播放器中。在这种情况下,node.js仅充当文件服务器,这意味着它利用操作系统的并行磁盘/网络I/O功能。对于网络I/O来说,大多数操作系统都同样能胜任,但是对于磁盘I/O来说,如果使用像Btrfs或ext4这样的快速文件系统,Linux往往比其他系统更强大(当然,RAID可以使几乎所有东西都变得快速)。 - slebetman
显示剩余13条评论

106

你似乎认为大部分处理工作都是在节点事件循环中处理的。实际上,Node将I/O工作分配给了线程。I/O 操作通常比CPU操作花费更多的时间,所以为什么要让CPU等待呢?另外,操作系统已经可以很好地处理I/O任务。事实上,由于 Node 不会等待,它实现了更高的 CPU 利用率。

类比一下,可以把 NodeJS 看做是服务员,负责点餐,而 I/O 厨师则在厨房里准备食物。其他系统有多个厨师,他们接受顾客的点餐、准备餐点、清理桌子,然后才会去招待下一个顾客。


21
谢谢您提供的餐厅比喻!我发现类比和实际例子更容易学习。 - LaVache
2
表述非常清晰,比喻恰当! - KJ Sudarshan
Node.js的JS部分是单线程的,而底层的C++部分则使用线程池。https://dev59.com/6VYM5IYBdhLWcg3w3jI4#70161215 - mercury
1
在餐厅的例子中:如果我们有一名服务员和10000名顾客,用一名服务员(一个线程)处理这些顾客会不会很慢? - Ali Sherafat
2
@AliSherafat有点晚了,但如果你问服务员是否可能过度工作,答案是肯定的。一般来说,I/O操作比内存中由CPU管理的操作慢数百倍。也就是说,真正的线程(厨师)在每个被服务员处理的请求中要做更多的工作。当然,在现实生活中,10000名顾客对于一个服务员来说太多了,但是管理10000个请求的转发和返回可能并不是太多。Node.js默认通过事件循环来完成这项工作,但也有使用更多服务员线程的方法(例如Worker线程)。 - chriskelly
在餐厅的例子中,服务员还负责收银活动(模拟CPU工作),因此当更多顾客进来时,响应时间会增加或服务时间会延长。 - Laukik

49

单线程事件循环模型处理步骤:

  • 客户端向Web服务器发送请求。

  • Node JS Web服务器内部维护有限的线程池,以为客户端请求提供服务。

  • Node JS Web服务器接收这些请求并将它们放入队列中。这就是所谓的“事件队列”。

  • Node JS Web服务器内部有一个组件,称为“事件循环”。它之所以被称为这个名字,是因为它使用无限循环来接收请求并处理它们。

  • 事件循环仅使用单个线程。它是Node JS平台处理模型的主要核心。

  • 事件循环检查是否有任何客户端请求被放置在事件队列中。如果没有,则无限期地等待传入的请求。

  • 如果是,则从事件队列中选择一个客户端请求

    1. 开始处理该客户端请求
    2. 如果该客户端请求不需要任何阻塞IO操作,则处理所有内容,准备响应并将其发送回客户端。
    3. 如果该客户端请求需要某些阻塞IO操作,例如与数据库、文件系统、外部服务交互,则会遵循不同的方法
  • 检查来自内部线程池的线程可用性
  • 选择一个线程并将该客户端请求分配给该线程。
  • 该线程负责接受该请求,处理它,执行阻塞IO操作,准备响应并将其发送回事件循环

    由@Rambabu Posa非常好地解释了更多的解释,请参见此Link


1
那篇博客中给出的图表似乎是错误的,他们在文章中提到的内容并不完全正确。 - rranj
3
据我所知,在Node.js中没有阻塞式I/O(除非您使用同步API),线程池仅在短暂的时间内用于处理I/O响应并将其传递给主线程。但是,在等待I/O请求时[没有线程](https://blog.stephencleary.com/2013/11/there-is-no-thread.html),否则线程池很快就会被堵塞。 - pmoleri
我解决了混淆。https://dev59.com/6VYM5IYBdhLWcg3w3jI4#70161215 - mercury
我认为最后三个步骤只适用于非阻塞I/O。当您有阻塞或同步任务时,它不会检查线程池中的线程。 - Mehedi Hasan Shifat

22
我了解到Node.js使用单线程和事件循环来处理请求,每次只处理一个请求(这是非阻塞的)。
但是,您可能没有完全理解基于事件的架构。在“传统”的(非事件驱动)应用程序架构中,进程会花费大量时间等待事件发生。 在Node.js之类的事件驱动架构中,进程不仅仅是等待,它可以继续其他工作。
例如:从客户端获取连接,接受连接,在HTTP的情况下读取请求标头,然后开始处理请求。您可能会读取请求正文,并且通常会向客户端发送一些数据(这只是为了展示该过程的简化)。
在每个阶段,大部分时间都花在等待来自另一端的某些数据上 - 实际上,在主JS线程中花费的时间通常相当少。
当I / O对象(例如网络连接)的状态发生更改时,需要进行处理(例如,在套接字上收到数据,套接字变为可写等),则将唤醒主Node.js JS线程,并提供需要处理的项目列表。
它查找相关的数据结构并在该结构上发出某些事件,导致回调运行,这些回调处理传入的数据或向套接字写入更多数据等。一旦处理了需要处理的所有I / O对象,主Node.js JS线程将再次等待,直到被告知有更多数据可用(或完成或超时了其他操作)。
下一次唤醒它可能是由于需要处理不同的I / O对象 - 例如不同的网络连接。每次,相应的回调都会运行,然后回到睡眠状态等待其他事件发生。
重要的是,不同请求的处理是交替进行的,它不会从头开始处理一个请求,然后再移动到下一个请求。

在我看来,这种方法的主要优点是慢请求不会阻塞快请求。例如,当你尝试通过2G数据连接向移动电话设备发送1MB的响应数据,或者你正在执行一个非常缓慢的数据库查询时,这种情况就比较容易出现。

在传统的多线程Web服务器中,通常会为每个处理的请求分配一个线程,并且它仅处理该请求直到完成。如果你有很多慢请求,会怎么样呢?你最终会得到很多线程挂起等待处理这些请求,而其他请求(可能是非常简单的请求,可以非常快地处理)则排在它们后面等待。

除了Node.js之外, 还有许多其他基于事件的系统,他们倾向于与传统模型相比具有类似的优缺点。

我不能声称事件驱动的系统在每种情况或工作负载下都更快 - 它们倾向于适用于I/O密集型的工作负载,而对于CPU密集型的工作负载则不太适用。


好的解释,让我们理解事件循环是如何同时处理多个请求的。 - mercury

20

补充 slebetman 的回答:

当你说 Node.JS 可以处理10,000个并发请求时,这些请求基本上都是非阻塞的请求,即这些请求主要涉及数据库查询。

在内部,Node.JS 的事件循环正在处理一个 线程池,每个线程处理一个 非阻塞请求,事件循环在将工作委派给线程池中的一个线程后继续监听更多请求。当其中一个线程完成工作时,它向 事件循环 发送一个信号,表示它已经完成,也就是所谓的 回调事件循环 然后处理这个回调并发送响应。

由于你是新手,可以阅读更多关于 nextTick 的内容来了解事件循环的内部工作原理。 阅读 http://javascriptissexy.com 上的博客,对我开始学习 JavaScript/NodeJS 时非常有帮助。


17

在多线程阻塞系统中,阻塞部分会使其效率降低。被阻塞的线程在等待响应时不能用于任何其他事情。

而非阻塞单线程系统则可以充分利用其单线程系统。

请参见下面的图表:enter image description here 在门口等待或者在客户选择食品时等待是“阻塞”服务员的全部能力。在计算机系统中,它可能等待IO、数据库响应或者任何能够阻塞整个线程的操作,即使线程在等待时还可以处理其他工作。

现在我们来看看非阻塞是如何工作的:

enter image description here 在非阻塞系统中,服务员只负责点餐和上菜,不在任何地方等待。他会分享自己的手机号码,以便当客人订完订单后拨打回电。同样,他会与厨房分享自己的电话号码,在订单准备好上菜时回电。

这就是NodeJS中事件循环的工作方式,比阻塞多线程系统表现更佳。


如果客户端没有自己的线程,那么它如何避免超时?是否有某种队列将回调函数添加到其中?然后,如果代码设计得很好并且不会阻塞事件循环,则可以快速处理此队列。 - MattieG4
@MattieG4 客户端在传递回调后继续执行其他操作。现在,回调接收者的工作是在任务完成后调用回调函数。是的,事件循环具有队列结构,在其中添加任务并按照先进先出的顺序提供服务。如果队列中有任何阻塞代码,则会延迟队列中的每个项目/任务。 - Anurag Vohra

14

为更加清晰理解代码执行时发生的情况,补充slebetman的回答。

Node.js内部线程池默认只有4个线程,而且不是整个请求都附加到来自线程池的新线程上,请求的整个执行过程就像任何正常的请求一样(没有任何阻塞任务),只是当请求有任何长时间运行或重操作如数据库调用、文件操作或http请求等时,任务将被排队到由libuv提供的内部线程池中。因为Node.js默认提供了4个线程内部线程池,所以每5个连续的请求将等待直到线程空闲,一旦这些操作完成,回调将被推送到回调队列中,并由事件循环接收并发送回响应。

现在又有另一个信息,那就是不止一个回调队列,还有许多队列。

  1. NextTick队列
  2. 微任务队列
  3. 计时器队列
  4. IO回调队列(请求、文件操作、数据库操作)
  5. IO轮询队列
  6. 检查阶段队列或SetImmediate
  7. 关闭处理程序队列

无论何时请求到达,代码按照排队的回调顺序执行。

并不是当有阻塞请求时,它就附加到一个新线程上。默认只有4个线程,因此还会发生另一种排队。

无论何时在代码中遇到像文件读取这样的阻塞进程,就会调用使用线程池的函数,一旦操作完成,回调函数就会传递到相应的队列中,然后按照顺序执行。 根据回调类型将所有内容加入队列,并按上述顺序进行处理。

NodeJs内部线程池默认只有4个线程...您能详细说明一下吗?NodeJs是如何使用这4个线程的? - mercury
1
NodeJS内部使用一些线程(默认为4个)来连接数据库,从磁盘读取文件,与操作系统交互等。您可以通过在运行NodeJS应用程序时将环境变量'UV_THREADPOOL_SIZE'设置为不同的数字来增加线程数。 - rranj

8
以下是一篇来自这篇中等难度的文章的好解释:
在NodeJS应用程序中,由于Node是单线程的,如果处理涉及到Promise.all需要8秒钟的时间,这是否意味着来自该请求之后的客户端请求需要等待八秒钟呢? 不需要。 NodeJS事件循环是单线程的。为NodeJS提供服务的整个服务器架构并非单线程。
在进入Node服务器架构之前,让我们看一下典型的多线程请求响应模型,Web服务器会有多个线程,当并发请求到达Web服务器时,Web服务器从线程池中选择threadOne,并且threadOne处理requestOne并回应给clientOne,当第二个请求到达时,Web服务器将从线程池中选择第二个线程并选择requestTwo进行处理并回应给clientTwo。threadOne负责requestOne所需的所有操作,包括进行任何阻塞IO操作。
线程需要等待阻塞IO操作是使其效率低下的原因。在这种模型下,Web服务器仅能提供与线程池中线程数相同数量的请求。
NodeJS Web服务器维护一个有限的线程池以提供客户端请求服务。多个客户端向NodeJS服务器发出多个请求。NodeJS接收这些请求并将其放置在事件队列中。 NodeJS服务器有一个内部组件,被称为事件循环,是一个无限循环,接收并处理请求。这个事件循环是单线程的。换句话说,事件循环是事件队列的监听器。 所以,我们有一个事件队列,在其中放置请求,我们有一个事件循环监听这些请求。下一步会发生什么呢? 监听器(事件循环)处理请求,如果它能够处理请求而不需要任何阻塞IO操作,则事件循环本身会处理请求并通过自己将响应发送回客户端。 如果当前请求使用阻塞IO操作,事件循环会查看线程池中是否有可用的线程,选择一个线程从线程池中获取并将特定请求分配给选定线程。该线程执行阻塞IO操作并将响应发送回事件循环,一旦响应到达事件循环,事件循环将响应发送回客户端。
NodeJS与传统的多线程请求/响应模型相比有什么优势? 采用传统的多线程请求/响应模型,每个客户端都会得到一个不同的线程,而对于NodeJS来说,较简单的请求直接由EventLoop处理。这是线程池资源的一种优化,没有为每个客户请求创建线程的开销。

那么在这种情况下,事件循环将自己处理请求并自行向客户端发送响应。是吗?而当使用I/O时,CPU会启动I/O来完成工作,然后当I/O完成时,会向CPU发送更新以便发送回客户端。 - Ralph Dingus
很好的解释和正确的陈述。因此,证明是:如果您在端点处理while循环阻塞了“代码”但未阻止主线程(事件循环),它将无法处理任何请求,因为该循环不会阻塞线程。因此,主线程认为我可以处理它而不从线程池中获取线程。如果调用非阻塞IO,则将任务传递给线程。 - Ali Berat Çetin

0
在node.js中,请求应该是IO绑定而不是CPU绑定。这意味着每个请求不应强制node.js进行大量计算。如果解决请求涉及大量计算,则node.js不是一个好选择。IO绑定需要很少的计算。大多数时间请求都花费在调用DB或服务上。
Node.js具有单线程事件循环,但它只是一位厨师。在幕后,大部分工作由操作系统完成,并且Libuv确保与操作系统的通信。来自Libuv文档的描述:
“在事件驱动编程中,应用程序表达对某些事件的兴趣,并在发生时对其进行响应。从操作系统收集事件或监视其他事件源的责任由libuv处理,用户可以注册回调以在事件发生时调用它们。”
传入的请求由操作系统处理。对于基于请求-响应模型的几乎所有服务器来说,这是非常正确的。传入的网络调用在OS非阻塞IO队列中排队。'事件循环不断轮询OS IO队列,这就是它如何了解传入的客户端请求的方式。 "轮询"意味着定期检查某些资源的状态。如果有任何传入请求,事件循环将接受该请求,并同步执行。在执行过程中,如果有任何异步调用(即setTimeout),它将被放入回调队列中。事件循环完成同步调用的执行后,可以轮询回调函数,如果发现需要执行的回调函数,则会执行该回调函数。然后它将轮询任何传入请求。如果您查看node.js文档,则会看到此图像:

enter image description here

来自文档阶段概述

poll: 检索新的 I/O 事件; 执行 I/O 相关的回调(几乎所有除了 close 回调、由计时器安排的回调和 setImmediate()); 当适当时,节点将在此处阻塞。

因此,事件循环不断地从不同的队列中进行轮询。如果任何请求需要外部调用或磁盘访问,则传递给操作系统,操作系统也有两个不同的队列。一旦 事件循环 检测到某些异步操作需要执行,它就会将它们放入队列中。一旦放入队列中,事件循环将处理下一个任务。

这里要提到的一件事是,事件循环持续运行。只有 CPU 可以将此线程移出 CPU,事件循环本身不会这样做。

来自文档:

Node.js 可扩展性的秘密在于它使用少量线程来处理众多客户端。如果 Node.js 能够使用更少的线程,那么它就可以将更多的系统时间和内存用于处理客户端,而不是为线程(内存、上下文切换)支付空间和时间开销。但由于 Node.js 只有少量线程,因此您必须明智地构建应用程序以充分利用它们。

以下是保持 Node.js 服务器快速运行的好方法:当每个客户端关联的工作在任何给定时间内都是“小型”时,Node.js 才能快速运行。

请注意,小任务意味着 I/O 绑定任务而不是 CPU。只有单个事件循环会处理客户端负载,前提是每个请求的工作大多是 I/O 工作。

上下文切换基本上意味着CPU资源不足,因此需要停止一个进程的执行以允许另一个进程执行。操作系统首先必须驱逐进程1,因此它将从CPU中获取此进程,并将其保存在主内存中。接下来,操作系统将通过从内存加载进程控制块来恢复进程2,并将其放置在CPU上进行执行。然后进程2将开始执行。在进程1结束和进程2开始之间,我们失去了一些时间。大量的线程可能会导致负载过重的系统在线程调度和上下文切换上花费宝贵的周期,这会增加延迟并对可扩展性和吞吐量施加限制。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接