技术背景
javaScript事件循环(Event Loop)是其作为单线程语言但能实现高效异步运行的核心基础。要想更深入的了解js,对事件循环就要有一个清楚地认识。在node出现之后,js的运行环境不再是单一的浏览器,同样的,node中在有事件循环。那么到底什么是事件循环呢?写事件循环的文章有很多,但是对于一些初学者看起来却有些不好理解,本文试着对event loop做一个有表象到宏观的认识,希望把事件循环表述的更加形象化。首先了解几个背景问题。
几个背景问题:
1,什么是线程,进程,二者有什么区别和联系,
进程是计算机系统资源分配的最小单位,该最小单位相互之间有独立的内存,进程有独立的内存空间;
线程是计算机cpu调度分配的最小单位,调度最小单位是cpu中可以独立运行的最小基本单位,线程没有属于自己的内存空间;
一个进程可以有很多线程,每条线程并行执行不同的任务。
形象化理解:
把计算机看做一家公司
进程就是独立的部门,每个部门有自己的资源;
线程就是每个部门的员工,每个员工没有资源,是最小的干活单位,共享部门资源;
2,js为什么是单线程的
这主要和js的用途有关,在node出现之前,js是作为浏览器的脚本语言,主要是实现用户与浏览器的交互,以及操作dom;这决定了它只能是单线程,否则会带来很复杂的同步问题。
举个例子:如果js被设计了多线程,如果有一个线程要修改一个dom元素,另一个线程要删除这个dom元素,此时浏览器就会一脸茫然,不知所措...
单线程和事件循环(event loop)
javaScript是单线程的,在执行代码时只能按顺序执行,为了解决代码执行时的阻塞,所以js是异步的,比如在遇到setTimeout时,不会定时器内容执行过后,再去执行之后的代码,而是先执行代码,等时间到后再去执行定时器。
基于这种异步的机制,javaScript有着一套自己执行代码的规则,来保证代码能够高效无阻塞的运行,这种规则就是事件循环。
node和浏览器都给js提供了运行的环境,但是二者的运行机制是稍有差异的。
浏览器js运行机制
不同的浏览器有不同的js引擎
虽然浏览器不同,不过起内部的事件循环规则是一致的。
node.js运行机制
node.js采用v8作为js的解析引擎,而在I/O处理方面采用了libuv。
libuv库负责node api的执行,它将不统的任务分给不同的线程,形成一个Event Loop。以异步的方式将执行的结果返回给V8引擎。
浏览器中的Event Loop
直接用文字描述Event Loop理解起来是比较费脑的,首先先介绍下Event Loop的相关概念。
Event Loop包括了 执行栈, 事件队列, 微任务,宏任务,执行栈和事件队列是事件循环中存储事件的地址,微任务和宏任务是事件循环中执行的事件。
执行栈:
js整体代码加载过后,会进入运行,这时会产生一个执行上下文(context),当代码执行完毕之后,该执行上下文被释放。对于一个执行上下文,也可以称为当前js执行环境,包括了私有作用域,当前作用域中的变量,上层作用域,当前作用域对象this。
由于js是单线程的,在执行前面的代码时,后面的代码等待执行,此时该部分代码(函数或者可直接执行的代码)被放到一个栈中,称为执行栈。
事件队列:
上面js的运行只考虑了同步事件,当js执行过程中遇到了异步事件(或者定时事件),会把对应的这些事件挂起,js并将这个事件加入与当前执行栈不同的另一个队列,继续执行当前执行上下文中的同步代码,这个存储异步事件的队列称为事件队列。
在一个执行环境中的执行栈清空之后,js此时会去查看事件队列是否为空,不为空,则继续执行这些事件。
执行事件队列中的事件时,会遵循执行栈的规则,首先会生成一个对应当前事件的执行上下文,然后生成执行栈,事件队列,当该执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。接着执行事件队列中的下一个事件,规则一样。当事件队列清空后,外层执行环境被销毁,执行结束。
从上面的分析中可以看出,事件循环指的是在事件队列中执行代码重复了外层执行栈的规则,一层一层深入,就形成了循环。
两个注意点:
1,同一个执行上下文中同步任务优先于异步任务
2,不同执行环境中的异步任务执行先后取决于其加入到事件队列的时间先后
3,事件队列在不同执行环境中是同一个
例子:
function a(){
console.log('a')
setTimeout( () => {
console.log('a1')
},0)
}
function b(){
console.log('b')
setTimeout( () => {
console.log('b1')
},0)
}
setTimeout( () => {
console.log('上层')
},0)
a()
b()
// 运行结果
// a
// b
// 上层
// a1
// b1
改变时间
function a(){
console.log('a')
setTimeout( () => {
console.log('a1')
},100)
}
function b(){
console.log('b')
setTimeout( () => {
console.log('b1')
},0)
}
setTimeout( () => {
console.log('上层')
},10)
a()
b()
// 运行结果
// a
// b
// b1
// 上层
// a1
形象化理解Event Loop:
把js代码中的方法看成是医院看病的病人,
执行栈是所有的病人,按顺序就诊
同步任务是直接就诊的病人
异步任务是需要化验的病人,拿到结果后就诊
对于循环
需要化验的病人如果是一个团体,后面排队的病人等待这个团体直接就诊的病人都看完后,就诊下一个团体,循环就诊。
微任务:
new Promise()
new MutaionObserver()
宏任务:
setInterval()
setTimeout()
- 整体代码
- I/O 操作、UI 渲染
在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。
总结:事件队列分为微任务队列,宏任务队列,同一次事件循环中,微任务永远在宏任务之前执行。
形象化理解:
微任务是需要化验的急诊病人
宏任务是需要化验的普通病人
node中的Event Loop
从node的运行机制我们知道,ibuv库负责node api的执行
每个阶段的含义:
- timers: 这个阶段执行定时器队列中的回调如
setTimeout()
和setInterval()
。 - I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和
setImmediate()
的回调。 - idle, prepare: 这个阶段仅在内部使用,可以不必理会。
- poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
- check:
setImmediate()
的回调会在这个阶段执行。 - close callbacks: 例如
socket.on('close', ...)
这种close事件的回调。
执行顺序
当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段,poll阶段相当于整体同步代码的解析,会生成一个执行栈,同时会把setImmediate的回调放入check队列,在setTimeout()
和 setInterval()定时到期后把其回调事件放入timers队列,
poll queue清空后,会转到check阶段,检查执行check队列,检查执行timer队列,之后进入到callbacks阶段,执行回调。check和timer两者的顺序是不固定的,受到代码运行的环境的影响。
进入一个新阶段之后,会重复上面各个阶段,直至执行完毕,进入下一个阶段。
总结:
poll轮询属于io观察者,process.nextTick()属于idle观察者, setImmediate()属于check观察者。
在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者.
第一次进入代码时,idle观察者不存在。
Node中的process.nextTick()
process.nextTick()是node中一个特殊的队列,这些事件会在每一个阶段执行完毕准备进入下一个阶段时优先执行。也就是说process.nextTick()在阶段切换时执行。并且,不管其有多深的回调,都会被一次执行完毕。
Promise
上面的阶段没有包括Promise,在node中,promise和浏览器中类似,执行在process.nextTick()之后,在setTimeout之前
实例:
const fs = require('fs')
const path = require('path')
const wait = () => new Promise((resolove, reject) => {
setTimeout(resolove(true), 3)
})
fs.readFile(path.resolve(__dirname, './vue.config.js'), 'utf-8', async (err, data) => {
console.log('读取的文件内容')
await wait()
console.log('测试测试')
process.nextTick(() => {
console.log('nextTick')
})
})
setTimeout(() => {
console.log('定时器任务0')
}, 0)
setTimeout(() => {
console.log('定时器任务100')
}, 1000)
setImmediate(() => {
console.log('立即执行')
})
Promise.resolve().then(() => {
console.log('promise')
})
process.nextTick(() => {
console.log('外层nextTick')
})
console.log('外层同步')
// 运行结果
// 外层同步
// 外层nextTick
// promise
// 定时器任务0
// 立即执行
// 读取的文件内容
// 测试测试
// nextTick
// 定时器任务100
形象化理解
固执的探险家(每个房间都要走到底)
所有的代码就像是已经设定好的迷宫,而引擎就是去探险的人,我们称为小呆。
小呆到达迷宫,已经有了地图,根据地图冒险。
迷宫有六个房间,分别是timer, i/ocallback, ide prepare(内部使用,已封闭), poll, check, close callback,
其中timer是虚拟现实房间,小呆随时可以看到里面的场景。
其他的每个房间又五个房间,有的开放,有的不开放。
探险规则:每次离开一个房间,都要检查有没有受伤(peocess.nextTick())
小呆首先进入poll房间,开始探险(执行poll 队列),之后进入check房间,timer房间(随机),探险完之后出来,进入close callback,探险完之后,进入io/callback房间,最后完成探险,离开。
小呆说任务总算完成了。
参考: