上周写的JS异步编程的浅思,一步一步将反人类的异步回调演化到带有async/await
关键字的同步/顺序执行,让个人异步编程处理能力有了质的突破,达到“异步编程的最高境界,就是根本不用关心它是否是异步”。javascript
那么,问题来了html
Node.js的这种异步是如何在单线程的JS中实现的呢?java
Node.js的异步设计,会有哪些好处,会有哪些限制和瓶颈呢?node
Node.js主要分为四大部分,Node Standard Library,Node Bindings,V8,Libuv。Node.js的结构图以下:git
能够看出,Node.js的结构大体分为三个层次github
Node Standard Library
是咱们天天都在用的标准库,如 Http、Buffer、fs 模块。它们都是由 JavaScript 编写的,能够经过require(..)
直接能调用。Node Bindings
是沟通 JS 和 C++ 的桥梁,封装 V8 和 Libuv 的细节,向上层提供基础API服务。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的架构图编程
从左往右分为两部分,一部分是与网络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.js→src/node_file.cc→uv_fs
大体流程图以下:
具体来讲,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 │
└───────────────────────┘
复制代码
事件循环的每一次循环都须要依次通过上述的阶段。每一个阶段都有本身的回调队列,每当进入某个阶段,都会从所属的队列中取出回调来执行,当队列为空或者被执行回调的数量达到系统的最大数量时,进入下一阶段。这六个阶段都执行完毕称为一轮循环。
举个例子:
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进程与事件循环之间是如何协调和事件循环本身是如何工做的。
这里有个地方须要说明一下,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——经过解读源码的方式,帮助我理解了事件循环。