Node.js的事件驱动模型
Node利用Javascript的特性,比如Continuation Passing Style(CPS)以及Event Loop,使得程序运行时表现优秀,CPS和Event Loop一套机制是用libuv库(libuv又根据OS的不同抽象了Unix下libev和Windos下ICOP)提供的。JS中处理IO业务的方法将回调函数当参数传递的编程风格就是CPS。而成为参数的回调函数的调用问题就得需要叫事件循环来控制。这一切就实现了Node的异步IO。
- 经典的服务器执行模型
经典的服务器执行模型有同步式、进程式、线程式。
同步式模型就是每次值处理一个请求,迭代地将所有请求处理完毕。无并发可言,处理效率当然很低。
进程式模型为每一个Web请求开启一个进程,这样可以同时处理多个请求,但式当请求量太大时有限的系统资源就比较吃紧了。并且为每个请求现场分配一个子进程比较消耗cpu时间。
线程式模型为每一个ieWeb请求开启一个线程处理。线程式又包括单线程模型、多线程模型,也就是一个进程中几个个线程,像Apache就可以选择Worker MPM(多进程多线程)还是Prefork MPM(多进程单线程)。多线程模型处理并发的方式是将每一个IO操作分配到单独的线程中。当客户端发出请求给服务器,服务器会对请求处理并准备好响应回传给客户端,服务器通过维持一个有限的线程池来执行能分离开的处理任务。
每一个线程分担一个任务听起来很不错,但这会冒出两个问题:其一,线程的数量是有限制的,当你的任务量大于可调度的线程时,就会发生等待处理资源的情况;其二,线程上的任务是共用资源,当线程面对堵塞IO的事务很长时间时,通过线程锁来阻止资源的使用。对于数据密集性的应用来说,这两个问题可能会导致低效的Web服务。建立一个单独的线程需要一些资源配置(runtime、heap、memory)以及处理线程之间的上下文。像图中这种web请求可能还好,文件请求和数据库请求基还是正交的,可以单独处理执行;但这还是避免不了在等待数据结果时当前线程的空闲等待,异步处理就能提高程序运行的效率。
- 事件驱动
与Nginx的服务原理类似,Node采用事件驱动的运行方式。不过nginx式多进程单线程,而Node通过事件驱动的方式处理请求时无需为每一个请求创建额外的线程。在事件驱动的模型当中,每一个IO工作被添加到事件队列中,线程循环地处理队列上的工作任务,当执行过程中遇到来堵塞(读取文件、查询数据库)时,线程不会停下来等待结果,而是留下一个处理结果的回调函数,转而继续执行队列中的下一个任务。这个传递到队列中的回调函数在堵塞任务运行结束后才被线程调用。
前面也说过Node Async IO = CPS + Callback,这一套实现开始于Node开始启动的进程,在这个进程中Node会创建一个循环,每次循环运行就是一个Tick周期,每个Tick周期中会从事件队列查看是否有事件需要处理,如果有就取出事件并执行相关的回调函数。事件队列事件全部执行完毕,node应用就会终止。Node对于堵塞IO的处理在幕后使用线程池来确保工作的执行。Node从池中取得一个线程来执行复杂任务,而不占用主循环线程。这样就防止堵塞IO占用空闲资源。当堵塞任务执行完毕通过添加到事件队列中的回调函数来处理接下来的工作。
当然这么华丽的运行机制就能解决前面说的两个弊端。node基于事件的工作调度能很自然地将主要的调度工作限制到了一个线程,应用能很高效地处理多任务。程序每一时刻也只需管理一个工作中的任务。当必须处理堵塞IO时,通过将这个部分的IO控制权交给池中的线程,能最小地影响到应用处理事件,快速地反应web请求。 当然对机器方便的事情对于写代码的人来说就需要更小心地划分业务逻辑,我们需要将工作划分为合理大小的任务来适配事件模型这一套机制。
- 事件队列调度
Node可以通过传递回调函数将任务添加到事件队列中,这种异步的调度可以通过5种方式来实现这个目标:异步堵塞IO库(db处理、fs处理),Node内置的事件和事件监听器(http、server的一些预定义事件),开发者自定义的事件和监听器、定时器以及Node全局对象process的.nextTick()API。
3.1 异步堵塞IO库
其IO库提供的API有Node自带的Module(比如fs)和数据库驱动API,比如mongoose的.save(doc, callback)就是将繁重的数据库Insert操作以及回调函数交给子线程来操作,主线程只负责任务的调度。当MongoDB返回给Node操作结果后,回调函数才开始执行。
Dtree.create(frontData, function (err, dtree) {
if (err) {
console.log('Error: createDTree: DB failed to create due to ', err);
res.send({'success': false, 'err': err});
} else {
console.log('Info: createDTree: DB created successfully dtree = ', dtree);
res.send({'success': true, 'created_id': dtree._id.toHexString()});
}
});
比如这段处理Dtree存储的回调函数只有当事件队列中的接收到来自堵塞IO处理线程的执行完毕才会被执行。
3.2 Node内置的事件和事件监听器
Node原生的模块都预定义来一些事件,比如NET模块的一套服务状态事件。当Net中的Socket检测到close就会调用放置在事件循环中的回调函数,下例中就是将sockets数组中删除相应的socket连接对象。
socket.on('close', function(){
console.log('connection closed');
var index = sockets.indexOf(socket);
//服务器端断开相应连接
sockets.splice(index, 1);
});
3.3 开发者自定义的事件
Node自身和很多模块都支持开发者自定义事件和处理持戟处理函数,当然既然是自定义,那么触发事件也是显性地需要开发者。在Socket.io编程中就有很好的例子,开发者可以自定义消息事件来处理端对端的交互。
//socket监听自定义的事件消息
socket.on('chatMessage', function(message){
message.type = 'message';
message.created = Date.now();
message.username = socket.request.user.username;
console.log(message);
//同时也可以像对方发出事件消息
io.emit('chatMessage', message);
});
3.4 计时器(Timers)
Node使用前端一致的Timeout和Interval计时器,他们的区别在Timeout是延时执行,Interval是间隔一段事件执行。值得注意的是这组函数其实不属于JS语言标准,他们只是扩展。在浏览器中,他们属于BOM,即它的确切定义为:window.setTimeout和window.setInterval;与window.alert, window.open等函数处于同一层次。Node把这组函数放置于全局范围中。
除了这两个函数,Node还添加Immediate计时器,setImmediate()函数是没有事件参数的,在事件队列中的当前任务执行结束后执行,并且优先级比Timeout、Interbal高。
计时器的问题在于它在事件循环中并非精确的执行回调函数。《深入浅出Node.js》举了一个例子:当通过setTimeout()设定一个任务在10毫秒后执行,但是如果在9毫秒后,有一个任务占用了5毫秒的CPU,再次炖老定时器执行时,事件就已经过期了。
3.5 Node全局对象process的.nextTick()API
这个延时执行函数函数是在添加任务到队列的开头,下一次Tick周期开始时就执行,也就是在其他任务前调度。
nextTick的优先级是高于immediate的。并且每轮循环,nextTick中的回调函数全部都会执行完,而Immediate只会执行一个回调函数。这里有得说明每个Tick过程中,判断事件循环中是否有事件要处理的观察者。在Node的底层libuv,事件循环是一个典型的生产者/消费者模型。异步IO、网络请求是事件的生产者,回调函数是事件的消费者,而观察者则是在中间将传递过来的事件暂存起来。回调函数的idle观察者在每轮事件循环开始被检查,而check观察者后于idle观察者检查,两者之间被检查的就是IO操作的观察者。
- 事件驱动与高性能服务器
前面大致介绍了Node的事件驱动模型,事件驱动的实质就是主循环线程+事件触发的方式来运行程序。Node的异步IO成功地使得IO操作与CPU操作分离成为一套高性能平台,既可以像Nginx一样构建服务器平台,也可以处理具体的业务。虽然Node没有Nginx在Web服务器方面那么专业,但不错的性能和更多的使用场景使得在实际开发中能够达到优异的性能。这一切也都归功与异步IO实现的核心——事件循环。在实际的项目中,我们可以结合不同工具的优点达到应用的最优性能。
references: