设计高性能Web服务器的要点在于非阻塞I/O和事件驱动
Node最大的特点是异步式I/O(非阻塞I/O)与事件紧密结合的编程模式,此模式与传统同步式I/O线性的编程思维不同,因为控制流很大程度要依靠事件和回调函数来组织,一个逻辑要拆分为若干单元。
Node是JavaScript运行时环境,为JavaScript提供了一个异步I/O编程框架。Node的指导思想:CPU执行指令是非常快速的,但I/O操作相对而言是极其缓慢的。Node解决的是给CPU执行的算法容易,I/O请求却很频繁的情况。
同步/异步
当请求到来时,相对于传统的进程或线程同步处理的方式。Node只在主线程中处理请求,如果遇到I/O操作则以异步方式发起调用,主线程立即返回,继续处理之后的任务。由于异步,一次客户端请求的处理方式由流式变为阶段式。而使用Node编写的JavaScript代码都运行在主线程中。
例如:假设一次客户端请求分为三个阶段
- 执行函数A
- 一次I/O操作
- 执行函数B
同步式处理
//同步式处理客户端请求
function request(){
$result = stageA();//执行函数A
$data = readfile();//读取文件,执行一次I/O操作
stageB($result, $data);//执行函数B,将前两部的结果作为参数。
}
同步式处理中每个请求使用一个线程或进程处理,一次请求处理完毕后线程被回收。上图同步式处理只显示了两个线程。如果客户端更多,线程数量会随之增加。
异步式处理
//异步式处理流程
function request(){
var result = stageA();//执行函数A
// 发起异步读取,此时主线程立即返回,处理后续任务。
readfileAsync(function(data){
//在随后的循环中执行回调函数
stageB(result, data);
});
}
Node异步执行中,Node使用一个主线程解决了所有问题,异步处理流程中,每一个方块代表了一个阶段任务的执行。
Node高性能的来源得益于异步的运行方式,如何理解异步对性能的性能的提升了。打个比方目前出入车辆管理规定,外地来的车辆进京需要办理进京证,而办进京证需等待一定时间。如果每个人都自己跑去办理,就好像开启多个线程同步处理,办理窗口有限,就得排队。而把这件事委托给第三方,就好比不开启线程或进程,将耗时的I/O请求委托给操作系统。这种情况下人们从办证的任务中解放出来,因而能继续做其他事情。若来了一个任务,交给第三方去处理,则第三方就有一个接单队列,只需要拿到所有的单,去办理地点逐个办理即可。
操作系统中一个杰出的设计是线程,操作系统把CPU处理时间分片后划分出许多短暂的时间片,在时间T1执行一个线程的指令,到时间T2再执行下一个线程的指令,各个线程轮流执行,结果好像是所有线程都在并行前进。这样,编程时可以创建多个线程,在同一期间执行,各个线程可以并行的完成不同的任务。
在单线程中,计算机是一台严格意义上的冯诺依曼式机器,一段代码调用了另一段代码时,只能采用同步调用。简单俩说,必须等待这段代码执行完毕并返回结果后,调用方才能继续向下执行。
有了多线程的支持后,可以采用异步调用,也就是说,调用方和被调方可以属于不同的线程,调用方启动被调方线程后,不等待对象返回结果就继续执行后续代码。
计算中有些处理时比较耗时的,调用这种处理代码时,调用方如果苦苦等待会严重影响程序的性能。
异步调用虽然原理并不复杂,但在使用中容易出现莫名其妙的问题,特别是不同线程共享代码或贡献数据时容易出现问题。因此,要设计一个安全高效的的编程方式需要比较多的设计经验,因此最好不要滥用异步。
JavaScript的异步处理上,ES5的回调函数callback
使我们陷入地狱,ES6的承诺promise
使我们脱离魔障,ES7的异步等待async-await
终于带领我们走向了光明。其实async-await
是promise
和generator
的语法糖,是为了编码时更加流畅,同时增强代码的可读性。async
用来表示函数是异步的,使用async
定义的函数会返回一个promise
对象,因此可使用then
方法添加回调函数。await
可以理解为async wait
的缩写,await
必须出现在async
函数内部,因此它是不能够单独使用的。await
后面可以跟任何JS表达式,作用是用来等待promise
对象的状态被resolved
。如果await
异步等待的是promise
对象则会造成异步函数停止执行并且等待promise
的解决,如果等待的是正常的表达式则会立即执行。
非阻塞I/O
什么是阻塞(block)呢?线程在执行中若遇到磁盘读写或网络通信(统称为I/O操作),通常要耗费较长的时间,此时操作系统会剥夺这个线程的CPU控制权,使其暂停执行,同时将资源让渡给其他工作线程,这种线程调度的方式称为阻塞。
- 传统同步式I/O(Synchronous I/O)或阻塞式I/O(Blocking I/O)
- 异步式I/O(Asynchronous I/O)或非阻塞式I/O(Non-blocking I/O)
当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以事件的方式通知。
为什么Node.js使用单线程、非阻塞的事件编程模型?
在阻塞模式下,多线程往往能提高系统吞吐量,因为一个线程阻塞时还有其他线程在工作,多线程可让CPU资源不被阻塞中的线程浪费。而在非阻塞模式下,线程不会被I/O阻塞,永远在利用CPU。多线程带来的好处仅仅是在多核CPU的情况下利用更多的核,而Node.js的单线程也能带来同样的好处。这就是为什么Node.js使用单线程、非阻塞的事件编程模型。
单线程事件驱动的异步式I/O比传统的多线程阻塞式I/O好在哪里呢?
简而言之,异步式I/O就是少了多线程的开销。对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度,同时在线程切换时还需执行内存换页,CPU的缓存被清空,切换回来时还要重新从内存中读取数据,破坏了数据的局部性。
异步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为什么使用单线程?
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的管理方式,也可很好地管理各个工作进程,以达到更高的健壮性。
关于如何通过子进程来充分利用硬件资源和提升应用的健壮性,这是一个值得探究的话题。
事件循环
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与事件驱动
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应用的健壮性,防止恶意攻击。
异步事件模式的弊端是不符合开发者的常规线性思路,需要把一个完整的逻辑拆分为一个个事件,增加了开发和调试的难度。