node.js中的核心概念

1,229 阅读10分钟

阅读之前你需具备的知识

  • 进程和线程是什么,它们区别又是什么?
  • 单线程和多线程的区别?

Node.js特点

  • 单线程

    同步进行,只有前面的代码执行完了才会往下面执行。但是node.js程序在宏观上看是并行的,这是由于它具有非阻塞I/O和事件驱动的特点。

    好处:减少了内存开销,不再有进程创建,销毁的开销;比较简单。

    坏处:一个用户造成了线程的崩溃,整个服务就都崩溃了。

  • 非阻塞I/O

    I/O会阻塞代码的执行,极大地降低了程序的执行效率。

    而非阻塞模式下,一个线程永远在执行计算操作,这个线程的CPU核心利用率永远是100%。

  • 事件驱动

    在node中,在一个时刻只能执行一个事件回调函数,但是在执行一个回调函数的中途,可以转而处理其他事件(I/O事件,网络请求...),这些事件需要消耗比较多的时间,把这些异步操作都先添加到事件队列的后面,然后返回继续执行原事件的回调函数,这称为事件循环机制。

    底层C++代码中,近半数都用于事件队列、回调函数队列的构建。

适用场景

从node.js的特点中我们可以发现,它最擅长的就是任务调度,如果业务中过度占用了CPU进行计算,实际上也相当于这个计算阻塞了这个单进程,这就不适合用Node.js开发。

应用程序需要处理大量并发的I/O,而在向客户端发出响应之前,应用程序内部并不需要进行非常复杂的处理的时候,就比较适合用Node.js开发。

Node.js也适合与Web socket配合,开发长连接的实时应用程序

事件循环

执行栈和事件队列

  • 执行栈

    当我们调用一个方法的时候,js会生成一个与这个方法相对应的执行环境。而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。

  • 事件队列

    当我们发起异步请求后,主线程并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。等异步任务返回结果后,该异步任务按照执行顺序,加入到与执行栈不同的另一个队列,这个队列被称为事件队列。

由此我们知道,在Node环境中javascript运行的线程是单线程的,事件循环的线程也是单线程的,但这两个不是一个线程。

JavaScript 事件循环机制分为浏览器和 Node 事件循环机制,浏览器 Event Loop 是 HTML 中定义的规范,Node Event Loop 是由 libuv 库实现。我们先来了解一下浏览器的事件循环~

浏览器事件循环

浏览器中的事件循环,主要就在理解宏任务和微任务这两种异步任务。

  • 宏任务(macrotask) setTimeOut 、 setInterval 、 setImmediate 、 I/O 、 各种callback、 UI渲染 、messageChannel等。

优先级:主代码块 > setImmediate > postMessage > setTimeOut/setInterval

  • 微任务(microtask) process.nextTick 、Promise 、MutationObserver 、async(实质上也是promise)

优先级:process.nextTick > Promise > MutationOberser

执行分区:

我们常常吧EventLoop中分为 内存、执行栈、WebApi、异步回调队列(包括微任务队列和宏任务队列)。 第一次事件循环中,JavaScript 引擎会把整个 script 代码当成一个宏任务执行,执行完成之后,再检测本次循环中是否寻在微任务,存在的话就依次从微任务的任务队列中读取执行完所有的微任务,再读取宏任务的任务队列中的任务执行,再执行所有的微任务,如此循环。JS 的执行顺序就是每次事件循环中的宏任务-微任务。

Node.js事件循环

Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv。libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

根据上图,我们可以了解到Node.js的运行机制分为一下几个步骤:

  • 我们写的js代码会交给v8引擎进行处理。
  • 解析后的代码会调用NodeApi,再交由libuv库去执行。
  • libuv会将不同的任务分配给不同的线程,形成一个事件循环(Event Loop)。
  • 任务处理完成后会以异步的方式将执行结果返回给V8引擎、再由v8返回给我们。

Node.js事件循环原理

1.timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调

2.I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调

3.idle, prepare 阶段:仅node内部使用

4.poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里

5.check 阶段:执行 setImmediate() 的回调

6.close callbacks 阶段:执行 socket 的 close 事件回调

我们重点看timers、poll、check这3个阶段,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。

  • timers 阶段

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

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。

  • poll 阶段

    poll 阶段主要有2个功能:

    1.处理 poll 队列的事件。

    2.当有已超时的 timer,执行它的回调函数。

event loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来event loop会去检查有无预设的setImmediate(),分两种情况:

1.若有预设的setImmediate(), event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列。

2.若没有预设的setImmediate(),event loop将阻塞在该阶段等待。

我们发现,没有setImmediate()会导致event loop阻塞在poll阶段,那这样之前设置的timer岂不是执行不了了?

所以,在poll阶段执行的时候,会传入一个timeout超时时间,该超时时间就是poll阶段的最大阻塞时间。timeout时间未到的时候,如果有事件返回,就执行该事件注册的回调函数。timeout超时时间到了,则退出poll阶段,执行下一个阶段。

  • check 阶段

setImmediate()的回调会被加入check队列中,check阶段的执行顺序在poll阶段之后。

总结

  • node 的初始化

    • 初始化 node 环境。
    • 执行输入代码。
    • 执行 process.nextTick 回调。
    • 执行 microtasks。
  • 进入 event-loop

    • 进入 timers 阶段

      • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
      • 检查是否有 process.nextTick 任务,如果有,全部执行。
      • 检查是否有microtask,如果有,全部执行。
      • 退出该阶段。
    • 进入IO callbacks阶段。

      • 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
      • 检查是否有 process.nextTick 任务,如果有,全部执行。
      • 检查是否有microtask,如果有,全部执行。
      • 退出该阶段。
  • 进入 idle,prepare 阶段:

    • 这两个阶段与我们编程关系不大,暂且按下不表。
  • 进入 poll 阶段

    • 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。

      • 第一种情况:

        • 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调。
        • 检查是否有 process.nextTick 回调,如果有,全部执行。
        • 检查是否有 microtaks,如果有,全部执行。
        • 退出该阶段。
      • 第二种情况:

        • 如果没有可用回调。
        • 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
    • 如果不存在尚未完成的回调,退出poll阶段。

  • 进入 check 阶段。

    • 如果有immediate回调,则执行所有immediate回调。
    • 检查是否有 process.nextTick 回调,如果有,全部执行。
    • 检查是否有 microtaks,如果有,全部执行。
    • 退出 check 阶段
  • 进入 closing 阶段。

    • 如果有immediate回调,则执行所有immediate回调。
    • 检查是否有 process.nextTick 回调,如果有,全部执行。
    • 检查是否有 microtaks,如果有,全部执行。
    • 退出 closing 阶段
  • 检查是否有活跃的 handles(定时器、IO等事件句柄)。

    • 如果有,继续下一轮循环。
    • 如果没有,结束事件循环,退出程序。

在事件循环的每一个子阶段退出之前都会按顺序执行如下过程:

  • 检查是否有 process.nextTick 回调,如果有,全部执行。
  • 检查是否有 microtaks,如果有,全部执行。
  • 退出当前阶段。

🌰

// 在node环境下,请输出以下代码的执行结果
console.log('1');

process.nextTick(function() {
    console.log('2');
});

setTimeout(() => {
  console.log('3');
}, 0);

async function async1() {
    console.log('4');
    await async2();
    console.log('5');
}

async function async2() {
	console.log('6');
}

async1();

new Promise(function(resolve) {
    console.log('7')
    resolve();
}).then(function() {
    console.log('8')
});

console.log('9');
  • 按照js由上到下的执行顺序,先遇到同步任务console输出1。

  • 再往下执行遇到process.nextTick,回调加入微任务队列。

  • 接着遇到setTimeout,setTimeout是宏任务,会先放到宏任务队列中。

  • promise中的异步体现在then和catch中,所以写在promise中的代码是被当做同步任务立即执行的。而在async/await中,在出现await出现之前,其中的代码也是立即执行的。async修饰的函数,默认返回 new Promise对象的resolve内容(若被async修饰的函数无返回值,则最终无返回值)。async方法内部,当程序执行到await方法时,会阻塞await方法后面的程序,进入await方法内部并执行到return前,然后跳出该async方法,执行与该async方法并列的同步任务。

    所以,接下来遇到async/await时,执行async1以及await后面的函数,先立刻输出4,6,然后先跳出async1(等到与async1方法并列的同步代码执行完后,跳回async1内部),此时await返回值为非Promise,继续执行async函数后面的代码。

    紧接着遇到promise,先立刻输出7,然后将then回调加到微任务队列。

  • 再往下遇到同步任务输出9。

  • 此时执行栈中的任务已经清空。由于微任务的优先级高于宏任务,所以会先执行微任务队列中的回调。因为微任务队列中nextTick任务在Promise.next前,所以先执行异步任务process.nextTick,接着是await返回结果后跳回async1内部继续执行async函数后面的代码,最后是then中的回调,所以输出结果依次为2,5,8。

  • 微任务队列清空,该轮循环结束,但是发现事件队列中还有任务,开始进入下一轮循环。

  • 新的一轮循环由timers阶段开始,发现有对应的setTimeout回调,输出3。

最终输出结果即为:1、4、6、7、9、2、5、8、3

Node.js 与浏览器的 Event Loop 差异

浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。 Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

参考

剖析nodejs的事件循环: juejin.cn/post/684490…

深入浅出NodeJS事件循环: juejin.cn/post/691676…