持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第10天,点击查看活动详情
1. 异步的追根溯源
1.1 JavaScript中的线程
要想追根溯源地了解JavaScript中的异步问题,我们必须深入到js引擎层面来探究这一问题,这就不得不提到线程。
众所周知,JavaScript是单线程的,也就是说js语言在解释和执行中只由一个线程负责。
但是在实际的场景中,JavaScript虽然是单线程的工作状态,但是如果在浏览器中运行,浏览器内核会自带DOM线程、AJAX线程等,这些线程虽然并不能直接解释和执行js代码,但是可以跟js的主线程进行配合,有效的完成工作。
我们可以这样约定,负责解释和执行JavaScript的为主线程,那么除此之外的线程我们成为伪线程。
1.2 JavaScript中的Runtime
我们在了解JavaScript工作方式之前,必须要弄清楚几个概念:
1.2.1 Stack(栈) Stack中是我们正在执行的任务,而其中的单个任务被称为“帧”。 注意:这个Stack既不是数据结构中后进先出的Stack,也不是内存区域中Satck的存放方式,而是指函数的执行方式(Call Stack)。
function f(b){
var a = 1;
return a+b;
}
function g(x){
var y = 2;
return f(y+x);
}
g(3);
以上述代码为例,首先调用 g 时,创建一帧,该帧包含了 g 的参数和局部变量。随后,当 g 调用 f 时,第二帧就会被创建,并且置于第一帧之上,当 f 返回时,其对应的帧就会出栈。同理,当 g 返回时,栈就为空了(先进后出)。
1.2.2 Heap(堆)
Heap是内存中的一块区域,通常将对象分配在这里。
1.2.3 Queue(队列)
一个 JavaScript runtime 包含了一个任务队列,该队列是由一系列待处理的任务组成,同时这些任务都有相对应的回调函数。当栈为空时,就会从任务队列中依次取出任务并处理,我们要记住,任务队列实际是一个先进先出的数据结构。
这就是以上三个概念的示意图:
1.3 JavaScript中的Event Loop
在了解了以上三个概念后,我们就可以更容易搞清楚EventLoop(事件循环)的作用,js主线程产生的Stack(栈)是主要负责处理当前任务,Queue(队列)储存着等待进入Stack(栈)执行的任务队列,那么如何将Queue(队列)中的任务压入Stack(栈)中?
负责这一动作的就是EventLoop(事件循环),EventLoop(事件循环)一直循环,每当Stack中为空的时候,Stack会把待执行任务的回调函数压入Stack中执行。
我们可以用下列代码示意一下EventLoop
while(queue.waitForMessage()){
queue.processNextMessage();
}
1.4 Node中如何执行异步
我们知道,JavaScript原始语言本身是没有AJAX或者fs.readFile的,它们分别来自于浏览器的Web Api和Node中的C++模块,由于我们主要讲述Node的异步执行,因此我们可以抛开浏览器的Web Api,当然他们的工作原理也有共通之处.
我在一篇关于事件循环文章中发现了一个很好的实例,可以解释Node中如何执行异步.
'use strict'
const express = require('express')
const superagent = require('superagent')
const app = express()
app.get('/', sendWeatherOfRandomCity) //处理请求,返回城市的天气信息
function sendWeatherOfRandomCity (request, response) {
getWeatherOfRandomCity(request, response)
sayHi()
}
const CITIES = [ // 储存城市名称的数组
'london',
'newyork',
'paris',
'budapest',
'warsaw',
'rome',
'madrid',
'moscow',
'beijing',
'capetown',
]
function getWeatherOfRandomCity (request, response) { // 处理城市天气信息的函数
const city = CITIES[Math.floor(Math.random() * CITIES.length)]
superagent.get(`wttr.in/${city}`)
.end((err, res) => {
if (err) {
console.log('O snap')
return response.status(500).send('There was an error getting the weather, try looking out the window')
}
const responseText = res.text
response.send(responseText)
console.log('Got the weather')
})
console.log('Fetching the weather, please be patient')
}
function sayHi () {
console.log('Hi')
}
app.listen(3000)
我们最终得到的打印信息是这样的
Fetching the weather, please be patient
Hi
Got the weather
我们都知道,console.log('Got the weather')由于异步的原因被打印在了最后的位置,但是Node在这个过程中到底具体是发生了什么,才导致这个结果呢?
我们不妨分析一下:
1. express 为“request”事件注册了一个处理程序,请求 “/” 时会被调用;
2. 跳过函数,开始监听 3000 端口;
3. 调用栈为空,等待“request”事件触发;
4. 请求到来,等待已久的事件触发,express 调用 sendWeatherOfRandomCity;
5. sendWeatherOfRandomCity 入栈;
6. getWeatherOfRandomCity 被调用并入栈;
7. 调用 Math.floor 和 Math.random,入栈、出栈,cities 中的某一个被赋值给 city;
8. 传入 'wttr.in/${city}' 调用 superagent.get,为 end 事件设置处理回调;
9. 发送 http://wttr.in/${city} http 请求到底层线程,继续向下执行;
10. 控制台打印 'Fetching the weather, please be patient',getWeatherOfRandomCity 函数返回;
11. 调用 sayHi,控制台打印 'Hi';
12. sendWeatherOfRandomCity 函数返回、出栈,调用栈变空;
13. 等待 http://wttr.in/${city} 发送响应;
14. 一旦响应返回,end 事件触发,end事件随即进入任务队列中,此时EventLoop发现栈已经空了,而且任务队列中有未处理的任务;
15. 因此 .end() 的匿名回调函数调用,带着其闭包内所有变量一起入栈,在栈中执行相关回调函数;
16. 最后,调用 response.send(),状态码为 200 或 500,再次发送到底层线程,response stream 不会阻塞代码执行,匿名回调出栈。
我们可以看到,我们可以把上述执行过程简化为:
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。
1.5 不止一个任务队列?
我们先看一道面试题
setTimeout(function() {
console.log(1)
}, 0);
new Promise(function executor(resolve) {
console.log(2);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);
});
console.log(5);
如果你的答案也是2 3 5 4 1 那么你可以跳过本节了.
这道题的难点在于4与1到底哪个先执行,要判断这个问题,我们就不得不提两个新概念,microtask(小型任务) 与 macrotask(巨型任务),它们各有一个任务队列。
Microtask :
- process.nextTick
- promise
- Object.observe(已废弃)
Macrotask:
- setTimeout
- setInterval
- setImmediate
- I/O
这两个任务队列有什么区别呢?
再一次时间循环中,macroktask优先被执行,在macroktask被执行完毕后microtask在同一个循环中接着被执行,直到执行完毕进入下一个循环。
我们可以清楚地看到整个程序的执行过程,那么回到这道面试题,Promise显然属于Microtask,是在第一个循环的末尾执行,而setTimeout属于Macrotask,是在第二个循环中执行,因此4先于1被打印出来.