Node.js事件轮询:面试必知的关键点

420 阅读12分钟

什么是Node.js

传统意义上,JavaScript主要运行在浏览器中,用于处理前端的交互和动态效果。Node.js是基于Chrome V8引擎开发的Javascript运行时环境,除了开发各种命令行工具,还可以使用Javascript来构建服务端应用。

除了作为一个强大的包管理器npm,供开发者使用和共享模块。Node.js使用事件驱动、非阻塞I/O模型还可以实现高效的异步编程。它的设计目标是使得开发者能够构建高性能、可扩展的网络应用程序。Node.js提供了许多内置模块,用于处理文件系统、网络、操作系统等各种操作。 image.png 上图为Node.js的内部架构,以下列举了和Node.js内部原理密接相关的特性点:

  • V8 Engine
  • JIT Paradigm
  • Non-blocking I/O
  • Libuv
  • Event Loop
  • Generators
  • Async Await

V8 Engine

V8是由Google通过C++语言开发的引擎,支持在运行时将JavaScript源代码编译为本地机器码(native matchine code),主要目的是用来优化浏览器端的javascript执行。

Javascript是一个解释性语言,除了编译代码、优化执行,还允许在编译代码之上执行。

V8可以分为三个阶段:

  • 生成语法树(systax tree):转换器负责将Javascript源代码转换为一颗抽象语法树(AST)。
  • 语法树转换为字节码(Bytecode): V8 Ignition将语法树转换为字节码。
  • 字节码转换为机器码:V8 TurboFan根据字节码生成图(Graph),并将字节码部分使用机器码替换。

最后两个环节在just-in-time(JIT)中操作。

JIT

能够被系统执行的任何程序,都必须将其代码转换为机器语言,常用机器码转换方式有:

  • 解释器(Interpreter):边翻译边执行,逐行执行该过程。特点是快速翻译,但执行过程较慢。
  • 编译器(Compiler):执行之前先将所有代码转换为机器码,转换过程相对较慢,但因为结果为机器码所以执行过程快。
  • 运行时编译(JIT compilation):结合了解析器、执行器的优点,能够快速的翻译和执行代码。在运行期间,代码通过Ignition解析,它保持了代码片段的跟踪并尽量复用,能够基于TurboFan对代码进行优化处理。

解释器的主要问题是,如果你将同一行代码传递10次,那么就会翻译10次。而JIT会避免重复编译这种问题。

JIT 编译能够分析内存占用情况,降低内存使用的手段为:减少内存使用、降低启动时间、降低代码复杂度。和Webpack前端打包类似,这些手段都是在将AST转换为字节码过程执行。

Threads

线程伴随着程序实体存在,是CPU需要执行的一个操作。通常分为单线程、多线程:

  • 单线程 一次只能执行一段代码。例如,如果向服务器发送多个请求,则下一个请求需要等待当前请求发回响应以后进行处理。

  • 多线程 多段代码可以同时并行执行。同时发送到服务器的每个请求都会创建一个新线程来处理它。

Node虽然是单线程的,但并不意味着它需要等待请求完成,也不意味着它内部不使用线程。

Non-blocking I/O

Javascript在Node.js的单线程环境执行,但可以启动一个I/O进程,并且不会阻塞非依赖该I/O进程的任务执行。也即是说,可以同时进行通信,继续其他任务的执行,而不用等待I/O的响应。如果一个任务需要相应,它将等待结果,然后执行回调。这样有助于并行任务执行以及资源的使用。

fs.readFile('path', (content) => {
  console.log(content);
});
console.log('Executed');
// Executed
// The file content

在上述的示例中,'Executed'先被执行,不会等待文件读取完成。Node.js会将读取文件的进程抛给一个'worker'执行,然后Node可以继续做第二个任务,即执行console.log(content)

Libuv

image.png Libuv是C实现的开源库,使用一个线程池去管理并发任务。上文中提到的worker也作为Libuv的一部分。这些Worker是在后台由Libuv管理的非阻塞异步I/O进程。他们从事件循环接收到的每个同步 I/O 操作都在后台线程上运行,不会阻塞主线程。

Libuv也提供了事件循环,一个事件循环只与一个线程绑定。在Libuv内部有很多概念,如句柄,是对一些资源的抽象,如TCP/UDP、信号等。有些句柄用于和事件循环交互,如idle、prepare、check、async types,在事件循环中这些句柄都作为一个特别的Action,例如在I/O轮询之前或之后处理一些事务,在讲解事件循环时也必定会提到这些Action。

Node常用的一些模块,如fs、child_process也是由Libuv提供,由Libuv提供的主要模块包含:

  • 事件循环
  • 异步TCP/UDP套接字
  • 异步DNS解析
  • 异步文件、文件系统操作
  • 线程池
  • 子进程(Child processes)
  • Polling
  • Streaming
  • Pipes

Event Loop

Node.js 是一个单线程事件驱动平台,能够运行非阻塞、异步编程。Node.js 的这些功能使其内存能够高效管理。尽管JavaScript是单线程的,但事件循环允许 Node.js执行非阻塞I/O操作。它是通过随时随地将操作分配给操作系统来完成。

大多数操作系统都是多线程的,因此可以处理在后台执行的多个操作。当这些操作之一完成时,内核通知Node.js,分配给该操作的相应回调被添加到最终将被执行的事件队列中。这将在本主题的后面进一步详细解释。

事件循环的特点:

  • 事件循环是一个无限循环,等待任务,执行它们,然后休眠直到它接收到更多任务。
  • 只有当调用堆栈为空时,事件循环才会从事件队列中执行任务,即不会有正在进行的任务。
  • 事件循环允许我们使用callback、promise。
  • 事件循环从最早的任务开始执行。

例如:

console.log("This is the first statement");
  
setTimeout(function(){
    console.log("This is the second statement");
}, 1000);
  
console.log("This is the third statement");

Output:

This is the first statement
This is the third statement
This is the second statement

上面的示例,第一个console log表达式被push到调用栈call stack,然后打印“This is the first statement”,同时该任务从调用栈中退出。接下来,将setTimeout 推送到队列queue并将任务发送到操作系统,同时为任务设置计时器。然后从堆栈中弹出此任务。接下来,第三个控制台日志语句被推送到调用堆栈,“This is the third statement”被打印,任务从堆栈中弹出。

当setTimeout函数设置的计时器(在本例中为1000 毫秒)用完时,回调将发送到事件队列。当event loop中的调用堆栈为空时,事件队列顶部的任务发送到调用堆栈。setTimeout函数的回调函数运行指令,“This is the second statement”被打印,任务从堆栈中弹出。

上面的示例,即使timer设置为0ms,执行顺序也一样。因为即使它的回调会立刻发送到事件队列,但只有当调用堆栈为空时才会执行事件队列,除非调用堆栈为空,也就是说代码执行到最后。

Event Loop工作流程

当启动Node.js,它将初始化事件循环,处理输入的脚本,这些脚本可能是一些异步API调用或timer调度。然后开始处理事件循环。上述示例初始化脚本过程由console.log()表达式、setTimeout()计时器构成。

使用Node.js过程中,一个名为libuv的模块被用来执行异步操作。该库还与Node的后台逻辑一起用于管理特殊线程池。这个线程池由四个线程组成,用于委托对事件循环来说过于繁重的操作。I/O 操作、打开和关闭连接、setTimeouts是此类操作的示例。

当线程池完成任务时,将调用回调函数来处理错误或执行其他操作。这个回调函数被发送到事件队列。当调用堆栈为空时,事件通过事件队列并将回调发送到调用堆栈。

下图为Node.js服务运行的事件循环流程: image.png

Event Loop阶段

Node.js中的事件循环由6个阶段组成,每个阶段会执行特定的任务,下面的流程图是事件循环的执行顺序: image.png

  • timers:这个阶段执行由setTimeout()、setInterval()规划的回调函数。
  • pending callbacks:执行延迟到下一个循环迭代的I/O回调。
  • idle, prepare:仅在内部使用。
  • poll:检索新的 I/O 事件;执行与I/O相关的回调(除了网络异常回调、在timers阶段规划的回调以及setImmediate设置的回调)。
  • check:setImmediate()回调在这里被调用。
  • close callbacks:一些关闭回调,例如socket.on('close', ...)。

Timers

Timers是事件循环的第一个阶段,Node会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。

例如,假设您计划在 100 毫秒阈值后执行超时,然后您的脚本开始异步读取一个需要 95 毫秒的文件:

onst fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

当事件循环进入轮询阶段时,它有一个空队列(此时 fs.readFile() 尚未完成),因此它将等待剩下的毫秒数,直到达到最快的一个计时器阈值为止。当它等待 95 毫秒过后时,fs.readFile() 完成读取文件,它的那个需要10毫秒才能完成的回调,将被添加到Polling 队列中并执行。当回调完成时,队列中不再有回调,因此事件循环机制将查看最快到达阈值的计时器,然后将回到Timers 阶段,以执行定时器的回调。在本示例中,您将看到调度计时器到它的回调被执行之间的总延迟将为102-106毫秒。

Pending Callbacks

该阶段执行一些系统操作的回调,如TCP错误。例如,如果TCP套接在连接服务时接收到ECONNREFUSED错误,这些异常回调将被push到消息队列并在pending callbacks阶段执行。

Idle, Prepare

“idle.ignore”阶段不是Node.js中事件循环的标准阶段,它仅在内部使用。 "Idle"阶段是事件循环无事可做的一段时间,可用于执行后台任务,例如运行垃圾收集或检查低优先级事件。

“idle.ignore”不是事件循环的官方阶段,它是一种忽略空闲阶段的方法,意思是它不会使用空闲阶段的时间来执行后台任务。

使用idle.ignore的示例可以是:

const { idle } = require('idle-gc');
 
idle.ignore();

这里使用了idle-gc包,允许开发人员忽略闲置阶段。如果想让事件循环一直处于忙碌,则可以使用以上代码忽略该阶段。

值得一提的是,通常不推荐使用idle.ignore,因为它可能会导致性能问题,只有在我们有非常具体的用例需要它时才应该使用它。

Polling

Polling阶段包含两个功能:

  • 计算阻塞时长,轮序I/O操作。
  • 处理轮询队列的事件。

当事件循环进入轮询阶段并且没有定时器被调度时,会发生以下两种情况之一:

如果轮询队列不为空,事件循环将遍历其回调队列并同步执行它们,直到队列耗尽或达到系统相关的硬限制。

如果轮询队列为空,则会发生以下两种情况之一:

  • 如果脚本已被setImmediate()调度,事件循环将结束Polling阶段并继续Check阶段以执行那些调度的脚本。
  • 如果脚本没有被setImmediate()调度,事件循环将等待回调被添加到队列中,然后立即执行它们。

一旦轮询队列为空,事件循环将检查已达到时间阈值的Timer。如果一个或多个Timer准备就绪,事件循环将返回到Timers阶段以执行这些计时器的回调。

check

该阶段允许在轮询阶段完成后立即执行回调。如果轮询阶段变得空闲并且脚本已经排队setImmediate(),事件循环会继续check阶段而不是等待。

setImmediate()实际上是一个特殊的定时器,运行在事件循环的一个独立阶段。它使用 libuv API安排回调在轮询阶段完成后执行。

通常,随着代码的执行,事件循环最终会进入轮询阶段,等待传入的连接、请求等。但是,如果已安排setImmediate回调并且轮询阶段空闲,它将结束并继续check阶段而不是等待轮询事件。

close callback

此阶段处理任何已由套接字的关闭事件添加到消息队列的回调。这意味着任何需要在套接字关闭时执行的代码都放在消息队列中并在此阶段进行处理。下面代码为关闭回调阶段如何工作的示例:

const net = require('net');
const server = net.createServer((socket) => {
    socket.on('close', () => {
        console.log('Socket closed');
    });
});
 
server.listen(8000);

在此示例中,使用net模块创建了一个服务,并在关闭回调阶段将“close”事件添加到消息队列,回调将在控制台打印“Socket closed”。当客户端关闭服务器的套接字时将触发此事件,并且回调将在close callback阶段由事件循环处理。

需要注意的是,这些阶段的执行顺序可以根据事件循环的具体实现而有所不同,但一般情况下,事件循环会按照上述顺序处理它们。

每个阶段按顺序执行,事件循环会不断循环这些阶段,直到消息队列为空。

事件循环是 Node.js的强大特性,它使得应用能够处理大量并发连接并执行非阻塞 I/O 操作。了解事件循环的工作原理对于在Node.js 中构建高效和高性能的服务器端应用程序至关重要。通过更好地理解事件循环,开发人员可以充分利用Node.js的功能并构建高性能、可扩展的应用程序。

setImmediate、setTimeout、nextTick区别

setImmediate、setImmediate对比

  • setImmediate():设计为一旦在当前轮询阶段完成,就执行脚本。
  • setTimeout(): 在最小阈值(ms单位)过后运行脚本。

Demo1:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);
 
setImmediate(() => {
  console.log('immediate');
});

Demo2:

const fs = require('fs');
 
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

Demo1分析: 执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则计时器将受进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。

  1. 由于第一次loop前的准备耗时超过1ms,当前的loop->time >=1 ,则uv_run_timer生效,timeout先执行
  2. 由于第一次loop前的准备耗时小于1ms,当前的loop->time = 0,则本次loop中的第一次uv_run_timer不生效,那么io_poll后先执行uv_run_check,即immediate先执行,然后等close cb执行完后,继续执行uv_run_timer。

Demo2分析: 如果你把这两个函数放入一个 I/O 循环内调用,setImmediate 总是被优先调用。

  1. setTimeout事件注册
  2. setImmediate事件注册
  3. 由于readFile的回调执行完毕,那么就会从uv_io_poll中出来,此时立即执行uv_run_check,所以immediate事件被执行掉
  4. 最后的uv_run_timer检查timeout事件,执行timeout事件

process.nextTick

process.nextTick()在图示中没有显示,即使它是异步API的一部分。这是因为process.nextTick()从技术上讲不是事件循环的一部分。相反,它都将在当前操作完成后处理 nextTickQueue,而不管事件循环的当前阶段如何。

Demo:

let bar;
 
function someAsyncApiCall(callback) {
  process.nextTick(callback);
}
 
someAsyncApiCall(() => {
  console.log('bar', bar);
});
 
bar = 1;

通过将回调置于 process.nextTick()中,脚本仍具有运行完成的能力,允许在调用回调之前初始化所有的变量、函数等。

const server = net.createServer(() => {}).listen(8080);
 
server.on('listening', () => { console.log('Server running');});

只有传递端口时,端口才会立即被绑定。因此,可以立即调用'listening' 回调。问题是.on('listening') 的回调在那个时间点尚未被设置。

为了绕过这个问题,'listening' 事件被排在nextTick() 中,以允许脚本运行完成。这让用户设置所想设置的任何事件处理器。

process.nextTick 、setImmediate对比

就用户而言,我们有两个类似的调用,但它们的名称令人费解。

  • process.nextTick() 在同一个阶段立即执行。
  • setImmediate() 在事件循环的接下来的迭代上触发。

实质上,这两个名称应该交换,因为process.nextTick()比setImmediate()触发得更快,但这是过去遗留问题,不太可能改变。

process.nextTick()会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用process.nextTick(),会导致出现I/O starving(饥饿)的问题。

Demo1:

const fs = require('fs')
const starttime = Date.now()
let endtime
 
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
 
let index = 0
 
function handler () {
  if (index++ >= 1000) return
  console.log(`nextTick ${index}`)
  process.nextTick(handler)
  // console.log(`setImmediate ${index}`)
  // setImmediate(handler)
}
 
handler()

Demo2:

const fs = require('fs')
const starttime = Date.now()
let endtime
 
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
 
let index = 0
 
function handler () {
  if (index++ >= 1000) return
   console.log(`setImmediate ${index}`)
   setImmediate(handler)
}
 
handler()

process.nextTick()的运行结果:

nextTick 1
nextTick 2
......
nextTick 999
nextTick 1000
finish reading time: 170

替换成setImmediate(),运行结果:

setImmediate 1
setImmediate 2
finish reading time: 80
......
setImmediate 999
setImmediate 1000

这是因为嵌套调用的 setImmediate() 回调,被排到了下一次event loop才执行,所以不会出现阻塞。

常用的Node服务框架

Node.js 有很多流行的服务框架可供选择,以下是其中一些常用的框架:

  • Express.js Express.js是最流行和广泛使用的Node.js Web应用程序框架。它提供了简洁而灵活的 API,使得构建Web应用程序变得容易。Express.js支持路由、中间件、模板引擎等功能,同时也有一个活跃的社区和丰富的插件生态系统。

    const express = require('express')
    const app = express()
    const port = 3000
    
    app.get('/', (req, res) => {
      res.send('Hello World!')
    })
    
    app.listen(port, () => {
      console.log(`Example app listening on port ${port}`)
    })
    

    Express.js应用支持以下类型的中间件:

    • Application-level middleware
    • Router-level middleware
    • Error-handling middleware
    • Built-in middleware
    • Third-party middleware
  • Koa.js Koa.js是一个由Express.js原作者设计的新一代Node.js Web框架。它通过使用 async/await和更简洁的中间件机制,提供了更优雅的异步编程体验。Koa.js 轻量、易扩展,适合构建高性能的Web应用程序。

    const Koa = require('koa');
    const app = new Koa();
    
    app.use(async ctx => {
      ctx.body = 'Hello World';
    });
    
    app.listen(3000);
    

    Koa.js提供了丰富的middleware, 主要分为以下几种类型:

    • Frameworks
    • Security
    • Body Parsing
    • Parameter Validation
    • Rate Limiting
    • Vhost
    • Routing and Mounting
    • Documentation
    • File Serving
    • HTTP2
    • JSON and JSONP Responses
    • Compression
    • Caching
    • Authentication
    • Sessions
    • Templating
    • CSS Preprocessor
    • Logging
    • i18n or L10n
  • Nest.js Nest.js 是一个用于构建可伸缩和高效的服务器端应用程序的渐进式Node.js框架。它基于TypeScript并借鉴了Angular框架的一些概念和设计模式。Nest.js提供了依赖注入、模块化架构、中间件等特性,帮助开发者构建结构清晰、可维护的应用程序。

    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      await app.listen(3000);
    }
    bootstrap();
    

    中间件写法:

    import { Injectable, NestMiddleware } from '@nestjs/common';
    import { Request, Response, NextFunction } from 'express';
    
    @Injectable()
    export class LoggerMiddleware implements NestMiddleware {
      use(req: Request, res: Response, next: NextFunction) {
        console.log('Request...');
        next();
      }
    }
    
    
  • Hapi.js Hapi.js 是一个可扩展的应用程序框架,适用于构建大型和复杂的 Web 服务。它具有强大的路由功能、插件系统和输入验证机制,使得构建 RESTful API 变得简单。Hapi.js 的设计注重可测试性和可靠性。

    'use strict';
    
    const Hapi = require('@hapi/hapi');
    const init = async () => {
        const server = Hapi.server({
            port: 3000,
            host: 'localhost'
        });
        await server.start();
        console.log('Server running on %s', server.info.uri);
    };
    process.on('unhandledRejection', (err) => {
        console.log(err);
        process.exit(1);
    });
    
    init();
    

    插件编写方式:

    'use strict';
    
    const myPlugin = {
        name: 'myPlugin',
        version: '1.0.0',
        register: async function (server, options) {
    
            // Create a route for example
    
            server.route({
                method: 'GET',
                path: '/test',
                handler: function (request, h) {
    
                    return 'hello, world';
                }
            });
    
            // etc ...
            await someAsyncMethods();
        }
    };
    
  • Socket.io Socket.io是一个用于构建实时应用程序的库,它基于WebSocket协议提供了跨浏览器的双向通信。Node.js与Socket.io结合使用,可以构建实时聊天应用、实时协作工具等具有实时数据交互需求的应用程序。

    Socket.IO由两部分组成:

    • socket.io:一个集成了Node.js HTTP Server的服务
    • socket.io-client:一个客户端包, 用于在浏览器端加载

    服务端实现:

    const express = require('express');
    const app = express();
    const http = require('http');
    const server = http.createServer(app);
    const { Server } = require("socket.io");
    const io = new Server(server);
    
    app.get('/', (req, res) => {
      res.sendFile(__dirname + '/index.html');
    });
    
    io.on('connection', (socket) => {
      console.log('a user connected');
    });
    
    server.listen(3000, () => {
      console.log('listening on *:3000');
    });
    

    客户端使用:

    <script src="/socket.io/socket.io.js"></script>
    <script>
      var socket = io();
      io.on('connection', (socket) => {
          console.log('a user connected');
          socket.on('disconnect', () => {
            console.log('user disconnected');
          });
        });
    </script>
    

除了以上的列举的服务框架,国内也提供有Egg.js、ThinkJS、midway、koa2/koa、Nodeclub等开源框架,并且在开发者社区中得到了广泛应用和支持,有活跃的开发者社区提供技术支持和贡献插件和组件。

总结

Node.js基于V8引擎提供了Javascript运行时环境,在运行时(JIT)将Javascript代码转换为环境可执行的机器码。通过底层的Libuv库,Node.js提供I/O、TCP/UDP、DNS操作能力。除此之外,基于Libuv提供的Event loop,Node.js实现非阻塞、可扩展的网络服务应用。

参考

  1. Finally understanding Node.js internals.
  2. Node.js Event Loop
  3. The Node.js Event Loop, Timers, and process.nextTick()