Node.js<八>——理解node事件循环以及stream

303 阅读16分钟

Node

Node的架构分析

  • 浏览器的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现,而Node中是由libuv实现的

  • 我们来看在很早就给大家展示的Node架构图:

    • 我们会发现libuv中主要维护了一个EventLoop和worker threads(线程池)
    • EventLoop负责调用系统的一些其他操作:文件的IONetworkchild-process
  • libuv是一个多平台的专注于异步IO的库,它最初是为Node开发的,但是现在也被使用到LuvitJuliapyuv等其他地方

阻塞IO和非阻塞IO

如果我们希望在程序中对一个文件进行操作,那么我们就需要打开这个文件:通过文件描述符

  • 我们思考:JS可以直接对一个文件进行操作吗?
  • 看起来是可以的,但是事实上我们任何程序中的文件操作都是需要进行系统调用(操作系统的文件系统)
  • 事实上对文件的操作,是一个操作系统的系统调用(IO系统,I是输入,O是输出)

操作系统通常为我们提供了两种调用方式:阻塞式调用和非阻塞式调用

  • 阻塞式调用:调用结果返回之前,当前线程处于阻塞态(阻塞态CPU是不会分配时间片的),调用线程只有在得到调用结果之后才会继续执行
  • 非阻塞式调用:调用执行之后,当前线程不会停止执行,只需要过一段时间来检查一下有没有返回结果即可

所以我们开发中的很多耗时操作,都可以基于这样的非阻塞式调用

  • 比如网络请求本身使用了Socket通信,而Socket本身提供了select模型,可以进行非阻塞方式的工作
  • 比如文件读写的IO操作,我们可以使用操作系统提供的基本事件的回调

非阻塞IO的问题

非阻塞IO虽然不会影响当前线程的执行,但是其也会存在一定的问题:我们并没有获取到需要文件操作(我们以读取为例)的结果

  • 那么就意味着为了可以知道是否读取到了完整的数据,我们需要频繁的去确定读取到的数据是否是完整的
  • 这个过程我们称之为轮询操作

那么这个轮询的工作由谁来完成呢?

  • 如果我们的主线程频繁的去进行轮询的工作,那么必然会大大降低性能
  • 并且开发中我们可能不只是一个文件的读写,可能是多个文件
  • 而且可能是多个功能:网络的IO、数据库的IO、自进程调用

libuv提供了一个线程池

  • 线程池会负责所有相关的操作,并且会通过轮询或者其他的方式等待结果
  • 当获取到结果时,就可以将对应的回调放到事件循环(某一个事件队列)中
  • 事件循环就可以负责接管后续的回调工作,告知JavaScript应用程序执行对应的回调函数

举个例子:在node当中使用了fs.writeFile('a.txt', (err, data) => {}),这个读取文件的操作是非阻塞的,线程池会帮助我们轮询查看读取操作有无完成,如果其已经读取到了文件的结果,那它是怎么告诉JS程序该文件以及读取完毕了呢?

通过事件循环,它会将读取到的数据和我们注册的回调函数放到事件循环某一个事件队列里面,取出来之后会告诉我们的JS端放入到函数调用栈中

阻塞和非阻塞,同步和异步的区别

阻塞和非阻塞是对于调用者来说的

  • 在我们这里就是系统调用,操作系统为我们提供了阻塞调用和非阻塞调用两种方式

同步和异步是对于调用者来说的

  • 在我们这里就是自己的程序
  • 如果我们在发起调用之后,不会进行其他任何的操作,只是等待结果,这个过程就称之为同步调用
  • 如果我们在发起调用之后,并不会等待结果,继续完成其它的工作,等到有回调时再去执行,这个过程就是异步调用

libuv采用的就是非阻塞异步IO的调用方式,这种方式相对来说性能会更高一点

为什么在node当中很少会提及线程安全相关的问题?

同一个进程里面是可能有多个线程的,如果多个线程在访问同一个文件,一个再读,一个再写,如果不加以规范,会导致读取的数据有误,所以像java的线程池在操作一个文件的时候会先对它加锁,但是在node中是不需要对文件进行其它处理的,这是为什么呢?

因为node是基于事件循环的,node会将这些异步读写文件的数据及其回调函数放到事件循环当中的某一个事件循环队列里面,不会出现读写操作同时出现的情况

Node事件循环的阶段

我们前面就强调过,事件循环像是一个桥梁,是连接着应用程序的JavaScript和系统调用之间的通道

  • 无论是我们的文件IO数据库网络IO定时器子进程,在完成对应的操作后,都会将对应的结果和回调函数放到事件循环(任务队列中)
  • 事件循环会不断的从任务队列中取出对应的任务(回调函数)来执行

但是一次完整的事件循环Tick分成很多个阶段:

  • 定时器(Timers):本阶段执行已经被setTimeout()setInterval()的回调函数
  • 待定回调(Pending Callback):对某些系统操作(如TCP错误类型)执行回调,比如TCP连接时接收到ECONNREFUSED
  • idleprepare:仅系统内部使用
  • 轮询(Poll):检索新的I/O事件;执行于I/O相关的回调;线程池里面完成的操作一般都是在轮询阶段会执行对应的回调
  • 检测:setImmediate()回调函数在这里执行
  • 关闭的回调函数:一些关闭的回调函数,如socket.on('close',() => {})childprocess.on('close', () => {})等等

Node的微任务和宏任务

我们会发现从一次事件循环的Tick来说,Node的事件循环更复杂,它也分为微任务和宏任务

  • 宏任务:setTimeoutsetIntervalIO事件、setImmediateclose事件
  • 微任务:Promisethen回调、process.nextTickqueueMicrotask

但是,Node中的事件循环不只是微任务队列和宏任务队列,执行顺序确实和浏览器一样:先执行微任务,再执行宏任务,但是无论是宏任务还是微任务,其都分为了好几个队列在里面,这些队列又有执行顺序,如下图所示(执行顺序从上到下,越在上面越快执行)

  • 微任务队列

    • next tick queue: process.nextTick
    • other queue: Promise的then回调、queueMicrotask
  • 宏任务队列

    • timer queue: setTimerout\setInterval
    • poll queue: IO事件
    • check queue: setImmediate
    • close queue: close事件

面试题<一>

考点:main script、setTimeout、Promise、then、queueMicrotask、await、async、setImmediate、process.nextTick

async function async1() {
  console.log('async1 start');
  await async2()
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

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

setTimeout(() => {
  console.log('setTimeout2');
}, 300)

setImmediate(() => {
  console.log('setImmediate');
})

process.nextTick(() => {
  console.log('nextTick1');
})

async1()

process.nextTick(() => {
  console.log('nextTick2');
})

new Promise(resolve => {
  console.log('promise1');
  resolve()
  console.log('promise2');
}).then(res => {
  console.log('promise3');
})

console.log('script end');

// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick1
// nextTick2
// async1 end
// promise3
// setTimeout0
// setImmediate
// setTimeout2
  1. 首先定义了两个函数,但都没有执行,直到遇到了第一个console语句,该函数压入函数调用栈执行打印script start后出栈
  2. 然后就遇到了setTimeout函数,由于其设置的时间是0s,所以对应的timer函数会立即被放到宏任务下的timer队列中
  3. 下面还有一个setTimeout语句,但是它的时间设置为300ms,所以浏览器会先将这个timer函数保存起来,等到300ms的时候再把它放到宏任务下的timer队列中去
  4. 接下里有一个setImmediate函数,其对应的回调函数会被放置到宏任务下的check队列中
  5. process.nextTick对应的回调函数会被放置到微任务队列下的next tick队列中去
  6. async1函数被执行,首先会打印async1 start,之前有提到过await关键字后面的代码相当于是直接放到new Promise语句里面的,所以会立即执行async2函数打印async2出来,但是await语句下面的代码相当于放置到了then回调中,所以console.log('async1 end')这个代码要被放到微任务下的other队列中
  7. 又遇到了一个process.nextTick函数,跟之前的那个一样,被放置到微任务队列下的next tick队列中去
  8. new Promise里面的函数直接被执行打印出promise1,紧接着又去执行了resolve函数,将promise的状态改为了fulfilled,但并不会阻止当前函数的执行,于是又将promise2打印了出来,then函数监听的时候发现promise的状态已经变化,所以直接将回调函数放入到了微任务下的other队列中
  9. 最后一个打印语句执行会打印script end,之后main script全部都被执行完了,时间循环要开始去从各种队列中取出任务了
  10. 首先取出来的是ticks队列中的任务,nextTick1nextTick2相继被打印出来
  11. 其次取出来的是other队列中的任务,async1 endpromise3相继被打印出来
  12. 然后取出的是timer队列中的任务,setTimeout0会被打印出来,但打印setTimeout2的语句还没有被加入到该队列中,所以该队列目前只有一个任务
  13. 所有队列的任务都取出来之后,事件循环并不会停止,因为它知道还有任务没有添加到队列中去,所以此时我们的线程也不会结束,直到打印setTimeout2的语句被添加到timer队列中,然后事件循环的tick将其取出并放到函数调用栈中执行打印setTimeout2后,整个线程的任务都执行完毕了,其会自动关闭
  14. 所以整个打印顺序是:script start -> async1 start -> async2 -> promise1 -> promise2 -> script end -> nextTick1 -> nextTick2 -> async1 end -> promise3 -> setTimeout0 -> setImmediate -> setTimeout2

面试题<二>

两个回调函数的执行顺序分析:

  • 情况一:setTimeout、setInterval
  • 情况二:setImmediate、setTimeout
setTimeout(() => {
  console.log('setTimeout');
}, 0)

setImmediate(() => {
  console.log('setImmediate');
})

虽然说情况二相对来说比较少见,但可能会出现。为什么会这种两种情况呢?

libuv底层是有个机制的,在事件循环的轮询队列(也就是检测IO事件的队列)中,停留时间是非常长的,为什么要这样设计呢?

node希望IO的回调尽可能的响应,这样用户就可以尽早拿到这些数据去做对应的操作了。所以它在轮询队列中会做一个长时间的停留,尽可能做到将回调函数放到轮询队列之后,在本次事件循环的tick里面就可以执行完

当遇到setTimeout(() => {}, 0)时,libuv帮助我们先保存了timer函数,从我们执行setTimeout函数到timer函数保存到对应的位置,虽然这里是0s,但实际上这个过程还是要消耗时间,只不过它保存了之后很快就会将保存的函数放到timer队列当中去

main script代码一执行完会做一件事情,就是初始化事件循环,这一个过程也是需要消耗时间的,而且只有初始化了事件循环,才会开始第一次tick

setImmediate中的回调函数有个特点,libuv不会在某个地方保存它,而是会直接将该函数放到check队列当中去,所以它不像其它回调函数还需要有个保存或者计时的过程,它加入到队列里面是很快的

  1. 第一种情况:

比如setTimeout将其回调函数加入到队列中的时间是10ms,而整个初始化事件循环的过程是20ms,那这样在事件循环第一次tick之前,setTimeout对应的回调函数早已添加到timer队列当中,而setImmediate对应的回调函数也已经加入到check队列了,那么第一次tick的时候按照各个队列的优先级,自然是先打印setTimeout,后打印setImmediate

  1. 第二种情况:

如果setTimeout将其回调函数加入到队列中的时间是10ms,而整个初始化事件循环的过程是5ms,那这样在事件循环第一次tick的时候,setTimeout对应的回调函数还没有添加到timer队列当中,但是setImmediate对应的回调函数已经加入到check队列了,那么事件循环在遍历到timer队列里面的时候发现是为空的,于是继续往其它队列走,到了check队列之后发现setImmediate在里面,那么肯定就将setImmediate打印出来了,而事件循环也知道还有任务没有被添加到队列当中,所以其还是会继续下一次tick,直到timer队列有任务并将setTimeout打印出来为止

思考

在面试题一中为什么setImmediate回调函数总是在setTimeout回调函数后面执行呢?

我觉得可能是因为代码量的问题,因为代码量一多是会影响到main script执行完毕的时间的,这样就让初始化事件循环的操作要推后了,加上事件循环初始化本身也需要时间,所以在第一次tick之前已经给足了将timer函数放到timer队列中去的时间,在第一次tick的时候自然也就先打印setTimeout,后打印setImmediate了

而在第二道面试题中,代码量较少,timer函数放到队列的时候有可能赶不上第一次tick,所以只能眼睁睁的看着setImmediate打印在setTimeout的前面

Stream

认识Stream

什么是流呢?

  • 我们可以想象当我们从一个文件中读取数据时,文件的二进制(字节)数据会源源不断的被读取到我们的程序中,而这一连串的字节,就是我们程序中的流

所以,我们可以这样理解流:

  • 流是连续字节的一种表现形式和抽象概念
  • 流应该是可读的,也是可写的

在之前学习文件的读写时,我们可以直接通过readFile或者writeFile方式读写文件,为什么还需要流呢?

  • 直接读取文件的方式,虽然简单,但是无法控制一些细节的操作
  • 比如从什么位置开始读,读到什么位置、一次性读取多少个字节
  • 读到某个位置后,暂停读取,某个时刻恢复读取等等
  • 或者这个文件非常大,比如一个视频文件,一次性全部读取并不合适

文件读写的Stream

  • 事实上Node中很多对象是基于流实现的

    • http模块的RequestResponse对象
    • process.stdout对象
  • 官方:另外所有的流都是EventEmitter的实例,我们看源码了之后就会发现,Stream这个构造函数继承了事件发射器类上所有包括其原型上所有的属性,相当于拷贝了一个事件发生器

  • Node中有四种基本流类型:

    • Writable:可以向其写入数据的流(例如:fs.createWriteStream())
    • Readable:可以从中读取数据的流(例如:fs.createReadStream()
    • Duplex:同时为Readable和Writable的流(例如:net.Socket()
    • Transform:Transform可以在写入和读取数据时修改或转换数据的流(例如:zlib.createDeflate()

Readable

传统读取一个文件的信息:

const fs = require('fs')
fs.readFile('./a.txt', (err, data) => {
  console.log(data); // <Buffer e5 93 88 e5 93 88>
})

这种方式是一次性将一个文件中所有的内容都读取到程序(内存)中,但是这种读取方式就会出现我们之前提到的很多问题。这个时候,我们可以使用fs.createReadStream方法,我们来看几个参数,更多参数可以参考官网

  • start:文件读取开始的位置
  • end:文件读取结束的位置
  • highWatherMark:一次性读取字节的长度,默认是64kb

在读取的时候,start和end对应的字节都是包括在内的,并且它们的起始值是0,相当于是索引

Readable的使用

创建文件的Readable

const fs = require('fs')
const reader = fs.createReadStream('./a.txt', {
  start: 2,
  end: 6,
  highWaterMark: 2 // 一次读取文件的字节数
})

我们如何获取到数据呢?

  • 可以监听data事件,获取读取到的数据
// 因为使用流读取文件可以不一次性读取完,所以监听的事件可能被多次触发
reader.on('data', data => {
  console.log(data);
})

也可以做一些其他的操作

  • 监听文件的打开,关闭和读取结束
reader.on('open', fd => {
  console.log(fd);
  console.log('文件被打开了');
})

reader.on('end', () => {
  console.log('文件读取完毕');
})

reader.on('close', () => {
  console.log('文件被关闭了');
})
  • 控制文件读取暂停或者恢复
// 每次读取完一次数据后,暂停一秒钟后再继续读取
reader.on('data', data => {
  console.log(data);
  reader.pause()
  setTimeout(() => {
    reader.resume()
  }, 1000)
})

Writable

传统的写入方式:

const fs = require('fs')

fs.writeFile('./b.txt', 'Hello', err => {
  console.log(err);
})

Scream的写入方式:

const writer = fs.createWriteStream('./a.txt', {
  flags:'r+',
  start: 2,
 })
  
writer.write('你好啊', err => {
  if (err) {
    console.log(err);
    return
  }
  console.log('写入成功');
})

// 我们可以连续写入多次,因为这个文件一直都没有被关闭
writer.write('你好啊', err => {
  if (err) {
    console.log(err);
    return
  }
  console.log('写入成功');
})

// 如果我们不会自动关闭,那么这个文件是不会被关闭的,所以监听的关闭事件也不会被触发
writer.on('close', () => {
  console.log('文件被关闭');
})  

关闭文件的方式:

// 可以关闭文件,但这个方法开发中不常用
writer.close()

writer.on('close', () => {
  console.log('文件被关闭');
})  

我们一般用end方法来关闭文件,其相当于是write方法和close方法的一个结合,先将对应的内容写入到文件之后再关闭文件

writer.end('你好')

writer.on('close', () => {
  console.log('文件被关闭');
})  

pipe方法的使用

传统的将一个文件的内容写入到另一个文件中的方法

const fs = require('fs')

fs.readFile('./a.txt', (err, data) => {
  fs.writeFile('./d.txt', data, err => {
    console.log(err);
  })
})

Scream的写法:

const fs = require('fs')

const reader = fs.createReadStream('./a.txt')
const writer = fs.createWriteStream('./c.txt')

reader.pipe(writer)
writer.close()