【事件循环】彻底理解Event Loop

410 阅读9分钟

目录

  1. JS异步是怎么实现的
  2. GUI线程
  3. JS引擎线程
  4. 定时器线程
  5. 事件触发线程
  6. 异步HTTP请求线程
  7. 浏览器的同步任务异步任务(宏任务 与 微任务)

一、JS异步是怎么实现的

我们都知道JS是单线程的,那单线程是怎么实现异步的呢?事实上所谓的"JS是单线程的"只是指JS的主运行线程只有一个,而不是整个运行环境都是单线程。JS的运行环境主要是浏览器,以大家都很熟悉的Chrome的内核为例,他不仅是多线程的,而且是多进程的:

image.png

上图只是一个概括分类,意思是Chrome有这几类的进程和线程,并不是每种只有一个比如渲染进程就有多个每个选项卡都有自己的渲染进程。有时候我们使用Chrome会遇到某个选项卡崩溃或者没有响应的情况,这个选项卡对应的渲染进程可能就崩溃了,但是其他选项卡并没有用这个渲染进程,他们有自己的渲染进程,所以其他选项卡并不会受影响。这也是Chrome单个页面崩溃并不会导致浏览器崩溃的原因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。

二、 GUI线程

GUI线程就是渲染页面的,他解析HTML和CSS,然后将他们构建成DOM树和渲染树就是这个线程负责的。

三、JS引擎线程

这个线程就是负责执行JS的主线程,前面说的"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。需要注意的是,这个线程跟GUI线程是互斥的。互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。所以while(true)这样长时间的同步代码在真正开发时是绝对不允许的

四、 定时器线程

setTimeout其实就运行在这里,他跟JS主线程根本不在同一个地方,所以“单线程的JS”能够实现异步。JS的定时器方法还有setInterval,也是在这个线程。

五、事件触发线程

定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是JS主线程。所以当时间到了定时器线程会将这个回调事件给到事件触发线程,然后事件触发线程将它加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列

六、 异步HTTP请求线程

这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行

JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行

七、浏览器的同步任务异步任务(宏任务 与 微任务)

1) 浏览器的Event Loop

事件循环就是一个循环,是各个异步线程用来通讯和协同执行的机制。各个线程为了交换消息,还有一个公用的数据区,这就是事件队列。各个异步线程执行完后,通过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:

image.png

流程讲解如下:

  1. 主线程每次执行时,先看看要执行的是同步任务,还是异步的API
  2. 同步任务就继续执行,一直执行完
  3. 遇到异步API就将它交给对应的异步线程,自己继续执行同步任务
  4. 异步线程执行异步API,执行完后,将异步回调事件放入事件队列上
  5. 主线程手上的同步任务干完后就来事件队列看看有没有任务
  6. 主线程发现事件队列有任务,就取出里面的任务执行
  7. 主线程不断循环上述流程

2) 定时器不准

Event Loop的这个流程里面其实还是隐藏了一些坑的,最典型的问题就是总是先执行同步任务,然后再执行事件队列里面的回调。这个特性就直接影响了定时器的执行,我们想想我们开始那个2秒定时器的执行流程:

  1. 主线程执行同步代码
  2. 遇到setTimeout,将它交给定时器线程
  3. 定时器线程开始计时,2秒到了通知事件触发线程
  4. 事件触发线程将定时器回调放入事件队列,异步流程到此结束
  5. 主线程如果有空,将定时器回调拿出来执行,如果没空这个回调就一直放在队列里。

上述流程我们可以看出,如果主线程长时间被阻塞,定时器回调就没机会执行,即使执行了,那时间也不准了,我们将开头那两个例子结合起来就可以看出这个效果:

const syncFunc = (startTime) => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 5000) {
      break;
    }
  }
  const offset = new Date().getTime() - startTime;
  console.log(`syncFunc run, time offset: ${offset}`);
}

const asyncFunc = (startTime) => {
  setTimeout(() => {
    const offset = new Date().getTime() - startTime;
    console.log(`asyncFunc run, time offset: ${offset}`);
  }, 2000);
}

const startTime = new Date().getTime();

asyncFunc(startTime);

syncFunc(startTime);

执行结果如下:

image-20200320163640760

通过结果可以看出,虽然我们先调用的asyncFunc,虽然asyncFunc写的是2秒后执行,但是syncFunc的执行时间太长,达到了5秒,asyncFunc虽然在2秒的时候就已经进入了事件队列,但是主线程一直在执行同步代码,一直没空,所以也要等到5秒后,同步代码执行完毕才有机会执行这个定时器回调。所以再次强调,写代码时一定不要长时间占用主线程

3) 引入微任务

前面的流程图我为了便于理解,简化了事件队列,其实事件队列里面的事件还可以分两类:宏任务和微任务微任务拥有更高的优先级,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务。执行每个宏任务之前都要检查下微任务队列是否有任务,如果有,优先执行微任务队列。所以完整的流程图如下:

image.png

上图需要注意以下几点:

  1. 一个Event Loop可以有一个或多个事件队列,但是只有一个微任务队列。
  2. 微任务队列全部执行完会重新渲染一次
  3. 每个宏任务执行完都会重新渲染一次
  4. requestAnimationFrame处于渲染阶段,不在微任务队列,也不在宏任务队列
1)常见宏任务有:
  1. script (可以理解为外层同步代码)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage
2)常见微任务有:
  1. Promise.then
  2. process.nextTick(Node.js)
  3. Object.observe(已废弃)
  4. MutaionObserver
3) demo
console.log('1');
setTimeout(() => {
  console.log('2');
},0);
Promise.resolve().then(() => {
  console.log('5');
})
new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
})
// 1 3 5 4 2
说明:
  • 先输出1,这个没什么说的,同步代码最先执行

  • console.log('2');setTimeout里面,setTimeout是宏任务,“2”进入宏任务队列

  • console.log('5');Promise.then里面,进入微任务队列

  • console.log('3');在Promise构造函数的参数里面,这其实是同步代码,直接输出

  • console.log('4');在then里面,他会进入微任务队列,检查事件队列时先执行微任务

  • 同步代码运行结果是“1,3”

  • 然后检查微任务队列,输出“5,4”

  • 最后执行宏任务队列,输出“2”

参考

总结

  • GUI线程就是渲染页面的,他解析HTML和CSS,然后将他们构建成DOM树和渲染树就是这个线程负责的。

  • JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行

  • 事件触发线程 对于实现事件循环 很关键。

  • 写代码时一定不要长时间占用主线程比如,let i=1000; while(i){ i--; ....} ,会导致定时器更加不准

总结2

  • JS所谓的单线程只是指主线程只有一个并不是整个运行环境都是单线程

  • JS的异步底层的多线程实现

  • 不同的异步API对应不同的实现线程

  • 异步线程主线程通讯靠的是Event Loop

  • 异步线程完成任务后将其放入任务队列

  • 主线程不断轮询任务队列拿出任务执行

  • 任务队列宏任务队列微任务队列的区别

  • 微任务队列优先级更高所有微任务处理完后才会处理宏任务

  • Promise是微任务

  • Node.js的Event Loop跟浏览器的Event Loop不一样,他是分阶段的

  • setImmediatesetTimeout(fn, 0)哪个回调先执行,需要看他们本身在哪个阶段注册的,如果在定时器回调或者I/O回调里面,setImmediate肯定先执行。如果在最外层或者setImmediate回调里面,哪个先执行取决于当时机器状况。

  • process.nextTick不在Event Loop的任何阶段,他是一个特殊API,他会立即执行,然后才会继续执行Event Loop

事件循环整体流程

  1. 主线程每次执行时,先看看要执行的是同步任务,还是异步的API
  2. 遇到同步任务直接执行
  3. 遇到异步API就将它交给对应的异步线程 并放入对应的任务队列,自己继续执行外层script同步任务
  4. script外层同步任务,与Promise构造函数中的代码,执行完成后
  5. 优先执行 微任务队列中的微任务
  6. 执行 宏任务队列中的宏任务