JavaScript深刻之事件循环机制(event loop)

单线程模型

众所周知,JavaScript 是单线程的,所谓单线程,就是指一次只能完成一个任务,若是有多个任务就必需要排队,前面的一个任务完成了,再执行后面的任务,以此类推。html

须要注意的是 JavaScript 只在一个线程上运行,不表明浏览器内核只有一个线程,事实上浏览器内部有多个线程,主线程用于 JavaScript 代码的编译和执行,其它线程都是在后台配合主线程。web

JavaScript 之因此选择单线程,跟历史有关系。JavaScript 从诞生起就是单线程,缘由是不想让浏览器变得太复杂,多线程须要面临锁、状态同步等问题,这对于一种网页脚本语言来讲开销太大。若是 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另外一个线程删除了这个节点,这时浏览器应该以哪一个线程为准?因此,为了不复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征。编程

同步和异步

上面说了 JavaScript 是单线程的,这种模式下,若是有一个很是耗时的任务进行的话,后面的任务都得排队等着,这时候应用程序就没法去作其余的事情,为此 JavaScript 语言的任务执行模式分为两个部分:同步(Synchronous)和异步(Asynchronous)api

  • 同步:就是上面说的排队等待的形式。
  • 异步:异步操做发生在未知或不可预测的时间,是指在执行一个任务的时候不能当即返回结果,而是在未来经过必定手段获得,后一个任务不用等前一个任务结束就执行。

那么 JavaScript 是如何来执行异步任务的呢,就是后面要讲的事件循环机制。promise

调用栈(call stack)

讲事件循环以前,咱们先来看一下 JavaScript 中的 call stack。下图是 JavaScript 引擎的一个简化图:浏览器

上图中看出 JavaScript 引擎主要包含两个部分:服务器

  1. Memory Heap (内存堆):这是内存分配发生的地方。
  2. Call Stack(调用栈):这是代码执行时存储函数调用的结构。

前面说了,JavaScript 是一种单线程编程语言,这意味着它只有一个 Call Stack 。所以,它一次仅能作一件事。Call Stack 是一个数据结构,它基本记录了咱们在程序执行中的所处的位置,若是咱们进入一个函数,咱们把它放在堆栈的顶部。若是咱们从一个函数中返回,咱们弹出堆栈的顶部。网络

上面图中能够看出,当开始执行 JS 代码时,首先向调用栈中压入一个 main()函数(表明了全局上下文),而后执行咱们的代码,根据先进后出的原则,后执行的代码会先弹出栈。数据结构

若是在调用堆栈中执行的函数调用须要花费大量时间才能进行处理,会发生什么? 例如,假设你想在浏览器中使用 JavaScript 进行一些复杂的图像转换。这时候浏览器就被阻塞了,这意味着浏览器没法渲染,它不能运行任何其余代码,它就是被卡住了。这时候就想到了咱们前面讲过的异步任务的处理方式,那么如何执行异步任务呢,就是下面要讲的事件循环(event loop)机制多线程

事件循环(event loop)

尽管容许执行异步 JavaScript 代码(如 setTimeout 函数),但直到 ES6 出现,实际上 JavaScript 自己历来没有任何明确的异步概念。 JavaScript 引擎历来都只是执行单个程序模块而不作更多别的事情。 那么,谁来告诉 JS 引擎去执行你编写的一大段程序?实际上,JS 引擎并非孤立运行,它运行在一个宿主环境中,对于大多数开发人员来讲,宿主环境就是一个典型的 Web 浏览器或 Node.js。全部环境中的共同点是一个称为事件循环的内置机制,它随着时间的推移处理程序中多个模块的执行顺序,并每次调用 JS 引擎。

因此,例如,当你的 JavaScript 程序发出一个 Ajax 请求来从服务器获取一些数据时,你在一个回调函数中写好了 “响应” 代码,JS 引擎将会告诉宿主环境:

“嘿,我如今暂停执行,可是每当你完成这个网络请求,而且你有一些数据,请调用这个函数并返回给我。

而后浏览器开始监听来自网络的响应,当响应返回给你的时候,宿主环境会将回调函数插入到事件循环中来安排回调函数的执行顺序。

咱们来看下面的图表:

咱们都使用过 setTimeout、AJAX 这些 API, 可是,这些 API 不是由 JS 引擎提供的。那这些 Web APIs 究竟是什么? 从本质上讲,它们是浏览器并行启动的一部分,是你没法访问的线程,你仅仅只能够调用它们。

前面说了浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器一般由如下常驻线程组成:

  • GUI 渲染引擎线程:顾名思义,该线程负责页面的渲染
  • JavaScript 引擎线程:负责 JS 的解析和执行
  • 定时触发器线程:处理定时事件,好比setTimeout, setInterval
  • 事件触发线程:处理DOM事件
  • 异步 http 请求线程:处理http请求

上图中看出,JavaScript 运行时,除了正在运行的主线程,还存在一个 callback queue(也叫task queue),即任务队列,里面是各类须要当前程序处理的异步任务(实际上,根据异步任务的类型,存在多个任务队列)。

异步执行的运行机制以下:

  1. 首先主线程(即 JavaScript 引擎)会在 call stack 中执行全部的同步任务。
  2. 当遇到异步任务(如好比setTimeout、Ajax)时,则交由 Web APIs 相应的线程来处理,Web APIs这边处理完毕后,会将相应的 callback 函数放入到任务队列中。
  3. event loop 会不断的监测 调用栈 和 任务队列,当调用栈为空的时候,event loop 就会把任务队列中的第一个事件取出推入到调用栈中。
  4. 执行渲染操做,更新界面
  5. 如此循环往复。

下面咱们经过一个例子来看一下具体的执行过程。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');

复制代码

setTimeout 有个要注意的地方,如上述例子延迟 5s 执行,不是严格意义上的 5s,正确来讲是至少 5s 之后会执行。由于 Web API 会设定一个 5s 的定时器,时间到期后将回调函数加到队列中,此时该回调函数还不必定会立刻运行,由于队列中可能还有以前加入的其余回调函数,并且还必须等到 Call Stack 空了以后才会从队列中取一个回调执行。这也是不少人说 JavaScript 中的定时器其实不是彻底精确的缘由。

关于事件循环的详细讲解,推荐一个视频《what the hack is event loop》

任务队列

每一个线程都有本身的事件循环,因此每一个 web worker 有本身的事件循环(event loop),因此它能独立地运行。一个事件循环有多个 task 来源,而且保证在 task 来源内的执行顺序,在每次循环中浏览器要选择从哪一个来源中选取 task,任务源能够分为 微任务(microtask)宏任务(macrotask),在ES6规范中,microtask 称为 jobs, macrotask 称为 task。

macrotask 主要包括下面几个:

  • script 主程序
  • setTimeout
  • setInterval
  • setImmediate(Node)
  • I/O
  • UI交互事件

microtask 主要包含:

  • Promise
  • MutationObserver
  • process.nextTick (Node)

参考 whatwg规范中关于任务队列的定义咱们能够了解到:

  1. 每一个事件循环都有一个微任务队列(microtask queue)。
  2. 浏览器每次都是先执行最旧的 macrotask,也就是先加进宏任务队里的那个 macrotask。
  3. 每次执行完一个 macrotask,就会检查 microtask queue 里面是否存在 microtask,若是有则不断执⾏ microtask,在 microtasks 执行时还能够加入更多的 microtask,而后一个一个的执行,直到 microtask 队列清空。
  4. 下一个循环,执行下一个 macrotask 中的任务,如此循环往复。

有点绕,咱们下面先看一个例子来解释一下:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
// script start
// script end
// promise1
// promise2
// setTimeout
复制代码

咱们来分析一下上面代码的具体执行步骤。表格中红色的表示当前正在执行的任务。

  1. 首先主线程执行同步代码,script 代码进入 call stack,当前正在执行的 macrotask 为 主script。
macrotasks microtasks call stack Log
script script script start
  1. 遇到 setTimeout 函数,将其回调函数加入到 macrotasks 中
macrotasks microtasks call stack Log
script script script start
setTimeout callback
  1. 继续往下执行,遇到 Promise,将已经resolved 的 Promise 回调加入到 microtasks。
macrotasks microtasks call stack Log
script Promise then 1 script script start
setTimeout callback
  1. 继续往下执行,输出 log ‘script end’,此时当前的 macrotask 执行完毕,前面说到当每一次 macrotask 执行完毕后都会去检查 microtask queue,此时开始处理 microtasks 中的任务。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
  1. 检查 microtasks 发现有一个 microtask,开始执行 Promise then 1 的回调,输出log。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
promise1
  1. 发现该回调还有一个 then 函数的回调,再把它(暂且称之为 Promise then 2)也放入到 microtasks 中,此时 Promise then 1 这个 microtask 执行完毕,被移除。此时 macrotasks 还未清空,所以要继续执行 microtasks, 输出log。
macrotasks microtasks call stack Log
script Promise then 2 Promise callback 2 script start
setTimeout callback script end
promise1
promise2
  1. 此时,主 script 这个 macrotask 执行完毕,开始执行下一个 macrotask,也就是 setTimeout callback,输出log,而 microtask queue 被清空。
macrotasks microtasks call stack Log
setTimeout callback setTimeout callback script start
script end
promise1
promise2
setTimeout
  1. setTimeout callback 这个 macrotask 执行完毕,此时检查 microtask queue 中没有任务,而且 macrotask queue 中也没有任务了,本次事件循环结束,call stack 清空。
相关文章
相关标签/搜索