Node.js理论实践之《异步非阻塞IO与事件循环》

Node.js是一个构建在Chrome浏览器V8引擎上的JavaScript运行环境, 使用单线程事件驱动非阻塞I/O的方式实现了高并发请求,libuv为其提供了异步编程的能力。javascript

架构组成

nodejs

从这张图上咱们能够看出,Node.js底层框架由Node.js标准库Node bindings底层库三个部分组成。java

Node.js标准库

这一层是由Javascript编写的,也就是咱们使用过程当中直接能调用的API,在源码中的lib目录下能够看到,诸如http、fs、events等经常使用核心模块node

Node bindings

这一层能够理解为是javascript与C/C++库之间创建链接的, 经过这个桥,底层实现的C/C++库暴露给javascript环境,同时把js传入V8, 解析后交给libuv发起非阻塞I/O, 并等待事件循环调度;linux

底层库

这一层主要有如下四块:编程

  • V8: Google推出的Javascript虚拟机,为Javascript提供了在非浏览器端运行的环境;
  • libuv:为Node.js提供了跨平台,线程池,事件池,异步I/O 等能力,是Nodejs之因此高效的主要缘由;
  • C-ares:提供了异步处理DNS相关的能力;
  • http_parser、OpenSSL、zlib等:提供包括http解析、SSL、数据压缩等能力;

顺带看一下libuv的架构图,可见Nodejs的网络I/O文件I/ODNS操做、还有一些用户代码都是在libuv工做的。promise

WechatIMG2

单线程

咱们知道任务调度通常有两种方案: 一是单线程串行执行,执行顺序与编码顺序一致,最大的问题是没法充分利用多核CPU,当并行极大的时候,单核CPU理论上计算能力是100%; 另外一种就是多线程并行处理,优势是能够有效利用多核CPU,缺点是建立与切换线程开销大,还涉及到锁、状态同步等问题, CPU常常会等待I/O结束,CPU的性能就白白消耗。浏览器

一般为客户端链接建立一个线程须要消耗2M内存,因此理论上一台8G的服务器,在Java应用中最多支持的并发数是4000。而Node.js只使用一个线程,当有客户端链接请求时,触发内部事件,经过非阻塞I/O,事件驱动机制,让其看起来是并行的。 理论上一台8G内存的服务器,能够同时容纳3到4万用户的链接。bash

Node.js采用单线程方案,免去锁、状态同步等繁杂问题,又能提升CPU利用率。Node.js高效的除了由于其单线程外,还必须配合下面要说的非阻塞I/O。服务器

非阻塞I/O

概念

首先要清楚,对于一个网络IO,会涉及到两个系统对象:网络

  1. 调用这个IO的进程或线程
  2. 系统内核(kernel)

而当一个读操做发生时,它会经历两个阶段:

  1. 等待数据准备好
  2. 将数据从内核中拷贝到用户进程中 记住这两点很重要,由于IO模型的区别就是在两个阶段上各有不一样的状况。

接下来理清这几个概念:

  • 阻塞I/O: 在发起I/O操做以后会一直阻塞着进程,不执行其余操做,直到获得响应或者超时为止;
  • 非阻塞I/O:发起I/O操做不等获得响应或者超时就当即返回,让进程继续执行其余操做,可是要经过轮询方式不断地去check数据是否已准备好
  • 多路复用I/O:又分为select、pool、epool。最大优势就是单个进程就能够同时处理多个网络链接的IO。 基本原理就是select/poll这个function会不断的轮询所负责的全部socket,当某个socket有数据到达了,就通知用户进程。 而epool经过callback回调通知机制.减小内存开销,不因并发量大而下降效率,linux下最高效率的I/O事件机制。
  • 同步I/O:发起I/O操做以后会阻塞进程直到获得响应或者超时。前三者阻塞I/O,非阻塞I/O,多路复用I/O都属于同步I/O。 注意非阻塞I/O在数据从内核拷贝到用户进程时,进程仍然是阻塞的,因此仍是属于同步I/O。
  • 异步I/O:直接返回继续执行下一条语句,当I/O操做完成或数据返回时,以事件的形式通知执行IO操做的进程。

总结

阻塞I/O和非阻塞I/O区别在于:在I/O操做的完成或数据的返回前是等待仍是返回(能够理解成一直等仍是分时间段等) 同步I/O和异步I/O区别在于 :在I/O操做的完成或数据的返回前会不会将进程阻塞(或者说是主动查询仍是被动等待通知)

设计理念

因为Node.js中采用了非阻塞型I/O机制,所以在执行了读数据的代码以后,将当即转而执行其后面的代码,把读数据返回结果的处理代码放在回调函数中,从而提升了程序的执行效率。 当某个I/O执行完毕时,将以事件的形式通知执行I/O操做的线程,线程执行这个事件的回调函数。

事件循环

event-loop

基本流程

  1. 每一个Node.js进程只有一个主线程在执行程序代码,造成一个执行栈(execution context stack);
  2. 主线程以外,还维护一个事件队列(Event queue),当用户的网络请求或者其它的异步操做到来时,会先进入到事件队列中排队,并不会当即执行它,代码也不会被阻塞,继续往下走,直到主线程代码执行完毕;
  3. 主线程代码执行完毕完成后,而后经过事件循环机制(Event Loop),检查队列中是否有要处理的事件,从队头取出第一个事件,从线程池分配一个线程来处理这个事件,而后是第二个,第三个,直到队列中全部事件都执行完了。 当有事件执行完毕后,会通知主线程,主线程执行回调,并将线程归还给线程池。这个过程就叫事件循环(Event Loop);
  4. 不断重复上面的第三步;

6个阶段

event-loop2

注意:

  • 每一个框都被称为事件循环的一个流程阶段.
  • 每一个阶段都有一个FIFO(先进先出)执行回调函数的队列,一般当事件循环进入到给定阶段会执行特定于该阶段的全部操做,而后执行该阶段队列的回调事件直到队列耗尽或者超过最大执行限度为止,而后事件循环就会走向下一阶段;
  • poll阶段中新的处理事件有可能会加入到内核的队列,即处理轮询事件时候又加入新的轮询事件,所以长时间运行回调事件会让poll阶段运行时间超过定时器的阈值;
  • 当全部阶段被顺序执行一次后,称 Event loop 完成了一个tick

阶段概述:

  1. timers(定时器)阶段:执行setTimeoutsetInterval调度的回调。
  2. pending callbacks(等待回调)阶段: 用于执行前一轮事件循环中被延迟到这一轮的I/O回调函数
  3. idle,prepare(闲置,准备)阶段: 只能内部使用。
  4. poll(轮询)阶段:最重要的阶段,执行I/O事件回调,在适当的条件下 node 会阻塞在这个阶段。
  5. check(检查)阶段:执行 setImmediate 的回调。
  6. close callbacks(关闭回调)阶段:执行close事件的回调, 如套接字(socket)或句柄(handle)忽然关闭;

event-loop3

事件循环的 PendingIdle/PrepareClose 阶段涂成灰色,由于这些是 Node 在内部使用的阶段。

Node.js开发者编写的代码仅以微任务形式在主线计时器(Timers) 阶段、轮询(Poll) 阶段和 查询(Check) 阶段中运行。

  • 为了尽量快的处理异步 I/O 事件,那么事件循环 tick 总有一种维持 poll 状态的倾向
  • 当前 poll 阶段应该维持(阻塞)多长时间是由 后续 tick 各个阶段是否存在不为空的回调函数队列最近的计时器时间节点 决定。 若全部队列为空且不存在任何计时器,那么事件循环将 无限制地维持在 poll 阶段;以实现一旦存在 I/O 回调函数加入到 poll 队列中便可当即获得执行;
  • check 阶段的回调函数队列中全部的回调函数都是来自 poll 阶段的 setImmediate 函数

poll 阶段主要有两个功能

  1. 当 timers 的定时器到期后,执行定时器(setTimeout 和 setInterval)的 callback
  2. 执行 poll 队列里面的 I/O callback
  • 若是 Event Loop 进入了 poll 阶段,且代码未设定 timer,可能发生如下状况:
    1. 若是 poll queue 不为空,Event Loop 将同步的执行 queue 里的 callback,直至 queue 为空,或者执行的 callback 到达系统上限。
    2. 若是 poll queue 为空,可能发生如下状况:
      • 若是代码使用 setImmediate() 设定了 callback,Event Loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的 queue。
      • 若是代码没有使用 setImmediate(),Event Loop 将阻塞在该阶段等待 callbacks 加入 poll queue,若是有 callback 进来则当即执行。 一旦 poll queue 为空,Event Loop 将检查 timers,若是有 timer 的时间到期,Event Loop 将回到 timers 阶段,而后执行 timer queue。

process.nextTick

process.nextTick() 不在 Event Loop 的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。

这里还须要提一下 macrotask 和 microtask 的概念,macrotask(宏任务)指 Event Loop 每一个阶段执行的任务,microtask(微任务)指每一个阶段之间执行的任务。

即上述 6 个阶段都属于 macrotask,process.nextTick() 属于 microtask

process.nextTick() 的实现和 v8 的 microtask 并没有关系,是 Node.js 层面的东西,应该说 process.nextTick() 的行为接近为 microtask。 Promise.then 也属于 microtask 的一种。

能够经过递归 process.nextTick()调用来“饿死”您的 I/O,阻止事件循环到达 轮询 阶段。

promise.then 回调像微处理同样执行,就像 process.nextTick 同样。 虽然,若是二者都在同一个微任务队列中,则将首先执行 process.nextTick 的回调。 优先级 process.nextTick > promise.then = queueMicrotask

案例分析

案例一

咱们来看这一段代码在不一样的环境下执行的结果:

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
复制代码

首先在浏览器环境下,其输出结果为:

timer1
promise1
timer2
promise2
复制代码

借助于前面关于浏览器中Javascript事件循环的知识,不能理解。

而后咱们将其在 Node.js v11.0.0 如下版本执行,获得结果:

timer1;
timer2;
promise1;
promise2;
复制代码

但若是是在 Node.js v11.0.0 以上(包括)版本中执行中,将获得结果:

timer1;
promise1;
timer2;
promise2;
复制代码

缘由是 node v11 如下只有所有执行了 timers 阶段队列的所有任务才执行微任务队列,而浏览器只要执行了一个宏任务就会执行微任务队列。 node v11 在 timer 阶段的 setTimeout,setInterval 和在 check 阶段的 immediate 都在 node v11 里面都修改成一旦执行一个阶段里的一个任务就马上执行微任务队列。 也是为了和浏览器保持一致。

案例二

  • setImmediate 设计为在当前轮询 poll 阶段完成后执行脚本
  • setTimeout 计划在以毫秒为单位的最小阈值过去以后运行脚本

不在 I/O 回调(即主模块)内的脚本,则两个计时器的执行顺序是不肯定的,由于它受机器性能的约束,好比:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
复制代码

输出顺序是不肯定的。

咱们知道 setTimeout 的回调函数在 timer 阶段执行,setImmediate 的回调函数在 check 阶段执行,Event loop 的开始会先检查 timer 阶段,可是在开始以前到 timer 阶段会消耗必定时间,因此就会出现两种状况:

  • timer 前的准备时间超过 1ms,知足 loop->time >= 1,则执行 timer 阶段(setTimeout)的回调函数。
  • timer 前的准备时间小于 1ms,则先执行 check 阶段(setImmediate)的回调函数,下一次 Event loop 执行 timer 阶段(setTimeout)的回调函数。

而若是这两个调用在一个I/O回调中,那么immediate老是先执行。

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
复制代码

分析以下:

  1. fs.readFile 的回调函数执行完后;
  2. 注册 setTimeout 的回调函数到 timer 阶段;
  3. 注册 setImmediate 的回调函数到 check 阶段;
  4. Event loop 从 pool 阶段出来继续往下一个阶段执行,刚好是 check 阶段,因此 setImmediate 的回调函数先执行。 本次 Event loop 结束后,进入下一次 Event loop,执行 setTimeout 的回调函数;

案例三

setInterval(() => {
  console.log('setInterval')
}, 100)

process.nextTick(function tick () {
  process.nextTick(tick)
})
复制代码

运行结果:setInterval 永远不会打印出来。

process.nextTick 会无限循环,将 Event loop 阻塞在 microtask 阶段,致使 Event loop 上其余 macrotask 阶段的回调函数没有机会执行。 解决方法一般是用 setImmediate 替代 process.nextTick,以下:

setInterval(() => {
  console.log('setInterval')
}, 100)

setImmediate(function immediate () {
  setImmediate(immediate)
})
复制代码

运行结果:每 100ms 打印一次 setInterval。

process.nextTick 内执行 process.nextTick 仍然将 tick 函数注册到当前 microtask 的尾部,因此致使 microtask 永远执行不完; setImmediate 内执行 setImmediate 会将 immediate 函数注册到下一次 Event loop 的 check 阶段,而不是当前正在执行的 check 阶段,因此给了 Event loop 上其余 macrotask 执行的机会。

案例四

setImmediate(() => {
  console.log('setImmediate1')
  setImmediate(() => {
    console.log('setImmediate2')
  })
  process.nextTick(() => {
    console.log('nextTick')
  })
})

setImmediate(() => {
  console.log('setImmediate3')
})
复制代码

运行结果在 node v11如下是:

setImmediate1
setImmediate3
nextTick
setImmediate2
复制代码

在node v11以上是:

setImmediate1
nextTick
setImmediate3
setImmediate2
复制代码

缘由同案例一

案例五

setImmediate(() => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
  }, 100)
  setImmediate(() => {
    console.log(3)
  })
  process.nextTick(() => {
    console.log(4)
  })
})
process.nextTick(() => {
  console.log(5)
  setTimeout(() => {
    console.log(6)
  }, 100)
  setImmediate(() => {
    console.log(7)
  })
  process.nextTick(() => {
    console.log(8)
  })
})
console.log(9)
复制代码

运行结果在 node v11如下是:

9
5
8
1
7
4
3
6
2
复制代码

在 node v11以上是:

9
5
8
1
4
7
3
6
2
复制代码

缘由请自行分析。

相关文章
相关标签/搜索