众所周知,js是一门单线程的编程语言,在设计之初,它就注定了单线程的命运,比如当我们处理dom时,如果有多个线程同时操作一个dom,那将非常混乱。
既然是单线程,那么它一定有一套严谨的规则,来使代码能够乖乖的按开发者的设计运行,今天我们就来研究其中的奥秘,了解一下js的event loop(事件循环)。
同步/异步
聊js事件环,绕不开聊异步(在我的另一篇文章拥抱并扒光Promise中对Promise这种异步解决方案有详细介绍)
为什么要异步?假设没有异步,我们发送一个ajax请求,后端代码运行的很慢,这时浏览器会发生阻塞,如果十秒才响应,这十秒我们该干嘛?(或许可以看博尔特跑个百米)
虽然在网页诞生之初,确实有这样的情况,但如今这样的页面是会被用户骂娘的。于是异步的作用显露无遗,js开启一个异步线程,什么时候请求完成,什么时候执行回调函数,而这期间,其他代码也可以正常运行。
任务队列(task queue)
既然是单线程,就像一次只能过一个人的独木桥,人要排队,那么代码也要排队。这时,同步代码和异步代码的排队机制是不一样的
同步:在主线程(相当于独木桥上)上排队的任务,前一个任务执行完,下一个任务才可以执行,如果前一个任务没执行完,下一个任务要一直等待。就像过独木桥,前面的人不过去,你死等也得等,不然就5253B翻腾两周半入水。
异步:主线程先不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。就像过独木桥,你害怕不敢过,你就让后面的人先过,什么时候你敢了你再过。而你调整心态的过程,主线程不考虑。
- 同步任务在主线程上执行,形成一个执行栈(xecution context stack)
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
主线程会不断的重复以上三步,这样就构成了事件环,用图表示
浏览器中的Event Loop
- 堆(heap)在JS运行时用来存放对象。
- 栈(stack)遵循“先进后出”原则,我们知道栈可以存放对象的地址,但本文中的栈是指用来执存放行JS主线程的执行栈(execution context stack)。
通过这张图,我们可以知道,主线程运行时,产生堆和执行栈,栈中的代码会调用一些api,比如seTtimeou、click等,这些异步操作会讲他们的回调放入callback queue中,当执行栈中的代码运行完,主线程回去读取queue中的任务。
console.log(1)
setTimeout(function(){
console.log(2)
})
console.log(3)
我们都知道结果是1 3 2,结合上面我们梳理一下这段代码的执行顺序
1、从上到下运行执行栈中的同步代码console.log(1)
2、看到setTimeout,把回调函数放入任务队列中去
3、执行console.log(3)
4、主线程上没有任务了,去任务队列中执行setTimeout的回调,console.log(2)
Node中的Event Loop
显然node要比浏览器复杂一些,它的流程是这样的:
- V8引擎解析JavaScript脚本。
- 解析后的代码,调用Node API。
- libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
- V8引擎再将结果返回给用户。
Node还有一些不同,它提供了另外两个与"任务队列"有关的方法:process.nextTick和setImmediate。它们可以帮助我们加深对"任务队列"的理解。
process.nextTick方法可以在当前"执行栈"的尾部,下一次Event Loop(主线程读取"任务队列")之前,触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。
setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。
大概可以理解成process.nextTick有权插队
setTimeout(function(){
console.log(1)
})
process.nextTick(function () {
console.log(2);
process.nextTick(function (){
console.log(3)
});
});
setTimeout(function () {
console.log(4);
})
虽然1在上面,但结果是2 3 1 4,就像我们上面说的,process.nextTick会在主线程读取任务队列时插队
再看setImmediate
setImmediate(function () {
console.log(1);
setImmediate(function B(){
console.log(2)
})
})
setTimeout(function () {
console.log(3);
}, 0)
结果可能是312,也可能是132
微任务/宏任务
为什么会出现上面有的先有的后的情况呢,难道除了人类社会代码世界也有特权么,是的,我们将任务分为两种:
微任务Microtask,有特权,可以插队,包括原生Promise,Object.observe(已废弃), MutationObserver, MessageChannel;
宏任务Macrotask,没有特权,包括setTimeout, setInterval, setImmediate, I/O;
最后,一段比较复杂的代码收尾。
console.log("1");
setTimeout(()=>{
console.log(2)
Promise.resolve().then(()=>{
console.log(3);
process.nextTick(function foo() {
console.log(4);
});
})
})
Promise.resolve().then(()=>{
console.log(5);
setTimeout(()=>{
console.log(6)
})
Promise.resolve().then(()=>{
console.log(7);
})
})
process.nextTick(function foo() {
console.log(8);
process.nextTick(function foo() {
console.log(9);
});
});
console.log("10")
执行顺序:
1,输出1
2,将setTimeout(2)push进宏任务
3,将then(5)push进微任务
4,在执行栈底部添加nextTick(8)
5,输出10
6,执行nextTick(8)
7,输出8
8,在执行栈底部添加nextTick(9)
9,输出9
10,执行微任务then(5)
11,输出5
12,将setTimeout(6)push进宏任务
13,将then(7)push进微任务
14,执行微任务then(7)
15,输出7
16,取出setTimeout(2)
17,输出2
18,将then(3)push进微任务
19,执行微任务then(3)
20,输出3
21,在执行栈底部添加nextTick(4)
22,输出4
23,取出setTimeout(6)
24,输出6