如果成真的话,以后开发Web应用就只需要一种语言了。
2009年年末,Ryan Dahl在柏林的一个JavaScript大会上宣布了一项名为Node.js的新技术。出乎所有人意料,这项关于JavaScript的技术居然不是运行在浏览器端的,要知道浏览器端对于JavaScript来说是拥有霸主地位的,毋庸置疑。
这项技术是关于在服务端运行JavaScript的。当时,这简单的一句描述,瞬间让听众眼前一亮,同时也宣告了这项技术的发布大获成功。
刚开始听到Node.js这个名字,你可能会以为它是一个JavaScript应用或者库。事实上,Node.js采用C++语言编写而成,是一个Javascript的运行环境。为什么采用C++语言呢?据Node.js创始人Ryan Dahl回忆,他最初希望采用Ruby来写Node.js,但是后来发现Ruby虚拟机的性能不能满足他的要求,后来他尝试采用Chorme的V8引擎(Google的Chrome浏览器使用的JavaScript执行环境),所以选择了C++语言。Node.js对Google V8引擎进行了封装。V8引擎执行JavaScript的速度非常快,性能非常好。同时还提供了很多系统级的API,如文件操作、网络编程等。浏览器端的Javascript代码在运行时会受到各种安全性的限制,对客户系统的操作有限。相比之下,Node.js则是一个全面的后台运行时,为JavaScript提供了其他语言能够实现的许多功能。
Node.js采用事件驱动、非阻塞I/O模型而得以轻量和高效,One More Thing,Node.js以单进程、单线程、高并发的方式运行。故而,Node.js可用于方便地搭建响应速度快、易于扩展的网络应用。非常适合在分布式设备上运行数据密集型的实时应用。
在浏览器中,事件作为一个极为重要的机制,给予JavaScript响应用户操作与DOM变化的能力;在Node.js中,事件驱动模型则是其高并发能力的基础。
程序响应外部的事件有如下两种方式:
中断
操作系统处理键盘等硬件输入就是通过中断来进行的,这个方式的好处是即使没有多线程,我们也可以放心地执行我们的代码,CPU收到中断信号之后自动地转去执行相应的中断处理程序,处理完成后会恢复原来的代码的执行环境继续执行。这种方式需要硬件的支持,一般来说都会被操作系统封装起来。
轮询
循环检测是否有事件发生,如果有就去执行相应的处理程序。这在底层和上层的开发中都有应用。
消息循环不断检测是否有消息(用户的UI操作、系统消息等)出现,有的话就分发消息,调用相应的回调函数进行处理。 轮询方式的一个缺点就是:如果在主线程的消息循环里进行耗时操作,程序就无法及时响应新的消息。这在JavaScript中表现明显。
然而JavaScript中并没有类似消息循环代码,我们只是简单地注册事件,然后等待被调用。这是因为浏览器、Node作为执行平台,已经将event loop实现了,JavaScript代码不需要介入到这个过程中,只需要作为被调用者安静地等待即可。
Node.js在解决并发连接的问题时为我们提供了新的思路,我们先来看一个传统的解决方案。
这里我们引入一个例子,想象一下我们在银行排队办理业务的场景。
1. 系统线程模型
只有一个业务员,用户排队办理业务,业务员一次处理一个用户,无法接待下一个用户,除非业务办理完成。基于系统线程模型(Thread-Based)。
这种模型的问题显而易见,服务端只有一个线程,并发请求(用户)到达时只能处理一个,其他的请求要排队等待,从而产生阻塞,正在享受服务的请求阻塞后面等待的请求。
2. 多线程——线程池模型
有多个业务员处理业务,每个业务员依然只能同时处理一个用户,其余的排队等待。在高并发时仍然需要排队等待。这是基于多线程模型(Multi-Thread)。
这个模型比上一个有很大进步,它通过增加服务端线程的数量来提高对并发请求的接收和响应,但在高并发的业务场景下,请求仍然需要等待,同时,它还有一个严重的问题:
服务端与客户端每建立一个连接,都要为这个连接分配一套配套的资源,主要体现为系统内存资源,以PHP为例,维护一个连接大约需要20M的内存,所以,并发量一大,就需要多开服务器。
下面来看一下Node.js的解决方案。
- 异步、事件驱动模型
还是银行办理业务的场景,用户依然需要排队(发起请求),等待响应,但是这次我们是先拿到一个排队号码。拿到号码后我们会找个位置坐下,而在我们后面的用户(请求)会继续得到处理,同样是拿到一个号码到一边等待,业务员能一直进行处理。每当业务员处理完前一个业务时,会依次喊号码,这次被喊到号码的开始办理业务,进行处理请求。
在这个模型里,喊号码的动作在Node.js里叫做回调函数(Callback),能在事件I/O(前面一个业务)处理完成后继续执行后面的逻辑(下一个业务)。
这体现了Node.js的特性:异步机制、事件驱动。
在业务过程中,没有阻塞新用户的连接(拿号码),也不需要维护拿到号码的用户与业务员的连接。
基于这样的机制,理论上陆续有用户请求连接,Node.js都可以进行响应,因此Node.js能支持比Java、PHP程序更高的并发量。
Node.js的设计思想中以事件驱动为核心,它提供的绝大多数API都是基于事件的、异步的风格。以Net模块为例,其中的net.Socket对象就有以下事件:connect、data、end、timeout、drain、error、close等,使用Node.js的开发者需要根据自己的业务逻辑注册相应的回调函数。这些回调函数都是异步执行的,这意味着虽然在代码结构中,这些函数看似是依次注册的,但是它们并不依赖于自身出现的顺序,而是等待相应的事件触发。事件驱动、异步编程的设计,重要的优势在于,充分利用了系统资源,执行代码无须阻塞等待某种操作完成,有限的资源可以用于其他的任务。此类设计非常适合于后端的网络服务编程,Node.js的目标也在于此。在服务器开发中,并发的请求处理是个大问题,阻塞式的函数会导致资源浪费和时间延迟。通过事件注册、异步函数,开发人员可以提高资源的利用率,性能也会改善。
事件驱动机制是Node.js通过内部单线程高效率地维护事件循环队列来实现的,没有多线程的资源占用和上下文切换,这意味着面对大规模的http请求,Node.js凭借事件驱动搞定一切。
虽然维护事件队列也需要成本,但由于Node.js是单线程,事件队列越长,得到响应的时间就越长,当并发量达到一定上限还是会力不从心。
总结一下Node.js解决并发连接的思路:
更改连接到服务器的方式,每个连接发射(emit)一个在Node.js引擎进程中运行的事件(Event),放进事件队列当中,而不是为每个连接生成一个新的OS线程,并为其分配一些配套内存。
Node.js解决的另外一个问题是I/O阻塞,假设这样一个业务场景:需要从多个数据源拉取数据,然后进行处理。
1. 串行获取数据,这是我们一般的解决方案,以PHP为例
``` php
//Get the Profile
$profile=$userProfileModel->getProfile($_userid);
//Get the Timeline
$timeline=$userTimelineModel->getTimeline($_userid);
//ToDo
//....
echo $return
```
从上面的代码中可以看出,假如获取profile和timeline的操作各需要1s,那么串行获取就需要2s。
2. Node.js的非阻塞I/O,通过发射/监听事件来控制执行过程
``` js
var proxy=new EventProxy();
proxy.all("profile","timeline",function(profile,timeline){
//ToDo
});
api.getUser("username",function(profile){
proxy.emit("profile",profile);
});
api.getTimeline("username",function(timeline){
proxy.emit("timeline",timeline);
});
```
Node.js遇到I/O事件时会创建一个线程去执行,然后主线程会继续向下执行。因此,获取profile的动作触发一个I/O事件,马上会执行获取timeline的动作。两个动作并发执行,假如各需要1s,那么总的时间也是1s,它们的I/O事件执行完毕后,发射一个事件:profile和timeline,事件代理接收后继续执行后面的逻辑,这就是Node.js的非阻塞I/O的特性。
Node.js所有请求以及同时传入的回调函数均发送至同一线程,该线程通常叫做 Event loop 线程,该线程负责在 I/O 执行完毕后,将结果返回给回调函数。这里要注意的是 I/O 操作本身并不在该线程内执行,所以不会阻塞后续请求。
Node.js从推出至今,充满赞美和饱受诟病的都是其单线程模型,所有的任务都在一个线程中完成(I/O等例外),优势的地方自然是免去了频繁切换线程的开销,以及减少资源互抢的问题等等。
Node.js使用单线程执行,客户发起 I/O 请求的同时传入一个函数,该函数会在 I/O 结果返回后被自动调用,而且该请求不会阻塞后续操作。
总结下,Java和PHP通过子线程的方式实现并行请求,Node.js通过回调函数(Callback)和异步机制实现并行请求。
- 优点
1. 高并发
2. I/O密集型应用
- 缺点
1. 不适合CPU密集型应用
CPU密集型应用给Node.js带来的挑战主要是:由于JavaScript单线程的原因,如果有长时间运行的计算(比如大循环),将会导致CPU时间片不能释放,使得后续I/O无法发起;