Node.js 中的事件轮询机制

Node.js 中的事件轮询机制

本文主要借助 libuv 来简单的了解一下 Node.js 中的事件轮询机制相关概念,注意与浏览器中的 EventLoop 区分开来,下面我们就先来看看什么是 libuv

关于浏览器中的 EventLoop 详细可见 JavaScript 并发模型

libuv

libuv 是一个高性能的,事件驱动的 I/O 库,并且提供了跨平台(如 windows, linux)的 API,如果想要参考更为详细的内容可以参考 An Introduction to libuv,中文教程可以参考 libuv 的中文教程libuv,下面我们就正式的来看看 libuvlibuv官方文档 在阐述其架构的时候有这么一张图,如下

仅仅凭着这么一张图并不能让我们对其内部机制理解得透彻,简单来说,在 Node.js 里面 V8 充当的角色更多的是语法解析层面,另外它还充当了 JavaScriptC/C++ 的桥梁,但是我们都知道 Node.js 中一切皆可异步,但这并不是通过 V8 来实现的,充当这个角色的其实就是 libuv,比如一个简单的 JavaScript 异步代码,使用 setTimeout 就可以实现

1
2
3
4
5
6
7
setTimeout(function () { console.log('timeout 0') }, 0)

console.log('outter')

// outter

// timeout 0

想要深挖为什么会出现这样的结果,要首先来研究一下 libuv 的事件轮询机制

事件轮询机制

事件轮询机制是一个执行模型,在不同的地方有不同的实现,浏览器和 Node.js 基于不同的技术实现了各自的 EventLoop,但是不要混淆 Node.js 和浏览器中的 EventLoop

简单来讲,Node.jsevent 是基于 libuv,而浏览器的 EventLoop 则在 HTML 5 的规范中明确定义,libuv 已经对 EventLoop 作出了实现,而 HTML 5 规范中只是定义了浏览器中 EventLoop 的模型,具体实现留给了浏览器厂商

libuv 中,有一个句柄(handle)的概念,每个句柄中存储数据和回调函数之类的信息,句柄在使用前要添加到对应的队列(Queue)或者堆(Heap)中,其实只有定时器句柄使用了 最小堆 的数据结构,其他句柄使用队列的数据结构进行存储,libuv 在进行每一次事件轮询的时候都会从每个类型的句柄中,取出关联的队列或者堆结构进行处理,流程图如下所示

Node.jsEventLoop 分为六个阶段,每个阶段的作用如下

  • timers,执行 setTimeout()setInterval() 中到期的 callback
  • I/O callbacks,上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段执行
  • idle, prepare,仅内部使用
  • poll,最为重要的阶段,执行 I/O callback,在适当的条件下会阻塞在这个阶段
  • check,执行 setImmediatecallback
  • close callbacks,执行 close 事件的 callback,例如 socket.on('close',func)

如果想要具体了解其内部执行流程,可以参考这篇文章中的 源码解析部分,我们这里只是简单介绍一下其执行的流程,上面的图片可以简化成下面的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌────────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └────────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

EventLoop 的每一次循环都需要依次经过上述的阶段,每个阶段都有自己的 callback 队列,每当进入某个阶段,都会从所属的队列中取出 callback 来执行,当队列为空或者被执行 callback 的数量达到系统的最大数量时,进入下一阶段,这六个阶段都执行完毕称为一轮循环,下面我们来分类查看

  • timer 阶段
    • timer 阶段其实使用一个最小堆而不是队列来保存所有元素(其实也可以理解,因为 timeoutcallback 是按照超时时间的顺序来调用的,并不是先进先出的队列逻辑),然后循环取出所有到期的 callback 执行
    • 其实简单来说就是,检查定时器,如果到了时间,就执行回调,其中这些定时器就是 setTimeoutsetInterval
  • I/O callbacks 阶段
    • 根据 libuv 的文档,一些应该在上轮循环 poll 阶段执行的 callback,因为某些原因不能执行,就会被延迟到这一轮的循环的 I/O callbacks 阶段执行,换句话说这个阶段执行的 callbacks 是上轮残留的
  • idleprepare 阶段
    • idleprepare 回调,仅仅在内部使用
  • poll 阶段
    • 轮询阶段,因为代码中难免会有异步操作,比如文件 I/O,网络 I/O 等,当这些异步操作完成就会通知 JavaScript 主线程,怎么通知呢?就是通过 'data''connect' 等事件使得事件循环到达 poll 阶段,而到达了这个阶段后
      • 如果当前已经存在定时器,而且有定时器到时间了,拿出来执行,EventLoop 将回到 timer 阶段
      • 如果没有定时器, 会去看回调函数队列
        • 如果队列不为空,拿出队列中的方法依次执行
        • 如果队列为空,检查是否有 setImmdiate 的回调
          • 有则前往 check 阶段
          • 没有则继续等待,相当于阻塞了一段时间(阻塞时间是有上限的),等待 callback 函数加入队列,加入后会立刻执行,一段时间后自动进入 check 阶段
  • check 阶段
    • 这是一个比较简单的阶段,直接执行 setImmdiate 的回调
  • close 阶段
    • 循环关闭所有的 closing handles

其实简单的总结一下就是,浏览器和 Node.js 两者最主要的区别在于浏览器中的微任务是在『每个相应的宏任务中』执行的,而 Node.js 中的微任务是在『不同阶段之间』执行的

另外需要注意的是,process.nextTick 是一个独立于 EventLoop 的任务队列,在每一个 EventLoop 阶段完成后会去检查这个队列,如果里面有任务,会让这部分任务优先于微任务执行

实例演示

下面我们通过一个实例来加深一下印象,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
console.log(`timer1`)
Promise.resolve().then(function () {
console.log(`promise1`)
})
}, 0)

setTimeout(() => {
console.log(`timer2`)
Promise.resolve().then(function () {
console.log(`promise2`)
})
}, 0)

但是需要注意的是,Node.js 版本在 >= 11 和在 11 以下的会有不同的表现,我们先来看 >= 11 的情况,它会和浏览器表现一致,一个定时器运行完立即运行相应的微任务,输出结果如下

1
2
3
4
timer1
promise1
time2
promise2

而在版本小于 11 的情况下,对于定时器的处理是:

  • 若第一个定时器任务出队并执行完,发现队首的任务仍然是一个定时器,那么就将微任务暂时保存,『直接去执行』新的定时器任务
  • 当新的定时器任务执行完后,再『一一执行』中途产生的微任务

因此会打印出这样的结果

1
2
3
4
timer1
timer2
promise1
promise2

线程模型

最后的部分,我们再来简单的了解一下 libuv 的线程模型,因为要想实现一个无堵塞的事件轮询必须依靠线程,libuv 中大体上可以把线程分为两类,一类是事件轮询线程,一类是文件 I/O 处理线程

第一类事件轮询线程是单线程,另外一类称其为文件 I/O 处理线程多少有些不准确,因为它不仅能处理文件 I/O,还能处理 DNS 解析,也能处理用户自己编写的 Node.js 扩展中的逻辑,它是一个线程池,如果你想自己编写一个 C++ 扩展来处理耗时业务的话,也会用上它

其实我们平时在听到 Node.js 相关的特性时,经常会对异步 I/O、非阻塞 I/O 有所耳闻,听起来好像是差不多的意思,但其实是两码事,下面我们就以原理的角度来剖析一下对 Node.js 来说,这两种技术底层是如何实现的

什么是 I/O

首先,我们有必要先把 I/O 的概念解释一下,I/OInput/Output,也就是输入和输出的意思,在浏览器端,只有一种 I/O,那就是利用 Ajax 发送网络请求,然后读取返回的内容,这属于网络 I/O,回到 Node.js 中,其实这种的 I/O 的场景就更加广泛了,我们在上面也提到过,主要分为两种

  • 文件 I/O,比如用 fs 模块对文件进行读写操作
  • 网络 I/O,比如 HTTP 模块发起网络请求

阻塞和非阻塞 I/O

阻塞和非阻塞 I/O 其实是针对操作系统内核而言的,而不是 Node.js 本身,阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作

对前者而言,在操作系统进行 I/O 的操作的过程中,我们的应用程序其实是一直处于等待状态的,什么都做不了,那如果换成非阻塞 I/O,调用返回后我们的 Node.js 应用程序可以完成其他的事情,而操作系统同时也在进行 I/O,这样就把等待的时间充分利用了起来,提高了执行效率,但是同时又会产生一个问题,那就是 Node.js 应用程序怎么知道操作系统已经完成了 I/O 操作呢?

为了让 Node.js 知道操作系统已经做完 I/O 操作,需要重复地去操作系统那里判断一下是否完成,这种重复判断的方式就是轮询,对于轮询而言,有以下这么几种方案

  • 一直轮询检查 I/O 状态,直到 I/O 完成,这是最原始的方式,也是性能最低的,会让 CPU 一直耗用在等待上面,其实跟阻塞 I/O 的效果是一样的
  • 遍历文件描述符(即文件 I/O 时操作系统和 Node.js 之间的文件凭证)的方式来确定 I/O 是否完成,I/O完成则文件描述符的状态改变,但 CPU 轮询消耗还是很大
  • epoll 模式,即在进入轮询的时候如果 I/O 未完成 CPU 就休眠,完成之后唤醒 CPU

总之,CPU 要么重复检查 I/O,要么重复检查文件描述符,要么休眠,都得不到很好的利用,所以我们希望的是,Node.js 应用程序发起 I/O 调用后可以直接去执行别的逻辑,操作系统默默地做完 I/O 之后给 Node.js 发一个完成信号,Node.js 执行回调操作就行,这也是最理想的情况,也是异步 I/O 的效果,那如何实现这样的效果呢?

其实在 linux 原生存在这样的一种方式,即(AIO),但两个致命的缺陷

  • 只有 linux 下存在,在其他系统中没有异步 I/O 支持
  • 无法利用系统缓存

是不是没有办法了呢?在单线程的情况下确实是这样,但是如果把思路放开一点,利用多线程来考虑这个问题,就变得轻松多了,下面我们来看看 Node.js 中的异步 I/O 方案

Node.js 中的异步 I/O 方案

其实我们可以让一个进程进行计算操作,另外一些进行 I/O 调用,I/O 完成后把信号传给计算的线程,进而执行回调,这不就好了吗?没错,异步 I/O 就是使用这样的『线程池』来实现的,只不过在不同的系统下面表现会有所差异,在 linux 下可以直接使用线程池来完成,在 windows 系统下则采用 IOCP 这个系统 API(其内部还是用线程池完成的)

有了操作系统的支持,那 Node.js 如何来对接这些操作系统从而实现异步 I/O 呢?这里我们就以文件 IO 处理来作为示例,来看看这两类线程之前是怎么通信的

1
2
3
4
5
let fs = require('fs')

fs.readFile('/test.txt', function (err, data) {
console.log(data)
})

执行代码的过程中大概发生了以下这些事情

  1. 首先,fs.readFile 调用 Node.js 的核心模块 fs.js
  2. 接下来,Node.js 的核心模块调用内建模块 node_file.cc,创建对应的文件 I/O 观察者对象
  3. 最后,根据不同平台(linux 或者 windows),内建模块通过 libuv 中间层进行系统调用

流程也就如下图所示

libuv 调用过程拆解

下面我们再来看看 libuv 中是如何来进行进行系统调用的,也就是 uv_fs_open() 中做了些什么?

创建请求对象

windows 系统为例来说,在这个函数的调用过程中,我们创建了一个文件 I/O 的请求对象,并往里面注入了回调函数

1
req_wrap -> object_ -> Set(oncomplete_sym, callback)

req_wrap 便是这个请求对象,req_wrapobject_oncomplete_sym 属性对应的值便是我们 Node.js 应用程序代码中传入的回调函数

推入线程池,调用返回

在这个对象包装完成后,QueueUserWorkItem() 方法将这个对象推进线程池中等待执行,至此现在 JavaScript 的调用就直接返回了,我们的 JavaScript 应用程序代码可以继续往下执行,当然,当前的 I/O 操作同时也在线程池中将被执行,这不就完成了异步么,但是别高兴太早,因为回调还没有执行,所以接下来便是执行回调通知的环节

回调通知

事实上现在线程池中的 I/O 无论是阻塞还是非阻塞都已经无所谓了,因为异步的目的已经达成,重要的是 I/O 完成后会发生什么,不过在此之前,我们先来看两个比较重要的方法 GetQueuedCompletionStatusPostQueuedCompletionStatus

  • 还记得之前提到过的 EventLoop 吗?在每一个 Tick 当中会调用 GetQueuedCompletionStatus 检查线程池中是否有执行完的请求,如果有则表示时机已经成熟,可以执行回调了
  • PostQueuedCompletionStatus 方法则是向 IOCP 提交状态,告诉它当前 I/O 完成了

所以现在我们可以言归正传,把后面的流程全部串联起来了,当对应线程中的 I/O 完成后,会将获得的结果存储起来,保存到相应的请求对象中,然后调用 PostQueuedCompletionStatus()IOCP 提交执行完成的状态,并且将线程还给操作系统,一旦 EventLoop 的轮询操作中,调用 GetQueuedCompletionStatus 检测到了完成的状态,就会把请求对象塞给 I/O 观察者

I/O 观察者现在的行为就是取出请求对象的存储结果,同时也取出它的 oncomplete_sym 属性,即回调函数,将前者作为函数参数传入后者,并执行后者,至此,回调函数就成功执行了

总结
  • 阻塞和非阻塞 I/O 其实是针对操作系统内核而言的,阻塞 I/O 的特点就是一定要等到操作系统完成所有操作后才表示调用结束,而非阻塞 I/O 是调用后立马返回,不用等操作系统内核完成操作
  • Node.js 中的异步 I/O 采用多线程的方式,由 EventLoopI/O 观察者,请求对象、线程池四大要素相互配合,共同实现

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×