node基础面试事件环?微任务、宏任务?一篇带你飞

10,852 阅读13分钟

培育能力的事必须继续不断地去做,又必须随时改善学习方法,提高学习效率,才会成功。 —— 叶圣陶

一、我们为什么要使用node,它的好处是什么?

Node的首要目标是提供一种简单的,用于创建高性能服务器的开发工具。还要解决web服务器高并发的用户请求。

解决高并发?

我们这里来举个例子,我们node和java相比,在同样的请求下谁更占优一点。看图

  • 当用户请求量增高时,node相对于java有更好的处理并发性能,它可以快速通过主线程绑定事件。java每次都要创建一个线程,虽然java现在有个线程池的概念,可以控制线程的复用和数量。
  • 异步i/o操作,node可以更快的操作数据库。java访问数据库会遇到一个并行的问题,需要添加一个锁的概念。我们这里可以打个比方,下课去饮水机接水喝,java是一下子有喝多人去接水喝,需要等待,node是每次都只去一个人接水喝。
  • 密集型CPU运算指的是逻辑处理运算、压缩、解压、加密、解密,node遇到CPU密集型运算时会阻塞主线程(单线程),导致其下面的时间无法快速绑定,所以node不适用于大型密集型CPU运算案例,而java却很适合。

node在web端场景?

web端场景主要是用户的请求或者读取静态资源什么的,很适合node开发。应用场景主要有聊天服务器电子商务网站等等这些高并发的应用。

二、node是什么?

Node.js是一个基于 Chrome V8 引擎的JavaScript运行环境(runtime),Node不是一门语言,是让js运行在后端的运行时,并且不包括javascript全集,因为在服务端中不包含DOMBOM,Node也提供了一些新的模块例如http,fs模块等。Node.js 使用了事件驱动、非阻塞式 I/O的模型,使其轻量又高效并且Node.js 的包管理器 npm,是全球最大的开源库生态系统。

总而言之,言而总之,它只是一个运行时,一个运行环境。

node特性

  • 主线程是单线程(异步),将后续的逻辑写成函数,传入到当前执行的函数中,当执行的函数得到了结果后,执行传入的函数(回调函数)
  • 五个人同时吃一碗饭(异步)。
  • 阻塞不能异步(现在假定数据库是厨师,服务员是node,顾客是请求,一般是厨师做菜让一个服务员递给多个用户,如果厨师邀请服务员聊天,就会导致阻塞,并且是针对内核说的)。
  • i/o操作,读写操作,异步读写(能用异步绝不用同步) 非阻塞式i/o,即可以异步读写。
  • event-driven事件驱动(发布订阅)。

node的进程与线程

进程是操作系统分配资源和调度任务的基本单位,线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。

在此之前我们先来看看浏览器的进程机制

自上而下,分别是:

  • 用户界面--包括地址栏、书签菜单等
  • 浏览器引擎--用户界面和渲染引擎之间的传送指令(浏览器的主进程)
  • 渲染引擎--浏览器的内核,如(webkit,Gecko)
  • 其他--网络请求,js线程和ui线程

从我们的角度来看,我们更关心的是浏览器的渲染引擎,让我们往下看。

渲染引擎

  • 渲染引擎是多线程的,包含ui线程和js线程。ui线程和js线程会互斥,因为js线程的运行结果会影响ui线程,ui更新会被保存在队列,直到js线程空闲,则被取出来更新。
  • js单线程是单线程的,为什么呢?假如js是多线程的,那么操作DOM就是多线程操作,那样的话就会很混乱,DOM不知道该听谁的,而这里的单线程指得是主线程是单线程的,他同样可以有异步线程,通过队列存放这些线程,而主线程依旧是单线程,这个我们后面再讲。所以在node中js也是单线程的。
  • 单线程的好处就是节约内存,不需要再切换的时候执行上下文,也不用管锁的概念,因为我们每次都通过一个。

三、浏览器中的Event Loop

这里我先要说一下浏览器的事件环,可能有人会说,你这篇文章明明是讲node的怎么会扯到浏览器。首先他们都是以js为底层语言的不同运行时,有其相似之处,再者多学一点也不怕面试官多问。好了我废话不多说,开始。

首先我们需要知道堆,栈和队列的关系和意义。

  • 堆(heap):堆是存放对象的一个空间(Object、function)
  • 队列(loop):是指存放所有异步请求操作的结果,直到有一个异步操作完成它的使命,就会在loop中添加一个事件,队列是先进先出的,比如下面的图,最先进队列的会先被打出去

隔山打牛!

  • 栈(stack):栈本身是存储基础的变量,比如1,2,3,还有引用的变量,这里可能有人会问你上面的堆不是存放引用类型的对象吗,怎么变栈里去了。这里我要解释一下,因为栈里面的存放的引用变量是指向堆里的引用对象的地址只是一串地址。这里栈代表的是执行栈,我们js的主线程。栈是先进后出的,先进后出就是相当于喝水的水杯,我们倒水进去,理论上喝到的水是最后进水杯的。我们可以看代码,follow me
function a(){
  console.log('a')
  function b(){
    console.log('b')    
    function c(){
      console.log('c')
    }
    c()
  }
  b()
}
a()

//这段代码是输出a,b,c,执行栈中的顺序的c,b,a,如果是遵循先进先出,就是输出c,b,a。所以栈先进后出这个特性大家要牢记。

OK,现在大家已经知道堆,栈和队列的关系,现在我们来看一张图。

我分析一下这张图

  • 我们的同步任务在主线程上运行会形成一个执行栈
  • 如果碰到异步任务,比如setTimeout、onClick等等的一些操作,我们会将他的执行结果放入队列,此期间主线程不阻塞
  • 等到主线程中的所有同步任务执行完毕,就会通过event loop在队列里面从头开始取,在执行栈中执行
  • event loop永远不会断
  • 以上的这一整个流程就是Event Loop(事件循环机制)

微任务、宏任务?

macro-task(宏任务): setTimeout,setImmediate,MessageChannel micro-task(微任务): 原生Promise(有些实现的promise将then方法放到了宏任务中),Object.observe(已废弃), MutationObserver

微任务和宏任务皆为异步任务,它们都属于一个队列,主要区别在于他们的执行顺序,Event Loop的走向和取值。那么他们之间到底有什么区别呢

每次执行栈的同步任务执行完毕,就会去任务队列中取出完成的异步任务,队列中又分为microtasks queues和宏任务队列等到把microtasks queues所有的microtasks都执行完毕,注意是所有的,他才会从宏任务队列中取事件。等到把队列中的事件取出一个,放入执行栈执行完成,就算一次循环结束,之后event loop还会继续循环,他会再去microtasks queues执行所有的任务,然后再从宏任务队列里面取一个,如此反复循环。

  • 同步任务执行完
  • 去执行microtasks,把所有microtasks queues清空
  • 取出一个macrotasks queues的完成事件,在执行栈执行
  • 再去执行microtasks
  • ...
  • ...
  • ...

我这么说可能大家会有点懵,不慌,我们来看一道题

setTimeout(()=>{
  console.log('setTimeout1')
},0)
let p = new Promise((resolve,reject)=>{
  console.log('Promise1')
  resolve()
})
p.then(()=>{
  console.log('Promise2')    
})

最后输出结果是Promise1,Promise2,setTimeout1

  • Promise参数中的Promise1是同步执行的,Promise还不是很了解的可以看看我另外一篇文章Promise之你看得懂的Promise,
  • 其次是因为Promise是microtasks,会在同步任务执行完后会去清空microtasks queues
  • 最后清空完微任务再去宏任务队列取值
Promise.resolve().then(()=>{
  console.log('Promise1')  
  setTimeout(()=>{
    console.log('setTimeout2')
  },0)
})

setTimeout(()=>{
  console.log('setTimeout1')
  Promise.resolve().then(()=>{
    console.log('Promise2')    
  })
},0)

这回是嵌套,大家可以看看,最后输出结果是Promise1,setTimeout1,Promise2,setTimeout2

  • 一开始执行栈的同步任务执行完毕,会去microtasks queues
  • 清空microtasks queues,输出Promise1,同时会生成一个异步任务setTimeout1
  • 宏任务队列查看此时队列是setTimeout1在setTimeout2之前,因为setTimeout1执行栈一开始的时候就开始异步执行,所以输出setTimeout1,在执行setTimeout1时会生成Promise2的一个microtasks,放入microtasks queues
  • 接着又是一个循环,去清空microtasks queues,输出Promise2
  • 清空完microtasks queues,就又会去宏任务队列取一个,这回取的是setTimeout2

四、node中的事件环

node的事件环相比浏览器就不一样了,我们先来看一张图,他的工作流程

  • 首先我们能看到我们的js代码(APPLICATION)会先进入v8引擎,v8引擎中主要是一些setTimeout之类的方法。
  • 其次如果我们的代码中执行了nodeApi,比如require('fs').read(),node就会交给libuv库处理,这个libuv库是别人写的,他就是node的事件环。
  • libuv库是通过单线程异步的方式来处理事件,我们可以看到work threads是个多线程的队列,通过外面event loop阻塞的方式来进行异步调用。
  • 等到work threads队列中有执行完成的事件,就会通过EXECUTE CALLBACK回调给EVENT QUEUE队列,把它放入队列中。
  • 最后通过事件驱动的方式,取出EVENT QUEUE队列的事件,交给我们的应用

node中的event loop

node中的event loop是在libuv里面的,libuv里面有个事件环机制,他会在启动node时,初始化事件环

  • 这里的每一个阶段都对应着一个事件队列
  • 每当event loop执行到某个阶段时,都会执行对应的事件队列中的事件,依次执行
  • 当该队列执行完毕或者执行数量超过上限,event loop就会执行下一个阶段
  • 每当event loop切换一个执行队列时,就会去清空microtasks queues,然后再切换到下个队列去执行,如此反复

这里我们要注意setImmediate是属于check队列的,还有poll队列主要是异步的I/O操作,比如node中的fs.readFile()

我们来具体看一下他的用法吧

setImmediate(()=>{
  console.log('setImmediate1')
  setTimeout(()=>{
    console.log('setTimeout1')    
  },0)
})
setTimeout(()=>{
  console.log('setTimeout2') 
  process.nextTick(()=>{console.log('nextTick1')})
  setImmediate(()=>{
    console.log('setImmediate2')
  })   
},0)
  • 首先我们可以看到上面的代码先执行的是setImmediate1,此时event loopcheck队列
  • 然后setImmediate1从队列取出之后,输出setImmediate1,然后会将setTimeout1执行
  • 此时event loop执行完check队列之后,开始往下移动,接下来执行的是timers队列
  • 这里会有问题,我们都知道setTimeout1设置延迟为0的话,其实还是有4ms的延迟,那么这里就会有两种情况。先说第一种,此时setTimeout1已经执行完毕
    • 根据node事件环的规则,我们会执行完所有的事件,即取出timers队列中的setTimeout2,setTimeout1
    • 此时根据队列先进先出规则,输出顺序为setTimeout2,setTimeout1,在取出setTimeout2时,会将一个process.nextTick执行(执行完了就会被放入微任务队列),再将一个setImmediate执行(执行完了就会被放入check队列
    • 到这一步,event loop会再去寻找下个事件队列,此时event loop会发现微任务队列有事件process.nextTick,就会去清空它,输出nextTick1
    • 最后event loop找到下个有事件的队列check队列,执行setImmediate,输出setImmediate2
  • 假如这里setTimeout1还未执行完毕(4ms耽误了它的终身大事?)
    • 此时event loop找到timers队列,取出*timers队列**中的setTimeout2,输出setTimeout2,把process.nextTick执行,再把setImmediate执行
    • 然后event loop需要去找下一个事件队列,这里大家要注意一下,这里会发生2步操作,1、setTimeout1执行完了,放入timers队列。2、找到微任务队列清空。,所以此时会先输出nextTick1
    • 接下来event loop会找到check队列,取出里面已经执行完的setImmediate2
    • 最后event loop找到timers队列,取出执行完的setTimeout1这种情况下event loop比上面要多切换一次

所以有两种答案

  1. setImmediate1,setTimeout2,setTimeout1,nextTick1,setImmediate2
  2. setImmediate1,setTimeout2,nextTick1,setImmediate2,setTimeout1

这里的图只参考了第一种情况,另一种情况也类似

五、node的同步、异步,阻塞、非阻塞

  • 同步:即为调用者等待被调用者这个过程,如果被调用者一直不反回结果,调用者就会一直等待,这就是同步,同步有返回值
  • 异步:即为调用者不等待被调用者是否返回,被调用者执行完了就会通过状态、通知或者回调函数给调用者,异步没有返回值
  • 阻塞:指代当前线程在结果返回之前会被挂起,不会继续执行下去
  • 非阻塞: 即当前线程不管你返回什么,都会继续往下执行

有些人可能会搞乱他们之间的关系,同步、异步是被调用者的状态,阻塞、非阻塞是调用者的状态、消息

接下来我们来看看他们的组合会是怎么样的

组合 意义
同步阻塞 这就相当于我去饭店吃饭,我需要在厨房等待菜烧好了,才能吃。我是调用者我需要等待上菜于是被阻塞,菜是被调用者做好直接给我是同步
异步阻塞 我去饭店吃饭,我需要等待菜烧好了才能吃,但是厨师有事,希望之后处理完事能做好之后通知我去拿,我作为调用者等待就是阻塞的,而菜作为被调用者是做完之后通知我的,所以是异步的,这种方式一般没用。
同步非阻塞 我去饭店吃饭,先叫了碗热菜,在厨房等厨师做菜,但我很饿,就开始吃厨房冷菜,我是调用者我没等热菜好就开始吃冷菜,是非阻塞的,菜作为被调用者做好直接给我是同步的,这种方式一般也没人用
异步非阻塞 我去饭店吃饭。叫了碗热菜,厨师在做菜,但我很饿,先吃冷菜,厨师做好了通知我去拿,我是调用者我不会等热菜烧好了再吃冷菜,是非阻塞的,菜作为被调用者通知我拿是异步的

结尾

希望大家看了本篇文章都有收获,这样出去面试的时候就不会这样

而是这样。好了,最后希望大家世界杯都能够逢赌必赢,自己喜欢的球队也能够杀进决赛