目录
Node.js最大的特点就是异步式I/O(或者非阻塞I/O)与事件紧密结合的编程模式。这种模式与传统的同步式I/O线性的编程思路有很大的不同,因为控制流很大程度上要靠事件和回调函数来组织,一个逻辑要拆分成若干个单元。
一、阻塞与线程
Q:什么是阻塞?
A: 线程在执行中如果遇到磁盘读写或网络通信(统称为I/O操作),通常要耗费较长的时间,这时操作系统会剥夺这个线程的CPU控制权,使其暂停执行,同时将资源让给其他的工作线程,这种线程调度方式称为阻塞。
同步式I/O(阻塞式I/O):当I/O操作完毕时,操作系统将这个线程的阻塞状态解除,恢复其对CPU的控制权,令其继续执行。这种I/O模式就是通常的同步式I/O(Synchronous I/O)或阻塞式I/O(Blocking I/O)。
异步式I/O(非阻塞式I/O):当线程遇到I/O操作时,将I/O请求发送给操作系统,继续执行下一条语句。当操作系统完成I/O操作时,以事件的形式通知执行I/O操作的线程,线程会在特定时候处理这个事件。为了处理异步I/O,线程必须有事件循环,不断地检查有没有未处理的事件,依次予以处理。
假设我们有一项工作,可以分为两个计算部分和一个I/O部分,I/O部分占的时间比计算多得多(通常都是这样)。如果我们使用阻塞I/O,那么要想获得高并发就必须开启多个线程。而使用异步式I/O时,单线程即可胜任。图示如下(多线程同步式I/O与单线程异步式I/O):
同步式I/O(阻塞式) | 异步式I/O(非阻塞式) |
利用多线程提供吞吐量 | 单线程即可实现高吞吐量 |
通过事件片分割和线程调度利用多核CPU | 通过功能划分利用多核CPU |
需要由操作系统调度多线程使用多核CPU | 可以将单进程绑定到单核CPU |
难以充分利用CPU资源 | 可以充分利用CPU资源 |
内存轨迹大,数据局部性弱 | 内存轨迹小,数据局部性强 |
符合线性的编程思维 | 不符合传统编程思维 |
Q:单线程事件驱动的异步式I/O比传统的多线程阻塞式I/O好在哪里呢?
A:异步式I/O少了多线程的开销。对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度,同时在线程切换的时候还要执行内存换页,CPU的缓存被清空,切换回来的时候还要重新从内存中读取信息,破坏了数据的局部性。
二、回调函数
在Node.js中用异步的方式读一个文件
var fs = require('fs')
fs.readFile('file.txt','utf-8',function(err, data) {
if(err) {
console.error(err)
}else{
console.log(data)
}
})
console.log('end')
运行的结果如下:
另,Node.js也提供了同步读文件的API,操作如下:
var fs = require('fs')
var data = fs.readFileSync('file.txt','utf-8')
console.log(data)
console.log('end')
运行的结果如下:
同步式读取文件是将文件名作为参数传入fs.readFileSync函数,阻塞等待读取完成后,将文件的内容作为函数的返回值赋给data变量,接下来控制台输出data的值,最后输出end。
异步式I/O通过回调函数来实现,end先被输出。fs.readFile接收了三个参数,第一个是文件名,第二个是编码方式,第三个是一个函数,将这个函数称为回调函数。 fs.readfile调用时所做的工作只是将异步式I/O请求发送给了操作系统,然后立即返回并执行后面的语句,执行完以后进入事件循环监听事件。当fs接收到I/O请求完成的事件时,事件循环会主动调用回调函数以完成后续工作。因此我们会先看到end.,再看到file.txt文件的内容。
三、事件
Node.js所有的异步I/O操作在完成时都会发送一个事件到事件队列。
Q:Node.js在什么时候会进入事件循环呢?
A:Node.js程序由事件循环开始,到事件循环结束,所有的逻辑都是事件的回调函数,所以Node.js始终在事件循环中,程序入口就是事件循环第一个事件的回调函数。事件的回调函数在执行的过程中,可能会发出I/O请求或直接发射(emit)事件,执行完毕后再返回事件循环,事件循环会检查事件队列中有没有未处理的事件,直到程序结束。如图所示: