设计高性能Web服务器的要点在于事件驱动、非阻塞I/O
异步式I/O与事件式编程
Node.js最大的特点是异步式I/O(非阻塞I/O)与事件紧密结合的编程模式,此模式与传统同步式I/O线性的编程思维不同,因为控制流很大程度要依靠事件和回调函数来组织,一个逻辑要拆分为若干单元。
阻塞与线程
什么是阻塞(block)呢?线程在执行中若遇到磁盘读写或网络通信(统称为I/O操作),通常要耗费较长的时间,此时操作系统会剥夺这个线程的CPU控制权,使其暂停执行,同时将资源让渡给其他工作线程,这种线程调度的方式称为阻塞。
当I/O操作完毕后,操作系统将这个线程的阻塞状态解除,恢复其对CPU的控制权,令其继续执行。这种I/O模式即传统的同步式I/O(Synchronous I/O)或阻塞式I/O(Blocking I/O)。
异步式I/O(Asynchronous I/O)或非阻塞式I/O(Non-blocking I/O)则针对所有I/O操作不采用阻塞的策略。当线程遇到I/O操作时,不会阻塞的方式等待I/O操作的完成或数据的返回,而只是将I/O请求发送给操作系统,继续执行下一条语句。当操作系统完成I/O操作时,以事件的形式通知执行I/O操作的线程,线程会在特定时候处理这个事件。为了处理异步I/O,线程必须有事件循环,不断地检查有没有未处理的事件,依次予以处理。
阻塞模式下,一个线程只能处理一项任务,要想提高吞吐量必须通过多线程。而非阻塞模式下,一个线程永远在执行计算操作,这个线程所使用的CPU核心利用率永远是100%,I/O以事件的方式通知。
在阻塞模式下,多线程往往能提高系统吞吐量,因为一个线程阻塞时还有其他线程在工作,多线程可让CPU资源不被阻塞中的线程浪费。而在非阻塞模式下,线程不会被I/O阻塞,永远在利用CPU。多线程带来的好处仅仅是在多核CPU的情况下利用更多的核,而Node.js的单线程也能带来同样的好处。这就是为什么Node.js使用单线、非阻塞的事件编程模型。
单线程事件驱动的异步式I/O比传统的多线程阻塞式I/O好在哪里呢?简而言之,异步式I/O就是少了多线程的开销。对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度,同时在线程切换时还需执行内存换页,CPU的缓存被清空,切换回来时还要重新从内存中读取数据,破坏了数据的局部性。
事件
Node.js所有的异步I/O操作在完成时都会发送一个事件到事件队列。从开发看来事件由EventEmitter对象提供。
事件循环机制
Node.js在什么时候会进入事件循环呢?Node.js程序由事件循环开始到事件循环结束,所有的逻辑都是事件的回调函数,所以Node.js始终在事件循环中,程序入口就是事件循环第一个事件的回调函数。事件的回调函数在执行过程中,可能会发出I/O请求或直接发射(emit)事件,执行完毕后再返回事件循环,事件循环会检查事件队列中有没有未处理的事件,直至程序结束。
Node.js没有显式的事件循环,它对开发者不可见,由libev库实现。libev支持多种类型的事件,如ev_io、ev_timer、ev_signal、ev_idle等,在Node.js中均被EventEmitter封装。libev事件循环的每一次迭代,在Node.js中就是一次Tick,libev不断检查是否有活动的、可供检测的事件监听器,直至检测不到时才退出事件循环,进程结束。
异步I/O
关于异步I/O典型的场景是AJAX调用,其收到响应在是发送AJAX结束之后输出的。在调用AJAX后,后续代码时被立即执行的,而收到响应的执行时间是不被预期的。我们只知道将在这个异步请求结束后执行,但并不知道具体的时间点。异步调用中对于结果值的捕获是符合“Don't call me, I will call you.”的原则的,这也是注重结果不关心过程的一种表现。
$.post(url, data, function(res){
console.log('收到响应');
});
console.log('发送AJAX结束');
Node中异步I/O非常常见,以读取文件为例。
var fs = require('fs');
fs.readFile(path, function(err,res){
console.log('文件读取完毕');
});
console.log('发起文件读取');
单线程
Node.js保持了JS在浏览器中单线程的特点,在Node中JS与其余线程是无法共享任何状态的。单线程最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换带来的性能上的开销。
同样,单线程也有自身的弱点,具体表现在
- 无法利用多核CPU
- 错误会引起整个应用退出,应用的健壮性值得考验。
- 大量计算占用CPU导致无法继续调用异步I/O
像浏览器中的JS与UI公用一个线程一样,JS长时间执行会导致UI的渲染和响应被中断。在Node中,长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。
最早解决这种大计算问题的方案是Google公司开发的Gears,它启用了一个完全能独立的进程,将需要计算的程序发送给这个进程,在结果得出后,通过事件将结果传递回来。这个模型将计算分发到其他进程上,以次来降低运算造成阻塞的几率。
后台H5制定了Web Workers的标准,Google放弃了Gears,全力支持Web Workers。Web Workers能够创建工作线程来进行计算,以解决JS大计算阻塞UI渲染的问题。工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作进程不能访问主线程中的UI。
Node采用了与Web Workers相同的思路来解决单线程中大量计算的问题(child_process)。子进程的出现,意味着Node可从容地应对单线程在健壮性和无法利用多核CPU方面的问题。通过将计算分发到各个子进程,可将大量计算分解掉,然后在通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过Master-Worker的管理方式,也可很好地管理各个工作进程,以达到更高的健壮性。
关于如何通过子进程来充分利用硬件资源和提升应用的健壮性,这是一个值得探究的话题。
异步式I/O与事件驱动
Node.js采用异步式I/O与事件驱动的设计,对于高并发的解决方案,传统采用多线程模型,也就是为每个业务逻辑提供一个系统线程,通过系统线程切换来弥补同步式I/O调用时的时间开销。
Node.js采用单线程模型,对于所有I/O都采用异步式的请求方式,避免频繁的上下文切换。Node在执行过程中会维护一个时间队列,程序在执行时进入事件循环等待下一个事件到来,每个异步式I/O请求完成后会被推送到事件队列,等待程序进程进行处理。
Node.js的异步机制是基于事件的,所有磁盘I/O、网络通信、数据库查询都以非阻塞的方式请求,返回的结果由事件循环来处理。
Node.js进程在同一时刻只会处理一个事件,完成后立即进入事件循环检查并处理后续的事件。其好处是CPU和内存在同一时刻集中处理一件事,同时尽可能让耗时的I/O操作并行执行。对于低速连接攻击,Node.js只是在事件队列中增加请求,等待操作系统的回应,因而不会有任何多线程开销,很大程度可提高Web应用的健壮性,防止恶意攻击。
异步事件模式的弊端是不符合开发者的常规线性思路,需要把一个完整的逻辑拆分为一个个事件,增加了开发和调试的难度。
技术架构
CommonJS
CommonJS试图定义一套普通应用程序使用的API,从而填补JS标准库简单不足。CommonJS的终极目标是制定一个类似C++标准库一样的规范,使得基于CommonJS API的应用程序可在不同环境下运行,如同C++编写的应用可使用不同的编译器和运行时函数库一样。
CommonJS规范包括模块(modules)、包(packages)、系统(system)、二进制(binary)、控制台(console)、编码(encoding)、文件系统(filesystem)、套接字(sockets)、单元测试(unit testing)等部分。
POSIX(Portable Operating System Interface)是一套操作系统API规范。遵守POSIX规范的操作系统指的是UNIX、Linux、MacOS等。