js中的事件循环(Event Loop)机制
一,关于线程
javascript从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。
单线程意味着,javascript代码在执行的任何时候,都只有一个主线程来处理所有的任务。
而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准。允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
二,浏览器环境下的事件循环机制
1,任务队列
js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为任务队列。被放入任务队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码...,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。
任务队列本质:
- 所有同步任务都在主线程上执行,形成一个**执行栈**(execution context stack)。
- 主线程之外,还存在一个”**任务队列**”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
- 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
下图中的stack表示我们所说的执行栈,web apis则是代表一些异步事件,而callback queue即任务队列
2,宏任务与微任务
以上的事件循环过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。
## 宏任务
(macro)task(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得 JS 内部(macro)task 与 DOM 任务能够有序的执行,**会在一个(macro)task 执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染**,流程如下:
(macro)task->渲染->(macro)task->...
(macro)task 主要包含:
script(整体代码)
setTimeout
setInterval
I/O
UI 交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)
## 微任务
microtask(又称为微任务),**可以理解是在当前 task 执行结束后立即执行的任务**。也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前。
所以它的响应速度相比 setTimeout(setTimeout 是 task)会更快,因为无需等渲染。也就是说,在某一个 macrotask 执行完后,就会将在它执行期间产生的所有 microtask 都执行完毕(在渲染前)。
microtask 主要包含:
Promise.then
MutaionObserver
process.nextTick(Node.js 环境)
## 运行机制
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
- 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)
## await 做了什么
从字面意思上看 await 就是等待,await 等待的是一个表达式,这个表达式的返回值可以是一个 promise 对象也可以是其他值。
很多人以为 await 会一直等待之后的表达式执行完之后才会继续执行后面的代码,实际上 await 是一个让出线程的标志。await 后面的表达式会先执行一遍,将 await 后面的代码加入到 microtask 中,然后就会跳出整个 async 函数来执行后面的代码。
由于因为 async await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是 microtask。
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
等价于
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() ={
console.log('async1 end');
})
}
三,node环境下的事件循环机制
在node中,事件循环表现出的状态与浏览器中大致相同。不同的是node中有一套自己的模型。node中事件循环的实现是依靠的libuv引擎。我们知道node选择chrome v8引擎作为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于libuv引擎中。
下面是一个libuv引擎中的事件循环的模型:
从上面这个模型中,我们可以大致分析出node中的事件循环的顺序:
外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...
以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。
这些阶段大致的功能如下:
- timers: 这个阶段执行定时器队列中的回调如
setTimeout()
和setInterval()
。 - I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和
setImmediate()
的回调。 - idle, prepare: 这个阶段仅在内部使用,可以不必理会。
- poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
- check:
setImmediate()
的回调会在这个阶段执行。 - close callbacks: 例如
socket.on('close', ...)
这种close事件的回调。
下面我们来按照代码第一次进入libuv引擎后的顺序来详细解说这些阶段:
poll阶段
当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。poll阶段的执行逻辑如下: 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调。 当queue为空时,会检查是否有setImmediate()的callback,如果有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,如果有,就把这些到期的timer的callback按照调用顺序放到timer queue中,之后循环会进入timer阶段执行queue中的 callback。 这两者的顺序是不固定的,收到代码运行的环境的影响。如果两者的queue都是空的,那么loop会在poll阶段停留,直到有一个i/o事件返回,循环会进入i/o callback阶段并立即执行这个事件的callback。
值得注意的是,poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种情况poll阶段会终止执行poll queue中的下一个回调:1.所有回调执行完毕。2.执行数超过了node的限制。
check阶段
check阶段专门用来执行setImmediate()
方法的回调,当poll阶段进入空闲状态,并且setImmediate queue中有callback时,事件循环进入这个阶段。
close阶段
当一个socket连接或者一个handle被突然关闭时(例如调用了socket.destroy()
方法),close事件会被发送到这个阶段执行回调。否则事件会用process.nextTick()
方法发送出去。
timer阶段
这个阶段以先进先出的方式执行所有到期的timer加入timer队列里的callback,一个timer callback指得是一个通过setTimeout或者setInterval函数设置的回调函数。
I/O callback阶段
如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操作系统执行的回调。例如一个TCP连接生错误时,系统需要执行回调来获得这个错误的报告。
四,setTimeOut、setImmediate、process.nextTick()的比较
## setTimeout()
将事件插入到了事件队列,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。
当主线程时间执行过长,无法保证回调会在事件指定的时间执行。
浏览器端每次 setTimeout 会有 4ms 的延迟,当连续执行多个 setTimeout,有可能会阻塞进程,造成性能问题。
## setImmediate()
事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行。和 setTimeout(fn,0)的效果差不多。
服务端 node 提供的方法。浏览器端最新的 api 也有类似实现:window.setImmediate,但支持的浏览器很少。
## process.nextTick()
插入到事件队列尾部,但在下次事件队列之前会执行。也就是说,它指定的任务总是发生在所有异步任务之前,当前主线程的末尾。
大致流程:当前”执行栈”的尾部–>下一次 Event Loop(主线程读取”任务队列”)之前–>触发 process 指定的回调函数。
服务器端 node 提供的办法。用此方法可以用于处于异步延迟的问题。
可以理解为:此次不行,预约下次优先执行。
五,常见考题
考题一:
//请写出输出内容 async function async1() { console.log("async1 start"); await async2(); console.log("async1 end"); } async function async2() { console.log("async2"); } console.log("script start"); // 1 setTimeout(function () { console.log("setTimeout"); }, 0); async1(); new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); console.log("script end");
答案:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
考题二:将 async2 中的函数也变成了 Promise 函数
async function async1() { console.log("async1 start"); await async2(); console.log("async1 end"); } async function async2() { //async2做出如下更改: new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); } console.log("script start"); setTimeout(function () { console.log("setTimeout"); }, 0); async1(); new Promise(function (resolve) { console.log("promise3"); resolve(); }).then(function () { console.log("promise4"); }); console.log("script end");
答案:
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout
考题三:将 async1 中 await 后面的代码和 async2 的代码都改为异步的
async function async1() { console.log("async1 start"); await async2(); //更改如下: setTimeout(function () { console.log("setTimeout1"); }, 0); } async function async2() { //更改如下: setTimeout(function () { console.log("setTimeout2"); }, 0); } console.log("script start"); setTimeout(function () { console.log("setTimeout3"); }, 0); async1(); new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); console.log("script end");
答案:
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
考题四:
async function a1 () { console.log('a1 start') await a2() console.log('a1 end') } async function a2 () { console.log('a2') } console.log('script start') setTimeout(() =>{ console.log('setTimeout') }, 0) Promise.resolve().then(() =>{ console.log('promise1') }) a1() let promise2 = new Promise((resolve) =>{ resolve('promise2.then') console.log('promise2') }) promise2.then((res) =>{ console.log(res) Promise.resolve().then(() =>{ console.log('promise3') }) }) console.log('script end')
答案:
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
以上题目,你都做对了吗,只要理解了事件循环,宏任务和微任务,这些题目万变不离其宗~