阅读 233

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

单线程模型

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

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

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

同步和异步

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

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

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

调用栈(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 清空。
关注下面的标签,发现更多相似文章
评论