代码之家  ›  专栏  ›  技术社区  ›  Saroj

节点如何处理并发请求?

  •  21
  • Saroj  · 技术社区  · 8 年前

    最近我一直在努力理解多个并发请求。我知道NodeJs是一种基于单线程事件循环的体系结构,在给定的时间点上,只有一条语句将被执行,即在主线程上,阻塞代码/IO调用由工作线程处理(默认值为4)。

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

    作为一个例子,假设我们在一条路径中有以下代码,比如 /索引 :

    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同时出现,NodeJ将如何处理这些请求?

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

    完成T1后,它会处理T2请求?对吗?或者它可以处理中间的T2(这意味着 readImage 正在运行时,它可以开始处理T2)?

    是这样吗?

    6 回复  |  直到 4 年前
        1
  •  28
  •   AncientSwordRage    4 年前

    你的困惑可能是因为没有充分关注事件循环。很明显,你知道这是如何工作的,但也许你还不知道全部情况。

    第1部分,事件循环基础

    当你打电话给 use 方法,则会创建另一个线程来侦听连接。

    然而,当请求进入时,因为我们处于与V8引擎不同的线程中(并且不能直接调用route函数),对函数的序列化调用会附加到 共享 事件循环,以便稍后调用。(“事件循环”在这个上下文中是一个糟糕的名称,因为它的操作更像队列或堆栈)

    在JavaScript文件的末尾,V8引擎将检查事件循环中是否有任何正在运行的指令或消息。如果没有,它将以代码0退出(这就是服务器代码保持进程运行的原因)。所以首先要理解的是 在到达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!
    

    正如你所料。

    在文件执行结束时,事件循环上将出现两个序列化函数调用,这两个函数都是 回拨 .因为事件循环是 FIFO (先进先出) 设置超时 首先出现的,将首先被调用。

    第一件事 回拨 是表演另一个吗 设置超时 .与之前一样,这将附加一个序列化调用, 这一次是去 inner 作用 ,转到事件循环。 设置超时 立即返回,执行将转到第一个 console.log .

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

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

    回归 回拨 是事件循环从自身移除该调用的信号。现在调用事件2:更多事情进入循环 回拨 ,并致电 内部的 .

    现在 回拨 是行中的下一个函数,因此下一步将调用它。这个过程会重复。打电话给 内部的 附加到事件循环中。A. 安慰日志 印刷品 Hello Callback! 最后,我们删除了对 回拨 从事件循环中。

    这样,事件循环就剩下了另外两个函数:

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

    这两个函数都不会进一步扰乱事件循环。他们一个接一个地执行,第二个等待第一个返回。然后,当第二个循环返回时,事件循环保留为空。这个事实,再加上当前没有其他线程在运行,会触发进程结束,返回代码为0。

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

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

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

    这个 使用 功能,是捕获传入请求事件的网关。这是一个抽象层,但为了简单起见,它有助于 想想 使用 功能就像你会 设置超时 。除此之外,它不会等待设定的时间量,而是在收到http请求时将回调附加到事件循环。

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

    无论哪一个请求先到达,都将首先由前面的第二个线程处理。一旦那个连接被打开,它就会被附加到事件循环中,我们继续下一个请求,然后重复。

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


    关于 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将立即返回,正如正确的异步函数所应该的那样。


    一旦 使用 启动回调执行后,将发生以下情况:

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

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

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

    那么接下来呢 使用 回调被调用,并重复相同的过程:记录、启动readImage线程、再次记录、返回。

    在这一点之后,readImage函数(取决于它们花费的时间)可能已经检索到了它们所需的内容,并将其回调追加到事件循环中。因此,它们将按照先检索数据的顺序执行。记住,这些操作是在不同的线程中进行的,因此它们不仅与主javascript线程并行,而且还与主javascript线程并行 平行 所以在这里,哪一个先被调用并不重要,重要的是哪一个先完成,并在事件循环中获得“DIB”。

    无论哪个 readImage 先完成的将是第一个执行的。所以 假设没有发生错误 ,我们将打印输出到控制台,然后写入对应请求的响应,保存在词法范围内。

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

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

    我希望这能帮助您理解您提供的示例的异步本质背后的机制。

        2
  •  5
  •   Anthony Liu    8 年前

    对于每个传入的请求,节点将逐个处理。这意味着必须有秩序,就像排队一样,先到先得。当节点开始处理请求时,所有同步代码都将执行,异步代码将传递给工作线程,这样节点就可以开始处理下一个请求。异步部分完成后,它将返回主线程并继续运行。

    所以,当同步代码花费的时间太长时,就会阻塞主线程,节点将无法处理其他请求,这很容易测试。

    app.use('/index', function(req, res, next) {
        // synchronous part
        console.log("hello index routes was invoked");
        var sum = 0;
        // useless heavy task to keep running and block the main thread
        for (var i = 0; i < 100000000000000000; i++) {
            sum += i;
        }
        // asynchronous part, pass to work thread
        readImage("path", function(err, content) {
            // when work thread finishes, add this to the end of the event loop and wait to be processed by main thread
            status = "Success";
            if(err) {
                console.log("err :", err);
                status = "Error"
            }
            else {
                console.log("Image read");
            }
            return res.send({ status: status });
        });
        // continue synchronous part at the same time.
        var a = 4, b = 5;
        console.log("sum =", a + b);
    });
    

    在完成所有同步部分之前,节点不会开始处理下一个请求。所以人们说不要阻挡主线。

        3
  •  3
  •   Doug    8 年前

    有很多文章解释了这一点,比如 this one

    长话短说就是 nodejs 它不是一个真正的单线程应用程序,它只是一种错觉。上面链接顶部的图表很好地解释了这一点,但是作为一个总结

    • NodeJS事件循环在单个线程中运行
    • 当它收到一个请求时,它会将该请求交给一个新线程

    例如,在代码中,正在运行的应用程序的PID为1。当您收到请求T1时,它会创建处理该请求的PID 2(需要1分钟)。在运行过程中,您会收到请求T2,这会产生PID 3,也需要1分钟。PID 2和PID 3都将在任务完成后结束,但PID 1将在事件进入时继续监听和传递事件。

    总之, NodeJS “单线程”是正确的,但它只是一个事件循环侦听器。当听到事件(请求)时,它会将它们传递给异步执行的线程池,这意味着它不会阻止其他请求。

        4
  •  2
  •   murtuza hussain    8 年前

    只需使用fork()在不同的文件中移动readImage()函数,即可创建子进程。

    父文件, parent.js :

    const { fork } = require('child_process');
    const forked = fork('child.js');
    forked.on('message', (msg) => {
       console.log('Message from child', msg);
    });
    
    forked.send({ hello: 'world' });
    

    子文件, child.js :

    process.on('message', (msg) => {
      console.log('Message from parent:', msg);
    });
    
    let counter = 0;
    
    setInterval(() => {
      process.send({ counter: counter++ });
    }, 1000);
    

    以上文章可能对你有用。

    在上面的父文件中,我们使用fork 小孩js (它将使用node命令执行文件),然后我们监听 message 事件这个 消息 事件将在孩子使用时发出 process.send 每秒钟都在做。

    要将消息从父级传递给子级,我们可以执行 send 函数,然后在子脚本中,我们可以听 消息 全球经济论坛 process 对象

    在执行 父母亲js 在上面的文件中,它首先发送 { hello: 'world' } 对象,然后分叉子进程将每秒发送一个递增的计数器值,以供父进程打印。

        5
  •  0
  •   Sebastian Scholle    8 年前

    V8 JS interpeter(即节点)基本上是单线程的。但是,它启动的进程可以是异步的,例如:。读取文件'。

    当express服务器运行时,它将根据需要打开新的进程来完成请求。因此,“readImage”函数将被启动(通常是异步的),这意味着它们将以任何顺序返回。但是,服务器将自动管理哪个响应发送到哪个请求。

    所以你不必去管它 readImage 响应将转到哪个请求。

    基本上,T1和T2不会同时返回,这几乎是不可能的。它们都严重依赖文件系统来完成“读取”,而且它们可能以任何顺序完成(这是无法预测的)。请注意,进程由操作系统层处理,本质上是多线程的(在现代计算机中)。

    如果您正在寻找队列系统,那么实现/确保按照请求的确切顺序读取/返回图像应该不会太难。

        6
  •  -1
  •   iLuvLogix    8 年前

    因为在Marcus之前的回答中没有更多内容可以补充,所以下面的图表解释了单线程事件循环机制:

    enter image description here