通俗易懂讲解什么是Event Loop

435 阅读6分钟

浏览器中的Event Loop事件循环

当代码需要进行一项异步任务时,主线程将这些代码放到幕后线程中去执行,主线程继续执行栈中剩余的代码。当幕后线程(background thread)里的代码执行完成后(比如setTimeout时间到了,ajax请求得到响应),该线程就会将它的回调函数放到任务队列(又称作事件队列、消息队列)中等待执行。而当主线程执行完栈中的所有代码后(即主线程空闲后),它就会检查任务队列是否有任务要执行,如果有任务要执行的话,那么就将该任务放到执行栈中执行。如果当前任务队列为空的话,它就会一直循环等待任务到来。因此,这叫做事件循环。

遇到异步任务—>交给幕后线程—>幕后线程执行完毕时将其回调函数放入任务队列—>主线程空闲时查找队列是否有任务—>有则拉到栈中执行、无则循环等待

  1. 任务队列

    • Macrotask(宏任务)

      • script(整体代码)
      • setTimeout
      • setInterval
      • I/O
      • UI交互事件
      • postMessage
      • MessageChannel
    • Microtask(微任务)

      • Promise.then(重点)
      • process.nextTick(nodejs)
      • Object.observe
  2. 事件循环执行流程

    一次事件循环只执行位于Macrotask队首的任务,执行完成后立即执行Microtask队列中的所有任务(一开始在js主线程中跑的任务就是Macrotask任务,因此执行完主线程的代码后,会从Microtask队列中取任务来执行)

  3. 定时器问题:setTimeout不保证可靠定时

    定时器中设置的时间仅保证任务会在delay毫秒后进入Macrotask队列,并不意味着它能立刻运行,因为可能当前主线程正在进行一个耗时的操作,也可能目前Microtask队列中有很多个任务。

  4. Js是阻塞还是非阻塞的?

    核心是同步阻塞,而对于js异步事件,因为有事件循环机制,所以异步事件就是由事件驱动异步非阻塞

    • 同步阻塞:小明一直盯着下载进度条,到 100% 的时候就完成(死等该事件)

    • 同步非阻塞:小明提交下载任务后就去干别的,每过一段时间就去瞄一眼进度条,看到 100% 就完成(轮询)

    • 异步阻塞:小明换了个有下载完成通知功能的软件,下载完成就“叮”一声。不过小明仍然一直等待“叮”的声音(死等关联事件,最傻)

    • 异步非阻塞:仍然是那个会“叮”一声的下载软件,小明提交下载任务后就去干别的,听到“叮”的一声就知道完成了。(等通知,最机智)

  5. requestAnimationFrame既不属于Microtask也不属于Macrotask

    同步任务→promise等微任务→制作render树→requestAnimationFrame→制作render树→第一帧重绘完成→setTimeout等宏任务

Node中的Event Loop事件循环

  1. Node简介

    Node.js采用V8作为js的解析引擎,而I/O处理方面使用libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制由它实现

  2. Node.js运行机制

    • V8引擎解析JavaScript脚本
    • 解析后的代码调用Node API
    • libuv库负责Node API的执行,它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给V8引擎
    • V8引擎将结果返回用户

  3. 事件循环六个阶段

    libuv中的事件循环分六个阶段,它们会按照顺序反复运行,每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量达到系统设定的阈值,就会进入下一阶段。

    • timers阶段

      执行setTimeout()、setInterval()的回调,由poll阶段控制(与浏览器不同,timers阶段有几个setTimeout、setInterval都会依次执行)

    • I/O callbacks阶段:处理上一轮循环中少数未执行的I/O回调

    • idle,prepare阶段:仅node内部使用

    • poll阶段

      获取新的I/O事件,适当的条件下node将阻塞在这里。

      该阶段系统会做两件事:回到timer阶段执行回调;执行I/O回调。在进入该阶段时:

      • 如果没有设定timer,则:

        1.若poll队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制;

        2.若poll队列为空,则

        • 若有setImmediate回调需要执行,poll阶段会停止并且进入到check阶段执行回调;
        • 若没有setImmediate回调需要执行,会等待回调被加入到队列中并立即执行回调,同时会有个超时时间防止死等。
      • 如果设定了timer且poll队列为空,则会判断是否有timer超时,如果有的话会回到timer阶段执行回调

    • check阶段:执行setImmediate()的回调

    • close callbacks阶段:执行socket的close事件回调

  4. MicroTask与MacroTask

    MacroTask:setTimeout、setInterval、setImmediate、script(整体代码)、I/O操作

    MicroTask:process.nextTick、Promise().then

  5. 注意点

  • setTimeout和setImmediate

    • 两者调用时机不同

      • setImmediate设计在poll阶段完成时执行,即check阶段;
      • setTimeout设计在poll阶段为空闲时,且设定时间达到后在timer阶段执行。
    • 二者在异步I/O callbacks内部调用时,总是先执行setImmediate再执行setTimeout;

    • 其他情况先后顺序不一定(setTimeout(func, 0)===setTimeout(func, 1),如果在准备时候花费时间大于1ms,则在timers阶段就会直接执行setTimeout回调,小于1ms先执行setImmediate回调)

  • process.nextTick

    独立于Event Loop之外,有一个自己的队列,当每个阶段完成后如果存在nextTick队列,就会转而清空nextTick队列中的所有回调函数,且优先于其他microtask执行

Node与浏览器的Event Loop差异

  • Microtask任务队列的执行时机不同

    • Node端:microtask在事件循环的各个阶段之间执行(node10 及以下timers阶段有几个setTimeout/setInterval都会先依次执行再执行MicroTask(node 11则和浏览器一致)
    • 浏览器端:microtask在事件循环的每个macrotask执行完之后执行
  • 同:

    执行完主线程任务,都首先执行microtask

例子:

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>promise1=>timer2=>promise2  游览器和node11
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2  node10及以下

参考:

面试一定会问到的-js事件循环

浏览器与Node的事件循环(Event Loop)有何区别?

【js事件循环】+ requestAnimationFrame与页面绘制在事件循环中的顺序关系