前段时间的时候看了一个外国哥们的视频,讲的是JS的事件循环的,想了一下自己在这方面的文章看的还是很多的,可是还是很多都是看了就忘,趁着这个机会,也对这个知识点做一个梳理和总结。
因为本菜鸡还是一个node开发,并不是一个正统的前端,所以在聊完JS的事件循环之后,还会顺带聊一下node上的事件循环有什么不一样的地方。
JavaScript的事件循环(Event Loop)
在说事件循环前,先聊聊这些
在说Event loop之前,先简单的说一些基本的概念,我想如果在面试的时候,能够按照这个顺序去说,也是可以有一定的加分的。
堆,栈,队列,宏任务,微任务
堆:它是一种数据结构,是一种由二叉树维护的一组一维数组,它是一种线性的数据结构,堆一共可以分为两种,分别是最大堆和最小堆。
栈: 它同样也是一种数据结构,它的存储规则是后进先出,也就是先进入的数据会被压入栈底,然后后进入的数据先从顶部弹出
队列:同栈非常的相似,它同样也是一个线性的数据结构,不过它的区别是,数据必须由队列的队尾入队,由队头出队,也就是满足先进先出的情况。
在JS中的任务一共可以分为两种,一种是宏任务,一种是微任务。
宏任务包括:同步执行的所有代码,setTimeout,setInterval,setImmediate,所有的I/O,UI渲染等
微任务包括:process.nextTick(node独有),promise,还有一个废弃的我忘了.....
JS是怎么做到异步的
相信大家都知道JS是一门异步的语言,那么它是怎么做到异步的呢,答案就是因为有了这个事件循环(在node里面是通过libuv来实现的)。
所有的同步任务都会被推入JS的执行栈中,通过后进先出的概念,来执行相对应的任务。当有异步任务出现的时候,它会被推入一条幕后线程中去,当它执行完之后,这条幕后线程会把这些任务的回掉函数推入到一个任务队列中去等待执行,当主线程的任务执行完成之后,事件循环就会把任务队列中的一个个任务重新推入执行栈中去执行,如果当前任务队列为空,它就会一直不停的循环,等待任务的到来,这也就是JS事件循环的原理。
通过视频那哥们的一张图是说明
事件循环是怎么处理那么多任务的
这一个也是JS整个事件循环的一个核心,在事件循环的处理任务的过程中,它优先处理的是宏任务,然后再处理微任务。
总结起来规则可以是这样的
1.检查宏任务队列,看是否为空,如果不为空,则进行第二步,如果为空则跳到第三步。
2.从宏任务队列的头部,取出一个任务(注意:这里只是取出一个,而不是全部)推入执行栈中,进行执行,然后进行下一步。
3.检查微任务队列是否为空,如果不为空,则进行下一步,如果为空,则回到第一步,重新开始新一轮的循环。
4.从微任务队列的头部取出一个任务,推入执行栈中去执行,执行完成之后再返回第三步继续检查。
用一段代码来演示这个的话,可以是这样的
console.log('开始')
setTimeout(() => {
console.log('serTimeout一号')
Promise.resolve()
.then(() => {
console.log('promise三号')
})
.then(() => {
console.log('promise四号')
})
}, 0)
new Promise((resolve) => {
console.log('promise一号')
resolve()
}).then(() => {
console.log('then一号')
})
setTimeout(() => {
console.log('setTimeout二号')
Promise.resolve().then(() => {
console.log('promise二号')
})
}, 0)
console.log('结束')
上面这一段代码的执行顺序是这样的
第一步,执行同步的console.log(),serTimeout一号二号被推入任务队列中,有一点需要注意的是其实在执行同步任务的时候,就已经实在执行宏任务了,所以,根据我上面说的,执行完宏任务,就会去执行微任务,也就是一号promise,也就是then一号。
第二步,接着进行第二轮的循环,也就是重新执行宏任务队列,也就是serTimeout一号,执行一个宏任务之后,接着去执行微任务队列,也就是promise三号和四号,在检查到微任务队列为空,则进入下一次循环。
第三步,同样回到第三次循环,接着执行setTimeout二号,以及promise二号。
所以总的执行顺序是下方这样的。
Node里面的事件循环
说完了JS的事件循环,接下来咱们也顺带聊一下Node里面的事件循环吧,虽然大体的思路是一样的,但是也有一些差别在里面。
Node到底是不是单线程
其实Node既可以说是单线程,但也可以说是多线程,Node最相关的就是JS,而我们知道JS就是一门单线程语言,可是又不是很对,因为除了主线程以外,还有网络的I/O线程这些在后面辅助着Node的运行,但是这些是不需要我们开发去关心的,我们需要关心的只有一条主线程就可以了,其他的交给Node的事件循环器去解决就可以了,也就是libuv。
到底什么是同步,异步,阻塞和非阻塞
这里引用一个我在一篇博客下面看到的一个说法,非常的大白话。
假设我们发起一个请求就是小明约小红去玩,然后小红需要化妆,就让小明在楼下等着。
同步阻塞:小明就在小红家楼下杵着,知道小红画完妆后下来找他。
同步非阻塞:小明跟小红楼下的门卫老大爷聊天吹水,但还是会时不时的去喵一下楼梯口,看小红是不是已经下来了。
异步阻塞:小明麻烦门卫的老大爷,如果小红下楼来了,就告诉他,然而他还是在原地站着,等待着大爷告诉他小红好了。
异步非阻塞:小明麻烦门卫大爷,然后自己就跑去隔壁的网吧打起紧张刺激的英雄联盟,然后等门卫大爷叫他的时候,他才关掉它0:12:0的亚索回去找小红。
Node是怎么做到异步非阻塞的
这里就要先上一张非常著名的图片,相信大家都见过这张图
没错,支撑起Node的整个事件循环的不是V8,而是另一个C++写的一个文件,也就是libuv,那么它到底是怎么运行的呢,这里我们可以把它分为六个步骤。
上一张官网的图
我们可以把整体的事件循环分为六个阶段,分别是timer,pending callback,idle,poll,check,还有close。
其中pending是执行一些延长到下一个循环的I/O回调,idle是内部调用的,而close主要是运用于一些如同socket.on()这样的阶段。
我们作为开发者,重点关注的三个阶段是timer,poll,还有check阶段即可。
timer:这个阶段主要执行的是setTimeout,还有setInterval这些定时任务的时间到了之后的回调任务。
poll:这个阶段也叫轮询阶段,这个也是我们需要重点关注的阶段。
check:这个阶段主要执行的是setImmediate的回调函数。
那么接下来我们一个个的解释上面的这三个阶段
setTimeout真的准的吗
前面我们提到了,setTimeout这些定时器是在timer这个阶段执行回掉的,那么它真的可以非常准确的执行我们定下的时间吗?
我们来看一下下面的这段代码的执行的情况
const nowTime = new Date()
setTimeout(() => {
new Promise((res) => {
let time = new Date()
while (time - nowTime < 200) {
time = new Date()
}
res()
}).then(() => {
console.log('请求成功')
})
}, 0)
setTimeout(() => {
const time = new Date()
console.log(345, time - nowTime)
}, 100)
用时为200ms的I/O请求,那么底下的100ms的定时器到底能不能准时的执行呢,打印出来的结果是这样的
我们可以看到,定时器并没有在100ms的时候马上执行,而是时间为200+的毫秒数,这是为什么呢。
其实我们所谓的定时器并不是说定好那个时间点,就去跑它的回调函数,而是设置一个阔值,它的回掉函数是在这个最小的阔值之后尽快的运行,也就是说,是有可能值执行的时间是会超过这个阔值的,所以上面的例子中,两个任务同时进入到timer队列,第一个任务执行所花的时间为200ms,所以当事件循环回来看到第二个定时任务的时间为100ms,已经小于200了,会被立马执行,但是时间上来说,已经超过了100ms了。
也就是说,每次事件循环开始的时候,它会检查timer阶段有没有已经到达阔值的定时任务,如果有就执行,没有就进入到下一个阶段。
究竟轮询是什么意思
轮询,也就是poll阶段,根据官网的说法,这个阶段一共有两个功能:
- 执行I/O回调。
- 处理轮询队列中的异步事件。
当进入到轮询阶段的时候,会有以下几种情况发生
- 轮询队列不为空,则循环取出队列中的事件来进行执行,知道队列变成空队列为止。
- 如果队列为空,又会有下面的两种情况发生。 2.1 判断是否设置了setImmediate,如果设置了,则直接跳过poll阶段,进入到下一个check的阶段去执行setImmediate中的回调。
2.2 如果没有设置setImmediate并且轮询队列为空,则它会变成阻塞的状态,知道有下一个回调任务进来,供他执行。
2.3 如果设置了timer事件,并且刚好轮训队列为空,且timer中的定时器已经超时了,那么就会回到第一阶段去执行timer中的回调函数.
setImmediate 和 setTimeout 之间有什么区别
在说明两者之间的区别之前,我们先来看一段代码
setTimeout(() => {
console.log(123)
}, 0)
setImmediate(() => {
console.log(222)
})
上面这段代码的执行顺序是这样的
那么下面这段代码的执行结果又会有什么不同呢
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
当我们执行下面的这段代码的时候,输出的结果是
第一次输出的结果,其实是受到了进程的性能上的限制,执行的结果是有可能不同的,也就是两个可能会反过来。
但是第二种情况输出的结果却是一定setImmediate > setTimeout ,这是因为在I/O的模块内执行的时候,它会跳过前面所有的阶段,直接进入到轮询阶段,因为轮询队列为空,然后又设置了setImmediate,所以会直接跳到check阶段,去执行setImmediate,然后再通过第二次循环,再去执行setTimeout。
Node独有的微任务 —— process.nextTick
这其实是一个独立于事件循环之外的一个异步API,它会在所有的事件循环的阶段执行完了之后去优先执行的一个异步事件。
也就是说,任意一个阶段如果设置了nextTick,当该阶段执行完成之后,libuv都会优先去清空nextTick队列中的回调事件,这里我举个例子来说明情况。
setTimeout(() => {
process.nextTick(() => {
console.log('tick1')
})
console.log('setTimeout1')
}, 0)
setImmediate(() => {
process.nextTick(() => {
console.log('tick2')
})
console.log('setImmediate')
})
new Promise((res) => {
process.nextTick(() => {
console.log('tick3')
})
console.log('promise')
res()
}).then(() => {
process.nextTick(() => {
console.log('tick4')
})
console.log('then')
})
它的执行结果也是下面这种情况
promise
tick3
then
tick4
setTimeout1
tick1
setImmediate
tick2
可以看出,再每一个阶段完成的时候,如果该阶段有配置process.nextTick,那么就会在该阶段完成的时候,下一个阶段开始之前触发一次,那么,这样子的设计有什么作用呢。
首先我们知道,在执行事件循环的时候,当执行完一个宏任务的时候,就会去清空微任务队列,在这个过程中,是处于一个阻塞的状态,例如下面这种情况
setTimeout(() => {
process.nextTick(() => {
console.log('tick1')
})
new Promise((res) => {
console.log('promise')
res()
})
.then(() => {
console.log('then1')
})
.then(() => {
console.log('then2')
})
.then(() => {
console.log('then3')
})
console.log('setTimeout1')
}, 0)
setImmediate(() => {
console.log(111)
})
当promise不执行完的时候,是不会执行到下一个阶段的,这时候就会对线程造成阻塞,那么这个时候就可以在nextTick这个回掉函数里面,对接下来要执行的promise进行一定的限制,从而避免这种无限制阻塞的情况发生。(这块我是这么理解的,如果有不对的地方,欢迎评论指出)
最后
这是一片篇记录帖,我是一名毕业快一年的node练习生,如果有朋友想要和我一起背头,请老哥一键三联!!!!爱你们!!!