为什么 js 是单线程的
众所周知,JavaScript 是一门单线程语言。那么,为什么 js 必须是单线程的呢?
因为,js 主要的宿主环境就是浏览器,并且其一个重要的用途就是来操作 Dom。反过来思考,如果 js 是多线程的,那么就会允许同一时间有多个代码块运行。那么,如果这多个代码块同时操作同一个 Dom,比如,一个代码块要修改某 dom 元素,而另一个代码块又要删除这个元素,那么浏览器该如何处理这个元素。因此,为了避免这种复杂性,js 必须是单线程的
执行栈(调用栈)与任务队列
什么是执行栈(调用栈)
执行栈,也就是 “调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文(包括全局执行上下文,局部函数执行上下文以及 eval 函数执行上下文)。
注意,因为 JavaScript 是单线程的,因此,其只有一个调用栈
例如:
1
2
3
4
5
6
7
8
9
function (){
b();
}
function b(){
console.log(1);
}
a();
对于以上代码,执行栈里面的任务是这样依次出入栈的:
进入该段整体代码 main() 入栈
执行 a() a() 入栈
调用 b b() 入栈
执行 b 函数 console.log(1) 入栈
输出完毕,console.log(1) 出栈
b() 出栈
a() 出栈
main() 出栈,代码执行完毕
任务
众所周知,我们可以将 js 的任务分为两大类:
同步任务
优先级最高,运行时立刻进入主线程运行
异步任务
只有当所有同步任务执行完毕后才会去执行异步任务,异步任务的执行过程如下:
异步任务进入 Event Table 执行,并且注册回调函数
当指定的异步任务完成时,Event Table 会将这个回调函数移入 Event Queue(值得注意的是,当有多个异步任务时,先执行完毕的异步任务的回调函数会先进入该任务队列)
主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行
上述过程会不断重复,这也是我们后面会提到的事件循环
上述的 event queue 指的是就是任务队列,任务队列内存放的都是异步任务完成后要执行的函数
另外,我们需要注意的是,只有异步任务完成之后要执行的回调函数才会被放入任务队列,等待主线程中的所有同步代码被执行完毕之后再执行。
因此,比如在 new Promise 过程中不再回调函数内的代码都是同步代码,只有 then() 和 catch() 中的回调函数才是异步代码
例如:
1
2
3
4
5
6
7
8
9
10
new Promise((resolve,reject)=>{
console.log(1); // 第二行
resolve(2); //第三行
}).then((v)=>{ // 第四行
console.log(v); // 第五行
}) // 第六行
// 输出结果
1
2
其中,第 1,2,3,4,6 行都是同步代码,只有第 5 行是异步的代码
以下是任务执行过程:
宏任务与微任务异步任务又可以分为宏任务与微任务
对于异步任务,它们也可以更加被细分为宏任务与微任务两种,具体如下
宏任务 (macrotask):
script(整体代码)、setTimeout、setInterval、UI 渲染、 I/O、postMessage、 MessageChannel、setImmediate(Node.js 环境)、ajax、外部请求等
微任务 (microtask):
Promise.then/catch、 MutaionObserver、process.nextTick(Node.js 环境)
那么宏任务与微任务的执行顺序是怎么样的呢?
网络上有两种说法,目前我还没有看到权威文章
如果,我们将整个 script(整体代码) 也算做是一个宏任务,那么执行顺序会是
宏任务->所有微任务->下一个宏任务
但是如果不是的话,那么就是
所有微任务->下一个宏任务
总之这些并不会影响代码的执行顺序
例如:
宏任务定时器与微任务 promise
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
setTimeout(()=>{
console.log(1)
},0)
new Promise((resolve,reject)=>{
resolve(2);
console.log(3);
}).then(v=>{
console.log(v);
return 4;
}).then(v=>{
console.log(v);
})
console.log(5);
// 执行结果
3
5
2
4
1
// 执行过程
1. 先进入整体代码script这个宏任务,该任务直接进入主线程,因此要执行掉所有的同步任务
2. 遇到setTimeout这一宏任务,我们将其放入宏任务队列
3. 遇到Promise构造函数,立即执行该构造函数,执行console.log(3);两个then()方法依次进入微任务队列
4. 执行console.log(5);
5. 第一个宏任务执行结束,接下来查看微任务队列是否有要解决的微任务,按照先进先出的原则执行完所有的微任务
6. 到这里,第一轮事件循环结束,进入下一轮事件循环
7. 从宏任务队列中取出下一个宏任务,也就是setTimeout的回调函数,执行它
...
宏任务 ajax 与微任务 promise
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const axios=require('axios')
axios.get('https://www.baidu.com').then(v=>{
console.log(1);
})
new Promise((resolve,reject)=>{
console.log(2);
resolve(3);
}).then(v=>{
console.log(v);
return (4);
}).then(v=>{
console.log(v);
})
console.log(5);
// 运行结果
2
5
3
4
1
最后,来一段超级复杂的代码
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
50
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')
})
})
//
1
7
6
8
2
4
3
5
9
11
10
12
注意 setTimeout
我们知道 setTimeout 也是一个异步 api。在每次事件循环中,会检查是否超过了指定事件,如果超过,就会将回调函放入宏任务队列等待被执行
以下是一个很棒的关于 setTimeout 宏任务与 promise.then 微任务的一个例子
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
console.log('global')
for (var i = 1;i <= 5;i ++) {
setTimeout(function(){
console.log(i)
},i*1000)
console.log(i)
}
new Promise(function (resolve){
console.log('promise1')
resolve()
}).then(function (){
console.log('then1')
})
setTimeout(function (){
console.log('timeout2')
new Promise(function (resolve){
console.log('timeout2_promise')
resolve()
}).then(function (){
console.log('timeout2_then')
})
}, 1000)
// global 1 2 3 4 5 promise1 then1 6 timeout2 timeout2_promise timeout2_then 6 6 6 6
考虑 ajax
我们现在来讨论下并行 ajax 的执行过程
1
2
ajax1();
ajax2();
宏任务队列出队首任务(整体代码)
遇到 ajax1(),它是一个异步任务,放入 event table 进行执行
遇到 ajax2(),它是一个异步任务,放入 event table 进行执行
event table 执行完毕的异步任务返回的回调函数会根据该事件的类型选择进入宏任务队列还是微任务队列(对于以上的 ajax1 和 ajax2,先执行完毕的 ajax 会先进入宏任务队列)
事件循环机制检查调用堆栈是否为空,然后不断地去检查事件队列(任务队列)是否为空。如果为空,则继续检查;如果不为空,则将该队列的第一个回调函数压入主线程去进行执行
事件循环在一次事件循环中,异步事件返回的结果会被放入到一个任务队列中,但是根据异步事件的类型,需要把事件放入到对应的微任务队列或宏任务队列中。
我们可以从两个角度去考虑事件循环,不仅仅是上文已经提到的同步与异步,还可以从微任务与宏任务来考虑事件循环
从同步与异步来看事件循环
如上文提到的所述:
检查每一个进入主线程的任务,如果为同步任务,那么压入主线程堆栈;如果为异步,将该任务移到 event table 中,当该异步任务执行完毕后,将异步任务绑定的回调函数移入事件队列(任务队列)中
Js 引擎会检查调用堆栈是否为空,然后不断地去检查事件队列(任务队列)是否为空。如果为空,则继续检查;如果不为空,则将该队列的第一个回调函数压入主线程去进行执行
对于,事件循环的思考:
为什么要进行事件循环?我觉得,不断地对事件队列去判断事件队列是否有任务的原因是因为,事件队列里存放的回调函数都是异步操作完成后才加入的。我们无法知道异步操作什么时候会完成。因此,就得不断去循环。而对于主线程,我们只需简单地判断堆栈是否为空(也就是堆栈长度是否为 0)即可
以上过程可以用伪代码进行描述
1
2
3
4
5
6
7
8
9
10
// [... some initialization ...]
// The Event Loop
while (true) {
if (! EventQueue.isEmpty()) {
event = EventQueue.pop_oldest_item();
event.callback(event [or some other kind of args]);
}
// [... defer to other non-JS tasks...]
}
从微任务与宏任务看事件循环
准备:
检查每一个进来的任务,如果是同步任务,那么压入主线程调用栈中;如果是微任务,那么放入微任务队列;如果是宏任务,那么放入宏任务队列
Event Loop(事件循环) 中,每一次循环称为 tick, 每一次 tick 的任务如下:
执行栈执行完主线程中的所有任务(也可以整体代码当做一个宏任务)
检查微任务队列,查看是否存在 Microtask,如果存在则不停的执行,直至清空 microtask 队列
更新 render(每一次事件循环,浏览器都可能会去更新渲染)
检查宏任务队列,查看是否存在 Macrotask,如果存在则取出一个任务进行执行(执行准备阶段操作)
重复以上步骤
总结:
微任务总是在下一个宏任务之前被执行完毕
综上
一次详尽的事件循环的理解:
从宏任务队列中出队列并运行最早的任务(例如 “脚本”(第一个任务往往都是 script 整体代码))
检查任务类型
如果是同步任务,放入主线程执行栈进行执行;
如果为异步任务时,那么就会将该任务放入 event table 中进行执行。当这些异步任务执行完毕后,会将异步任务的回调函数放入任务队列(event queue)中。
此时,我们需要知道任务队列并不是只有一个队列,它又可以分为宏任务队列和微任务队列;
引擎可以根据该异步事件的类型,将该异步事件放入指定的宏任务队列或者是微任务队列。
检查微任务队列,查看是否存在 Microtask,如果存在则不停的执行,直至清空 microtask 队列
更新 render(每一次事件循环,浏览器都可能会去更新渲染)
如果宏任务队列为空,请等到出现宏任务。
回到步骤 1