本文主要借助 libuv
来简单的了解一下 Node.js
中的事件轮询机制相关概念,注意与浏览器中的 EventLoop
区分开来,下面我们就先来看看什么是 libuv
关于浏览器中的
EventLoop
详细可见 JavaScript 并发模型
libuv
libuv
是一个高性能的,事件驱动的 I/O
库,并且提供了跨平台(如 windows
, linux
)的 API
,如果想要参考更为详细的内容可以参考 An Introduction to libuv,中文教程可以参考 libuv 的中文教程 和 libuv,下面我们就正式的来看看 libuv
,libuv
的 官方文档 在阐述其架构的时候有这么一张图,如下
仅仅凭着这么一张图并不能让我们对其内部机制理解得透彻,简单来说,在 Node.js
里面 V8
充当的角色更多的是语法解析层面,另外它还充当了 JavaScript
和 C/C++
的桥梁,但是我们都知道 Node.js
中一切皆可异步,但这并不是通过 V8
来实现的,充当这个角色的其实就是 libuv
,比如一个简单的 JavaScript
异步代码,使用 setTimeout
就可以实现
1 | setTimeout(function () { console.log('timeout 0') }, 0) |
想要深挖为什么会出现这样的结果,要首先来研究一下 libuv
的事件轮询机制
事件轮询机制
事件轮询机制是一个执行模型,在不同的地方有不同的实现,浏览器和 Node.js
基于不同的技术实现了各自的 EventLoop
,但是不要混淆 Node.js
和浏览器中的 EventLoop
简单来讲,Node.js
的 event
是基于 libuv
,而浏览器的 EventLoop
则在 HTML 5 的规范中明确定义,libuv
已经对 EventLoop
作出了实现,而 HTML 5
规范中只是定义了浏览器中 EventLoop
的模型,具体实现留给了浏览器厂商
在 libuv
中,有一个句柄(handle
)的概念,每个句柄中存储数据和回调函数之类的信息,句柄在使用前要添加到对应的队列(Queue
)或者堆(Heap
)中,其实只有定时器句柄使用了 最小堆 的数据结构,其他句柄使用队列的数据结构进行存储,libuv
在进行每一次事件轮询的时候都会从每个类型的句柄中,取出关联的队列或者堆结构进行处理,流程图如下所示
Node.js
的 EventLoop
分为六个阶段,每个阶段的作用如下
timers
,执行setTimeout()
和setInterval()
中到期的callback
I/O callbacks
,上一轮循环中有少数的I/O callback
会被延迟到这一轮的这一阶段执行idle, prepare
,仅内部使用poll
,最为重要的阶段,执行I/O callback
,在适当的条件下会阻塞在这个阶段check
,执行setImmediate
的callback
close callbacks
,执行close
事件的callback
,例如socket.on('close',func)
如果想要具体了解其内部执行流程,可以参考这篇文章中的 源码解析部分,我们这里只是简单介绍一下其执行的流程,上面的图片可以简化成下面的流程
1 | ┌───────────────────────┐ |
EventLoop
的每一次循环都需要依次经过上述的阶段,每个阶段都有自己的 callback
队列,每当进入某个阶段,都会从所属的队列中取出 callback
来执行,当队列为空或者被执行 callback
的数量达到系统的最大数量时,进入下一阶段,这六个阶段都执行完毕称为一轮循环,下面我们来分类查看
timer
阶段- 在
timer
阶段其实使用一个最小堆而不是队列来保存所有元素(其实也可以理解,因为timeout
的callback
是按照超时时间的顺序来调用的,并不是先进先出的队列逻辑),然后循环取出所有到期的callback
执行 - 其实简单来说就是,检查定时器,如果到了时间,就执行回调,其中这些定时器就是
setTimeout
、setInterval
- 在
I/O callbacks
阶段- 根据
libuv
的文档,一些应该在上轮循环poll
阶段执行的callback
,因为某些原因不能执行,就会被延迟到这一轮的循环的I/O callbacks
阶段执行,换句话说这个阶段执行的callbacks
是上轮残留的
- 根据
idle
和prepare
阶段idle
和prepare
回调,仅仅在内部使用
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 | setTimeout(() => { |
但是需要注意的是,Node.js
版本在 >= 11
和在 11
以下的会有不同的表现,我们先来看 >= 11
的情况,它会和浏览器表现一致,一个定时器运行完立即运行相应的微任务,输出结果如下
1 | timer1 |
而在版本小于 11
的情况下,对于定时器的处理是:
- 若第一个定时器任务出队并执行完,发现队首的任务仍然是一个定时器,那么就将微任务暂时保存,『直接去执行』新的定时器任务
- 当新的定时器任务执行完后,再『一一执行』中途产生的微任务
因此会打印出这样的结果
1 | timer1 |
线程模型
最后的部分,我们再来简单的了解一下 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/O
即 Input/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 | let fs = require('fs') |
执行代码的过程中大概发生了以下这些事情
- 首先,
fs.readFile
调用Node.js
的核心模块fs.js
- 接下来,
Node.js
的核心模块调用内建模块node_file.cc
,创建对应的文件I/O
观察者对象 - 最后,根据不同平台(
linux
或者windows
),内建模块通过libuv
中间层进行系统调用
流程也就如下图所示
libuv 调用过程拆解
下面我们再来看看 libuv
中是如何来进行进行系统调用的,也就是 uv_fs_open()
中做了些什么?
创建请求对象
以 windows
系统为例来说,在这个函数的调用过程中,我们创建了一个文件 I/O
的请求对象,并往里面注入了回调函数
1 | req_wrap -> object_ -> Set(oncomplete_sym, callback) |
req_wrap
便是这个请求对象,req_wrap
中 object_
的 oncomplete_sym
属性对应的值便是我们 Node.js
应用程序代码中传入的回调函数
推入线程池,调用返回
在这个对象包装完成后,QueueUserWorkItem()
方法将这个对象推进线程池中等待执行,至此现在 JavaScript
的调用就直接返回了,我们的 JavaScript
应用程序代码可以继续往下执行,当然,当前的 I/O
操作同时也在线程池中将被执行,这不就完成了异步么,但是别高兴太早,因为回调还没有执行,所以接下来便是执行回调通知的环节
回调通知
事实上现在线程池中的 I/O
无论是阻塞还是非阻塞都已经无所谓了,因为异步的目的已经达成,重要的是 I/O
完成后会发生什么,不过在此之前,我们先来看两个比较重要的方法 GetQueuedCompletionStatus
和 PostQueuedCompletionStatus
- 还记得之前提到过的
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
采用多线程的方式,由EventLoop
、I/O
观察者,请求对象、线程池四大要素相互配合,共同实现