Node中的非阻塞以及事件循环

什么是非阻塞

  • 我们应该都听过node是单线程的,这句话其实对也不对,因为一个进程往往是包含多个线程的,一个用node搭建的应用运行在一个计算机分配到进程中里面包含有多个线程,其中有且仅有一个线程是用来处理JavaScript脚本代码的,这也就是node是单线程的由来,其他的线程则是用来处理Node多任务的I/O操作,由此可见,我们也可以认为Node是多线程的,那么既然Node只有一个线程去处理js代码,那为什么只有单线程的node又能号称轻松应对高并发的场景的?

image

  • 要知道,JavaScript本身是一个单线程阻塞的同步语言,js引擎在处理js代码的时候会初始化一个函数调用栈,这个调用栈是唯一的,他的特点是,当调用栈被一个task()占用的时候,他说不能够执行其他脚本的,我们可以看一下下面一段代码:
const a = ()=>{
console.log('我是a');
}
const b = ()=>{
a()
}
b() // 我是a

/*
首先初始化调用栈 b()入栈执行 -> a()入栈执行 -> 输出log -> a()出栈 -> b()出栈 -> 调用栈清空
*/

image

  • 下面我们修改一下上面的这段代码,加入一个while死循环
const a = ()=>{
console.log('我是a');
}
const b = ()=>{
while (true) {}
a()
}
b() // 控制台没有输出
/*
调用栈被b()死循环占用,无法执行其他脚本
*/

image

  • 经过上述两者的对比我们可以看出,所谓node单线程就是当函数调用栈被占用时,js是无法去处理其他的请求的

非阻塞

  • 下面我用一个贴近业务开发的例子来讲讲,Node中的非阻塞

image

上面的例子我们可以看到,我们搭建一个node服务器,当服务器启动的时候,node会开辟一条线程去处理请求,同时初始化一个函数调用栈,当服务器接收到一个请求的时候,线程开始工作,第二行代码开始执行,parse()入栈执行获取参数,执行完成后出栈,耗时1ms,随后执行read()读取磁盘文件操作(属于is操作,比较耗时),占用栈时间,耗时50ms后出栈,随后send()入栈,将结果返回给客户端耗时1ms后出栈,随后task()执行完毕出栈,函数栈清空,node处理整个请求耗时52ms,感觉还是很快的,但是如果当有1000个请求过来,node排队处理这1000个请求,那么后面的请求的等待处理时间会非常的唱,这显然是不行的.

因此node要想在单线程上更快的处理请求就需要将阻塞线程以及长时间占用函数栈的操作尽快的清理出去,不要让耗时的操作长期占用调用栈以及线程,由此异步模块以及事件循环就因此诞生了!如下图所示

image

  • 上图可见,node借助事件循环以及异步模块实现了非阻塞的运行机制,异步模块主要负责处理耗时的I/O操作以及一些异步api的运行,他本身是多线程的,它可以同时处理多个任务,事件循环则负责监听以及派发事件,他通过轮询的方式及不停的监听异步模块的处理进度,等处理完成后,事件循环将回调函数派发到函数调用栈中去执行,也就是所谓事件的调度者,这里有一点要注意,事件循环并不占据单独的线程

image

由上面的动图中可以看出,node在执行高并发操作的时候,并不是以队列的形式同步执行完成每一个请求的,它是将一些耗时的异步操作以及I/O操作丢给异步模块处理,将一些不耗时的同步代码放到调用栈中执行,在ChromeV8引擎的加持下,主线程能够以飞快的速度处理每一个请求,完成调用栈的每一次清空操作,但是调用栈每一次清空后都会查看事件循环中是否有经异步模块处理好的回调函数可执行,如果没有就执行下一个请求;当异步模块处理好那些耗时的操作后,会将处理好的回调函数推入事件循环中,等下一次调用栈清空后调用该回调,这就是node的非阻塞执行顺序以及原理.

image

总结:

  • node将耗时的异步I/O操作全部丢给异步模块处理,当异步操作有结果后,就会将该结果(回调函数)推入事件循环中等待执行,事件循环会在调用栈清空时按照某一个优先级的顺序推入到调用栈之中去执行,而这个顺序就是事件循环.

事件循环

  • 事件循环之所以存在,是因为在一段JavaScript脚本中,js代码并总是按顺序同步执行的,无论是在node中还是浏览器中,为了不阻塞线程,很多情况下一些代码是通过回调的方式去异步执行的,这也就是异步编程为什么在node中是一种常态,js代码被打乱就需要一种机制去协调各个事件的执行顺序,这种机制就是 – 事件循环

下面是node中基本上所有的异步api操作

image

  • 针对以上三类的异步操作,node中的事件循环在内部初始化了三个任务队列:
    1. Timer队列:用于处理定时器的回调
    2. Poll队列:用于处理I/O操作的回调,事件循环会在空闲的情况下在这里暂停,以等待新的I/O事件
    3. Check队列: 用于处理node内部的异步操作回调(如setImmediate)

如下图

image

下面展示前几个队列的事件循环执行顺序

  • Timer队列以及Poll队列

image

  • 存在Check队列,这里使用node自带的异步api(setImmediate),他会在事件循环初始化的时候就推入Check队列中去

image

  • 微任务以及process.nextTick

image