Fork me on GitHub

JS事件循环

“JavaScript 是单线程、异步、非阻塞、解释型脚本语言。”

进程与线程

进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位。

本质来说,这两个名词都是CPU工作时间片的一个描述。

  • 进程描述了CPU运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
  • 线程是进程中的更小单位,描述了执行一段指令所需的时间。

例如:打开一个Tab页面,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、js引擎线程、http请求线程等等。当我们发起一个请求实际就是创建了一个线程,请求结束该线程可能就会被销毁。

为什么JavaScript是单线程?

上面的例子提到了JS引擎线程和渲染线程,在JS运行的时候可能会阻止UI渲染,说明两个线程是互斥的。这与JS的用途有关:

作为浏览器脚本语言,JS的主要用途是与用户互动及操作DOM。如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以为了避免复杂性,JS从诞生起就是单线程,单线程可以达到节约内存,节约上下文切换时间,没有锁的问题的好处。
⚠️ 对于锁的问题,形象的来说就是当读取一个数字15的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

JavaScript为什么需要异步?

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

如果JS中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。对于用户而言,阻塞就意味着”卡死”,这样就导致了很差的用户体验。

比如Ajax操作从网络读取数据,因为IO(输入输出)设备很慢,不得不等结果出来再往下执行,这时候CPU是闲着的。所以JS语言设计者意识到主线程可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务,等到IO设备返回了结果再回过头把挂起的任务继续执行。

执行栈与任务队列

栈和队列

栈和队列,两者都是线性结构,但是栈遵循的是后进先出(last in first off,LIFO),开口封底。而队列遵循的是先进先出 (fisrt in first out,FIFO),两头通透。

执行栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
平时在开发中,我们可以在报错中找到执行栈的痕迹

1
2
3
4
5
6
7
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()

可以看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。
因为栈存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话就会出现爆栈的问题

1
2
3
4
function bar() {
bar()
}
bar()

任务队列

“任务队列”是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。

因此任务被分成两种-同步任务(synchronous)和异步任务(asynchronous)

同步任务指的是在主线程上排队执行的任务,只有一个任务执行完毕才能执行后一个任务;异步任务指的是不进入主线程,而进入任务队列的任务,只有当任务队列通知主线程某个异步任务可以执行了,该任务才会进入主线程执行。
异步一般是指:

  • 网络请求
  • 计时器
  • DOM事件监听

事件和回调函数

“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。

所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列”上第一位的事件就自动进入主线程。但是,由于存在后文提到的”定时器”功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

浏览器中的Event Loop

JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:

  • JS引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步http请求线程
  • GUI渲染线程

JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等…),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由事件触发线程将异步对应的回调函数加入到消息队列中,消息队列中的回调函数等待被执行。而JS引擎线程继续后面的其他任务,这样便实现了异步非阻塞

事件循环机制:

  • JS引擎线程维护一个执行栈,同步代码会依次加入到执行栈中依次执行并出栈
  • JS引擎线程遇到异步函数,会将异步函数交给相应的webapi,继续执行后面的任务
  • webapi在条件满足的时候将异步任务对应的回调加入到消息队列中,等到执行
  • 执行栈为空时,JS引擎线程回去取消息队列中的回调函数,并加入到执行栈中执行
  • 完成后出栈,执行栈再次为空,重复上面的操作。

在底层的 C/C++ 代码中,这个事件循环是一个跑在独立线程中的循环,我们用伪代码来表示,大概是这样的:

1
2
3
4
while(TRUE) {
r = wait();
execute(r);
}

整个循环做的事情基本上就是反复“等待 - 执行”。当然,实际的代码中并没有这么简单,还有要判断循环是否结束、宏观任务队列等逻辑。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log('script start')

setTimeout(() => {
console.log('timer 1 over')
}, 1000)

setTimeout(() => {
console.log('timer 2 over')
}, 0)

console.log('script end')

// script start
// script end
// timer 2 over
// timer 1 over

setTimeout 的第二个参数并不能代表回调执行的准确的延时事件,它只能表示回调执行的最小延时时间,因为回调函数进入消息队列后需要等待执行栈中的同步任务执行完成,执行栈为空时才会被执行。

以上机制在ES5之前的情况下够用了,但是ES6引入了 Promise,这样,不需要浏览器的安排,JavaScript 引擎本身也可以发起任务了。因此需要考虑任务队列的深层原理:microtask(微任务)和 macrotask(宏任务)

宏任务与微任务

采纳 JSC 引擎的术语,我们把宿主发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务。

上图摘自《掘金小册:前端面试之道》

执行JS代码遇到异步代码,会被挂起并在需要执行的时候加入到Task队列中。不同的任务源会被分配到不同的 Task 队列中,任务源可以分为微任务(microtask) 和宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()

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

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')

/*
新chrome 浏览器打印结果
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout

旧chrome 浏览器打印结果
script start
async2 end
Promise
script end
promise1
promise2
async1 end
setTimeout
*/

⚠️ 新旧浏览器差异原因是什么呢?async/await内部做了什么?

async 函数会返回一个 Promise 对象,如果在函数中 return 一个直接量(普通变量),async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。如果你返回了promise那就以你返回的promise为准。

await 是在等待,等待运行的结果也就是返回值。await后面通常是一个异步操作(promise),但是这不代表 await 后面只能跟异步操作 await 后面实际是可以接普通函数调用或者直接量的。

所以把上面async的这两个函数改造一下

1
2
3
4
5
6
7
8
new Promise((resolve, reject) => {
console.log('async2 end')
// Promise.resolve() 将代码插入微任务队列尾部
// resolve 再次插入微任务队列尾部
resolve(Promise.resolve(undefined))
}).then(() => {
console.log('async1 end')
})

也就是说,如果 await 后面跟着 Promise 的话,async1 end 需要等待三个 tick 才能执行到。那么其实这个性能相对来说还是略慢的,所以 V8 团队借鉴了 Node 8 中的一个 Bug,在引擎底层将三次 tick 减少到了二次 tick。但是这种做法其实是违法了规范的,当然规范也是可以更改的,这是 V8 团队的一个 PR,目前已被同意这种做法。

谷歌(金丝雀)73版本与老版本

谷歌(金丝雀)73版本更改了规范

⚠️ 区别在于RESOLVE(thenable)和Promise.resolve(thenable)。

  • 使用对PromiseResolve的调用来更改await的语义,以减少在公共awaitPromise情况下的转换次数。
  • 如果传递给 await 的值已经是一个 Promise,那么这种优化避免了再次创建 Promise 包装器,在这种情况下,我们从最少三个 microtick 到只有一个 microtick。

所以 Event Loop 执行顺序如下:

  • 「宏任务」、「微任务」都是队列,一段代码执行时,会先执行宏任务中的同步代码。
  • 进行第一轮事件循环的时候会把全部的js脚本当成一个宏任务来运行。
  • 如果执行中遇到setTimeout之类宏任务,那么就把这个setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。
  • 如果执行中遇到 promise.then() 之类的微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码都执行完成后,依次执行所有的微任务。
  • 第一轮事件循环中当执行完全部的同步脚本以及微任务队列中的事件,如有必要会渲染页面,这一轮事件循环就结束了,开始第二轮事件循环。
  • 第二轮事件循环同理先执行同步脚本,遇到其他宏任务代码块继续追加到「宏任务的队列」中,遇到微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行当前所有的微任务。
  • 开始第三轮,循环往复…

微任务包括 process.nextTick(node独有) ,promise ,MutationObserver(h5新特性)

宏任务包括 script,setTimeout,setInterval ,setImmediate(node,浏览器暂不支持),I/O ,UI rendering。

宏任务是一个栈按先入先执行的原则,微任务也是一个栈也是先入先执行。 但是每个宏任务都对应会有一个微任务栈,宏任务在执行过程中会先执行同步代码再执行微任务栈。

Node中的Event Loop

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

libuv是 Node 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。Event Loop就是在libuv中实现的。

NodeJS运行机制:

  • V8引擎解析JS脚本
  • 解析后的代码,调用Node API
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  • V8引擎再将结果返回给用户。

从上图中,大致看出node中的事件循环的顺序:

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段(按照该顺序反复运行)…

  • timers 阶段:执行timer(setTimeout、setInterval)中到期的callback,并且是由 poll 阶段控制的。 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调。
  • idle, prepare 阶段:仅node内部使用
  • poll 阶段:
    poll 是一个非常重要的阶段,这一阶段中,系统会做两件事:

    1. 回到 timer 阶段执行回调
    2. 执行 I/O 回调
  • check 阶段:执行setImmediate(setImmediate()是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate指定的回调函数)的callback。

  • close callbacks 阶段:执执行close事件的callback,例如socket.on(‘close’[,fn])或者http.server.on(‘close, fn)。

⚠️ 上面六个阶段都不包括 process.nextTick()

setTimeout 和 setImmediate

二者非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在poll阶段完成时执行,即check阶段;
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行,但它在timer阶段执行

process.nextTick

这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})

// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
console.log('1')
setTimeout(function() {
console.log('2')
process.nextTick(function() {
console.log('3')
})
new Promise(function(resolve) {
console.log('4')
resolve()
}).then(function() {
console.log('5')
})
})

process.nextTick(function() {
console.log('6')
})

new Promise(function(resolve) {
console.log('7')
resolve()
}).then(function() {
console.log('8')
})

setTimeout(function() {
console.log('9')
process.nextTick(function() {
console.log('10')
})
new Promise(function(resolve) {
console.log('11')
resolve()
}).then(function() {
console.log('12')
})
})
// node11:1 7 6 8 2 4 3 5 9 11 10 12
// node10及以前:1 7 6 8 2 4 9 11 3 10 5 12

Node与浏览器的 Event Loop 差异

  • 浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。
  • Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

⚠️注意:由于node版本更新到11,Event Loop运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这点就跟浏览器端一致。

举个栗子🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')

// node环境:
// start=>end=>promise3=>timer1=>timer2=>promise1=>promise2

浏览器环境:start=>end=>promise3=>timer1=>promise1=>timer2=>promise2
node环境则分为两种情况:

  • node11版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这就跟浏览器端运行一致,最后的结果为timer1=>promise1=>timer2=>promise2
  • 如果是node10及其之前版本:要看第一个定时器执行完,第二个定时器是否在完成队列中。

    • 如果第二个定时器还未在完成队列中,最后的结果为timer1=>promise1=>timer2=>promise2
    • 如果是第二个定时器已经在完成队列中,则最后的结果为timer1=>timer2=>promise1=>promise2

参考文章:
阮一峰-JavaScript 运行机制详解
https://juejin.im/post/5be5a0b96fb9a049d518febc
https://juejin.im/post/5c148ec8e51d4576e83fd836
https://juejin.im/post/5c36b3b0f265da611f07e409
https://juejin.im/post/5c3d8956e51d4511dc72c200

本文标题:JS事件循环

文章作者:tongtong

发布时间:2019年03月19日 - 20:03

最后更新:2019年04月26日 - 16:04

原始链接:https://ilove-coding.github.io/2019/03/19/重新理解js事件循环/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!
-------------本文结束-------------