首页 > 解决方案 > 节点如何处理并发请求?

问题描述

我最近一直在阅读 nodejs,试图了解它如何处理多个并发请求。我知道 NodeJs 是一个基于单线程事件循环的架构,在给定的时间点,只有一个语句将被执行,即在主线程上,阻塞代码/IO 调用由工作线程处理(默认为 4 )。

现在我的问题是,当使用 NodeJs 构建的 Web 服务器收到多个请求时会发生什么?我知道这里有很多类似的问题,但还没有找到我的问题的具体答案。

举个例子,假设我们在/index这样的路由中有以下代码:

app.use('/index', function(req, res, next) {
    
    console.log("hello index routes was invoked");
    
    readImage("path", function(err, content) {
        status = "Success";
        if(err) {
            console.log("err :", err);
            status = "Error"
        }
        else {
            console.log("Image read");
        }
        return res.send({ status: status });
    });

    var a = 4, b = 5;
    console.log("sum =", a + b);
});

假设该readImage()函数需要大约 1 分钟来读取该图像。
如果两个请求 T1 和 T2 同时进来,NodeJs 将如何处理这些请求?

它会先处理请求 T1,然后在排队请求 T2 的同时处理它吗?我假设如果遇到任何异步/阻塞的东西,例如readImage,它然后将其发送到工作线程(然后稍后当异步东西完成时,线程通知主线程并且主线程开始执行回调?),然后继续执行下一行代码?

当 T1 完成后,它会处理 T2 请求吗?那是对的吗?或者它可以在两者之间处理 T2(意味着当代码readImage运行时,它可以开始处理 T2)?

那正确吗?

标签: javascriptnode.jsasynchronousconcurrencyevent-log

解决方案


您的困惑可能来自对事件循环的关注不够。显然,您知道这是如何工作的,但也许您还没有全貌。

第 1 部分,事件循环基础

当您调用该use方法时,会在幕后创建另一个线程来侦听连接。

然而,当一个请求进来时,因为我们在与 V8 引擎不同的线程中(并且不能直接调用路由函数),对该函数的序列化调用被附加到共享事件循环中,以便稍后调用. (在这种情况下,'事件循环'是一个糟糕的名字,因为它更像是一个队列或堆栈)

在 JavaScript 文件的末尾,V8 引擎将检查事件循环中是否有任何正在运行的广告或消息。如果没有,它将以代码 0 退出(这就是服务器代码保持进程运行的原因)。所以要理解的第一个 Timing 细微差别是在到达 JavaScript 文件的同步结束之前不会处理任何请求。

如果在进程启动时附加了事件循环,则事件循环上的每个函数调用将被一个一个地同步处理。

为简单起见,让我将您的示例分解为更具表现力的示例。

function callback() {
    setTimeout(function inner() {
        console.log('hello inner!');
    }, 0); // †
    console.log('hello callback!');
}

setTimeout(callback, 0);
setTimeout(callback, 0);

setTimeout时间为 0,是一种快速简便的方法,可以在没有任何计时器复杂性的情况下将某些内容放在事件循环中,因为无论如何,它始终至少为 0 毫秒。

在此示例中,输出将始终为:

hello callback!
hello callback!
hello inner!
hello inner!

在调用其中任何一个之前,这两个序列化调用callback都附加到事件循环中。这是有保证的。发生这种情况是因为在文件完全同步执行之前,无法从事件循环中调用任何内容。

将文件的执行视为事件循环中的第一件事可能会有所帮助。因为事件循环中的每个调用只能连续发生,所以它成为一个合乎逻辑的结果,在其执行期间不能发生其他事件循环调用;只有上一次调用完成后,才能调用下一个事件循环函数。

第 2 部分,内部回调

同样的逻辑也适用于内部回调,可以用来解释为什么程序永远不会输出:

hello callback!
hello inner!
hello callback!
hello inner!

就像你可能期望的那样。

在文件执行结束时,两个序列化函数调用将在事件循环中,都是 for callback。由于事件循环是一个FIFO(先进先出),因此setTimeout先来的将首先被调用。

第一件事callback是执行另一个setTimeout. 和以前一样,这将附加一个序列化调用,这次是inner函数,事件循环。setTimeout立即返回,执行将继续执行第一个console.log.

此时,事件循环如下所示:

1 [callback] (executing)
2 [callback] (next in line)
3 [inner]    (just added by callback)

的返回callback是事件循环从自身中删除该调用的信号。现在,这在事件循环中留下了 2 件事:1 次对 的调用callback和 1 次对 的调用inner

Nowcallback是下一个函数,所以接下来会调用它。该过程不断重复。调用inner附加到事件循环。console.log打印出来,我们通过从事件循环Hello Callback!中删除这个调用来完成。callback

这为事件循环留下了另外 2 个功能:

1 [inner]    (next in line)
2 [inner]    (added by most recent callback)

这些函数都不会进一步干扰事件循环。他们一个接一个地执行,第二个等待第一个返回。然后当第二个返回时,事件循环为空。这一事实与当前没有其他线程在运行的事实相结合,触发了进程的结束,该进程以返回码 0 退出。

第 3 部分,与原始示例相关

在您的示例中发生的第一件事是在进程中创建一个线程,该线程将创建一个绑定到特定端口的服务器。请注意,这发生在预编译的 C++ 代码中,而不是 JavaScript,并且不是一个单独的进程,它是同一进程中的一个线程。请参阅:C++ 线程教程

所以现在,只要有请求进来,你的原始代码的执行就不会受到干扰。相反,传入的连接请求将被打开、保留并附加到事件循环中。

use功能是捕获传入请求事件的网关。它是一个抽象层,但为了简单起见,use函数像setTimeout. 除了等待一定时间之外,它会在传入的 http 请求时将回调附加到事件循环。

所以,让我们假设有两个请求进入服务器:T1 和 T2。在您的问题中,您说它们同时出现,因为这在技术上是不可能的,我将假设它们一个接一个,它们之间的时间可以忽略不计。

无论哪个请求先进来,都将首先由之前的辅助线程处理。一旦该连接被打开,它就会被附加到事件循环中,我们继续下一个请求,并重复。

在第一个请求被添加到事件循环后的任何时候,V8 都可以开始执行use回调。


快速搁置一下readImage

由于不清楚是否readImage来自特定库、您编写的内容或其他内容,因此无法准确判断在这种情况下它将做什么。但是只有两种可能性,所以它们是:

  1. 它是完全同步的,从不使用备用线程或事件循环
    function readImage (path, callback) {
        let image = fs.readFileSync(path);
        callback(null, image);
        // a definition like this will force the callback to
        // fully return before readImage returns. This means
        // means readImage will block any subsequent calls.
    }
  1. 它完全是异步的,并利用了 fs 的异步回调。
    function readImage (path, callback) {
        fs.readFile(path, (err, data) => {
            callback(err, data);
        });
        // a definition like this will force the readImage
        // to immediately return, and allow exectution
        // to continue.
    }

出于解释的目的,我将在 readImage 将立即返回的假设下进行操作,因为正确的异步函数应该如此。


一旦use开始执行回调,将发生以下情况:

  1. 将打印第一个控制台日志。
  2. readImage将启动一个工作线程并立即返回。
  3. 将打印第二个控制台日志。

在所有这些过程中,需要注意的是,这些操作是同步发生的;在这些完成之前,无法启动其他事件循环调用。readImage可能是异步的,但调用它不是,工作线程的回调和使用是使其异步的原因。

在这个use回调返回之后,下一个请求可能已经完成解析并被添加到事件循环中,而 V8 正忙于处理我们的控制台日志和 readImage 调用。

因此use调用下一个回调,并重复相同的过程:记录,启动 readImage 线程,再次记录,返回。

在此之后,readImage 函数(取决于它们需要多长时间)可能已经检索到它们需要的内容并将它们的回调附加到事件循环中。因此,它们将在接下来执行,以先检索数据的顺序执行。请记住,这些操作是在不同的线程中发生的,所以它们不仅与主 javascript 线程并行发生,而且彼此并行,所以在这里,先调用哪个并不重要,重要的是哪个先完成,并在事件循环中获得“dibs”。

谁先readImage完成,谁就先执行。因此,假设没有发生错误,我们将打印到控制台,然后写入对应请求的响应,并保存在词法范围内。

当该发送返回时,下一个 readImage 回调将开始执行:控制台日志,并写入响应。

此时,两个 readImage 线程都已死亡,并且事件循环为空,但持有服务器端口绑定的线程正在保持进程处于活动状态,等待将其他内容添加到事件循环中,然后循环继续。

我希望这可以帮助您了解您提供的示例的异步性质背后的机制。


推荐阅读