精华 深入理解node.js异步编程:基础篇
发布于 10 年前 作者 melote 63907 次浏览 最后一次编辑是 8 年前

###【本文是基础内容,大神请绕道,才疏学浅,难免纰漏,请各位轻喷】 ##1. 概述 目前开源社区最火热的技术当属Node.js莫属了,作为使用Javascript为主要开发语言的服务器端编程技术和平台,一开始就注定会引人瞩目。 当然能够吸引众人的目光,肯定不是三教九流之辈,必然拥有独特的优势和魅力,才能引起群猿追逐。其中当属异步IO和事件编程模型,本文据Node.js的异步IO和事件编程做深入分析。 ##2. 什么是异步 同步和异步是一个比较早的概念,大抵在操作系统发明时应该就出现了。举一个最简单的生活中的例子,比如发短信的情况会比较好说明他们的区别: 同步:正在处于苦逼工作状态中的我,但狗屎运的交到了女朋友并正处于处于热恋期,因此发送短信给她询问那个餐厅吃饭,急不可耐的看着手机等待短信回复,收到信息看完是否加班或者下班; 异步:正处于公司运营决策关键工作状态中的你,不可以被打断太久,随便发送了一条询问老婆什么时候做好晚饭然后吃饭的短信后立马返回工作,一边工作一边等待短信回复通知,根据通知决定是否再工作和下班。 由此可以看出,同步和异步的特点是:

  1. 至少在两个对象之间需要协作(男朋友和女朋友,老公和老婆);
  2. 两个对象都需要处理一系列的事情(工作和吃饭)。 另一个类似的关于CPU计算和磁盘操作编的例子: 同步:CPU需要计算10个数据,每计算一个结果后,将其写入磁盘,等待写入成功后,再计算下一个数据,直到完成。 异步:CPU需要计算10个数据,每计算一个结果后,将其写入磁盘,不等待写入成功与否的结果,立刻返回继续计算下一个数据,计算过程中可以收到之前写入是否成功的通知,直到完成。

##3. 为什么需要异步 知其然,还要知其所以然,读者可能会问,为什么存在异步?根据上面发短信和磁盘操作的例子,答案很明显,为了提高办事的效率,CPU计算速度和磁盘的读写速度差太远了,磁盘供不应求,因此有了计算机的存储系统的分层设计,平衡了效率和成本。可以说懒惰推动人类的进步,任何可以降低花费时间而达到同等功效的方法肯定会被优先采用。发送短信时等待对方回复的时间纯粹的浪费掉了,CPU写入磁盘等待返回的结果的等待时间也被无情的消耗了,这是一个讲究效率的时代完全不能忍受的,因此让员工一直处于忙碌状态,最大限度的榨取员工价值是老板追求的,让CPU和磁盘都不停的满负荷处理事务也是效率需要的。因此,异步处理出现了。

##4. Node.js异步IO与事件 初次接触Node.js,恐怕任何人都会被先先灌输的第一条Node.js就与众不同的地方:异步IO和事件驱动。毫无疑问,这确实是Node.js最令人津津乐道的特色之处,也是本文重点分析的地方。 ###4.1 Node.js异步机制 由于异步的高效性,node.js设计之初就考虑做为一个高效的web服务器,作者理所当然地使用了异步机制,并贯穿于整个node.js的编程模型中,新手在使用node.js编程时,往往会羁绊于由于其他编程语言的习惯,比如C/C++,觉得无所适从。我们可以从以下一段简单的睡眠程序代码窥视出他们的区别,下面是摘自《linux程序设计》打印10个时间的C代码:

#include <time.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
    int i;
    time_t the_time;
    for(i = 1; i <= 10; i++) {
        the_time = time((time_t *)0);
        printf("The time is %ld\n", the_time);
        sleep(2);
    }
    exit(0);
}

编译后打印结果如下: The time is 1396492137 The time is 1396492139 The time is 1396492141 The time is 1396492143 The time is 1396492145 The time is 1396492147 The time is 1396492149 The time is 1396492151 The time is 1396492153 The time is 1396492155 从C语言的打印结果可以发现,是隔2秒打印一次,按照C程序该有的逻辑,代码逐行执行。以下Node.js代码本意如同上述C代码,使用目的隔2秒打印一次时间,共打印10条(初次从C/C++转来接触Node.js的程序员可能会写出下面的代码):

function test() {
    for (var i = 0; i < 10; i++) {
        console.log(new Date);
        setTimeout(function(){}, 2000);	//睡眠2秒,然后再进行一下次for循环打印
    }
};
test();

打印结果: Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
Tue Apr 01 2014 14:53:22 GMT+0800 (中国标准时间)
观察结果发现都是在14:53:22同一个时间点打印的,根本就没有睡眠2秒后再执行下一轮循环打印!这是为什么?从官方的文档我们看出setTimeout是第二个参数表示逝去时间之后在执行第一个参数表示的callback函数,因此我们可以分析, 由于Node.js的异步机制,setTimeout每个for循环到此之后,都注册了一个2秒后执行的回调函数然后立即返回马上执行console.log(new Date),导致了所有打印的时间都是同一个点,因此我们修改for循环的代码如下:

for (var i = 0; i < 10; i++) {
setTimeout(function(){
console.log(new Date);
}, 2000);	

}

执行结果如下所示: Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:30:35 GMT+0800 (中国标准时间) 神奇,仍然是同一个时间点,见鬼!冷静下来分析,时刻考虑异步,for循环里每次setTimeout注册了2秒之后执行的一个打印时间的回调函数,然后立即返回,再执行setTimeout,如此反复直到for循环结束,因为执行速度太快,导致同一个时间点注册了10个2秒后执行的回调函数,因此导致了2秒后所有回调函数的立即执行。 我们在for循环之前添加console.log("before FOR: " + new Date)和之后console.log("after FOR: " + new Date),来验证我们的推测,打印结果如下(后面省略8条相同的打印行): before FOR: Thu Apr 03 2014 09:42:43 GMT+0800 (中国标准时间)
after FOR: Thu Apr 03 2014 09:42:43 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:42:45 GMT+0800 (中国标准时间)
Thu Apr 03 2014 09:42:45 GMT+0800 (中国标准时间) …… (省略与上一行8条相同的打印行) 由此可以窥视出Node.js异步机制的端倪了,在for循环中的代码于其后的代码几乎在一个单位秒内完成,而定时器中的回调函数则按要求的2秒之后执行,也是同一秒内执行完毕。那么如何实现最初C语言每隔2秒打印一个系统时间的需求函数呢,我实现了如下一个wsleep函数,放在for循环中,可以达到该目的:

function wsleep(milliSecond) {
    var startTime = new Date().getTime();
    while(new Date().getTime() <= milliSecond + startTime) {
    }
}

但是该函数有一个令他无法在项目中使用的缺陷,请问为什么? ###4.2 Node.js事件编程 事件编程并不是一个新的概念,做过界面UI编程的程序猿们可以觉得事件再熟悉不过了,特别是客户端开发和web开发的感触颇深吧,如Android、ios、或是javascript前端编程的工程师们,一个按钮、一个列表项、一个长按操作等等,每次按下都会由操作系统或者浏览器产生一个事件,你需要做的工作就是编写和注册这个事件的回调函数(可能各自领域内不称为回调函数,但是从操作系统的角度考虑其实就是一个回调函数),当这个事件发生时,执行你的回调函数。Node.js与众不同的时,它基因里就是由事件和异步组成的。请看用于生产环境中的真实项目代码的一个片段(略去了一些不相关的代码),我加上一段关于事件信息的注释,让读者更清晰:

    self.sio.sockets.on('connection', function(socket) {		//监听socket连接事件
        var addr = socket.handshake.address;
        var limiter = new RateLimiter(constant.RL_MAXREQRATELIMIT, constant.RL_RATELIMITUNIT, true);
        var connect = new Connection(socket);
        then(function(defer) {	
            if (ipLimit) {
                throttle.throttleHandle(connect, null, defer);	//结果回调处理事件
            } else {
                defer(null);	//发送处理结果事件
            }
        }).all(function(defer) {	//收到处理结果事件
            socket.on('message', function(data) {	//监听数据传输事件
     			cloudKeyMain(connect, 1, data, cloudKeyApi);
            });
        });
        socket.on("disconnect", function(data) {	//监听socket离线事件
            var currentSockClient = connect.client;
            if (currentSockClient) {
                currentSockClient.signalOffline();	//发送客户端离线事件
            }

        });
    });

从上面的代码,我们可以看出Node.js无所不在的事件机制,事件机制让我们专注与代码业务的处理流程,提高了软件开发的效率,降低了代码之间的耦合,让人不被琐事缠绕,编程更有趣。如何开始一个简单的Node.js事件编程呢,答案是使用Node.js的javascript API核心模块events的events.EventEmitter类即可完成,下面以一个QQ的在线和离线来说明, 事件机制的使用主要包括3个方面的内容:

  1. 继承events.EventEmitter事件类,主要是屏蔽事件机制的实现(其实原理很简单),让我们直接使用;
  2. 事件的注册;
  3. 事件的发布。
var events = require('events');
var util = require('util');
function MyQQ() {
events.EventEmitter.call(this);
//……
}
util.inherits(MyQQ, events.EventEmitter);

OK,上述代码就完成了事件机制的添加,此时,我们的工作为QQ添加事件注册函数进行事件的注册,事件注册主要是使用EventEmitter的on()完成,因为我们继承了EventEmitter,可以直接使用on函数,我们在on函数的第二个参数callback函数中自定义处理业务,并注册自己的上线事件,以下是一个QQ上线时简单的处理业务:

function onlineHandle(QQNumber) {
	//获取和QQNumber的联系人列表
	//获取离线消息
	//……
}
var myQQ = new MyQQ();
myQQ.on(“onLine”, onlineHandle);

上述代码完成了事件的处理,下面轮到在什么时候发布这个事件,下述的一个业务场景中可能是需要发布该事件的,发布事件用emit()函数:

function main() {
	//连接服务器
	//检测登录状态
	//登录服务器成功后发布事件
	myQQ.emit(“onLine”,123655245);
}

上述myQQ.emit()函数执行后发布了onLine事件后,会立即执行onlineHandle()函数,处理我们注册的业务逻辑,需要注意的是,事件发布函数emit第二个参数后的参数个数需要和我们注册时的处理函数参数个数相同并且顺序一致才能正确处理,为什么有这样的要求?这需要从Node.js事件的原理说起。基本上所有的事件机制都是用设计模式中观察者模式实现,观察着模式网络资料一大堆,如何想要深入了解的话可以网络搜索或者阅读权威书籍,可以参考《设计模式:可复用面向对象软件的基础》和《Head First设计模式》。

13 回复

赞 写得非常好

好文章, 果断收藏

js回调有个最最致命问题,就是犯烂使用, 我肯定可以这样说,你写出来,过一分钟你的代码就死了 因为你再看的时候,连自己都看不懂

我是看这本书入门的,professional_node.js

我就拿上面的例子来说

then(function(defer) {    
	if (ipLimit) {
		throttle.throttleHandle(connect, null, defer);    //结果回调处理事件
	} else {
		defer(null);    //发送处理结果事件
	}
}).all(function(defer) {    //收到处理结果事件
	socket.on('message', function(data) {    //监听数据传输事件
		 cloudKeyMain(connect, 1, data, cloudKeyApi);
	});
}).close(function(target){
	if(target!=null)
		target.close();
}).request(function(target,data){
	if(data.commond=='send'){
		target.write(data.data);
	}else if(data.commond=='saveUser'){
		target.setUserDao(function(repository){
			repository.save(data.data);
		});
	}
	//....
});

这种代码最垃圾了。大家看了有什么感受

我在这里非常强调一个观点,不要写出这样的代码 这样是浪费你自己的生命

其实异步编程是很基础的东西,专门开这帖子置顶,不如开个 node.js 学习指南,指导学习路线图,让别人少走弯路。

promise/A的异步编程就是解决回调嵌套太多太深的问题,不知道大神有更好的解决方式,提出来大家瞻仰一下。

有本书叫 javascript异步编程(async javascript)

为一般都这么写,您怎么看? then(onThenHandler).all(onAlwaysHandler).close(onCloseHandler).request(onRequestHandler); function onThenHandler(defer){//todo;}; function onAlwaysHandler(defer){//todo;}; function onCloseHandler(target){//todo;}; function onRequestHandler(target, data){//todo;};

function wsleep(milliSecond) {
    var startTime = new Date().getTime();
    while(new Date().getTime() <= milliSecond + startTime) {
    }
}

这个写法不建议,会阻塞CPU,20s内都不能做别的事情,推荐用下面写法:

for (var i = 0; i < 10; i++) {
    setTimeout(function () {
        console.log(new Date());
    }, 2000*(i+1));
}
回到顶部