Node.js 异步异闻录

本文首发在我的博客:http://muyunyun.cn/posts/7b9fdc87/html

提到 Node.js, 咱们脑海就会浮现异步、非阻塞、单线程等关键词,进一步咱们还会想到 buffer、模块机制、事件循环、进程、V八、libuv 等知识点。本文起初旨在理顺 Node.js 以上易混淆概念,然而一入异步深似海,本文尝试基于 Node.js 的异步展开讨论,其余的主题只能往后慢慢补上了。(附:亦能够把本文看成是朴灵老师所著的《深刻浅出 Node.js》一书的小结)。node

异步 I/O

Node.js 正是依靠构建了一套完善的高性能异步 I/O 框架,从而打破了 JavaScript 在服务器端止步不前的局面。git

异步 I/O VS 非阻塞 I/O

听起来异步和非阻塞,同步和阻塞是相互对应的,从实际效果而言,异步和非阻塞都达到了咱们并行 I/O 的目的,可是从计算机内核 I/O 而言,异步/同步和阻塞/非阻塞其实是两回事。程序员

注意,操做系统内核对于 I/O 只有两种方式:阻塞与非阻塞。github

调用阻塞 I/O 的过程:编程

调用非阻塞 I/O 的过程:segmentfault

在此先引人一个叫做轮询的技术。轮询不一样于回调,举个生活例子,你有事去隔壁寝室找同窗,发现人不在,你怎么办呢?方法1,每隔几分钟再去趟隔壁寝室,看人在不;方法2,拜托与他同寝室的人,看到他回来时叫一下你;那么前者是轮询,后者是回调。设计模式

再回到主题,阻塞 I/O 形成 CPU 等待浪费,非阻塞 I/O 带来的麻烦倒是须要轮询去确认是否彻底完成数据获取。从操做系统的这个层面上看,对于应用程序而言,无论是阻塞 I/O 亦或是 非阻塞 I/O,它们都只能是一种同步,由于尽管使用了轮询技术,应用程序仍然须要等待 I/O 彻底返回。promise

Node 的异步 I/O

完成整个异步 I/O 环节的有事件循环、观察者、请求对象以及 I/O 线程池。浏览器

事件循环

在进程启动的时候,Node 会建立一个相似于 whlie(true) 的循环,每一次执行循环体的过程咱们称为 Tick。

每一个 Tick 的过程就是查看是否有事件待处理,若是有,就取出事件及其相关的回调函数。若是存在相关的回调函数,就执行他们。而后进入下一个循环,若是再也不有事件处理,就退出进程。

伪代码以下:

while(ture) {
  const event = eventQueue.pop()
  if (event && event.handler) {
    event.handler.execute()  // execute the callback in Javascript thread
  } else {
    sleep() // sleep some time to release the CPU do other stuff
  }
}

观察者

每一个 Tick 的过程当中,如何判断是否有事件须要处理,这里就须要引入观察者这个概念。

每一个事件循环中有一个或多个观察者,而判断是否有事件须要处理的过程就是向这些观察者询问是否有要处理的事件。

在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件都有对应的观察者。

请求对象

对于 Node 中的异步 I/O 而言,回调函数不禁开发者来调用,在 JavaScript 发起调用到内核执行完 id 操做的过渡过程当中,存在一种中间产物,它叫做请求对象。

请求对象是异步 I/O 过程当中的重要中间产物,全部状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操做完后的回调处理

fs.open() 为例:

fs.open = function(path, flags, mode, callback) {
  bingding.open(
    pathModule._makeLong(path),
    stringToFlags(flags),
    mode,
    callback
  )
}

fs.open 的做用就是根据指定路径和参数去打开一个文件,从而获得一个文件描述符。

从前面的代码中能够看到,JavaScript 层面的代码经过调用 C++ 核心模块进行下层的操做。

从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块经过 libuv 进行系统调用,这是 Node 里经典的调用方式。

libuv 做为封装层,有两个平台的实现,实质上是调用了 uv_fs_open 方法,在 uv_fs_open 的调用过程当中,会建立一个 FSReqWrap 请求对象,从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中。回调函数则被设置在这个对象的 oncomplete_sym 属性上。

req_wrap -> object_ -> Set(oncomplete_sym, callback)

对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推人线程池中等待执行。

至此,JavaScript 调用当即返回,由 JavaScript 层面发起的异步调用的第一阶段就此结束(即上图所注释的异步 I/O 第一部分)。JavaScript 线程能够继续执行当前任务的后续操做,当前的 I/O 操做在线程池中等待执行,无论它是否阻塞 I/O,都不会影响到 JavaScript 线程的后续操做,如此达到了异步的目的。

执行回调

组装好请求对象、送入 I/O 线程池等待执行,其实是完成了异步 I/O 的第一部分,回调通知是第二部分。

线程池中的 I/O 操做调用完毕以后,会将获取的结果储存在 req -> result 属性上,而后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操做已经完成,并将线程归还线程池。

在这个过程当中,咱们动用了事件循环的 I/O 观察者,在每次 Tick 的执行过程当中,它会调用 IOCP 相关的 GetQueuedCompletionStatus 方法检查线程池中是否有执行完的请求,若是存在,会将请求对象加入到 I/O 观察者的队列中,而后将其当作事件处理。

I/O 观察者回调函数的行为就是取出请求对象的 result 属性做为参数,取出 oncomplete_sym 属性做为方法,而后调用执行,以此达到调用 JavaScript 中传入的回调函数的目的。

小结

经过介绍完整个异步 I/O 后,有个须要重视的观点是 JavaScript 是单线程的,Node 自己实际上是多线程的,只是 I/O 线程使用的 CPU 比较少;还有个重要的观点是,除了用户的代码没法并行执行外,全部的 I/O (磁盘 I/O 和网络 I/O) 则是能够并行起来的。

异步编程

Node 是首个将异步大规模带到应用层面的平台。经过上文所述咱们了解了 Node 如何经过事件循环实现异步 I/O,有异步 I/O 必然存在异步编程。异步编程的路经历了太多坎坷,从回调函数、发布订阅模式、Promise 对象,到 generator、asycn/await。趁着异步编程这个主题恰好把它们串起来理理。

异步 VS 回调

对于刚接触异步的新人,很大概率会混淆回调 (callback) 和异步 (asynchronous) 的概念。先来看看维基的 Callback 条目:

In computer programming, a callback is any executable code that is passed as an argument to other code

所以,回调本质上是一种设计模式,而且 jQuery (包括其余框架)的设计原则遵循了这个模式。

在 JavaScript 中,回调函数具体的定义为:函数 A 做为参数(函数引用)传递到另外一个函数 B 中,而且这个函数 B 执行函数 A。咱们就说函数 A 叫作回调函数。若是没有名称(函数表达式),就叫作匿名回调函数。

所以 callback 不必定用于异步,通常同步(阻塞)的场景下也常常用到回调,好比要求执行某些操做后执行回调函数。讲了这么多让咱们来看下同步回调和异步回调的例子:

同步回调:

function f2() {
  console.log('f2 finished')
}

function f1(cb) {
  cb()
  console.log('f1 finished')
}

f1(f2)  // 获得的结果是 f2 finished, f1 finished

异步回调:

function f2() {
  console.log('f2 finished')
}

function f1(cb) {
  setTimeout(cb, 1000) // 经过 setTimeout() 来模拟耗时操做
  console.log('f1 finished')
}

f1(f2)  // 获得的结果是 f1 finished, f2 finished

小结:回调能够进行同步也能够异步调用,可是 Node.js 提供的 API 大多都是异步回调的,好比 buffer、http、cluster 等模块。

发布/订阅模式

事件发布/订阅模式 (PubSub) 自身并没有同步和异步调用的问题,但在 Node 的 events 模块的调用中多半伴随事件循环而异步触发的,因此咱们说事件发布/订阅普遍应用于异步编程。它的应用很是普遍,能够在异步编程中帮助咱们完成更松的解耦,甚至在 MVC、MVVC 的架构中以及设计模式中也少不了发布-订阅模式的参与。

以 jQuery 事件监听为例

$('#btn').on('myEvent', function(e) {  // 订阅事件
  console.log('I am an Event')
})

$('#btn').trigger('myEvent')           // 触发事件

能够看到,订阅事件就是一个高阶函数的应用。事件发布/订阅模式能够实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。下面咱们来看看发布/订阅模式的简易实现。

var PubSub = function() {
  this.handlers = {}
}

PubSub.prototype.subscribe = function(eventType, handler) { // 注册函数逻辑
  if (!(eventType in this.handlers)) {
    this.handlers[eventType] = []
  }
  this.handlers[eventType].push(handler)  // 添加事件监听器
  return this // 返回上下文环境以实现链式调用
}

PubSub.prototype.publish = function(eventType) {  // 发布函数逻辑
  var _args = Array.prototype.slice.call(arguments, 1)
  for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) {  // 遍历事件监听器
    _handlers[i].apply(this, _args) // 调用事件监听器
  }
}

var event = new PubSub // 构造 PubSub 实例

event.subscribe('name', function(msg) {
  console.log('my name is ' + msg)  // my name is muyy
})

event.publish('name', 'muyy')

至此,一个简易的订阅发布模式就实现了。然而发布/订阅模式也存在一些缺点,建立订阅自己会消耗必定的时间与内存,也许当你订阅一个消息以后,以后可能就不会发生。发布-订阅模式虽然它弱化了对象与对象之间的关系,可是若是过分使用,对象与对象的必要联系就会被深埋,会致使程序难以跟踪与维护。

Promise/Deferred 模式

想象一下,若是某个操做须要通过多个非阻塞的 IO 操做,每个结果都是经过回调,程序有可能会看上去像这个样子。这样的代码很难维护。这样的状况更多的会发生在 server side 的状况下。代码片断以下:

operation1(function(err, result1) {
  operation2(result1, function(err, result2) {
    operation3(result2, function(err, result3) {
      operation4(result3, function(err, result4) {
        callback(result4) // do something useful
      })
    })
  })
})

这时候,Promise 出现了,其出现的目的就是为了解决所谓的回调地狱的问题。让咱们看下使用 Promise 后的代码片断:

promise()
  .then(operation1)
  .then(operation2)
  .then(operation3)
  .then(operation4)
  .then(function(value4) {
    // Do something with value4
  }, function (error) {
    // Handle any error from step1 through step4
  })
  .done()

能够看到,使用了第二种编程模式后能极大地提升咱们的编程体验,接着就让咱们本身动手实现一个支持序列执行的 Promise。(附:为了直观的在浏览器上也能感觉到 Promise,为此也写了一段浏览器上的 Promise 用法示例)

在此以前,咱们先要了解 Promise/A 提议中对单个异步操做所做的抽象定义,定义具体以下所示:

  • Promise 操做只会处在 3 种状态的一种:未完成态、完成态和失败态。
  • Promise 的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能相互转化。
  • Promise 的状态一旦转化,将不能被更改。

Promise 的状态转化示意图以下:

除此以外,Promise 对象的另外一个关键就是须要具有 then() 方法,对于 then() 方法,有如下简单的要求:

  • 接受完成态、错误态的回调方法。在操做完成或出现错误时,将会调用对应方法。
  • 可选地支持 progress 事件回调做为第三个方法。
  • then() 方法只接受 function 对象,其他对象将被忽略。
  • then() 方法继续返回 Promise 对象,已实现链式调用。

then() 方法的定义以下:

then(fulfilledHandler, errorHandler, progressHandler)

有了这些核心知识,接着进入 Promise/Deferred 核心代码环节:

var Promise = function() { // 构建 Promise 对象
  // 队列用于存储执行的回调函数
  this.queue = []
  this.isPromise = true
}

Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) { // 构建 Progress 的 then 方法
  var handler = {}
  if (typeof fulfilledHandler === 'function') {
    handler.fulfilled = fulfilledHandler
  }
  if (typeof errorHandler === 'function') {
    handler.error = errorHandler
  }
  this.queue.push(handler)
  return this
}

如上 Promise 的代码就完成了,可是别忘了 Promise/Deferred 中的后者 Deferred,为了完成 Promise 的整个流程,咱们还须要触发执行上述回调函数的地方,实现这些功能的对象就叫做 Deferred,即延迟对象。

Promise 和 Deferred 的总体关系以下图所示,从中可知,Deferred 主要用于内部来维护异步模型的状态;而 Promise 则做用于外部,经过 then() 方法暴露给外部以添加自定义逻辑。

接着来看 Deferred 代码部分的实现:

var Deferred = function() {
  this.promise = new Promise()
}

// 完成态
Deferred.prototype.resolve = function(obj) {
  var promise = this.promise
  var handler
  while(handler = promise.queue.shift()) {
    if (handler && handler.fulfilled) {
      var ret = handler.fulfilled(obj)
      if (ret && ret.isPromise) { // 这一行以及后面3行的意思是:一旦检测到返回了新的 Promise 对象,中止执行,而后将当前 Deferred 对象的 promise 引用改变为新的 Promise 对象,并将队列中余下的回调转交给它
        ret.queue = promise.queue
        this.promise = ret
        return
      }
    }
  }
}

// 失败态
Deferred.prototype.reject = function(err) {
  var promise = this.promise
  var handler
  while (handler = promise.queue.shift()) {
    if (handler && handler.error) {
      var ret = handler.error(err)
      if (ret && ret.isPromise) {
        ret.queue = promise.queue
        this.promise = ret
        return
      }
    }
  }
}

// 生成回调函数
Deferred.prototype.callback = function() {
  var that = this
  return function(err, file) {
    if(err) {
      return that.reject(err)
    }
    that.resolve(file)
  }
}

接着咱们以两次文件读取做为例子,来验证该设计的可行性。这里假设第二个文件读取依赖于第一个文件中的内容,相关代码以下:

var readFile1 = function(file, encoding) {
  var deferred = new Deferred()
  fs.readFile(file, encoding, deferred.callback())
  return deferred.promise
}

var readFile2 = function(file, encoding) {
  var deferred = new Deferred()
  fs.readFile(file, encoding, deferred.callback())
  return deferred.promise
}

readFile1('./file1.txt', 'utf8').then(function(file1) { // 这里经过 then 把两个回调存进队列中
  return readFile2(file1, 'utf8')
}).then(function(file2) {
  console.log(file2)   // I am file2.
})

最后能够看到控制台输出 I am file2,验证成功~,这个案例的完整代码能够点这里查看,并建议使用 node-inspector 进行断点观察,(这段代码里面有些逻辑确实很绕,经过断点调试就能较容易理解了)。

从 Promise 链式调用能够清晰地看到队列(先进先出)的知识,其有以下两个核心步骤:

  • 将全部的回调都存到队列中;
  • Promise 完成时,逐个执行回调,一旦检测到返回了新的 Promise 对象,中止执行,而后将当前 Deferred 对象的 promise 引用改变为新的 Promise 对象,并将队列中余下的回调转交给它;

至此,实现了 Promise/Deferred 的完整逻辑,Promise 的其余知识将来也会继续探究。

Generator

尽管 Promise 必定程度解决了回调地狱的问题,可是对于喜欢简洁的程序员来讲,一大堆的模板代码 .then(data => {...}) 显得不是很友好。因此爱折腾的开发者们在 ES6 中引人了 Generator 这种数据类型。仍然以读取文件为例,先上一段很是简洁的 Generator + co 的代码:

co(function* () {
  const file1 = yield readFile('./file1.txt')
  const file2 = yield readFile('./file2.txt')
  console.log(file1)
  console.log(file2)
})

能够看到比 Promise 的写法简洁了许多。后文会给出 co 库的实现原理。在此以前,先概括下什么是 Generator。能够把 Generator 理解为一个能够遍历的状态机,调用 next 就能够切换到下一个状态,其最大特色就是能够交出函数的执行权(即暂停执行),让咱们看以下代码:

function* gen(x) {
  yield (function() {return 1})()
  var y = yield x + 2
  return y
}

// 调用方式一
var g = gen(1)
g.next()  // { value: 1, done: false }
g.next()  // { value: 3, done: false }
g.next()  // { value: undefined, done: true }

// 调用方式二
var g = gen(1)
g.next()  // { value: 1, done: false }
g.next()  // { value: 3, done: false }
g.next(10)  // { value: 10, done: true }

由此咱们概括下 Generator 的基础知识:

  1. Generator 生成迭代器后,等待迭代器的 next() 指令启动。
  2. 启动迭代器后,代码会运行到 yield 处中止。并返回一个 {value: AnyType, done: Boolean} 对象,value 是此次执行的结果,done 是迭代是否结束。并等待下一次的 next() 指令。
  3. next() 再次启动,若 done 的属性不为 true,则能够继续从上一次中止的地方继续迭代。
  4. 一直重复 2,3 步骤,直到 done 为 true。
  5. 经过调用方式二,咱们可看到 next 方法能够带一个参数,该参数就会被看成上一个 yield 语句的返回值

另外咱们注意到,上述代码中的第一种调用方式中的 y 值是 undefined,若是咱们真想拿到 y 值,就须要经过 g.next(); g.next().value 这种方式取出。能够看出,Generator 函数将异步操做表示得很简洁,可是流程管理却不方便。这时候用于 Generator 函数的自动执行的 co 函数库 登场了。为何 co 能够自动执行 Generator 函数呢?咱们知道,Generator 函数就是一个异步操做的容器。它的自动执行须要一种机制,当异步操做有告终果,可以自动交回执行权。

两种方法能够作到这一点:

  • Thunk 函数。将异步操做包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象。将异步操做包装成 Promise 对象,用 then 方法交回执行权。

co 函数库其实就是将两种自动自动执行器(Thunk 函数和 Promise 对象),包装成一个库。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或者是 Promise 对象。下面分别用以上两种方法对 co 进行一个简单的实现。

基于 Thunk 函数的自动执行

在 JavaScript 中,Thunk 函数就是指将多参数函数替换成单参数的形式,而且其只接受回调函数做为参数的函数。Thunk 函数的例子以下:

// 正常版本的 readFile(多参数)
fs.readFile(filename, 'utf8', callback)

// Thunk 版本的 readFile(单参数)
function readFile(filename) {
    return function(callback) {
        fs.readFile(filename, 'utf8', callback);
    };
}

在基于 Thunk 函数和 Generator 的知识上,接着咱们来看看 co 基于 Thunk 函数的实现。(附:代码参考自co最简版实现)

function co(generator) {
  return function(fn) {
    var gen = generator()
    function next(err, result) {
      if(err) {
        return fn(err)
      }
      var step = gen.next(result)
      if (!step.done) {
        step.value(next) // 这里能够把它联想成递归;将异步操做包装成 Thunk 函数,在回调函数里面交回执行权。
      } else {
        fn(null, step.value)
      }
    }
    next()
  }
}

用法以下:

co(function* () {  // 把 function*() 做为参数 generator 传入 co 函数
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')

  console.log(file1) // I'm file1
  console.log(file2) // I'm file2
  return 'done'
})(function(err, result) {  // 这部分的 function 做为 co 函数内的 fn 的实参传入
  console.log(result) // done
})

上述部分关键代码已进行注释,下面对 co 函数里的几个难点进行说明:

  • var step = gen.next(result), 前文提到的一句话在这里就颇有用处了:next方法能够带一个参数,该参数就会被看成上一个yield语句的返回值;在上述代码的运行中一共会通过这个地方 3 次,result 的值第一次是空值,第二次是 file1.txt 的内容 I'm file1,第三次是 file2.txt 的内容 I'm file2。根据上述关键语句的提醒,因此第二次的内容会做为 file1 的值(看成上一个yield语句的返回值),同理第三次的内容会做为 file2 的值。
  • 另外一处是 step.value(next), step.value 就是前面提到的 thunk 函数返回的 function(callback) {}, next 就是传入 thunk 函数的 callback。这句代码是条递归语句,是这个简易版 co 函数能自动调用 Generator 的关键语句。

建议亲自跑一遍代码,多打断点,从而更好地理解,代码已上传github

基于 Promise 对象的自动执行

基于 Thunk 函数的自动执行中,yield 后面需跟上 Thunk 函数,在基于 Promise 对象的自动执行中,yield 后面天然要跟 Promise 对象了,让咱们先构建一个 readFile 的
Promise 对象:

function readFile(fileName) {
  return new Promise(function(resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) reject(error)
      resolve(data)
    })
  })
}

在基于前文 Promise 对象和 Generator 的知识上,接着咱们来看看 co 基于 Promise 函数的实现:

function co(generator) {
  var gen = generator()

  function next(data) {
    var result = gen.next(data) // 同上,经历了 3 次,第一次是 undefined,第二次是 I'm file1,第三次是 I'm file2
    if (result.done) return result.value
    result.value.then(function(data) { // 将异步操做包装成 Promise 对象,用 then 方法交回执行权
      next(data)
    })
  }
  next()
}

用法以下:

co(function* generator() {
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')

  console.log(file1.toString())  // I'm file1
  console.log(file2.toString())  // I'm file2
})

这一部分的代码上传在这里,经过观察能够发现基于 Thunk 函数和基于 Promise 对象的自动执行方案的 co 函数设计思路几乎一致,也所以呼应了它们共同的本质 —— 当异步操做有告终果,自动交回执行权。

async

看上去 Generator 已经足够好用了,可是使用 Generator 处理异步必须得依赖 tj/co,因而 asycn 出来了。本质上 async 函数就是 Generator 函数的语法糖,这样说是由于 async 函数的实现,就是将 Generator 函数和自动执行器,包装进一个函数中。伪代码以下,(注:其中 automatic 的实现能够参考 async 函数的含义和用法中的实现)

async function fn(args){
  // ...
}

// 等同于

function fn(args) {
  return automatic(function*() {  // automatic 函数就是自动执行器,其的实现能够仿照 co 库自动运行方案来实现,这里就不展开了
    // ...
  })
}

接着仍然以上文的读取文件为例,来比较 Generator 和 async 函数的写法差别:

// Generator
var genReadFile = co(function*() {
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')
})

// 改用 async 函数
var asyncReadFile = async function() {
  var file1 = await readFile('./file1.txt')
  var file2 = await 1  // 等同于同步操做(若是跟上原始类型的值)
}

整体来讲 async/await 看上去和使用 co 库后的 generator 看上去很类似,不过相较于 Generator,能够看到 Async 函数更优秀的几点:

  • 内置执行器。Generator 函数的执行必须依靠执行器,而 Aysnc 函数自带执行器,调用方式跟普通函数的调用同样;
  • 更好的语义。async 和 await 相较于 * 和 yield 更加语义化;
  • 更广的适用性。前文提到的 co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面则能够是 Promise 或者原始类型的值;
  • 返回值是 Promise。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,所以能够直接使用 then() 方法进行调用;

参考资料

相关文章
相关标签/搜索