JavaScript事件循环机制


事件循环(Event Loop)

事件循环是JavaScript的执行机制 。

前置知识

js是单线程语言

  • 什么是单线程?

    主程序只有一个线程,即同一时间片段内其只能执行单个任务。

  • 单线程意味着什么?

    单线程就意味着,所有任务都需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就需要一直等着。这就会导致IO操作(耗时但cpu闲置)时造成性能浪费的问题。

  • 如何解决单线程带来的性能问题?

    答案是异步!主线程完全可以不管IO操作,暂时挂起处于等待中的任务,先运行排在后面的任务。等到IO操作返回了结果,再回过头,把挂起的任务继续执行下去。于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

同步任务和异步任务

  • 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务指的是,不进入主线程、而进入”任务队列“(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

任务队列(Event Queue)

  • 宏任务(macrotask)
    • setTimeout
    • setInterval
    • setImmediate (Node独有)
    • requestAnimationFrame (浏览器独有)
    • I/O
    • UI rendering (浏览器独有)
  • 微任务(microtask)
    • process.nextTick (Node独有)
    • Promise
    • Object.observe
    • MutationObserver

setTimeout(fn,0)

指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。 (关于setTimeout要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。)

setInterval(fn,0)

会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。每过ms秒,会有fn进入Event Queue。一旦setInterval的 回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了 。

requestAnimationFrame

请求动画帧,是一个宏任务,html5 提供的一个专门用于请求动画的API,相比起setTimeout由系统决定回调函数的执行时机。60Hz的刷新频率,那么每次刷新的间隔中会执行一次回调函数,不会引起丢帧,不会卡顿。

Promise与process.nextTick(callback)

process.nextTick(callback):类似node.js版的”setTimeout”,在事件循环的下一次循环中调用 callback 回调函数。

不同类型的任务会进入对应的Event Queue,比如setTimeoutsetInterval会进入相同的Event Queue。

Event Loop是谁规定的?

  • Engine(执行引擎): 如V8 Engine,V8 实现并提供了 ECMAScript 标准中的所有数据类型、操作符、对象和方法(注意并没有 DOM)。 Event Loop 是属于 JavaScript Runtime 的,是由宿主环境提供的(比如浏览器,node)。
  • Runtime(执行环境): Chrome 提供了 window、DOM,而 Node.js 则是 require、process 等等。

我们知道了Event Loop在不同的宿主环境中的表现可能不同:

  • 浏览器的Event Loop是在html5的规范中明确定义。
  • NodeJS的Event Loop是基于libuv实现的。可以参考Node的官方文档以及libuv的官方文档
  • libuv已经对Event Loop做出了实现,而HTML5规范中只是定义了浏览器中Event Loop的模型,具体的实现留给了浏览器厂商。

本文只讨论浏览器和Node中事件循环。

JavaScript执行机制

  • 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

怎么知道主线程执行栈为空?

js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

浏览器事件循环

看一张图,理解了这张图就理解了浏览器的事件循环。如果看不到图,看这里

执行规则

  • 首先在执行栈(call stack)中的内容执行完毕清空后,会在事件队列(Event queue)检查一下哪些是宏任务哪些是微任务,然后执行所有的微任务,然后执行一个宏任务,之后再次执行所有的微任务。也就是说在主线程(main thread)任务执行完毕后会把任务队列中的微任务全部执行,然后再执行一个宏任务,这个宏任务执行完再次检查队列内部的微任务,有就全部执行没有就再执行一个宏任务
  • JS是单线程但是浏览器是多线程。你的异步任务是浏览器开启对应的线程来执行的,最后放入JS引擎中进行执行。
  • 所以在执行定时器、事件、ajax这些异步事件的时候是另外三个线程在执行代码,并不是JS引擎在做事情,在这些线程达到某一特定事件把任务放入JS引擎的线程中,同时GUI线程(渲染界面HTML的线程)与JS线程是互斥的,在JS引擎执行时GUI线程会被冻结、挂起。
  • 在执行微任务队列microtask queue中任务的时候,如果又产生了microtask,那么会继续添加到队列的末尾,也会在这个周期执行,直到microtask queue为空停止

浏览器主线程常驻线程

  • GUI 渲染线程
    • 绘制页面,解析 HTML、CSS,构建 DOM 树,布局和绘制等
    • 页面重绘和回流
    • 与 JS 引擎线程互斥,也就是所谓的 JS 执行阻塞页面更新
  • JS 引擎线程
    • 负责 JS 脚本代码的执行
    • 负责执行准备好待执行的事件,即定时器计数结束,或异步请求成功并正确返回的事件
    • 与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染
  • 事件触发线程
    • 负责将准备好的事件交给 JS 引擎线程执行
    • 多个事件加入任务队列的时候需要排队等待(JS 的单线程)
  • 定时器触发线程
    • 负责执行异步的定时器类的事件,如 setTimeoutsetInterval
    • 定时器到时间之后把注册的回调加到任务队列的队尾
  • HTTP 请求线程
    • 负责执行异步请求
    • 主线程执行代码遇到异步请求的时候会把函数交给该线程处理,当监听到状态变更事件,如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行

一些补充

  • async 隐式返回 Promise 作为结果,执行完 await 之后直接跳出 async 函数,让出执行的所有权,当前任务的其他代码执行完之后再次获得执行权进行执行
  • 立即 resolve 的 Promise 对象,是在本轮”事件循环”的结束时执行,而不是在下一轮”事件循环”的开始时。
  • 在一轮Event Loop中多次修改同一DOM,只有最后一次会进行绘制。
  • 渲染更新(Update the rendering)会在Event Loop中的tasks和microtasks完成后进行,但并不是每轮Event Loop都会更新渲染,这取决于是否修改了DOM和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处DOM,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
  • 如果希望在每轮Event Loop都即时呈现变动,可以使用requestAnimationFrame

Node事件循环

Node的事件循环是基于libuv实现,而libuv是 Node 的新跨平台抽象层,libuv使用异步IO事件驱动的编程方式,核心是提供I/O的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。

看一张图,如果看不到图,看这里

(1)V8引擎解析JavaScript脚本。

(2)解析后的代码,调用Node API。

(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

(4)V8引擎再将结果返回给用户。

当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。

下面的图表展示了事件循环操作顺序的简化概览。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Node事件循环的6个阶段

  • 定时器(timers):本阶段执行已经被 setTimeout()setInterval() 的调度回调函数。
  • 待定回调(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 检测(check)setImmediate() 回调函数在这里执行。
  • 关闭的回调函数(close callbacks):一些关闭的回调函数,如:socket.on('close', ...)

具体内容可以看Node官方文档

Node事件循环过程

  1. 执行全局Script的同步代码
  2. 执行microtask微任务
  3. 开始执行macrotask宏任务,共6个阶段,从第1个阶段开始执行相应每一个阶段macrotask中的所有任务,注意,这里是所有每个阶段宏任务队列的所有任务,在浏览器的Event Loop中是只取宏队列的第一个任务出来执行,每一个阶段的macrotask任务执行完毕后,开始执行微任务,也就是步骤2
  4. Timers Queue -> 步骤2 -> I/O Queue -> 步骤2 -> Check Queue -> 步骤2 -> Close Callback Queue -> 步骤2 -> Timers Queue ……
  5. 这就是Node的Event Loop

浏览器和Node事件循环的差异

  • 浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。
  • 而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

Node > 11 ?

观察下面代码

setTimeout(() => {
  console.log('timer1')
  setImmediate(function () {
    console.log('immd 1')
  })
  Promise.resolve().then(function () {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  setImmediate(function () {
    console.log('immd 2')
  })
  Promise.resolve().then(function () {
    console.log('promise2')
  })
}, 0)

在node11及以上版本打印:

timer1
promise1
timer2
promise2
immd 1
immd 2

在 node 版本为 8.11.2 打印:

timer1
timer2
promise1
promise2
immd 1
immd 2

这是因为在 node11 的版本之后,也是每个 Macrotask 执行完后,就去执行 Microtask 了,和浏览器的模型一致。

参考

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

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

Node.js VS 浏览器以及事件循环机制

NodeJs 的 Event loop 事件循环机制详解

带你彻底弄懂Event Loop


评论
 上一篇
计算机网络之HTTP 计算机网络之HTTP
HTTP简介 HTTP协议是基于TCP/IP协议之上的应用层协议 超文本传输协议(英文:HyperText Transfer Protocol,缩写:HTTP)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP是万维网的数据通
下一篇 
Vue之组件通信 Vue之组件通信
组件通信props/$emit 在 Vue 中,父子组件的关系可以总结为 prop 向下传递,事件向上传递。父组件通过 prop 给子组件下发数据,子组件通过事件给父组件发送消息。 父组件向子组件传递数据代码示例父组件 在父组件里引入子组
2020-09-08
  目录