阅读 2650

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

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

那么,问题来了

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

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

Node.js架构

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

Node.js架构图

可以看出,Node.js的结构大致分为三个层次

  • 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为代表的另一类请求,异步处理的底层支撑机制是完全不一样的。

对于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进程分配给事件循环。也就是说,timeout1、timeout3与timeout2、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资源的优势。

参考:

1、《深入浅出Node.js》——虽然基于V0.10版本写作的,但仍然有很多内容让我豁然开朗。

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

文章分类
前端