Node中的异步机制

83 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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 那么你可以跳过本节了.

这道题的难点在于41到底哪个先执行,要判断这个问题,我们就不得不提两个新概念,microtask(小型任务) 与 macrotask(巨型任务),它们各有一个任务队列。

Microtask

  1. process.nextTick
  2. promise
  3. Object.observe(已废弃)

Macrotask

  1. setTimeout
  2. setInterval
  3. setImmediate
  4. I/O

这两个任务队列有什么区别呢? 再一次时间循环中,macroktask优先被执行,在macroktask被执行完毕后microtask在同一个循环中接着被执行,直到执行完毕进入下一个循环。

我们可以清楚地看到整个程序的执行过程,那么回到这道面试题,Promise显然属于Microtask,是在第一个循环的末尾执行,而setTimeout属于Macrotask,是在第二个循环中执行,因此4先于1被打印出来.