JS事件循环

最近在看js事件循环,事件循环是js运行的核心,js是单线程的,js的异步事件就是依赖于事件循环机制,这里进行总结一下。

事件循环

首先,我们来解释下事件循环是什么东西:就我们所知道的,浏览器的js是单线程的,也就是说,在同一时刻,最多有且只有一个代码段在执行,可是浏览器又能很好的处理异步请求,这是为什么呢?

关于执行中的线程:

  • 主线程:也就是js引擎执行的线程,这个线程只有一个,页面渲染,函数处理都在这个主线程上执行。
  • 工作线程:也称幕后线程,这个线程可能存在于浏览器或js引擎内,与主线程是分开的,处理文件读取,网络请求灯异步事件。

如图:

从上图我们可以看出,js主线程它是有一个执行栈的,所有的js代码都会在执行栈里运行,在执行代码过程中,如果遇到一些异步代码(比如setTimeout,ajax,promise.then以及用户点击等操作),那么浏览器就会将这些代码放到另一个线程(工作线程)中执行,在前端由浏览器底层执行,在node端由libuv执行,这个线程的执行不阻塞主线程的执行,主线程执行栈中剩余的代码。

当工作线程里的代码执行完成后(比如setTimeout时间都了,ajax请求的到了响应),该线程就会将它的回调函数放在任务队列中(又称为事件队列,消息队列)中等待执行,而当主线程执行完栈中的所有代码后,它会检查任务队列是否有任务要执行,如果有任务要执行的话,那么久将该任务放到执行任务栈中执行。如果当前任务队列为空的话,它就会一直循环等待任务到来。跟大部分框架的消息队列其实本质原理都是一样的。下面我们来具体分析这套机制的运行过程。

任务队列

那么,问题来了。如果任务队列中,有很多个任务的话,那么要先执行哪个任务呢?js是有两个任务队列的,一个叫做Macrotask Queue(Task Queue)大任务,一个叫Microtask Queue小任务。

Macrotask常见的任务:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • 用户交互操作,UI渲染

Micraotask常见任务:

  • Promise
  • process.nextTick(nodejs)
  • Object.observe(不推荐使用)

重点来了
事件循环执行流程如下:
主线程在执行主流程

  1. 检查Macrotask队列是否为空,若不为空,则进行下一步,若为空,则跳到第3步;
  2. 从Macrotask队列中取队首(在队列时间最长)的任务进去执行栈中执行,执行完后进入下一步;
  3. 检查Microtask队列是否为空,若不为空,则进入下一步,否则,跳到第一步(开始新的事件循环);
  4. 从Microtask队列中取队首(在队列中时间最长)的任务进去事件队列执行,执行完后,跳到第3步中,在执行过程中新增的microtask任务会在当前事件循环周期内执行,而新增的macrotask任务只能等到下一个事件循环才能执行。

来看一段代码

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
console.log(1)
setTimeout(function() {
//settimeout1
console.log(2)
}, 0);
const intervalId = setInterval(function() {
//setinterval1
console.log(3)
}, 0)
setTimeout(function() {
//settimeout2
console.log(10)
new Promise(function(resolve) {
//promise1
console.log(11)
resolve()
})
.then(function() {
console.log(12)
})
.then(function() {
console.log(13)
clearInterval(intervalId)
})
}, 0);

//promise2
Promise.resolve()
.then(function() {
console.log(7)
})
.then(function() {
console.log(8)
})
console.log(9)

运行结果

1,9,7,8,2,3,10,11,12,13

运行结果分析

  • 第一次事件循环:

    1. console.log(1)被执行,输出1
    2. settimeout1执行,加入macrotask队列
    3. setinterval2执行,加入macrotask队列
    4. settimeout2执行,加入macrotask队列
    5. promise2执行,它的两个then函数加入microtask队列
    6. console.log(9)执行,输出9
    7. 根据事件循环定义,接下来会执行新增的microtask任务,(上面标记的:在执行过程中新增的microtask任务会在当前事件循环周期内执行),按照进入队列的顺序,执行console.log(7)和console.log(8),输出7和8,microtask队列为空,回到第1步,进入下一个事件循环,此时macrotask队列为:settimeout1,setinterval1,settimeout2。
  • 第二次事件循环:
    从macrotask队列里面取出队首元素:settimeout1并执行,输出2,microtask队列为空,回到第1步,进行下一个事件循环,此时macrotask队列为:setinterval1,settimeout2。

  • 第三次事件循环:
    从macrotask队列里取位于队首元素:setiverval1并执行,输出3,然后又将新生成的setinterval1(间隔生产)加入macrotask队列,mincrotask队列为空,回道第1步,进入下一个事件循环,此时macrotask队列为:settimeout2,setinterval1。

  • 第四次事件循环
    从macrotask对列中取出队首元素:settimeout2并执行,输出10,并且执行new promise内的函数(new promise内的函数式同步操作,并不是异步操作),输出11,并且将它的两个then函数加入到microtask队列中,再从microtask队列中(当前事件循环周期内执行)取队首的任务执行,直到队列为空。因此,两个新增的microtask任务按照顺序执行,输出12和13,并且将setinterval1清空(不会再产生setiterval1的事件)。

  • 结束
    此时,mac和mic队列都为空,浏览器会一直检查队列是否为空,等待新的任务加入队列。在第一次循环中,为什么不是macrotask先执行呢?因为按照流程的话,不是应该先检查macrotask队列是否为空,再检查microtask队列吗。
    原因:因为一开始js主线程中跑的任务就是macrotask任务,而根据事件循环流程,一次事件循环只会执行一个macrotask任务,因此,执行完主线程的代码后,它就去从microtask队列中取首任务来执行了。

注意:由于在执行microtask任务的时候,只有当microtask队列为空的时候,它才会进入下一个事件循环,因此,如果它源源不断地产生新的microtask任务,就会导致主线程一直在执行microtask任务,而没有办法执行macrotask任务,这样我们就无法进行UI渲染/IO操作/ajax请求了,因此,我们应该避免这种情况发生,在nodejs中process.necttick就可以设置最大的调用次数,以此来防止阻塞主线程。

定时器问题

由上,我们来引入一个新的问题,定时器的问题。定时器是否真实可靠呢?比如我执行一个命令:settimeout(task,100),他是否就能准确的在100ms后执行呢,如果知道上面运行机制就知道答案是否定的。

看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
const s = new Date().getSeconds();

setTimeout(function() {
// 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);

while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds");
break;
}
}

主线程跑了2s后才结束while循环,这才去执行macrotask中的settimeout回调任务。其实,你执行setTimeout(task,100)后只是确保这个任务会在100ms进入macrotask队列,但并不意味着他能立刻运行,可能当前主线程正在进行一个耗时的操作,也可能目前microtask队列有很多个任务,所以用setTimeout作为倒计时其实不会保证准确。

阻塞和非阻塞

关于 js 阻塞还是非阻塞的问题,我觉得可以这么理解,不够在这之前,我们先理解下同步、异步、阻塞还是非阻塞的解释,在网上看到一段描述的非常好,引用下:

同步阻塞:小明一直盯着下载进度条,到 100% 的时候就完成。
同步非阻塞:小明提交下载任务后就去干别的,每过一段时间就去瞄一眼进度条,看到 100% 就完成。(轮询)
异步阻塞:小明换了个有下载完成通知功能的软件,下载完成就“叮”一声。不过小明仍然一直等待“叮”的声音(看起来很傻,不是吗最蠢)
异步非阻塞:仍然是那个会“叮”一声的下载软件,小明提交下载任务后就去干别的,听到“叮”的一声就知道完成了。

理解 Node.js 事件循环