Event Loop
是 JavaScript
异步编程的核心思想
JavaScript
是一门 单线程的、动态的、解释型的、跨平台的 语言。
单线程意味着同一时间只能做一件事,这是因为
JavaScript
生来作为浏览器脚本语言,主要用来处理与用户的交互、网络请求以及DOM
操作,更注重用户体验。这就决定了它只能是单线程,否则会带来很复杂的同步问题,对于一种网页脚本来说就太复杂了!
比如一个JS
线程在某个DOM
节点上添加内容,而另一个JS
线程删除这个DOM
节点,那么浏览器就懵逼了^_^
,不知道听谁的!
说JavaScript
是单线程,其实是JS引擎是单线程的,跨平台也是指JavaScript
的宿主环境(多数是浏览器)是跨平台的。
浏览器
浏览器是JavaScript
最主要的宿主环境,它是多进程、多线程的
多进程
Browser进程:浏览器的主进程,负责协调、主控。
第三方插件进程:每种类型的插件对应一个进程,仅当使用时才创建。
GPU进程:最多一个,用于3D
绘制。
渲染进程:浏览器内核,默认每个Tab
页面对应一个进程,互不影响,控制页面渲染、脚本执行、事件处理等等。有时候会优化,如多个空白Tab
页会合并成一个进程。
在浏览网页时,同时打开十几个窗口就会占用十几个进程,整个计算机就会越来越慢。
-
浏览器选择多进程方案的优点
- 避免页面渲染影响整个浏览器
- 避免第三方插件影响整个浏览器
- 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
通俗讲就是,其中一个窗口崩溃了,也不会影响整个浏览器,其他页面照常运行。
多线程
浏览器的渲染进程(内核)是多线程的,主要有以下几类:
-
GUI
线程- 绘制页面,解析
HTML
、CSS
,构建DOM
树,布局和绘制等 - 页面重绘和回流
- 与
JS
引擎互斥,也就是说JS
的执行会阻塞页面更新
- 绘制页面,解析
-
JavaScript
引擎线程- 也称为JS内核,负责JS脚本程序的执行,如
V8
引擎 - 负责执行准备好的事件,即定时器计数结束,或异步请求成功并返回正确的事件
- 运行
JS
脚本期间,GUI
线程都是处于挂起状态,被冻结。如果JS
执行时间过长,就会造成页面渲染不连贯,导致阻塞页面渲染的感觉。
- 也称为JS内核,负责JS脚本程序的执行,如
-
事件触发线程
- 负责将准备好的事件交给
JS
引擎线程执行,这些事件可以是当前执行的代码块如定时任务,也可来自浏览器内核的其他线程如鼠标点击、AJAX
异步请求等 - 多个事件加入任务队列时需要排对等待,因为
JS
是单线程的。
- 负责将准备好的事件交给
-
定时器线程
- 负责执行异步的定时器类的事件,如
setTimeout、setInterval
- 定时器计数结束后,把注册的回调函数加入到任务队列的队尾
-
为什么不采用JS引擎计数呢?因为
JS
引擎是单线程,如果处于阻塞状态就会影响计时的准确性,因此通过一个单独的线程来计数并触发回调函数。
- 负责执行异步的定时器类的事件,如
-
网络请求线程
- 负责执行异步请求
(XMLHttpRequest连接)
- 主线程执行代码遇到异步请求时,就会把新开一个线程发起请求。监听到状态变更事件时,如果有回调函数,该线程会把它加入到
JS
引擎处理的任务队列队尾等待处理。
- 负责执行异步请求
Event Loop
数据结构
- 栈:
stack
,遵循后进先出(LIFO)
原则的有序集合。在编译器和内存中存储基本数据类型、对象的指针、方法调用等 - 队列:
queue
,遵循先进先出(FIFO)
原则的有序集合 - 堆:
heap
,基于树抽象数据类型的一种特殊的数据结构
既然JavaScript
是单线程的,那么在处理耗时任务时,就会阻塞主线程,即浏览器一直卡着。为此,JavaScript有了同步和异步的概念。
JS运行机制
-
执行栈:Call Stack
所有同步任务都在主线程上执行,形成一个执行栈,因为JS
是单线程的,所以执行栈每次只能执行一个任务。同步任务执行完后,由任务队列提供任务给执行栈执行。 -
任务队列:Task Queue
异步任务会被放置到Task Table(异步处理模块)
,当异步任务已经完成了,则压入任务队列中。
所以,任务队列中存放的是已经完成的异步操作,而不是说注册一个异步任务就会被放进任务队列中。
任务队列有多种,如宏任务队列microtask
,微任务队列macrotask
。
当执行栈为空时,
JS
引擎会检查任务队列是否为空,如果有事件,则取来第一个任务压入执行栈中执行。所以本质上来说,JavaScript
中的异步仍然还是同步行为!
正是因为
JS
是单线程,当异步任务完成时并不能一定立即得到响应,因为主线程可能正在处理其他同步任务,等空闲下来才会执行那些已经完成的异步任务(的回调函数)。这也就是为啥定时器并不可能精确地在指定时间输出回调函数的结果。
JS引擎的这种处理同步和异步操作的机制,称为事件循环-- Event Loop
结合Wikipedia
对 Event Loop
的解释,简单来说就是Event Loop
是一个程序结构,用于等待和分派消息和事件。
通俗地说,Event Loop
是浏览器或Node
的一种协调JavaScript
单线程运行时不会阻塞的一种机制。
异步任务又分为微任务、宏任务,在ES6
规范中,微任务称为Jobs
,宏任务称为Task
-
宏任务
script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、MessageChannel、setImmediate(Node)
-
微任务
Promise.then、MutationObserver(H5新特性)、process.nextTick(Node环境)
注意:Promise.then
是微任务,但 Promise
的 executor
是一个同步函数!
function Promise(executor) {
executor(resolve, reject)
}
另外,Promise
可以连续的 .then()
,在第一个 then
没有执行结束之前,第二个 then
是不会加入微任务队列的!
浏览器EventLoop
事件执行顺序
- 执行同步任务,无需特殊处理,顺序执行 --> 第一轮从 script 开始,直到同步任务执行结束
- 从宏任务队列中取出第一个任务执行
- 如果产生了宏任务,则放入宏任务队列,等待下次轮循执行
- 如果产生了微任务,则放入微任务队列。
- 执行完当前宏任务之后,取出微任务队列中的所有任务依次执行
- 如果微任务执行过程中产生了新的微任务,仍然放入本次轮循的微任务队列等待执行,直到微任务队列为空
- 轮循,重复 2-4 步骤
举个栗子
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise((resolve, reject) => {
console.log("promise1")
resolve()
}).then(() => {
console.log("then11")
new Promise((resolve, reject) => {
console.log("promise2")
resolve();
}).then(() => {
console.log("then2-1")
}).then(() => {
console.log("then2-2")
})
}).then(() => {
console.log("then12")
})
new Promise((resolve, reject)=>{
console.log("promise3")
resolve()
}).then(()=>{
console.log("then31")
})
console.log('script end')
-
console.log('script start')
执行同步任务, 输出script start; -
setTimeout
产生宏任务,注册到宏任务队列表,计数结束会进入宏任务队列-- [setTimeout]
,下次Event Loop
执行; -
new Promise (promise1)
的构造声明(同步任务),输出promise1,执行resolve()
匹配到第一个then
,产生微任务,进入微任务队列-- [then11]
; -
new Promise (promise3)
的构造声明,输出promise3,resolve()
匹配到then
,进入微任务队列-- [then11, then31]
; -
console.log('script end')
执行同步任务,输出script end,同步任务执行结束,任务栈为空; - 检查微任务队列,取出第一个微任务
then11
压入执行栈执行,输出then11;向下遇到promise2
的构造声明,输出promise2,resolve()
匹配到第一个then
,进入微任务队列-- [then31, then2-1]
; -
then11
执行结束,匹配到promise1
的第二个then
,进入微任务队列-- [then31, then2-1, then12]
; - 继续轮循微任务队列,取出
then31
,输出then31-- [then2-1, then12]
- 取出微任务队首
then2-1
,输出then2-1,向下触发promise2
的第二个then
,进入微任务队列-- [then12, then2-2]
; - 取出微任务
then12
,输出then12; - 取出微任务
then2-2
,输出then2-2; - 微任务执行完毕,本次轮循结束。清空任务栈,进入下一次轮循;
- 取出宏任务队列的第一个任务
setTimeout
,输出setTimeout;
结果: script start -> promise1-> promise3 -> script end -> then11 -> promise2 -> then31-> then2-1 -> then12 -> then2-2 -> setTimeout
微任务存在的原因
宏任务和微任务都是异步任务,简单来说,微任务的存在就是为了及时处理一些任务,以免等到最后再执行的时候拿到已经污染的数据。
比较经典的一个案例:在银行排队办理业务,每个排号人就是一个宏任务,当叫到你时,表示一个宏任务开始执行。你在办理存款业务后,又想咨询一下最近的理财情况,这就会产生一个微任务,柜员不可能再让你去重新排号,而是立即给你讲解,即微任务执行。而且,在当前的微任务没有执行完成时,也不会执行下一个宏任务。
宏任务和微任务的真实面目
其实JavaScript
中并不存在什么宏任务和微任务,Chrome
源码中也没有相关的代码或说明!
在JS
大会上提到过微任务这个名词,但并没有说明什么是微任务。
个人认为,宏任务和微任务只是为了方便学习JavaScript
而总结出来的概念。
宏任务的理解
在Chrome
里,每个Tab
页面就对应一个进程,且该进程由包含诸如GUI
线程、JS
引擎线程、定时器线程、网络线程等等,这些线程之间的通信是通过向任务队列中添加一个任务(postTask)
来实现的。宏任务的本质可认为是多线程事件循环或消息循环,也就是线程间通信的一个消息队列。
以setTimeout
来说,当执行它时,浏览器会通过Event Loop
:我有一个任务交给你。Event Loop
:好的,我把它加入我的todoList
中,之后我会执行它,它是需要调用API
的。
宏任务的真实面目是浏览器派发的,与JS引擎无关,参与了 Event Loop 调度的任务
微任务的理解
微任务是在运行宏任务/同步代码时产生的,属于当前任务,所以它不再需要浏览器的支持,内置在JS
当中,不需要API
支持,直接在JS
引擎中执行。
Event Loop 遇到 async/await
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
}).then(function () {
console.log('promise1')
}).then(function () {
console.log('promise2')
}).then(function () {
console.log('promise3')
})
console.log('script end')
await
只会影响 async
函数内的执行顺序,async
函数之外的代码正常执行。
async/await
是生成器generator
的语法糖,分析时可以转化为Promise.resolve().then()
console.log('script start')
function async1() {
Promise.resolve(async2()).then(() => {
console.log('async1 end')
})
}
function async2() {
console.log('async2 end')
}
async1()
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
}).then(function () {
console.log('promise1')
}).then(function () {
console.log('promise2')
}).then(function () {
console.log('promise3')
})
console.log('script end')
在执行 async1()
时,Promise.resolve(async2())
是同步代码,则会先执行 async2()
,输出async2 end,然后匹配到then
,产生微任务,加入微任务队列--[async1 end]
。
执行结果:script start -> async2 end -> Promise -> script end -> async1 end -> promise1 -> promise2 -> promise3 -> setTimeout
其实这一题是颇具争议的,原因是
await
之后的代码[async1 end]
与当前循环的其他微任务[promise1, promise2, promise3]
的执行顺序问题。
不同浏览器甚至同一浏览器的不版本,执行顺序也是不同的。在Chrome73(金丝雀)
版本之前,V8
在处理async/await
时很暴力地额外增加了两个Promise
,致使await
之后的代码会在执行两轮微任务之后执行--> [promise1, promise2, async1 end, promise3]
。
为了降低V8
的损耗,Chrome73
版本减少了一层Promise
,使用PromiseResolve
对async
做了优化,立即把await
之后的代码加入微任务队列,执行顺序正如题目中所分析的那样。
Node Event Loop
Node
中的 Event Loop
和浏览器中的是完全不相同的东西。Node
采用 V8
作为 JS
的解析引擎,而 I/O
处理方面使用了自己设计的libuv
。
libuv
是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
事件循环机制的六个阶段
Node
有多个宏任务队列,而浏览器只有一个!
-
Timers
:计时器,执行setTimeout、setInterval
的回调函数,由Poll
控制 -
I/O Callbacks
:处理一些上一轮循环中的少数未执行的I/O
回调(网络、流、文件操作等) -
Idel,Prepare
:闲置阶段,Node
内部使用 -
Poll
:轮循,是一个至关重要的阶段,适当的条件下会阻塞在这里,但不会一直阻塞;- 这个阶段主要做两件事:[ 回到
Timer
阶段执行回调 ], [ 执行 I/O 回调 ] - 如果
Timers
阶段已经设置了定时器,Poll
会检查计数是否结束,如果计数超时且I/O
队列为空,则回到Timers
阶段执行回调; - 如果没有设置定时器,会发生两件事:
- 如果
Poll(I/O)
队列不为空,会遍历队列并同步执行,直到队列为空或者达到系统限制; - 如果队列为空,也会发生两件事:
- 如果有
setImmediate
需要执行,则进入Check
阶段执行回调; - 如果没有
setImmediate
,则等待I/O
回调被加入Poll
队列,会有一个超时时间,以防一直等待。
- 如果有
- 如果
- 这个阶段主要做两件事:[ 回到
-
Check
:检查,处理setImmediate
的回调函数 -
Close Callbacks
:关闭回调,如socket.on('close')
Close Callbacks
是最后一个阶段,然后主线程会去检查是否还有异步任务,如果有,则继续重新轮询,否则程序结束。
轮循顺序
- 每个阶段对应一个宏任务队列,相当于对宏任务做了一个分类;
- 每个阶段都要等待对应的宏任务队列执行完,才会进入下一个阶段的宏任务队列;
Timers -> I/O Callbacks -> Poll -> setImmediate -> Close Callbacks
- 各个阶段之间执行微任务队列。
Event Loop 过程
- 执行全局的
script
同步代码; - 执行微任务队列,先执行所有的
nextTick
队列中的所有任务,再执行其他的微任务队列中的所有任务; - 开始执行宏任务,共六个阶段,从第一个阶段开始执行自己宏任务队列中的所有任务(浏览器是从宏任务队列中取出第一个任务执行!)
- 每个阶段的宏任务执行完毕后,开始执行微任务
TimersQueue -> 步骤2 -> I/O Queue -> 步骤2 -> CheckQueue -> 步骤2 -> CloseQueue -> 步骤2 -> TimersQueue ->...
值得注意的是,
nextTick
是一个独立的队列,优先级高于微任务,所有在当前宏任务/同步任务之后完毕后,会先执行nextTick
队列中的所有任务,然后再去执行微任务队列中的所有任务。
setTimeout setImmediate process.nextTick
-
setTimeout
与setInterval
的区别除了执行频次不同,其他都相同。
在底层创建一个setTimeout
的中间对象,并放到实现定时器的红黑树中,每次tick
开始时,都会到这个红黑树中检查是否存在超时的回调,如果存在,则一一按照超时顺序取出来加入任务队列。
因此,可以得出这样一个结论:JS定时器是不可靠的,因为单线程,如果一个tick耗时过长,其后触发的定时器都将被延迟。
特点:精确度不高,可能延迟执行,且因为动用了红黑树,所以消耗资源较大; -
setImmediate
用于把一些需要长时间运行的操作放在一个回调函数中,并在浏览器完成其他操作后立即运行回调函数。从定义上来看是为了防止一些耗时长的操作阻塞后面的操作,所以属于比较靠后的Check
阶段。
特点:消耗的资源小,也不会造成阻塞,但效率也是最低的。 -
process.nextTick
会加入一个单独的微任务队列,优先级高于其他微任务。
特点:效率最高,消费资源小,但会阻塞CPU的后续调用; -
setTimeout
采用的类似I/O
观察者,setImmediate
采用的是Check
观察者,而process.nextTick()
采用的是Idle
观察者。
执行优先级:Idle观察者 > I/O观察者> Idle观察者
-
setTimeout(fn, 0)
看似与setImmediate(fn)
效果相同,然而事实并非如此!**
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
多次执行这段代码会发现,两者的顺序并不固定。虽然Timers
阶段确实早于Check
阶段,但setTimeout
的计数真的结束了吗?
在浏览器中,setTimeout
的最小延迟是4ms
;Node
中的最小值是1ms
。
在代码启动、运行时会消耗一定的事件,如果在Event Loop
检查到Timers
阶段之前的耗时>=1ms
,那么就执行setTimeout
的回调函数,即setTimeout
早于setImmediate
;反之,setImmediate
早于setTimeout
!
利用这个原理,就可以控制setTimeout
和setImmediate
的先后顺序,比如:
- 可以手动延长启动时间,让
setTimeout
始终早于setImmediate
; - 把
setTimeout
和setImmediate
放入读取文件的回调中,让程序从I/O Callbacks
阶段开始执行,那么setImmediate
将早于setTimeout
。require('fs').readFile('./xxx', () => { setTimeout(() => { console.log('setTimeout') }, 0) setImmediate(() => { console.log('setImmediate') }) })
- 考虑到
setTimeout
需要使用红黑树,且计数设置为0
时会被Node
强制转换为1
,存在性能上的问题,所以对于setTimeout(fn, 0)
建议替换为setImmediate / process.nextTick
- 最后一个栗子
async function async1() {
console.log("async1 start"); // 2
await async2();
console.log("async1 end"); // 7
}
async function async2() {
console.log('async2'); // 3
}
console.log("script start"); // 1
setTimeout(function () {
console.log("setTimeout"); // 9
});
async1()
new Promise(function (resolve) {
console.log("promise1"); // 4
resolve();
}).then(function () {
console.log("then11"); // 8
});
setImmediate(() => {
console.log("setImmediate") // 10
})
process.nextTick(() => {
console.log("process") // 6
})
console.log('script end'); // 5
执行顺序:script start -> async1 start -> async2 -> promise1 -> script end -> process -> async1 end -> then11 -> setTimeout -> setImmediate
Node11.x
Node11.x
开始,Event Loop
的运行原理发生了一些变化,一旦执行了一个阶段里的宏任务(setTimeout、setImmediate)
,就立即执行其微任务队列,这一变化与浏览器端一致了。
setTimeout(() => console.log('timeout1'))
setTimeout(() => {
console.log('timeout2')
Promise.resolve().then(() => console.log('promise resolve'))
})
-
node 10
及之前的版本
要考虑上一个定时器执行完成时,下一个定时器是否到时间并加入了任务队列中,如果未到时间,先执行其他的代码。
timer1
执行完之后timer2
到了任务队列中,顺序为timer1 -> timer2 -> promise resolve
timer1
执行完之后timer2
还没到任务队列中,顺序为timer1 -> promise resolve -> timer2
-
node 11
及其之后的版本
timeout1 -> timeout2 -> promise resolve
网友评论