对Node.js异步的进一步理解

上周写的JS异步编程的浅思,一步一步将反人类的异步回调演化到带有async/await关键字的同步/顺序执行,让个人异步编程处理能力有了质的突破,达到“异步编程的最高境界,就是根本不用关心它是否是异步”。javascript

那么,问题来了html

Node.js的这种异步是如何在单线程的JS中实现的呢?java

Node.js的异步设计,会有哪些好处,会有哪些限制和瓶颈呢?node

Node.js架构

Node.js主要分为四大部分,Node Standard Library,Node Bindings,V8,Libuv。Node.js的结构图以下:git

Node.js架构图

能够看出,Node.js的结构大体分为三个层次github

  • Node Standard Library是咱们天天都在用的标准库,如 Http、Buffer、fs 模块。它们都是由 JavaScript 编写的,能够经过require(..)直接能调用。
  • Node Bindings是沟通 JS 和 C++ 的桥梁,封装 V8 和 Libuv 的细节,向上层提供基础API服务。
  • 这一层是支撑 Node.js 运行的关键,由 C/C++ 实现。
  • V8是 Google 开发的 javascript 引擎,为 javascript 提供了在非浏览器端运行的环境,能够说它就是 Node.js 的发动机。它的高效是 Node.js 之因此高效的缘由之一。
  • Libuv为Node.js提供了跨平台,线程池,事件池,异步 I/O 等能力,是Node.js如此强大的关键。
  • C-ares提供了异步处理 DNS 相关的能力。
  • http_parser、OpenSSL、zlib等,提供包括 http 解析、SSL、数据压缩等其余的能力。

libuv 架构

下图是官网的关于libuv的架构图编程

官网的libuv架构图

从左往右分为两部分,一部分是与网络I/O相关的请求,而另一部分是由文件I/O, DNS Ops以及User code组成的请求。浏览器

从图中能够看出,对于Network I/O和以File I/O为表明的另外一类请求,异步处理的底层支撑机制是彻底不同的。bash

对于Network I/O相关的请求,根据OS平台不一样,分别使用Linux上的epoll,OSX和BSD类OS上的kqueue,SunOS上的event ports以及Windows上的IOCP机制。网络

而对于File I/O为表明的请求,则使用thread pool。利用thread pool的方式实现异步请求处理,在各种OS上都能得到很好的支持。

举个例子

var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {
	//..do something
});
复制代码

这段代码的调用过程大体可描述为:lib/fs.jssrc/node_file.ccuv_fs

大体流程图以下:

fs.open流程图

具体来讲,fs.open(..)的做用是根据指定路径和参数去打开一个文件,从而获得一个文件描述符,这是后续全部I/O操做的初始操做。

接着,Node.js经过process.binding调用 C/C++ 层面的 Open 函数,而后经过它调用 libuv 中的具体方法 uv_fs_open。

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

第二阶段,则是回调通知。线程池中I/O操做调用完毕以后,会告诉事件循环,已经完成了。事件循环每一次循环中,都会检查是否有执行完的I/O,若是有,则取出结果和对应的回调函数执行。以此达到调用javascript中传入的回调函数的目的。

到此,整个异步I/O的流程才算彻底结束。

这里须要特别说明的是,平台判断的流程,这一步是在编译的时候已经决定好的,并非在运行时才判断。

事件循环

"事件循环是一个程序结构,用于等待和发送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)"

在进程启动时,Node.js便会建立一个相似于while(true)的循环,每执行一次循环体的过程就是查看是否事件待处理,若是有,就取出事件及其相关的回调函数。若是存在关联的回调函数,就执行它们。而后进入下一个循环,若是再也不有事件处理,就退出进程。

事件循环流程图

上面只是简单的描述了事件循环的流程。咱们知道,Node.js不止有一些异步I/O,还有其余的异步API:setTimeout、setInterval、setImmediate等等。他们之间的又是按照什么样的流程工做的呢?

nodejs的事件循环会分为6个阶段,每一个阶段的做用以下

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
复制代码
  • timers:执行setTimeout() 和 setInterval()中到期的callback。
  • I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  • idle, prepare:仅内部使用
  • poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  • check:执行setImmediate的callback
  • close callbacks:执行close事件的callback,例如socket.on("close",func)

事件循环的每一次循环都须要依次通过上述的阶段。每一个阶段都有本身的回调队列,每当进入某个阶段,都会从所属的队列中取出回调来执行,当队列为空或者被执行回调的数量达到系统的最大数量时,进入下一阶段。这六个阶段都执行完毕称为一轮循环。

举个例子:

console.log(1);
console.log(2);
const timeout1 = setTimeout(function(){
    console.log(3)
    const timeout2 = setTimeout(function(){
        console.log(6);
    })
},0)
const timeout3 = setTimeout(function(){
    console.log(4);
    const timeout4 = setTimeout(function(){
        console.log(7);
    })
},0)
console.log(5)
复制代码

若是能说出上面的例子的打印结果,说明大体理解了js进程与事件循环之间是如何协调和事件循环本身是如何工做的。

  • 顺序执行打印出1
  • 顺序执行打印出2
  • js进程将timeout1(为了说明方便,就用它来指代第一个定时器,下同)分配给事件循环里的timers,并返回
  • js进程将timeout3分配给事件循环的timers,并返回
  • 顺序执行打印出5
  • libuv在timers阶段会循环检查定时器的时间是否过时了。当它检查timeout1的时间到了,就通知js进程执行timeout1的回调,打印出3,并将timeout2分配给事件循环的timers。
  • 接着检查到timeout3的时间过时了,则通知js进程执行timeout3的回调,打印出4,并将timeout4分配给事件循环的timers。
  • 这里事件循环将进入下一阶段,直到循环到了timers阶段,取出超出时间最小的定时器,执行回调。打印出6,接着打印出7。

这里有个地方须要说明一下,timeout1里的timeout2和timeout3里的timeout4,须要分别等待timeout1和timeout3的回调被执行了,再由js进程分配给事件循环。也就是说,timeout一、timeout3与timeout二、timeout4不是在同一轮事件循环中执行的。

优点和难点

Node.js带来的最大特性莫过于基于事件驱动的非阻塞I/O模型,这是它的灵魂所在。非阻塞I/O可使CPU与I/O并不相互依赖等待,让资源获得更好的利用。

Node.js利用事件循环的方式,使javascript线程像一个分配任务和处理结果的大管家,I/O线程池里的各个I/O线程都是小二,负责兢兢业业地完成分配来的任务,小二与管家之间互不依赖,因此能够保持总体的高效率。

这个模型的缺点:管家没法承担过多细节性的任务,若是承担太多,则会影响到任务的调度,管家忙个不停,小二却得不到活干。好比说,js循环百万次,就会阻塞javascript线程,致使管家忙于处理循环了,不能去调度任务了。

事件循环模型面对海量请求时,而海量请求同时都做用在单线程上,就须要防止任何一个计算耗费过多的逻辑片断。只要计算不影响到异步I/O的调度,也能应用于CPU密集型的场景。

建议对CPU的耗用不要超过10ms,或者将大量的计算分解为诸多的小量计算,经过setImmediate(..)进行调度。只要合理利用Node.js的异步模型与V8的高性能,就能够充分发挥CPU和I/O资源的优点。

参考:

一、《深刻浅出Node.js》——虽然基于V0.10版本写做的,但仍然有不少内容让我豁然开朗。

二、不要混淆nodejs和浏览器中的event loop——经过解读源码的方式,帮助我理解了事件循环。

相关文章
相关标签/搜索