JavaScript 执行机制之 Event Loop

我心飞翔 分类:javascript

为什么JavaScript是单线程?

JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么 JavaScript 不能有多个线程呢?这样能提高效率啊。

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核 CPU 的计算能力,HTML5提出 Web Worker 控制,且不得操作DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

JavaScript如何在浏览器中运行?

JS 的运行通常是在浏览器中进行的,具体由 JS 引擎去解析和运行。

浏览器引擎(浏览器内核)

目前最为流行的浏览器为:Chrome,IE,Safari,FireFox,Opera。浏览器的内核是多线程的。一个浏览器通常由以下几个常驻的线程:

  • 渲染引擎线程:顾名思义,该线程负责页面的渲染
    • Chrome/Safari/Opera用的是Webkit引擎,IE用的是Trident引擎,FireFox用的是Gecko引擎
    • 不同的引擎对同一个样式的实现不一致,就导致了经常被人诟病的浏览器样式兼容性问题
  • JS引擎线程:负责JS的解析和执行
  • 定时触发器线程:处理定时事件,比如setTimeout, setInterval
  • 事件触发线程:处理DOM事件
  • 异步http请求线程:处理http请求

需要注意的是,渲染引擎JS引擎 共用同一线程,所以是不能同时进行的。渲染引擎在执行任务的时候,JS引擎会被挂起。因为JS可以操作DOM,若在渲染中JS处理了DOM,浏览器可能就不知所措了。

JS引擎

JS引擎可以说是JS虚拟机,负责JS代码的解析和执行。通常包括以下几个步骤:

  • 词法分析:将源代码分解为有意义的分词
  • 语法分析:用语法分析器将分词解析成语法树
  • 代码生成:生成机器能运行的代码
  • 代码执行

不同浏览器的JS引擎也各不相同,Chrome用的是V8,FireFox用的是SpiderMonkey,Safari用的是JavaScriptCore,IE用的是Chakra。

本质上来说,是浏览器在运行时只开启了一个JS引擎线程来解析和执行JS。

执行栈与任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为 I/O 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript 语言的设计者意识到,这时主线程完全可以不管 I/O 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 I/O 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)

  • 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,主线程的任务执行完毕,就会去“任务队列”取任务来执行。

具体来说,运行机制如下:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

下图就是主线程和任务队列的示意图。

image

只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。

浏览器的Event Loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)

image

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

JavaScript 通过任务队列管理所有异步任务,而任务队列还可以细分为 MacroTask QueueMicoTask Queue 两类。

宿主环境发起宏观任务;JavaScript引擎发起微观任务。

那么为什么要分为 宏任务微任务

实时性。宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,可能会被上个任务阻塞,对一些高实时性的需求就不太符合了;有些任务需要及时执行,就作为微任务,在当前宏任务执行结束之后立即执行;

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。为了直观理解,你可以看下面这段代码:

function timerCallback2(){
  console.log(2)
}
function timerCallback(){
  console.log(1)
  setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)
 

在这段代码中,我的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。你可以打开 Performance 工具,来记录下这段任务的执行过程,也可参考文中我记录的图片:

img

setTimeout 函数触发的回调函数都是宏任务,如图中,左右两个黄色块就是 setTimeout 触发的两个定时器任务。

现在你可以重点观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。

所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如监听 DOM 变化的需求(MutationObserver)。

所以,微任务可以在实时性和效率之间做一个有效的权衡

MacroTask Queue

MacroTask Queue(宏任务队列) 主要包括 setTimeoutsetIntervalsetImmediate, NodeJS中的 I/O 等。

MicroTask Queue

MicroTask Queue(微任务队列) 主要包括:PromisesMutationObserver 等。

注意!Node的 process.nextTick() 不属于微任务,它在技术上不是事件循环的一部分,参考: Node 官方文档

由于浏览器事件循环的执行机制与Node不同,所以想了解它请阅读下一节Node的Event Loop。

事件循环,宏任务,微任务的关系如图所示:

image

总结

在执行任何 JS 文件时,JS 引擎将内容包装在函数中,并将函数与启动或启动事件相关联。JS 引擎发出启动事件,这些事件被添加到任务队列(作为宏任务macrotask),也就是我们的程序代码。

初始化时,JS 引擎先拉出宏任务队列的第一个任务并执行回调处理程序,因此我们的代码运行。

然后遇到异步任务就会将其加入到任务队列,代码执行完之后,会在任务队列里取出所有微任务执行,执行完毕进入下一次循环。

**事件循环每次只会入栈一个 macrotask ,主线程执行完该任务后又会先检查 microtasks 队列并完成里面的所有任务后再进入次轮循环执行下一个 macrotask **。

所以,

微任务回调函数保存在一个 数组 当中,宏任务回调函数保存在 链表 中。

每轮循环执行顺序:一个宏任务 => 所有微任务 => (下一轮)一个宏任务 => 所有微任务 => ...

Node.js的Event Loop

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。

请看下面的示意图(作者@BusyRich)。

image

Node 的官方文档是这样介绍的。

"When Node.js starts, it initializes the event loop, processes the provided input script which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop."

这段话很重要,需要仔细读。它表达了三层意思。

首先,有些人以为,除了主线程,还存在一个单独的事件循环线程。不是这样的,只有一个主线程,事件循环是在主线程上完成的。

当有可能的时候,它们会把操作转移到系统内核中去。(这里需要libuv做跨平台的兼容,例:*nix, windows)

其次,Node 开始执行脚本时,会先进行事件循环的初始化,但是这时事件循环还没有开始,会先完成下面的事情。

  • 同步任务
  • 发出异步请求
  • 规划定时器生效的时间
  • 执行 process.nextTick() 等等

最后,上面这些事情都干完了,事件循环就正式开始了。

事件循环的六个阶段

事件循环会无限次地执行,一轮又一轮。只有异步任务的回调函数队列清空了,才会停止执行。

每一轮的事件循环,分成六个阶段。这些阶段会依次执行。

  1. timers:定时器阶段,处理setTimeout()setInterval()的回调函数。

  2. I/O callbacks:除了定时器setImmediate()用于关闭请求的回调函数 其他的回调函数都在这个阶段执行。

  3. idle, prepare:仅供系统内部调用,通常由 libuv 跨平台调用,这里可以忽略。

  4. poll:这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。

    这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。

  5. check:执行setImmediate()的回调函数。

  6. close callbacks:执行关闭请求的回调函数,比如 socket.on('close', ...)

每个阶段都有一个先进先出的回调函数队列。进入到某个阶段,只有当前阶段的回调函数队列清空了,该执行的回调函数都执行了,事件循环才会进入下一个阶段。

若当前阶段同步任务执行完毕,存在 process.nextTick()微任务(目前也就Promises) ,先执行前者回调队列,再后者回调队列。

可以看下图解释更清楚:

img

process.nextTick

Process.nextTick 这个名字有点误导,实质上,应该与 setImmediate() 名称交换,因为 process.nextTick()setImmediate() 触发得更直接,但这是过去遗留问题。

虽然 process.nextTick 是异步API中的一部分,但它在技术上不是事件循环的一部分。相反,无论事件循环的当前阶段如何,都将在当前操作完成后处理 nextTickQueue

setTimeout 和 setImmediate

由于 setTimeout 在 timers 阶段执行,而 setImmediate 在 check 阶段执行。所以,setTimeout 会早于setImmediate 完成。

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
 

上面代码应该先输出1,再输出2,但是实际执行的时候,结果却是不确定,有时还会先输出2,再输出1

这是因为setTimeout的第二个参数默认为0。但是实际上,Node 做不到0毫秒,最少也需要1毫秒,根据官方文档,第二个参数的取值范围在1毫秒到2147483647毫秒之间。也就是说,setTimeout(f, 0) 等同于 setTimeout(f, 1)

实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行 setImmediate 的回调函数。

但是,下面的代码一定是先输出2,再输出1。

const fs = require('fs');

fs.readFile('test.js', () => {
  setTimeout(() => console.log(1));
  setImmediate(() => console.log(2));
});
 

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate 才会早于 setTimeout 执行。

事件循环的示例

假设 一个是 100ms 后执行的定时器,一个是文件读取,它的回调函数需要 95ms。请问运行结果是什么?

const fs = require('fs');

const timeoutScheduled = Date.now();

// 异步任务一:100ms 后执行的定时器
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms`);
}, 100);

// 异步任务二:文件读取后,有一个 200ms 的回调函数
fs.readFile('test.js', () => {
  const startCallback = Date.now();
  while (Date.now() - startCallback < 200) {
    // 什么也不做
  }
});
 

脚本进入第一轮事件循环以后,没有到期的定时器,也没有已经可以执行的 I/O 回调函数,所以会进入 Poll 阶段,等待内核返回文件读取的结果。由于读取小文件一般不会超过 100ms,所以在定时器到期之前,Poll 阶段就会得到结果,因此就会继续往下执行。

第二轮事件循环,依然没有到期的定时器,但是已经有了可以执行的 I/O 回调函数,所以会进入 I/O callbacks 阶段,执行fs.readFile的回调函数。这个回调函数需要 200ms,也就是说,在它执行到一半的时候,100ms 的定时器就会到期。但是,必须等到这个回调函数执行完,才会离开这个阶段。

第三轮事件循环,已经有了到期的定时器,所以会在 timers 阶段执行定时器。最后输出结果大概是200多毫秒。

注意!在 Poll 阶段,若执行队列为空,如果定时器到期,会回滚到 timer 阶段执行到期的定时器。

练习题

  1. 第一题
setTimeout(function(){
  console.log("will be executed at the top of the next Event Loop")
},0)
var p1 = new Promise(function(resolve, reject){
    setTimeout(() => { resolve(1); }, 0);
});
setTimeout(function(){
    console.log("will be executed at the bottom of the next Event Loop")
},0)
for (var i = 0; i < 100; i++) {
    (function(j){
        p1.then(function(value){
           console.log("promise then - " + j)
        });
    })(i)
}
 

代码输出结果是什么呢?快点确认一下吧:

// 浏览器环境
will be executed at the top of the next Event Loop
promise then - 0
promise then - 1
promise then - 2
...
promise then - 99
will be executed at the bottom of the next Event Loop

// node环境
will be executed at the top of the next Event Loop
will be executed at the bottom of the next Event Loop
promise then - 0
promise then - 1
promise then - 2
...
promise then - 99
 

解析:

// 浏览器环境

  1. 首先同步执行完所有代码,其间注册了三个setTimeout异步任务,100个Promise异步任务;
  2. 然后检查MacroTask队列,取第一个到期的MacroTask,执行输出will be executed at the top of the next Event Loop;
  3. 然后检查MicroTask队列,发现没有到期的MicroTask,进入第4步;
  4. 再次检查MacroTask,执行第二个setTimeout处理函数,resolve Promise;
  5. 然后检查MicroTask队列,发现Promise已解决,其异步处理函数均可执行,依次执行,输出promise then - 0 至promise then - 99
  6. 最后再次检查MacroTask队列,执行输出will be executed at the bottom of the next Event Loop;

// node环境

进入 timer 阶段,所有定时器回调函数全部执行,然后执行微任务。


  1. 第二题
// 唯有node环境,哈哈
process.nextTick(function () {
    console.log('nextTick延迟执行1');
});

Promise.resolve().then(() => console.log('promise微任务执行'));

process.nextTick(function () {
    console.log('nextTick延迟执行2');
});

setImmediate(function () {
    console.log('setImmediate延迟执行1');

    process.nextTick(function () {
        console.log('强势插入');
    });
    
    console.log('setImmediate延迟执行1.1')
});
setImmediate(function () {
    console.log('setImmediate延迟执行2');
});
console.log('正常执行');
 

执行结果:

正常执行
nextTick延迟执行1
nextTick延迟执行2
promise微任务执行
setImmediate延迟执行1
setImmediate延迟执行1.1
setImmediate延迟执行2
强势插入
 

参考

  1. Node.js 事件循环,定时器和 process.nextTick() by Node官网
  2. Node 定时器详解 by 阮一峰
  3. Microtask and Macrotask: A Hands-on Approach by Chidume Nnamdi
  4. setImmediate(callback[, ...args]) by Node官网
  5. JavaScript 运行机制详解:再谈Event Loop 阮一峰
  6. JavaScript单线程异步的背后——事件循环机制
  7. event loop一篇文章足矣
  8. JS事件循环机制(event loop)之宏任务/微任务
  9. 这一次,彻底弄懂 JavaScript 执行机制
  10. 浅析setTimeout与Promise
  11. 详解JavaScript中的Event Loop(事件循环)机制

回复

我来回复
  • 暂无回复内容