nodejs的事件循环,node的事件循环

  nodejs的事件循环,node的事件循环

  本文是对Nodejs的高级研究。它带你详细了解Nodejs中的异步I/O和事件循环。希望对你有帮助!

  node.js速度课程简介:进入学习

  本文详细讲解了nodejs中的两个难点部分异步I/O事件循环,对nodejs的核心知识点进行了梳理和补充。[推荐研究:《nodejs 教程》]

  送玫瑰,想看完心情好的同学可以送点个赞,鼓励我继续创作前端硬文。

  像往常一样,让我们以问题开始今天的分析:

  1说说nodejs的异步I/O?2说说nodejs的事件循环机制?3介绍nodejs中事件周期的各个阶段?4 Nodejs中的promise和nextTick有什么区别?5 Nodejs中的setImmediate和setTimeout有什么区别?6 setTimeout准确吗?什么情况会影响SetTimeout的执行?7 NodeJS中事件循环和浏览器的区别?

异步I/O

  

概念

  处理器对寄存器、缓存等封装外的任何数据资源的访问都可以视为I/O操作,包括内存、磁盘、显卡等外部设备。在 Nodejs 中像开发者调用 fs 读取本地文件或网络请求等操作都属于I/O操作。(最普遍抽象 I/O 是文件操作和 TCP/UDP 网络操作)

  Nodejs是单线程的。在单线程模式下,任务按顺序执行。但如果前一个任务耗时过长,必然会影响后续任务。通常,I/O和cpu之间的计算可以并行进行。但是在同步模式下,I/O会导致后续任务的等待,阻塞任务的执行,使资源得不到很好的利用。

  解决以上问题,Nodejs 选择了异步I/O的模式,让单线程不再阻塞,更合理的使用资源。

  

如何合理的看待Nodejs中异步I/O

  前端开发者可能更清楚浏览器环境下js的异步任务,比如发起ajax请求。就像ajax是浏览器提供给js执行环境可以调用的api一样,在Nodejs中提供http模块可以让js做同样的事情。比如监控发送http请求,除了http,nodejs还有操作本地文件的fs文件系统等。

  上述fs http任务在nodejs中称为I/O任务。了解了I/O任务之后,我们来分析一下Nodejs中I/O任务的两种形式:——阻塞和非阻塞。

  

nodejs中同步和异步IO模式

   nodejs为大多数I/O操作提供阻塞非阻塞。阻塞意味着当执行I/O操作时,我们必须等待结果才能执行js代码。下面的阻塞代码

  同步I/O模式

  /* TODO:阻塞*/

  const fs=require( fs );

  const data=fs.readFileSync(。/file . js’);

  console.log(data)代码被阻塞:读取同一个目录下的file.js文件,结果数据是缓冲区结构,读取时会阻塞代码的执行,所以console.log(data)会被阻塞,只有返回结果时才能正常打印数据。异常处理:上述操作的一个致命点是,如果出现异常(比如同一个目录下没有file.js文件),整个程序会报错,下一段代码不会执行。通常需要try catch来捕捉错误边界。代码如下:/* TODO:blocking-catching exception */

  尝试{

  const fs=require( fs );

  const data=fs.readFileSync(。/file 1 . js’);

  console.log(数据)

  }catch(e){

  Console.log(出现错误:,e)

  }

  Console.log(正常执行)如前所述,即使出现错误,也不会影响后续代码的执行和错误导致的应用程序的退出。同步I/O模式导致代码执行等待I/O结果,浪费了等待时间,使CPU的处理能力得不到充分利用,I/O失败甚至会使整个线程退出。整个调用堆栈上的I/O阻塞图如下:

  异步I/O模式

  这就是刚才介绍的异步I/O。首先,看看异步模式下的I/O操作:

  /* TODO:非阻塞异步I/O */

  const fs=require(fs )

  fs.readFile(。/file.js ,(err,data)={

  console.log(err,data) //空缓冲区63 6f 6e 73 6f 6c 65 2e 6c 6f 67 28 27 68 65 6c 6c 6f 2c 77 6f 72 6c 64 27 29

  })

  先打印console . log(111)//111 ~

  fs.readFile(。/file1.js ,(err,data)={

  Console.log(err,data)//Save[没有这样的文件或目录,打开。/file1.js],找不到文件。

  })回调回调是异步执行的,返回的第一个参数是错误信息。如果没有错误,则返回null,第二个参数是执行的fs.readFile的真实内容。这种异步形式可以优雅地捕捉I/O执行中的错误,比如读取file1.js文件时,出现找不到对应文件的异常行为,会通过第一个参数形式直接传递给回调。比如上面的回调,作为异步回调函数,就像setTimeout(fn)的fn一样,不会阻塞代码执行。得到结果后会触发。Nodejs异步执行I/O回调的细节会慢慢分析。

  对于异步I/O的处理,Nodejs使用线程池来处理异步I/O任务。线程池中将有多个I/O线程同时处理异步I/O操作。例如,在上面的例子中,这将发生在整个I/O模型中。

  接下来,我们将一起探讨异步I/O执行过程。

  

事件循环

  和浏览器一样,Nodejs也有自己的执行模型,3354 eventLoop。事件循环的执行模型受主机环境的影响,且不是javascript执行引擎(如v8)的一部分,导致不同主机环境下事件循环的模式和机制不同。直观的体现就是Nodejs和browsers用于微任务和宏任务。接下来将详细讨论Nodejs的事件循环及其各个阶段。

  Nodejs的事件循环有几个阶段,包括一个处理I/O回调的阶段。每个执行阶段都可以称为Tick。每个Tick会查询是否有更多的事件以及相关的回调函数,比如上面的异步I/O的回调函数,它会检查当前I/O是否在I/O处理阶段完成。如果是,相应的I/O回调函数将被执行,所以这检查I/O是否完成。

  

观察者

  上面提到了I/O观察者的概念,也说了Nodejs会有多个阶段。其实每个阶段都有一个或多个对应的观察者,他们的工作非常明确。在每一个对应的Tick过程中,对应的观察者寻找是否有对应的事件要执行,如果有,则取出来执行。

  浏览器的事件来自于用户的交互和一些网络请求比如ajax。在Nodejs中,事件来自网络请求http、文件I/O等。这些事件都有相应的观察者。我在这里列举一些重要的观察者。

  文件I/O操作3354 I/o观察器;网络I/O操作——网络I/O观察器;Process.nextTick ——空闲观察器setImmediate ——检查观察器setTimeout/setInterval ——延迟观察器.在Nodejs中,相应的观察者接收相应类型的事件。在事件周期中,他们将被询问是否有任何任务要执行。如果有,观察者会取出任务,交给事件循环执行。

  

请求对象与线程池

  从JavaScript的调用到计算机系统执行I/O回调,请求对象起着非常重要的作用。让我们以异步I/O操作为例。

  请求对象:例如,在调用fs.readFile之前,实质上是调用libuv上的方法来创建一个请求对象。这个request对象保存了这个I/O请求的信息,包括这个I/O的主体和回调函数,然后异步调用第一阶段完成,JavaScript会继续执行执行栈上的代码逻辑,当前的I/O操作会以request对象的形式放入线程池,等待执行。达到异步I/O的目的.

  线程池:Nodejs的线程池由Windows下的内核(IOCP)提供,在Unix系统下由libuv自己实现。线程池用于执行部分I/O(系统文件的操作)。线程池的默认大小是4,对多个文件系统操作的请求可以被阻塞在一个线程中。那么I/O操作是如何在线程池中执行的呢?如前一步所述,异步I/O会将请求对象放入线程池。首先,它将确定当前线程池中是否有可用的线程。如果线程可用,它将执行请求对象的I/O操作,并将执行结果返回给请求对象。在事件周期中的I/O处理阶段,I/O观察者会得到完成的I/O对象,然后取出回调函数和结果调用进行执行。I/O 回调函数就这样执行,而且在回调函数的参数重获取到结果。

  

异步 I/O 操作机制

  以上描述了异步I/O的整个执行过程,从异步I/O的触发到I/O回调再到执行。事件循环观察者请求对象线程池构成了整个异步I/O执行模型。

  用一张图来说明四者之间的关系:

  总结以上过程:

  第一阶段:每次异步 I/O 的调用,先在nodejs底部设置请求参数和回调字母,形成请求对象

  第二阶段:将形成的请求对象放入线程池。如果线程池中有空闲的I/O线程,这个I/O任务将被执行并获得结果。

  第三阶段:事件循环I/O 观察者中的timer,会从请求对象中找到已经得到结果的I/O请求对象,取出结果和回调函数,将回调函数放入事件循环中,执行回调,完成整个异步I/O任务。

  您如何看待异步I/O任务的完成?以及如何获得完成的任务?Libuv作为中间层,在不同的平台上使用不同的手段。在unix下由epoll轮询,在Windows下由IOCP实现,在FreeBSD下由kqueue实现。

  

事件循环

  上面已经提到事件循环不是javascript引擎的一部分。事件循环的机制是由宿主环境实现的,所以在不同的宿主环境中事件循环是不同的。不同的主机环境指的是浏览器环境或者nodejs环境,但是nodejs的主机环境在不同的操作系统中也是不同的。接下来用图描述Nodejs中的事件循环和JavaScript引擎的关系。

  参考libuv下nodejs的事件周期,关系如下:

  以浏览器下javaScript的事件循环为参考,关系如下:

  事件循环本质上类似于while循环,如下所示。我用一段代码模拟一下事件循环的执行过程。

  Constqueue=[.]//队列中有挂起的事件。

  while(true){

  //开始循环

  //执行队列中的任务

  //.

  if(queue.length===0){

  Return //退出进程

  }

  }Nodejs启动后,就像创建while循环一样,队列中有要处理的事件。在每个循环中,如果有更多的事件,就取出来执行。如果有一个回调函数与事件相关联,那么执行回调函数,然后开始下一个循环。如果循环体中没有事件,流程将退出。我将流程图总结如下:

  那么事件循环是如何处理这些任务的呢?我们在Nodejs中列出了一些常见的事件任务:

  SetTimeout或setInterval延迟定时器。异步I/O任务:文件任务、网络请求等。SetImmediate任务。Process.nextTick任务。许诺微任务。接下来,我们将讨论这些任务的原理以及nodejs如何处理这些任务。

  

1 事件循环阶段

  对于不同的事件任务,会在不同的事件循环阶段执行。根据nodejs的官方文档,一般情况下,nodejs中的事件循环根据操作系统的不同可能会有特殊的阶段,但一般可以分为以下六个阶段(代码块的六个阶段):

  /*

  ^^^^^9

   待定回调 - i/o

   闲着,准备

  来袭:

   民意测验的人脉,

  数据等。

   检查

  关闭回调

  */第一阶段:pending callback。定时器阶段主要做的是执行setTimeout或者setInterval注册的回调函数。

  第二阶段:idle prepare,大部分的I/O回调任务都是在poll阶段执行的,但是也有一些上次事件周期遗留下来的延迟的I/O回调函数,所以这个阶段就是调用上一个事件周期延迟的I/O回调函数。

  第三阶段:阶段poll,仅供nodejs内部模块使用。

  第四阶段:check阶段投票阶段。这个阶段主要做两件事。第一,这个阶段会执行异步I/O的回调函数;2.计算当前轮询阶段阻塞后续阶段的时间。

  第五阶段:close阶段,poll阶段回调函数队列为空时,开始进入check阶段,主要执行setImmediate回调函数。

  第六阶段:实际的数据结构不是队列,执行注册关闭事件的回调函数。

  我接下来会详细分析每个阶段的执行特点以及对应的事件任务。让我们来看看这六个阶段是如何在底层源代码中体现的。

  我们来看看libuv下nodejs的事件循环的源代码(unix和win有一点区别,但不影响进程。这里以unix为例。):

  int uv_run(uv_loop_t* loop,uv_run_mode模式){

  //保存上一个进程。

  while (r!=0 loop-stop_flag==0) {

  /*更新事件周期的时间*/

  uv__update_time(循环);

  /*第一阶段:计时器阶段执行*/

  uv__run_timers(循环);

  /*第二阶段:待定阶段*/

  ran _ pending=uv _ _ run _ pending(loop);

  /*第三阶段:空闲准备阶段*/

  uv__run_idle(循环);

  uv__run_prepare(循环);

  超时=0;

  if ((mode==UV_RUN_ONCE!ran _ pending) mode==UV _ RUN _ DEFAULT)

  /*计算超时时间*/

  timeout=uv_backend_timeout(循环);

  /*第四阶段:投票阶段*/

  uv__io_poll(循环,超时);

  /*第五阶段:检查阶段*/

  uv__run_check(循环);

  /*第六阶段:关闭阶段*/

  uv__run_closing_handles(循环);

  /*判断当前线程还有任务*/

  r=uv _ _ loop _ alive(loop);

  /*保存以下流程*/

  }

  return r;

  }我们看到这六个阶段是按顺序执行的。只有前一阶段的任务完成了,才能进行下一阶段。当uv__loop_alive判断当前事件循环中没有任务时,则退出线程。

2 任务队列

  在整个事件周期中,libuv的事件周期有四个队列(promise 队列),nodejs有两个队列(nextTickPriorityQueue)。

  

libuv 处理任务队列

  事件周期的每个阶段将执行相应任务队列的内容。

  定时器队列(二叉最小堆):本质数据结构为ImmediateList,二进制最小堆的根节点获取最近时间线上定时器对应的回调函数。

  I/O事件队列:存储I/O任务。

  立即队列(nextTick):多个立即队列,节点层存储在链表数据结构中。

  关闭回调事件队列:放置要关闭的回调函数。

  

非 libuv 中间队列

  Microtasks队列:存储nextTick的回调函数。这在nodejs中是唯一的。两个任务中的代码会阻塞事件循环的有序进行微队列承诺:存储承诺的回调函数。中间队列的执行特征:

  首先要明白,两个中间队列不是在libuv中执行的,它们都是在nodejs层执行的。libuv层处理完每个阶段的任务后,会和节点层进行通信,所以会先处理两个队列中的任务。

  nextTick任务的优先级高于Microtasks任务中的Promise回调。也就是说,node会先清除nextTick中的任务,再清除Promise中的任务。为了验证这一结论,请给出一个打印结果的标题示例,如下所示:

  /* TODO:打印顺序*/

  setTimeout(()={

  console . log( setTimeout execution )

  },0)

  const p=新承诺((resolve)={

  Console.log(承诺执行)

  解决()

  })

  p.then(()={

  Console.log(承诺回调执行)

  })

  process.nextTick(()={

  Console.log(nextTick execution )

  })

  Console.log(代码执行完毕)上面代码块中nodejs的执行顺序是什么?

  效果:

  打印结果:承诺执行-代码执行完毕-nextTick执行-承诺回调执行-setTimeout执行

  说明:很容易理解为什么要印这个。在主代码事件循环中,首先打印承诺执行和代码执行,nextTick放在nextTick队列中,承诺回调放在Microtasks队列中,setTimeout放在timer堆中。接下来,主循环完成,两个队列的内容被清除。首先,清除nextTick队列,并打印nextTick执行。接下来,清除微任务队列,并打印承诺回调执行。最后判断事件循环中有定时器任务。然后,启动一个新的事件循环,首先打印计时器任务和setTimeout执行。整个过程就完成了。

  无论是nextTick的任务还是无极里的任务,效果:,I/O都饿死了,所以两个任务里的逻辑需要小心处理。例如:/* TODO:阻塞I/O情况*/

  process.nextTick(()={

  const now=新日期()

  /*阻止代码三秒*/

  while(新日期()现在3000 ){}

  })

  fs.readFile(。/file.js ,()={

  console.log(I/O: file )

  })

  setTimeout(()={

  console.log(setTimeout:)

  }, 0);延时器计时器观察者(Expired timers and intervals)

  事件周期中的定时器任务和I/O任务按顺序执行需要三秒钟。也就是说,nextTick中的代码阻塞了事件循环的有序进行。

3 事件循环流程图

  接下来,使用流程图来显示事件周期的六个阶段的执行顺序以及两个优先级队列的执行逻辑。

  

4 timer 阶段 - 计时器 timer / 延时器 interval

  执行机制:计时器观察器用于检查通过setTimeout或setInterval创建的异步任务。内部原理类似异步I/O,但timer/timer内部实现不使用线程池。通过setTimeout或者setInterval,timer对象会被插入到delayer的定时器观察者内部的二进制最小堆中。在每个事件周期期间,将从二进制最小堆的顶部取出计时器对象,以确定计时器/间隔是否已经到期。如果是,那么调用它并出列。然后检查当前队列中的第一个,直到没有过期的,并移动到下一个阶段。

  

libuv 层如何处理 timer

  我们先来看看libuv层是怎么处理的,timer。

  void uv _ _ run _ timers(uv _ loop _ t * loop){

  结构堆节点*堆节点;

  uv _ timer _ t * handle

  for(;) {

  /*在循环中查找timer_heap中的根节点(值最小)*/

  heap _ node=heap _ min((struct heap *)loop-timer _ heap);

  /* */

  if (heap_node==NULL)

  打破;

  handle=container_of(堆节点,uv_timer_t,堆节点);

  if(句柄超时循环时间)

  /*如果执行时间比事件循环事件长,则不必在此循环中执行*/

  打破;

  uv_timer_stop(句柄);

  uv_timer_again(句柄);

  handle-timer_cb(句柄);

  }

  }以上句柄超时可以理解为到期时间,即定时器返回函数的执行时间。当timeout大于当前事件循环的开始时间时,说明还没到执行时间,回调函数应该还没有执行。然后根据二进制最小堆的性质,父节点总是小于子节点。如果根节点的时间节点不满足执行时间,则其他定时器不满足执行时间。此时,退出定时器阶段的回调函数执行,直接进入事件循环的下一阶段。当到期时间小于当前事件周期滴答的开始时间时,意味着至少有一个定时器到期。然后循环迭代最小定时器堆的根节点,调用这个定时器对应的回调函数。循环的每一次迭代更新定时器,最小堆的根节点是最近的时间节点。是在libuv中执行上述定时器阶段的特征。接下来,我们来分析如何处理节点中的定时器延迟。

  

node 层如何处理 timer

  在nodejs中,setTimeout和setInterval是由Nodejs自己实现的。让我们来看看实现细节:

  函数setTimeout(回调,after){

  //.

  /*判断参数逻辑*/

  //.

  /*创建计时器观察器*/

  const timeout=new Timeout(回调,after,args,false,true);

  /*将计时器观察器插入计时器堆*/

  插入(超时,超时。_ idle time out);

  返回超时;

  }setTimeout:逻辑很简单,就是创建一个定时器时间观察器,放在定时器堆里。那么超时做了什么?

  函数超时(回调,after,args,isRepeat,isRefed) {

  *=1之后

  如果(!(after=1 after=2 ** 31 - 1)) {

  After=1 //如果延迟超时为0或大于2 ** 31-1,则将其设置为1。

  }

  这个。_ idleTimeout=after//延迟时间

  这个。_ idlePrev=this

  这个。_ idleNext=this

  这个。_ idleStart=null

  这个。_ onTimeout=null

  这个。_onTimeout=回调;//回调函数

  这个。_ timerArgs=args

  这个。_repeat=isRepeat?之后:null

  这个。_ destroyed=false

  initAsyncResource(this, time out );

  }在nodejs中,setTimeout和setInterval本质上都是超时类。如果超过最大时间值2 ** 31-1或setTimeout(callback,0),将_idleTimeout设置为1,并转换为setTimeout(callback,1)执行。

timer 处理流程图

  用流程图描述一下。让我们创建一个计时器,然后转到事件循环中计时器执行的流程。

  

timer 特性

  这里有两点需要注意:

  精度问题:定时器监视器,每次将执行一个定时器。在一个定时器被执行后,nextTick和Promise将被清除。到期时间是决定两者是否执行的重要因素。还有一点就是poll会计算阻塞定时器执行的时间,这对定时器阶段任务的执行也有非常重要的影响。结论为了一次验证一个定时器任务,首先看一个代码片段:

  setTimeout(()={

  console.log(setTimeout1:)

  process.nextTick(()={

  console.log(nextTick )

  })

  },0)

  setTimeout(()={

  console.log(setTimeout2:)

  },0)打印结果:

  nextTick队列在事件周期的每个阶段结束时执行,两个延迟的阈值都是0。如果任务在计时器阶段一次完成并过期,那么将打印settimeout 1-settimeout 2-next tick。实际上会先执行一个定时器任务,然后执行下一个tick任务,最后执行下一个定时器任务。

  性能问题:关于settimeout的计数器,计时器不准确。虽然nodejs中的事件周期很快,但是延时设备的超时类的创建会占用一些事件,然后上下文、I/O、nextTick队列、微任务的执行会阻塞延时设备的执行。即使在检查定时器到期时,也会消耗一些cpu时间。

  timeout代表什么:如果想用setTimeout(fn,0)执行一些不是马上调用的任务,那么性能就不如process.nextTick真实,首先setTimeout的精度不够。还有一点就是里面有一个timer对象,需要在libuv底层执行,占用了一些性能,可以用process.next tick来解决这个场景。

  

5 pending 阶段

  在挂起阶段使用的I/O回调函数,用于处理此事件循环之前的延迟。首先,看看libuv中的执行时间。

  static int uv _ _ run _ pending(uv _ loop _ t * loop){

  队列* q;

  队列pq;

  uv__io_t* w

  /* pending_queue为空,清空队列并返回0 */

  if(QUEUE _ EMPTY(loop-pending _ QUEUE))

  返回0;

  QUEUE_MOVE(loop-pending_queue,pq);

  而(!Queue _ empty (PQ)) {/*如果pending _ queue不为空,则清除I/O回调。1 */

  q=QUEUE _ HEAD(pq);

  QUEUE _ REMOVE(q);

  QUEUE _ INIT(q);

  w=QUEUE_DATA(q,uv__io_t,pending _ QUEUE);

  w-cb(loop,w,poll out);

  }

  返回1;

  }如果持有I/O回调的任务的pending_queue为空,那么直接返回0。如果pending_queue有I/O回调任务,则执行回调任务。

6 idle, prepare 阶段

   idle执行一些libuv内部操作,prepare为下一次I/O轮询做一些准备。接下来,我们来分析一下重要的民调阶段。

  

7 poll I / O 轮询阶段

  在正式解释投票阶段的作用之前,先看一下。在libuv中,轮询阶段的执行逻辑:

  超时=0;

  if ((mode==UV_RUN_ONCE!ran _ pending) mode==UV _ RUN _ DEFAULT)

  /*计算超时*/

  timeout=uv_backend_timeout(循环);

  /*输入I/O轮询*/

  uv__io_poll(循环,超时);初始化超时=0,这个轮询阶段的超时由uv_backend_timeout计算。超时将影响异步I/O和后续事件循环的执行。紧急

  首先,理解不同超时在I/O轮询中意味着什么。

  当timeout=0时,意味着轮询阶段不会阻塞事件周期的进程,这意味着有更紧急的任务要执行。那么当前的轮询阶段就不会被阻塞,它会尽快进入下一个阶段,尽快结束当前的滴答,进入下一个事件周期,然后执行这些获取timeout的任务。当timeout=-1时,意味着事件循环将一直被阻塞,所以可以停留在异步I/O的轮询阶段,等待新的I/O任务完成。当超时等于常数时,表示io轮询周期阶段此时可以停留的时间。什么时候超时会不变?会马上宣布的。无限制阻塞

  timeout的获取是通过uv_backend_timeout,那么它是如何获得的呢?

  int uv _ back end _ time out(const uv _ loop _ t * loop){

  /*当前事件周期任务停止且不阻塞*/

  if(循环-停止_标志!=0)

  返回0;

  /*当当前事件循环不活动时,它不会阻塞*/

  如果(!uv__has_active_handles(循环)!uv__has_active_reqs(循环))

  返回0;

  /*当空闲句柄队列不为空时,它返回0,即它没有被阻塞。*/

  如果(!QUEUE_EMPTY(循环空闲句柄)

  返回0;

  /*当I /* i/o挂起队列不为空时。*/

  如果(!QUEUE_EMPTY(循环挂起队列))

  返回0;

  /*存在关闭回调*/

  if(循环关闭句柄)

  返回0;

  /*计算是否有最小延迟 timer */

  返回uv__next_timeout(循环);

  UV _ backend _ timeout做的主要事情有:

  当当前事件周期停止时,它不会被阻止。当当前事件循环不活动时,它不会阻塞。当空闲队列(setImmediate)不为空时,返回0,不阻塞。当I/o挂起队列不为空时,它不会被阻塞。当回调函数关闭时,它不会被阻塞。如果以上都不满足,那么通过uv__next_timeout计算是否存在延时阈值最小(执行最紧急)的timer delay设备,返回延时时间。我们来看看uv__next_timeout逻辑。

  int uv _ _ next _ time out(const uv _ loop _ t * loop){

  const结构heap _ node * heap _ node

  const uv _ timer _ t * handle

  uint64 _ t diff

  /*找到具有最小延迟时间的定时器*/

  heap _ node=heap _ min((const struct heap *)loop-timer _ heap);

  如果(heap_node==NULL) /*怎么会没有计时器,那么返回-1,继续进入轮询状态*/

  return-1;

  handle=container_of(堆节点,uv_timer_t,堆节点);

  /*如果定时器任务到期,则返回0,轮询阶段不会阻塞*/

  if(句柄超时=循环时间)

  返回0;

  /*从当前事件周期的事件中减去当前最小阈值的计时器,这可以证明轮询可以停留多长时间*/

  diff=句柄-超时-循环-时间;

  返回(int)diff;

  UV _ } uv _ _ next _ timeout做的事情如下:

  找到具有最小时间阈值的计时器(具有最高优先级的计时器)。如果没有计时器,则返回-1。投票阶段将是执行io_poll。这样做的好处是,一旦I/O结束,I/O回调函数会直接加入poll,然后执行相应的回调函数。如果有定时器,但timeout=loop.time证明已经过期,则返回0,轮询阶段不阻塞,过期的任务优先。如果没有过期,从当前事件周期的事件中减去当前最小阈值的计时器得到的值可以证明poll可以停留多长时间。当停留完成后,证明有一个超时的计时器,然后进入下一个滴答。poll阶段本质

  下一步是uv__io_poll的真正执行。其中有一个epoll_wait方法。根据超时,它轮询I/O是否完成,如果完成,它执行I/O回调。这也是unix下实现异步I/O的重要环节。

  poll 阶段流程图

  接下来,总结投票阶段的本质:

  轮询阶段是判断事件周期是否被超时阻塞。Poll也是轮询的一种,轮询i/o任务。事件周期倾向于继续轮询阶段,其目的是更快地执行I/O任务。如果没有其他任务,就一直处于轮询阶段。如果在其他阶段有更紧急的任务要执行,如计时器和关闭,则轮询阶段不会阻塞,将执行下一个滴答阶段。check 做的事就是处理 setImmediate 回调。

  我用流程图表达了我在整个投票阶段所做的事情,省略了一些细节。

  

8 check 阶段

  如果轮询阶段进入空闲状态,且setImmediate函数中有回调函数,轮询阶段将打破无限等待状态,进入检查阶段执行检查阶段的回调函数。

  setImmediate定义,我们先来看看setImmediate在Nodejs中是怎么定义的。

  

Nodejs 底层中的 setImmediate

  setImmediate执行

  函数setImmediate(callback,arg1,arg2,arg3) {

  validateCallback(回调);/*检查回调函数*/

  /*创建直接类*/

  return new Immediate(callback,args);

  }调用setImmediate本质上是调用nodejs中的setImmediate方法,先检查回调函数,然后创建一个Immediate类。让我们来看看直接类。立即上课{

  构造函数(回调,参数){

  这个。_ idleNext=null

  这个。_ idlePrev=null/*初始化参数*/

  这个。_ onImmediate=callback

  这个。_ argv=args

  这个。_ destroyed=false

  this[kRefed]=false;

  initAsyncResource(this, Immediate );

  this . ref();

  immediate info[k count];

  immediate queue . append(this);/*添加*/

  }

  }Immediate class会初始化一些参数,然后将当前的Immediate class插入immediateQueue链表中。ImmediateQueue本质上是一个链表,它存储每个立即。setImmediate执行流程图

  在轮询阶段之后,它将立即进入检查阶段,执行immediateQueue中的立即数。在每个事件循环中,将首先执行一个setImmediate回调,然后清空nextTick和Promise队列的内容。为了验证这个结论,也像setTimeout一样,看一下下面的代码块:

  setImmediate(()={

  console.log(setImmediate1 )

  process.nextTick(()={

  console.log(nextTick )

  })

  })

  setImmediate(()={

  console.log(setImmediate2 )

  })

  打印set immediate 1-next tick-set immediate 2。在每个事件周期中,执行一个set immediate,然后清除下一个tick队列,并在下一个事件周期中执行另一个set immediate 2。

  010-59000

  

setTimeout setImmediate

  接下来我们来对比一下。

  setTimeout 和 setImmediate,如果开发者期望延时执行的异步任务,那么接下来对比一下 setTimeout(fn,0) 和 setImmediate(fn) 区别。

  setTimeout 是 用于在设定阀值的最小误差内,执行回调函数,setTimeout 存在精度问题,创建 setTimeout 和 poll 阶段都可能影响到 setTimeout 回调函数的执行。setImmediate 在 poll 阶段之后,会马上进入 check 阶段,会执行 setImmediate回调。如果 setTimeout 和 setImmediate 在一起,那么谁先执行呢?

  首先写一个 demo:

  setTimeout(()=>{

   console.log(setTimeout)

  },0)

  setImmediate(()=>{

   console.log( setImmediate )

  })猜测

  先猜测一下,setTimeout 发生 timer 阶段,setImmediate 发生在 check 阶段,timer 阶段早于 check 阶段,那么 setTimeout 优先于 setImmediate 打印。但事实是这样吗?

  实际打印结果

  从以上打印结果上看, setTimeout 和 setImmediate 执行时机是不确定的,为什么会造成这种情况,上文中讲到即使 setTimeout 第二个参数为 0,在 nodejs 中也会被处理 setTimeout(fn,1)。当主进程的同步代码执行之后,会进入到事件循环阶段,第一次进入 timer 中,此时 settimeout 对应的 timer 的时间阀值为 1,若在前文 uv__run_timer(loop) 中,系统时间调用和时间比较的过程总耗时没有超过 1ms 的话,在 timer 阶段会发现没有过期的计时器,那么当前 timer 就不会执行,接下来到 check 阶段,就会执行 setImmediate 回调,此时的执行顺序是: setImmediate -> setTimeout

  但是如果总耗时超过一毫秒的话,执行顺序就会发生变化,在 timer 阶段,取出过期的 setTimeout 任务执行,然后到 check 阶段,再执行 setImmediate ,此时 setTimeout -> setImmediate

  造成这种情况发生的原因是:timer 的时间检查距当前事件循环 tick 的间隔可能小于 1ms 也可能大于 1ms 的阈值,所以决定了 setTimeout 在第一次事件循环执行与否。

  接下来我用代码阻塞的情况,会大概率造成 setTimeout 一直优先于 setImmediate 执行。

  /* TODO: setTimeout & setImmediate */

  setImmediate(()=>{

   console.log( setImmediate )

  })

  setTimeout(()=>{

   console.log(setTimeout)

  },0)

  /* 用 100000 循环阻塞代码,促使 setTimeout 过期 */

  for(let i=0;i<100000;i++){

  }效果:

  100000 循环阻塞代码,这样会让 setTimeout 超过时间阀值执行,这样就保证了每次先执行 setTimeout -> setImmediate

  特殊情况:确定顺序一致性。我们看一下特殊的情况。

  const fs = require(fs)

  fs.readFile(./file.js,()=>{

   setImmediate(()=>{

   console.log( setImmediate )

   })

   setTimeout(()=>{

   console.log(setTimeout)

   },0)

  })如上情况就会造成,setImmediate 一直优先于 setTimeout 执行,至于为什么,来一起分析一下原因。

  首先分析一下异步任务——主进程中有一个异步 I/O 任务,I/O 回调中有一个 setImmediate 和 一个 setTimeout 。在 poll 阶段会执行 I/O 回调。然后处理一个 setImmediate万变不离其宗,只要掌握了如上各个阶段的特性,那么对于不同情况的执行情况,就可以清晰的分辨出来。

  

9 close 阶段

close 阶段用于执行一些关闭的回调函数。执行所有的 close 事件。接下来看一下 close 事件 libuv 的实现。

  static void uv__run_closing_handles(uv_loop_t* loop) {

   uv_handle_t* p;

   uv_handle_t* q;

   p = loop->closing_handles;

   loop->closing_handles = NULL;

   while (p) {

   q = p->next_closing;

   uv__finish_close(p);

   p = q;

   }

  }uv__run_closing_handles 这个方法循环执行 close 队列里面的回调函数。

10 Nodejs 事件循环总结

接下来总结一下 Nodejs 事件循环。

  Nodejs 的事件循环分为 6 大阶段。分别为 timer 阶段,pending 阶段,prepare 阶段,poll 阶段, check 阶段,close 阶段。

  nextTick 队列和 Microtasks 队列执行特点,在每一阶段完成后执行, nextTick 优先级大于 Microtasks ( Promise )。

  poll 阶段主要处理 I/O,如果没有其他任务,会处于轮询阻塞阶段。

  timer 阶段主要处理定时器/延时器,它们并非准确的,而且创建需要额外的性能浪费,它们的执行还收到 poll 阶段的影响。

  pending 阶段处理 I/O 过期的回调任务。

  check 阶段处理 setImmediate。 setImmediate 和 setTimeout 执行时机和区别。

  

Nodejs事件循环习题演练

接下来为了更清楚事件循环流程,这里出两道事件循环的问题。作为实践:

  

习题一

process.nextTick(function(){

   console.log(1);

  });

  process.nextTick(function(){

   console.log(2);

   setImmediate(function(){

   console.log(3);

   });

   process.nextTick(function(){

   console.log(4);

   });

  });

  setImmediate(function(){

   console.log(5);

   process.nextTick(function(){

   console.log(6);

   });

   setImmediate(function(){

   console.log(7);

   });

  });

  setTimeout(e=>{

   console.log(8);

   new Promise((resolve,reject)=>{

   console.log(8+promise);

   resolve();

   }).then(e=>{

   console.log(8+promise+then);

   })

  },0)

  setTimeout(e=>{ console.log(9); },0)

  setImmediate(function(){

   console.log(10);

   process.nextTick(function(){

   console.log(11);

   });

   process.nextTick(function(){

   console.log(12);

   });

   setImmediate(function(){

   console.log(13);

   });

  });

  console.log(14);

   new Promise((resolve,reject)=>{

   console.log(15);

   resolve();

  }).then(e=>{

   console.log(16);

  })如果刚看这个 demo 可以会发蒙,不过上述讲到了整个事件循环,再来看这个问题就很轻松了,下面来分析一下整体流程:

  第一阶段: 首先开始启动 js 文件,那么进入第一次事件循环,那么先会执行同步任务:最先打印:

  nextTick 队列:

  nextTick -> console.log(1)

  nextTick -> console.log(2) -> setImmediate(3) -> nextTick(4)

  Promise队列

  Promise.then(16)

  check队列

  setImmediate(5) -> nextTick(6) -> setImmediate(7)

  setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13)

  timer队列

  setTimeout(8) -> promise(8+promise) -> promise.then(8+promise+then)

  setTimeout(9)

  第二阶段:在进入新的事件循环之前,清空 nextTick 队列,和 promise 队列,顺序是 nextTick 队列大于 Promise 队列。清空 nextTick ,打印:

  执行第二个 nextTick 的时候,又有一个 nextTick ,所以会把这个 nextTick 也加入到队列中。接下来马上执行。

  接下来清空Microtasks

  此时的 check 队列加入了新的 setImmediate。

  check队列setImmediate(5) -> nextTick(6) -> setImmediate(7)

  setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13)

  setImmediate(3)

  然后进入新的事件循环,首先执行 timer 里面的任务。执行第一个 setTimeout。执行第一个 timer:

  此时发现一个 Promise 。在正常的执行上下文中:

  然后将 Promise.then 加入到 nextTick 队列中。接下里会马上清空 nextTick 队列。

  执行第二个 timer:

  接下来到了 check 阶段,执行 check 队列里面的内容:执行第一个 check:

  此时发现一个 nextTick ,然后还有一个 setImmediate 将 setImmediate 加入到 check 队列中。然后执行 nextTick 。

  执行第二个 check

  此时发现两个 nextTick 和一个 setImmediate 。接下来清空 nextTick 队列。将 setImmediate 添加到队列中。

  此时的 check 队列是这样的:

  setImmediate(3)

  setImmediate(7)

  setImmediate(13)

  接下来按顺序清空 check 队列。打印

  到此为止,执行整个事件循环。那么整体打印内容如下:

  

总结

本文主要讲的内容如下:

  异步 I/O 介绍及其内部原理。Nodejs 的事件循环,六大阶段。Nodejs 中 setTimeout ,setImmediate , 异步 i/o ,nextTick ,Promise 的原理及其区别。Nodejs 事件循环实践。更多编程相关知识,请访问:编程视频!!

  以上就是Nodejs进阶学习:深入了解异步I/O和事件循环的详细内容,更多请关注我们其它相关文章!

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: