Node.js近几年来甚是火爆,开发者也变得越来越多。而本人本科阶段是写java后台的,同实验室有位写Node.js的同学一直给我大力推荐Node.js,但是因为分工不同一直了解不多,只知道Node.js是用JavaScript写后端,而且据说非常简单。
后来上了研究生,接手的第一个项目技术基于Node.js的Cesium,使用Cesium(一个用于显示三维地球和地图的开源js库)模拟飞机飞行,只好硬着头皮上,期间对Node.js的异步和事件驱动的理解经历了多个反复,总是理解了,过两天又觉得理解的不对,重新去查资料理解,到了如今才觉得真的理解了。现将其理解总结出来,聊以自慰(其实是怕忘了)。
关于Cesium,后面有时间会专门总结一下。
1. 什么是 Node.js
Node.js既不是语言,也不是JavaScript的框架,它只是一个可以使JavaScript运行在服务器端的开发平台,它使得JavaScript可以为Web应用提供一条龙的服务,而且有些NoSQL数据库中用的就是JavaScript语言(比如CouchDB和MongoDB),所以跟它们简直是天作之合。(只是说JavaScript也可以写后端了,前后端的概念仍需要严格区分)
Node.js的V8虚拟机在性能上得到了巨大的提升,这个引擎肯定不是JavaScript编写的,而是用C++写的,而且因为它去掉了中间环节,执行的不是字节码,用的也不是解释器,而是直接编译成了本地机器码。
Node.js架构如图所示:
2. 异步式(非阻塞)I/O
Node.js最大的特点便是异步式(非阻塞)I/O。阻塞的意思就是说代码按行顺序执行,都是第一行执行完再执行第二行。而非阻塞正好相反,若第一行需要耗费时间过长时,可能会现将此任务挂起,先执行第二行代码,最后回来执行第一行。
Node.js 的异步机制是基于事件的,所有的磁盘 I/O、网络通信、数据库查询都以非阻塞的方式请求,返回的结果由事件循环来处理(至于为什么还会在setImmediate、setTimeout、process.nextTick中出现,后面原理会说),其余和同步一样。
如:
var fs = require('fs');
console.log('a');
fs.readFile('aa.json', function (err, data) {//readFile是一个异步,或者是非阻塞式IO
console.log('b');
})
console.log('c');
在Node.js中所有的I/O操作不会阻塞,它会先将I/O请求发送给操作系统,继续执行下面的语句,等此I/O操作完成后以事件的形式通知Node.js中此线程(Node.js是单线程的,永远只有一个线程)。故为了处理异步 I/O,线程必须有事件循环,不断地检查有没有未处理的事件,依次予以处理。
在阻塞模式下,一个线程只能处理一项任务,要想提高吞吐量必须通过多线程。而非阻塞模式下,一个线程永远在执行计算操作,这个线程所使用的 CPU 核心利用率永远是 100%,I/O 以事件的方式通知。在阻塞模式下,多线程往往能提高系统吞吐量,因为一个线程阻塞时还有其他线程在工作,多线程可以让 CPU 资源不被阻塞中的线程浪费。而在非阻塞模式下,线程不会被 I/O 阻塞,永远在利用 CPU。【1】
这也就是Node.js的哲学:宁肯把一个线程累死,胜过让一堆线程闲着。
这样做还有一个好处在于,因为系统每创建一个线程需要开辟格外的资源,同时在多线程间进行切换的时候还要执行内存换页, CPU 的缓存被清空,切换回来的时候还要重新从内存中读取信息,破坏了数据的局部性。当然,事件驱动的单线程异步模型与多线程同步模型到底谁更好是一件非常有争议的事情,因为尽管消耗资源,后者的吞吐率并不比前者低。
它的缺点也很明显,大多数程序员已经的思维都是同步的阻塞式编程方式,使用异步适应起来很慢。比如本人在Node.js编程中,忘了异步还好说,经常会碰到以前使用同步及其容易解决的问题,很难使用异步的方法去解决。虽然Node.js中已经有不少专门解决异步式编程问题的库(如async),但使用起来还是比较复杂,且大多数情况下并不推荐使用。推荐使用信号量机制进行初步的解决,即多加state。
3. 异步的具体流程和原理
总的来说就是使用回调函数加上本身的事件循环机制实现的。
3.1 事件循环机制呢:
Node.js 在执行的过程中会维护一个事件队列,程序在执行时进入事件循环等待下一个事件到来,每个异步式 I/O 请求完成后会被推送到事件队列,等待程序进程进行处理。在开发者看来,事件由 EventEmitter 对象提供。
程序入口就是事件循环第一个事件的回调函数,Node.js 程序由事件循环开始,到事件循环结束,所有的逻辑都是事件的回调函数。事件的回调函数在执行的过程中,可能会发出 I/O 请求或直接发射(emit)事件,执行完毕后再返回事件循环,事件循环会检查事件队列中有没有未处理的事件,直到程序结束。如图说明了事件循环的原理:
3.2 回调函数
var fs = require('fs');
console.log('a');
fs.readFile('aa.json', function (err, data) {//readFile是一个异步,或者是非阻塞式IO
console.log('b');
})
console.log('c');
还是上面那串代码,在fs.readFile中一共接受了2个参数,第一个是文件名,第二个是一个函数,这个函数就是回调函数。fs.readFile 调用时所做的工作只是将异步式 I/O 请求发送给了操作系统,然后立即返回并执行后面的语句,执行完以后进入事件循环监听事件。当 fs 接收到 I/O 请求完成的事件时,事件循环会主动调用回调函数以完成后续工作。因此我们会先看到c,再看b的输出。(回调函数只能处理一次性事件(即此事件只用执行一次),如果需要处理重复事件则需要用到事件发射器。其实回调就是在事件发射器中发射这个事件。)
3.3 一个实例
我在使用Cesium中模拟飞行,飞机需要实时从数据库中读取数据,先读取1000条给数组msg,当数组msg的长度<100时再次读取。代码大概如图所示:
var msg = new Array();
var ite
for (ite = 0; ite < 9000; ite++) {
setTimeout("moveModel();", 1000 + 20 * ite);
}
//读取数据库,每次读1000条数据
readData();
//移动模型
function moveModel() {
msg.shift();
if (msg.length < 100)
//读取数据库,每次读1000条数据,并
readData();
}
function readData() {
//此处通过发get方法查询数据库,返回结果通过JSON.parse给数组msg1
...
//将msg1放在msg的末尾
if(msg1.length>0){
msg = msg.concat(msg1);
}
}
结果问题来了,当msg.length=99时,if中的代码开始执行,读取数据库,因为是非阻塞的,所以并不会等待这次数据库查询结束后才继续,而是立即执行下一次轮询,再一次执行到if中的代码。此时上一次的数据库还是没有返回,msg=98,仍然<100,则再次查询数据库。如此反复,直至第一次结果返回过来,才结束。而且因为数据库连接的关系,第一次查询数据库的花费时间将最长,第二次及第二次以后查询的时候将需要的额外花费都将减少,如果因为非阻塞性地原因,一共执行了4次查询,则很有可能会使得19行代码中的msg1并不是第一次查询返回的数据,而是第20次的。这就使得第1-3次的数据丢失了。
为什么是4次呢,其实这是个比较底层的原理的,虽然Node.js是单线程,但是真实执行I/O操作的其实是底层的线程池,这个线程池默认大小是4。详细细节可以参考文献[4].
解决这个方法可以使用互斥量机制(也可用信号量机制),这是专业的叫法,其实就是多写个变量state,初始值为0。当state=0时,允许读取,state=1时不允许读取。(相当于加锁).
var msg = new Array();
var state = 0;
var ite
for (ite = 0; ite < 9000; ite++) {
setTimeout("moveModel();", 1000 + 20 * ite);
}
//读取数据库,每次读1000条数据
readData();
//移动模型
function moveModel() {
msg.shift();
if (msg.length < 100 && state == 0) {
//每次读取之前将state设为1,即上锁
state = 1;
//读取数据库,每次读1000条数据,
readData();
}
}
function readData() {
//此处通过发get方法查询数据库,返回结果通过JSON.parse给数组msg1
...
//将msg1放在msg的末尾
if(msg1.length>0){
msg = msg.concat(msg1);
//每次读取结束后将state=0,即开锁
state = 0;
}
}
4. 关于setImmediate、setTimeout和process.nextTick方法
上面说过Node.js会维持一个事件队列,则setImmediate()说的便是在此队列时不用它,作为异步先挂起来,当下个队列开始时第一个执行它。
setImmediate(()=>{
console.log('setImmediate')
})
setTimeout就是延时执行了,可以和上面3.3一样,做间隔执行。详细执行步骤可以参考https://blog.csdn.net/sahadev_/article/details/90703250
//当 delay 大于 2147483647 或小于 1 时,
//delay 将设置为 1。 非整数的延迟会被截断为整数。
setTimeout(() => {
console.log('setTimeout')
}, 0);
process.nextTick()方法可以在当前事件队列的尾部。也就是说,它指定的任务总是发生在所有异步任务之前,当前主线程的末尾。(nextTick虽然也会异步执行,但是不会给其他io事件执行的任何机会)
process.nextTick(()=>{
console.log('nextTick')
})
5. Node.js并不是所有项目的灵丹妙药。
没有任何一项技术或方法可使软件工程的生产力能像摩尔定律一样在十年内提高超过十倍,不仅当时没有,现在也没有,今后也不会有。
Node.js虽然是一项新的技能,但是大多数新技术的本质不过就是旧技术的重新组合。不能因为一个技术新而倾向于用这个技术,而应该考虑具体的语言特性。Node.js不适合做得东西:第一种必然是耗时过多的单线程逻辑,比如计算密集型任务。第二种就是并发比较低的场景,这时候如单用户多任务型应用。第三种就是逻辑过于复杂的事务,人的思维天生是线性的,使用Node.js非线性处理时会使得项目变得更加复杂,难以维护。最后一种是JavaScript的坑,JavaScript 内部支持的仍是定长的UCS2 而不是变长的UTF-16,因此对于处理UCS4 的字符它无能为力。所有的JavaScript 引擎都被迫保留了这个缺陷,包括V8 在内,因此你无法使用Node.js 处理罕见的字符。
参考文献:
[1] 郭家寶(BYVoid).Node.js开发指南[M].人民邮电出版社:北京
[2] [美] Mike Cantelon,吴海星译.Node.js实战[M].人民邮电出版社:北京,2014-5:.
[3] 暗语321,博客园,setTimeout和setImmediate以及process.nextTick的区别
[4] 鸡蛋炒番茄,SegmentFault 思否,nodejs真的是单线程吗?