事件循环(Event Loop)
这玩意可以说是js 中的重中之重吧,基本上哪里都能遇到它,搞不懂它怎么行!内容较长,干货满满!
一、单线程
JavaScript 是 单线程 的(这个很重要),也就是说js 引擎中负责执行 js 代码的只有一个线程,称为主线程。主线程执行时,进入函数调用栈执行代码。
单线程意味着: 同一时间片断内只能执行单个任务。所有任务都要排队,只有等前一个任务结束后,才会执行下一个任务。
console.log('1111');
console.log('2222');
console.log('3333');
二、浏览器不是单线程的
虽然JS运行在浏览器中,是单线程的,但浏览器不是单线程的,例如Webkit或是Gecko引擎,都可能有如下线程:
下面这段可以简单略过,等到看完文章再来细品就好。
-
javascript引擎: 是单线程执行的,浏览器无论什么时候都只有一个JS线程在运行JS程序。
-
GUI渲染线程: 负责渲染浏览器界面,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。但需要注意 GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。(所以页面优化时,会把不重要的js放到页面尾部加载)
-
浏览器事件触发线程:当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX 异步请求等。可以看成一个任务派送线程,把对应的异步任务派送到队列中或者其他线程,等待 js 引擎执行。
-
定时触发器线程:浏览器定时计数器并不是由 JavaScript 引擎计数的, 因为 javaScript 引擎是单线程的, 如果用js引擎计时,那就不存在异步了。 我认为通过单独线程来计时并触发定时是更为合理的方案。当计时结束后把回调函数派送到队列。
-
异步 http 请求线程:在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的处理队列中等待处理。
三、任务
单线程效率很低,那么怎么解决这个问题呢?那就是事件循环啦!事件循环是怎么做到的呢?那就是采用两种任务执行:
- 同步任务(synchronous)。主线程中的任务。
- 异步任务(asynchronous):
- 启动相应的线程,执行异步任务,所以不会阻塞主线程的执行。等到线程把任务完成后,触发事件派送,把回调函数放入任务队列中等待执行。(比如setTimeout)
- 或者,直接触发事件派送,把回调函数放入任务队列中等待执行。(比如process.nextTick)
我个人感觉,第一种异步是宏任务,第二种异步是微任务,符合逻辑。哈哈。
下面我就把第一种异步称为 宏异步任务,第二种异步称为 微异步任务 啦。
异步任务就是其中的回调函数不会立即在主线程中执行,而是被派送到任务队列中等待执行。
异步任务是立即执行的。真正异步的是其中的回调函数。
四、异步任务与事件的关系
一、宏异步任务
不知道大家对事件循环理解的时候有没有疑惑,如果js 的是单线程的,那么为什么要搞事件循环呢,搞来搞去还不是主线程去执行吗,效率哪里快了。
那么关键就在这了,宏异步任务会启动浏览器相应的线程去执行任务,当宏异步任务完成后,就会触发相应事件,把其中的回调函数添加到任务队列中去等待执行。
就拿setTimeout 异步函数来说:
setTimeout(function(){
console.log("i am timeout.")
}, 0)
console.log("i am end.")
//i am end.
//i am timeout.
这段代码执行时,主线程遇到了setTimeout 异步函数,它启动定时触发器线程,然后继续执行,“i am end.” 输出,然后,0秒后,计时结束,定时触发器线程触发 计时结束事件 ,把里面的回调函数放入 任务队列中,事件循环开始,回调函数执行输出 “i am timeout”。
所以说为什么是异步呢,就是它会启动相应的线程去执行任务,不阻塞主线程执行,也会节省很多时间。当然也可以用来改变函数执行顺序。
可以把宏异步任务看成以下三个步骤:
- 启动相应的浏览器线程去执行异步的操作。
- 等待异步操作完成后,触发相应事件,把回调函数派送到任务队列中。
- 事件循环,读取任务队列,执行回调函数。
二、微异步任务
微异步任务呢,就是效率很高的异步了,它直接触发事件派送,在任务队列中排队等待。
注意: setTimeout,promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务(回调函数)。
五、任务队列
当任务多了,就需要排队处理,那么这个排的队伍就是任务队列。
任务队列 又分两种,为macro-task(宏任务)与micro-task(微任务),新标准中分别称为task与jobs。
-
宏任务——MacroTask(Task):
setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI
rendering ,ajax ,DOM, BOM 事件 -
微任务——MicroTask(在ES2015规范中称为Job):
process.nextTick, Promise, MutationObserver
宏任务是在下轮循环的开始执行,微任务是在本轮循环的结尾执行
注意:这里是为了方便理解,不管哪种异步任务都是要进入主线程(函数调用栈)中执行的(很重要)。还有,队列和栈是不同的数据结构,别搞混了。不清楚的可以看本系列二,js中的数据结构。
任务源队列:
属于同一种任务源的任务,会放入一个任务源队列中。比如settimeout 源队列,Promise 源队列。
六、事件循环
事件循环就是: 主线程的任务执行完之后(栈空了以后),去读取微任务(MicroTask) 队列的任务,按队列顺序读取到函数调用栈中执行。 微任务队列为空后,把宏任务(MacroTask) 队列的任务 按顺序读取到函数调用栈中执行。
更简单的说:只要主线程空了(同步),就会去读取”任务队列”,这就是JavaScript的事件运行机制。
有栗子有真相:
console.log("glob");
setTimeout(function(){
console.log("timeout");
});
// setTimeout 函数,不加时间,默认为0;
// promise 函数本身不是异步,resolve, reject 等函数是异步的(也就是后面then中的回调函数)。
new Promise(function(resolve, reject) {
console.log('promise');
resolve();
}).then(function() {
console.log('then');
})
// glob1
// promise
// then
// timeout
开始分析:
- 从头执行这段代码,首先"glob1" 输出
- 然后遇到setTimeout,开启定时触发器线程,开始计时。0秒后,计时结束,触发计时结束事件,把setTimeout 回调函数,放入 setTimeout 源任务队列。
- promise 函数执行,“promise” 输出
- 然后遇到 resolve ,立即触发事件,把then 中的回调函数放入 promise 源任务队列。
- 主线程为空,开始事件循环。
现在的情况是这样的:
一、读取微任务(macro-task) 队列
本轮循环开始。主线程读取Promise 源队列,执行console.log(“then”); “then” 输出。
二、此时,微任务(macro-task)队列为空,开始下轮循环
主线程读取宏任务(macro)队列,执行 console.log(“timeout”), 输出"timeout"。
三、所有队列为空,主线程为空,执行结束。
其实,事件循环的难点在于微任务。因为它是在本轮循环的结尾开始循环的,也就是说,只有在微任务队列为空时,才会开始下轮的循环。
来个大难度的:
注意:下面的代码只能在node 的环境中执行。方法:建一个js文件,比如test.js,然后node test.js
console.log('golb1');
setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
})
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then')
})
})
setImmediate(function() {
console.log('immediate1');
process.nextTick(function() {
console.log('immediate1_nextTick');
})
new Promise(function(resolve) {
console.log('immediate1_promise');
resolve();
}).then(function() {
console.log('immediate1_then')
})
})
process.nextTick(function() {
console.log('glob1_nextTick');
})
new Promise(function(resolve) {
console.log('glob1_promise');
resolve();
}).then(function() {
console.log('glob1_then')
})
setTimeout(function() {
console.log('timeout2');
process.nextTick(function() {
console.log('timeout2_nextTick');
})
new Promise(function(resolve) {
console.log('timeout2_promise');
resolve();
}).then(function() {
console.log('timeout2_then')
})
})
process.nextTick(function() {
console.log('glob2_nextTick');
})
new Promise(function(resolve) {
console.log('glob2_promise');
resolve();
}).then(function() {
console.log('glob2_then')
})
setImmediate(function() {
console.log('immediate2');
process.nextTick(function() {
console.log('immediate2_nextTick');
})
new Promise(function(resolve) {
console.log('immediate2_promise');
resolve();
}).then(function() {
console.log('immediate2_then')
})
})
一、主线程从头开始执行这段代码
注意啊,任务队列不是固定的,谁先被派送,谁排前面,谁先执行。队列就是这个意思了。
我的意思就是说,setTimeout 源队列不一定一直排第一个,Promise 源队列,也不一定一直排第二个!
执行完的情况是这样的:
输出的情况如下:
golb1
glob1_promise
glob2_promise
二、第一轮循环开始
也就是说,一开始,第一轮循环是从主线程开始往后的。
主线程读取 微任务(micro-task) 队列,先读取process.nextTick 源队列,第一个回调函数执行。
输出的情况如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
然后是process.nextTick第二回调函数:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
读取Promise 源任务队列,执行第一个回调函数。
此时结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
然后读取Promise 源队列的第二个回调函数执行:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
现在队列的情况是这样的:
微任务队列为空,开始第二轮循环。
第二轮循环开始,读取setTimeout 源队列的第一个回调函数。
而这个回调函数内,又含有其他异步任务,然后触发事件派送,排队等待。函数执行完的队列是这样的。
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
这时候就是关键了!!!重要的事说三遍!!!
这时候就是关键了!!!重要的事说三遍!!!
这时候就是关键了!!!重要的事说三遍!!!
因为此时的setTimeout 源队列不为空,所以主线程不会去读微任务队列,而是继续读取setTimeout 源任务队列的第二个回调函数执行。
执行结果队列如下:
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
再次关键了!!!重要的事说三遍!!!
再次关键了!!!重要的事说三遍!!!
再次关键了!!!重要的事说三遍!!!
此时,主线程不会去读取宏任务队列的第二个队伍:setImmediate 源队列,而是继续往后,因为第一个宏任务队列(setTimeout源队列)为空了。所以读取微任务队列的process.nextTick 源队列的第一个回调函数执行。
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
继续读process.nextTick 源队列 第二个回调函数执行。然后是Promise 源队列,我这里就直接简化了,不一一列了,反正读取到微任务队列为空。
结果队列如下:
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
第二轮循环结束。
第三轮循环开始,读取setImmediate 源队列第一个回调函数执行。
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
immediate1
immediate1_promise
然后是读取宏任务队列的 setImmediate 源队列 的第二个回调函数执行。
结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
immediate1
immediate1_promise
immediate2
immediate2_promise
setImmediate 源队列为空,开始读取微任务队列。我就不细说了,上面说过了。逐个读取微任务队列回调函数执行。
最终队列为空,事件循环结束。
最终结果如下:
golb1
glob1_promise
glob2_promise
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
immediate1
immediate1_promise
immediate2
immediate2_promise
immediate1_nextTick
immediate2_nextTick
immediate1_then
immediate2_then