JavaScript 事件循环机制(Event Loop)
前言
Javascript 是一门 单线程语言,即同一时间只能执行一个任务,即代码执行是同步并且阻塞的。当一个任务在执行时,其他任务都要排队等待。但只能同步执行肯定是不行的,所以有了来实现异步的函数, Event Loop 就是为了确保异步代码可以在同步代码执行后继续执行的。
那么为什么 JavaScript 不设计成多线程的语言呢?
这是由其执行的环境是浏览器环境所决定的。试想一下如果 JavaScript 是多线程语言的话,那么当两个线程同时对 Dom 节点进行操作的时候,则可能会出现有歧义的问题,例如一个线程操作的是在一个 Dom 节点中添加内容,另一个线程操作的是删除该 Dom 节点,那么应该以哪个线程为准呢?所以 JavaScript 作为浏览器的脚本语言,其设计只能是单线程的。
需要注意的是,Html5 提出了 Web Worker,允许创建多个在后台运行的子线程来执行 JavaScript 脚本。但是由于子线程完全受主线程控制,而且不能够干扰用户界面(即不能操作 Dom),所以这并没有改变 JavaScript 单线程的本质。
上面讲到,JavaScript 是一门单线程的脚本语言。所谓单线程,就是指所有的任务都需要排队一个个执行,只有前一个任务执行完了才可以执行后一个任务。这就造成了一个问题,如果前一个任务耗时过长,则会阻塞下一个任务的执行,在页面上用户的感知便会是浏览器卡死的现象。
而由于在大部分的情况中,造成任务耗时过长不是任务本身计算量大而导致 CPU 处理不过来,而是因为该任务需要与 IO 设备交互而导致的耗时过长,但这时 CPU 却是处于闲置状态的。所以为了解决这个问题,便有了本文章的 JavaScript(也可以说是浏览器的)事件循环(Event Loop)机制。
一、进程和线程
什么是进程:
进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。
什么是线程:
线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位。一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。
二、事件循环(Event Loop)机制
HTML标准中是这样解释的:为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。每个代理都有一个关联的事件循环。大体意思就是浏览器运行时有一个叫事件循环的机制。
在 JavaScript 事件循环机制中,使用到了三种数据对象,分别是栈(Stack)、堆(Heap)和队列(Queue)。
- 栈:一种后进先出(LIFO)的数据结构。可以理解为取乒乓球时的场景,后面放进去的乒乓球反而是最先取出来的。
- 堆:一种树状的的数据结构。可以理解为在图书馆中取书的场景,可以通过图书索引的方式直接找到需要的书。
- 队列:一种先进先出(FIFO)的数据结构。即我们平时排队的场景,先排的人总是先出队列。
在 JavaScript 事件循环机制中,使用的栈数据结构便是执行上下文栈,每当有函数被调用时,便会创建相对应的执行上下文并将其入栈;使用到堆数据结构主要是为了表示一个大部分非结构化的内存区域存放对象;使用到的队列数据结构便是任务队列,主要用于存放异步任务。
三、任务队列
在 JavaScript 事件循环机制中,任务队列分为宏任务(macro-task)和微任务(micor-task)两种。
1. 宏任务:当前调用栈中执行的代码成为宏任务。
例如:I/O、setTimeout、setInterval、setImmediate(Node.js 环境) 、requestAnimationFrame(浏览器独有) 、UI rendering (浏览器独有)
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
2. 微任务: 当前(此次事件循环中)宏任务执行完,在下一个宏任务开始之前需要执行的任务,可以理解为回调事件。
例如:Promise.then、Object.observe(已废弃)、MutationObserver(html5新特性) process.nextTick(Node.js 环境);
MutationObserver 接口提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。
Object.observe() 方法用于异步地监视一个对象的修改。当对象属性被修改时,方法的回调函数会提供一个有序的修改流。然而,这个接口已经被废弃并从各浏览器中移除。你可以使用更通用的 Proxy 对象替代。
process.nextTick() 是 Node 的一个定时器,让任务可以在指定的时间运行。其中 Node 一共提供了 4 个定时器,它们分别是 setTimeout()、setInterval()、setImmediate()、process.nextTick()。
上述所描述的 setTimeout、Promise 等都是指一种任务源,其对应一种任务队列,真正放入任务队列中的,是任务源指定的异步任务。在代码执行过程中,遇到上述任务源时,会将该任务源指定的异步任务放入不同的任务队列中。
四、事件循环(Event Loop)执行顺序
- 主线程执行 JavaScript 整体代码,形成执行上下文栈,当遇到各种任务源时将其所指定的异步任务挂起,接受到响应结果后将异步任务放入对应的任务队列中,直到执行上下文栈只剩全局上下文;
- 将微任务队列中的所有任务队列按优先级、单个任务队列的异步任务按先进先出(FIFO)的方式入栈并执行,直到清空所有的微任务队列;
- 将宏任务队列中优先级最高的任务队列中的异步任务按先进先出(FIFO)的方式入栈并执行;
- 重复第 2 3 步骤,直到清空所有的宏任务队列和微任务队列,全局上下文出栈。
简单来说,事件循环机制的流程就是,主线程执行 JavaScript 整体代码后将遇到的各个任务源所指定的任务分发到各个任务队列中,然后微任务队列和宏任务队列交替入栈执行直到清空所有的任务队列,全局上下文出栈。
这里要注意的是,任务源所指定的异步任务,并不是立即被放入任务队列中的,而是在接收到响应结果后才会将其放入任务队列中排队。如 setTimeout 中指定延迟事件为 1s,则在 1s 后才会将该任务源所指定的任务队列放入队列中;I/O 交互只有接收到响应结果后才将其异步任务放入队列中排队等待执行。
在宏任务队列中,各个队列的优先级为:
setTimeout > setInterval > setImmediate > I/O
微任务队列中,各个队列的优先级为:
process.nextTick > Promise > Object.observe > MutationObserver
五、实例练习
下面代码结果输出的顺序是什么:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log( 'async2');
}
setTimeout(function () {
console.log("settimeout");
},0);
new Promise(function (resolve) {
console.log("promise1");
resolve( console.log("promise3"));
}).then(function () {
console.log("promise2");
}).catch(function(){
console.log("promise6");
});
async1();
console.log("script start");
setTimeout(function () {
new Promise(function (resolve) {
console.log("promise4");
resolve();
}).then(function () {
console.log("promise5");
});
},0);
process.nextTick(function(){
console.log("nextTick")
});
console.log('script end');
以上代码输出结果是:
promise1
promise3
async1 start
async2
script start
script end
nextTick
promise2
async1 end
settimeout
promise4
promise5