JavaScript 事件循环
进程与线程
单线程的非阻塞脚本语言
同步任务和异步任务
参考:
一次弄懂 Event Loop(彻底解决此类面试问题)
进程与线程
进程是程序在执行过程中分配和管理资源的基本单位,
线程是 CPU 调度和分配的基本单位,它可与同属于一个进程的其他线程共享进程所拥有的全部资源。
进程和线程都是一个时间段的描述,是 CPU 工作时间段的描述,不过是颗粒大小不同。
一个进程可以包含多个线程。
单个 CPU 总是运行一个进程,其他进程处于非运行状态。
一个 CPU 的一个时间点上运行的可能是一个进程上的不同线程。
可以简单的比喻:线程=车厢,进程=火车。
JavaScript 是单线程的非阻塞脚本语言
什么是单线程
主程序只有一个线程,即同一时间片段内主程序只能执行单个任务。
为什么 JavaScript 是单线程
因为 JavaScript 的执行环境是浏览器。主要用途是与用户互动,以及操作 DOM。
例如,操作 Dom 的时候,如果是多线程的操作一个 Dom,一个向其添加事件,而另一个删除了这个 dom,这时无法处理。
单线程意味着什么
单线程意味着,JavaScript 代码在执行的任何时候,都只有一个主线程来处理所有的任务。
如何解决单线程带来的性能问题
异步。所以 JavaScript 是非阻塞,意味着当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O 事件)的时候,主线程会挂起(pending)这个任务,然后异步任务返回结果的时候再根据一定规则去执行相应的回调。
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
宏任务和微任务
宏任务(MacroTask/Task)由宿主(Node、浏览器)发起的
- script 全部代码、
- setTimeout、
- setInterval、setImmediate(浏览器暂时不支持,只有 IE10 支持,具体可见 MDN)
- I/O、
- postMessage、
- UI Rendering。
requestAnimationFrame
特性:
宏任务所处的队列就是宏任务队列
第一个宏任务队列中只有一个任务:执行主线程上的 JS 代码;如果遇到上方的异步任务,会创建出一个新的宏任务队列,存放这些异步函数执行完成后的回调函数。
宏任务队列可以有多个
宏任务中可以创建微任务,但是在宏任务中创建的微任务不会影响当前宏任务的执行。
微任务(MicroTask/jobs)JavaScript 自身(JS 引擎)发起
- Process.nextTick(Node 独有)、
- Promise()、
- await、
- MutationObserver(html5 新特性)
Object.observe(废弃)、
特性:
微任务所处的队列就是微任务队列
在上一个宏任务队列执行完毕后,如果有微任务队列就会执行微任务队列中的所有任务
new promise((resolve)=>{ 这里的函数在当前队列直接执行 }).then( 这里的函数放在微任务队列中执行 )
微任务队列上创建的微任务,仍会阻碍后方将要执行的宏任务队列
由微任务创建的宏任务,会被丢在异步宏任务队列中执行
setTimeout/Promise 等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。 所以 setTimeout 和 Promise 是立即执行的,setTimeout 的第一个参数-回调函数,和 Promise 的 then 方法才会放到任务队列中。
浏览器中的 Event Loop
执行栈/函数调用栈(call stack)
同步代码的执行,按照顺序添加到执行栈中
任务队列/事件队列
异步代码的执行。遇到异步事件,主线程不会等待,而是将事件挂起,继续执行栈中的其他任务。当异步任务返回结果,将他放到事件队列中
,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。
1 | console.log('start') |
同步任务和异步任务
同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
异步任务: 异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程。
事件循环顺序
- script(整体代码)开始第一次循环,之后全局上下文进入函数调用栈。
- 如果遇到微任务,先将异步任务交给对应的线程,当异步任务满足条件需要执行时,异步代码压入微任务队列中,同理宏任务进入宏任务队列;
- 调用栈清空(只剩全局),然后从微任务队列的队头依次取出微任务并执行,如果遇到其他宏任务和微任务,按照上一步执行,直到清空微任务队列
- 取出队头的宏任务执行,执行步骤同 2-3,一直循环下去。
- 例子 1
1 | console.log('start') |
步骤(盗图,懂了,就不想画了)
重点,因为例子中的 setTimeout 的延迟是 0,所以是立刻入队(宏任务队列)的,如果是1000
就是 1s 后入队,如果宏任务队列内没有其他任务,并且主程序空闲(没有其他宏任务,微任务清空),就会执行,这样就是延迟 1s 执行。否则主程序还在执行其他任务,即使 1s 后入队,延迟时间会大于 1s。
例子 2
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
29console.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')
//新 script start - async2 end - Promise - script end - async1 end - promise1 - promise2
//旧 script start - async2 end - Promise - script end - promise1 - promise2 - async1 end
首先 async/await 在底层转换成了 promise 和 then 回调函数,是 promise 的语法糖。
1 | async function async1() { |
简化理解:
老版浏览器
1 | function f(){ |
这样await
后面返回还是Promise
的话,Promise.resolve()
先进入微任务队列,这样函数async1
就会挂起,代码接着执行下面的Promise
,promise1
和promise2
进入微任务队列。最后这一轮微任务队列清空输出promise1
和promise2
,resolve(Promise.resolve())
执行, console.log('async1 end')
才进入微任务队列。
新版浏览器
1 | function f(){ |
比老版减少了一次 Promise
Node 中的 Event Loop
分为 6 个阶段
定时器检测阶段(timers)
执行setTimeout
和setInterval
中到期的callback
,并且是由 poll 阶段控制的。同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/a.js', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
理论上
setTimeout
的回调函数是100ms
后执行,但是,当进入事件循环时,它有一个空队列(fs.readFile()尚未完成),因此定时器将等待剩余毫秒数,当到达 95ms 时,fs.readFile()完成读取文件并且其需要 10 毫秒完成回调添加到轮询队列并执行。
当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后回到 timers 阶段以执行定时器的回调。
在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为 105 毫秒左右。I/O事件回调阶段(I/O callbacks)
/pending callback
上一轮循环中少数的callback
会放在这一阶段执行
闲置阶段(idle, prepare)
:仅在内部使用
轮询阶段(poll)
:
用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
- 回到
timer
阶段执行回调 执行
I/O
回调并且在进入该阶段时如果没有设定了
timer
的话,会发生以下两件事情如果
poll
队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制- 如果
poll
队列为空,则会发生以下两种情况之一:- 如果有
setImmediate()
回调需要执行,则会立即停止执行poll
阶段并进入执行check
阶段以执行回调。 - 如果没有
setImmediate()
回到需要执行,poll
阶段将等待callback
被添加到队列中,然后立即执行。
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
- 如果有
检查阶段(check)
此阶段允许人员在 poll 阶段完成后立即执行回调。
如果 poll 阶段闲置并且 script 已排队 setImmediate(),则事件循环到达 check 阶段执行而不是继续等待。setImmediate()
实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用libuv API
来调度在 poll 阶段完成后执行的回调。通常,当代码被执行时,事件循环最终将达到 poll 阶段,它将等待传入连接,请求等。
但是,如果已经调度了回调 setImmediate(),并且轮询阶段变为空闲,则它将结束并且到达 check 阶段,而不是等待 poll 事件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18console.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 版本为 v11.x, 其结果与浏览器一致。执行一个宏任务之后都去清空微任务队列。
1
2
3
4
5
6
7start
end
promise3
timer1
promise1
timer2
promise2如果 v10 版本上述结果存在两种情况:
如果 time2 定时器已经在执行队列中了,先清空完 timer 队列再清空执行微任务
1
2
3
4
5
6
7start
end
promise3
timer1
timer2
promise1
promise2
- 如果 time2 定时器没有在执行对列中,执行结果为(没看到这种情况,我的理解是 timer1 执行的很快,timer2 还没进入队列),执行结果跟 v11.1 效果一样
关闭事件回调阶段(close callbacks)
执行 close 事件
setImmediate() 的 setTimeout()的区别
- setImmediate()设计用于在当前 poll 阶段完成后 check 阶段执行脚本 。
setTimeout() 安排在经过最小(ms)后运行的脚本,在 timers 阶段执行。
例子1
2
3
4
5
6
7
8setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});执行定时器的顺序将根据调用它们的上下文而有所不同。 如果从主模块中调用两者,那么时间将受到进程性能的限制。
如果在I/O
周期内移动两个调用,则始终首先执行立即回调:1
2
3
4
5
6
7
8
9
10const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
其结果可以确定一定是immediate => timeout
。
主要原因是在 I/O
阶段读取文件后,事件循环会先进入 poll
阶段,发现有 setImmediate
需要执行,会立即进入 check
阶段执行 setImmediate
的回调。
然后再进入timers
阶段,执行setTimeout
,打印timeout
。
Process.nextTick()
process.nextTick()
虽然它是异步 API 的一部分,但未在图中显示。这是因为从技术上讲,它不是事件循环的一部分。它有一个自己的队列,当每个阶段完成后,如果存在 nextTick
队列,就会清空队列中的所有回调函数,并且优先于其他 microTask
执行。
1 | let bar; |
对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。
面试题
微任务中创建微任务
1 | Promise.resolve().then(function F1() { |
- 首先执行宏任务,script,遇到 promise,将微任务放入微任务队列【F1】,
- 当前宏任务执行完毕,开始清空微任务队列,执行 F1,输出
promise1
,将 F4 放入任务队列【F4】(剩下的 then 需要等到 F4 执行完毕才入队),所以 F1 执行完了,F2 入队【F4,F2】, - 继续清空微任务,执行 F4,输出
promise2
,F5 入队【F2,F5】,同理上,F4 执行完毕,F7 入队【F2,F5,F7】, - 执行 F2,输出
promise3
,F2 执行完,F3 入队【F5,F7,F3】, - 执行 F5,输出
promise4
,F5 执行完,F6 入队【F7,F3,F6】, - 执行 F7,输出
promise5
, - 执行 F3,输出
promise6
, - 执行 F6,输出
promise?
,
微任务队列中创建的宏任务
1 | new Promise((resolve) => { |
重点:定时器是过多少毫秒之后才会加到事件队列里
task 2 改为立即执行,那么它会在 macro task 3 之前执行
1 | new Promise((resolve) => { |
宏任务中创建微任务
1 | // 宏任务队列 1 |