这段代码到底怎么走?终于搞定Event loop

731 阅读5分钟

众所周知,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


参考:

图解搞懂JavaScript引擎Event Loop

JavaScript 运行机制详解:再谈Event Loop