本文主要参阅了以下两篇文章,对JS的Event Loop运行机制基础知识进行了整理。
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
JavaScript 运行机制详解:再谈Event Loop
背景知识
进程与线程
大家都知道JavaScript是单线程的,这就引申出一个问题,进程与线程是什么,他们的区别是什么?
先给出进程和线程的定义:
- 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
- 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
用工厂和工人的例子来形象阐述:
- 进程是一个工厂,工厂有它的独立资源 -> 系统分配的内存(独立的一块内存)
- 工厂之间相互独立 -> 进程之间相互独立
- 线程是工厂中的工人,多个工人协作完成任务 -> 多个线程在进程中协作完成任务
- 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成
- 工人之间共享工厂的资源 -> 同一进程下的各个线程之间共享进程的内存空间(包括代码段、数据集、堆等)
补充:
- 我们所说的单线程和多线程,是指一个进程内是单一线程还是多线程。
- 进程间的通信方式包括: 管道pipe、 命名管道FIFO、消息队列MessageQueue、共享存储SharedMemory、信号量Semaphore、套接字Socket、信号。
浏览器是多进程的
关于浏览器进程问题可以简单基础三点:
- 浏览器是多进程的。
- 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)。
- 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。
平时 coding 接触到最多的一个浏览器进程是浏览器渲染进程(浏览器内核),它管理着页面渲染。脚本执行,事件处理等。要同时处理这么多事情,渲染进程显然是多线程的,它主要包括以下5个常驻线程:
- GUI渲染线程,负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
- JS引擎线程,也称为JS内核,负责处理Javascript脚本程序,(例如V8引擎)。
- 事件触发线程,用来控制事件循环(可以理解为,JS引擎线程自己都忙不过来,需要浏览器另开线程协助)。
- 定时触发器线程,浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确),JS中常用的
setInterval
和setTimeout
就归这个线程管理。 - 异步http请求线程,也就是
ajax
发出http请求后,接收响应、检测状态变更等都是这个线程管理的。
我们常说的JavaScript是单线程的,其实就是说的JS引擎是单线程的,它仅仅是浏览器渲染进程种的一个线程。为什么呢?因为JavaScript的主要作用是与用户互动,以及操作DOM
,如果JavaScript有两个线程,一个线程对一个DOM
节点执行 A 操作,另一个线程这个DOM
节点执行 B 操作,那么就会起冲突,所以JavaScript在前端的应用就注定了它是单线程的。
然而JavaScript的单线程特性就注定我们不用它去完成密集的 cpu 运算,因为密集 cpu 运算耗时过长,阻塞页面渲染。为了解决这个问题,HTML5提出 Web Worker 标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM
。
浏览器中的Event Loop
JavaScript 是单线程的带来的问题是:所有任务都必须同步执行,问题就出现了,很多 I/O 过程是非常耗时的(如http 请求数据),如果要等到 I/O 过程结束再执行后续任务,就会出现页面的卡顿、cpu 的闲置。于是异步的任务就出现了,异步任务是指挂起处于等待中的任务,继续执行同步任务,等到结果返回再去继续执行被挂起的任务。于是,JavaScript 的任务可以分为同步任务和异步任务。下面就引出 Event Loop 机制:
- 所有同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个回调函数。
- 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到执行栈中,开始执行。
如上图所示,执行栈中的代码会调用一个异步的API,它们会在任务队列中添加各种事件(或者说回调函数),另外用户的操作如click
、mousedown
等都会在任务队列中添加事件。只要执行栈中的代码执行完毕,主线程就会去读取任务队列,将可执行的回调函数放到执行栈中执行。
总结一下:
执行栈执行完毕 -> 主线程读取任务队列,并执行回调函数 -> 执行栈执行完毕 -> 主线程读取任务队列,并执行回调函数 ...
这个过程一直循环下去,所以就叫事件循环(Event Loop)。
宏任务和微任务
先看一道经典的面试题目:
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
造成以上执行顺序的原因是Event Loop中的异步任务其实可以进一步细化为宏任务(macrotask)和微任务(microtask)。
常见的 macro-task 比如: setTimeout、setInterval、 setImmediate、 script(整体代码)、I/O 操作等。
常见的 micro-task 比如: process.nextTick、Promise、MutationObserver 等。
注意:script(整体代码)它也是一个宏任务;此外,宏任务中的 setImmediate、微任务中的 process.nextTick 这些都是 Node 独有的。
为了进一步区分,将第一小节中的任务队列细分为两个:宏任务队列和微任务队列。EventLoop的执行机制也可以进一步细化:
- 所有同步任务都在主线程上执行,形成一个执行栈,每次执行栈执行的代码整体就是一个宏任务
- 执行栈中的同步代码执行过程中会产生新的宏任务和微任务,它们会被分别添加到宏任务队列和微任务队列中
- 执行栈中同步任务执行完后,会先取出微任务队列中的所有微任务,将其执行完;然后执行页面渲染;然后再从宏任务队列中取出一个宏任务放入执行栈执行
大概步骤如下:
执行栈执行完毕 -> 读取微任务队列所有任务,执行 -> 渲染 -> 读取宏任务队列中的一个宏任务,放入执行栈执行 -> 执行栈执行完毕 ->...
需要注意的点有两个:
- Event-Loop每次从宏任务队列中只会取出一个宏任务,而对于微任务队列则会将其清空
- 微任务的执行时机在渲染之前,宏任务的执行时机在渲染之后
setTimeout 和 setInterval
前面提到了浏览器的定时触发器线程,它的主要作用就是计时,setTimeout
和 setInterval
就由它来控制,原理就是到达设置时间后,往任务队列中添加这两个函数中指定的回调函数。
setTimeout()
方法用于在指定的毫秒数后调用函数或计算表达式。但是需要注意的是,实际是计时结束后定时触发器线程才会将回调函数放到任务队列中去,此时任务队列中这个回调之前可能已经有一些事件待处理,并且一定要执行栈的任务执行完后才会开始执行任务队列中的任务,所以 setTimeout()
中回调开始执行的时间是:执行栈执行时间 + 任务队列前方回调执行时间 + 延迟时间
setInterval()
方法可按照指定的时间间隔来周期性调用函数或计算表达式。它的问题在于:每次都精确的隔一段时间将一个回调放到任务队列中,并没有考虑到内部回调函数执行所需时间,这就会导致两种问题:
- 回调函数执行需要时间,两个函数执行的时间间隔会小于设定值;
- 如果回调函数执行时间大于设定间隔,就会出现上一个加入任务队列中的回调还没执行完,下一个回调就被加入任务队列了,就会出现累计效应,即后面的回调会连续执行。
Node中的Event Loop
浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
Node中的Event Loop运行机制大致如下:
- 执行全局的 Script 代码(与浏览器无差);
- 把微任务队列清空:注意,Node 清空微任务队列的手法比较特别。在浏览器中,我们只有一个微任务队列需要接受处理;但在 Node 中,有两类微任务队列:next-tick 队列和其它队列。其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务;
- 开始执行 macro-task(宏任务)。注意,Node 执行宏任务的方式与浏览器不同:在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到了系统限制);
- 步骤3开始,会进入 3 -> 2 -> 3 -> 2…的循环。
这里有一个重要的区别,在node11之前,每一次Event-Loop中是尝试清空宏任务队列中的所有宏任务,而node11后的Event-Loop机制已经与浏览器趋同,都是每个循环都只执行宏任务队列中的一个任务,然后立即去执行微任务队列。如下题:
setTimeout(() => {
console.log('timeout1');
}, 0);
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(function() {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timeout3')
}, 0)
node11之前的输出为:
timeout1
timeout2
timeout3
promise1
而node11后和浏览器的输出为:
timeout1
timeout2
promise1
timeout3
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。