这篇文章上次修改于 298 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

深入Node.js异步I/O

为什么会有异步I/O?

1、可以消除UI阻塞

2、假设请求资源A的时间为M,请求B的时间为N,同步的请求耗时为M+N,异步占用时间为Max(M, N),实现多个资源并发请求。

3、当业务复杂时,异步I/O与同步差异会更加明显

4、I/O是昂贵的,分布式I/O是更昂贵的

5、Node.js适用于I/O密集型,不适用于CPU密集型

一、操作系统的异步I/O

异步与非阻塞在应用中是一个概念,但对于操作系统的内核确是两个不同的概念。

操作系统对计算机进行了抽象,将所有输入输出抽像为文本。内核在进行I/O操作时,通过文件描述符进行管理,文件描述符(非负整数)类似于应用程序与系统内核之间的凭据。应用程序要进行I/O调用,需要先打开文件描述符,然后根据文件描述符去实现文件的数据读写。

操作系统的内核的异步包含两种方式:

  • 阻塞

调用阻塞I/O之后,一定要等待系统内核完成所有操作后, 调用才结束。阻塞I/O造成CPU等待I/O,浪费等待时间,CPU 的处理能力不能得到充分利用。

  • 非阻塞

为了提高性能,内核提供了非阻塞I/O。非阻塞I/O跟阻塞I/O的区别就是调用之后不带回数据立即返回,要获取数据,还需要通过文件描述符再次读取。

非阻塞I/O返回之后,CPU的时间片可以用来处理其他事物,性能得到了提升。

但I/O并没有完成,立即返回没有我们需要的数据,返回的是当前调用状态。这是就需要进行轮询来确认I/O是否完成。(阻塞I/O造成CPU等待浪费,非阻塞I/O带来的就是需要CPU处理状态判断,浪费了CPU资源。)

轮询技术的演进

read

最原始、性能最低的一种,通过重复检查I/O的状态来完成数据的读取。在得到最终数据之前,CPU一直耗在等待上。

select

在read的基础上改进的一种方案,通过对文件描述符上的事件状态进行判断。select采用一个1024长度的数组来存储状态,所以最多可以同时检查1024个文件描述符

poll

改进select,采用链表的方式避免了数组长度的限制,也避免了不需要的检查。链表的读取效率是很低下的,所以当文件描述符较多时,性能亦很低下。轮训与select相似,但性能上有所改善。

epoll

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。通俗的讲就是在进入轮询后如果没有检测到I/O事件,将处于休眠状态,直到事件发生将其唤醒。利用事件通知、执行回调,而不是遍历查询,不浪费CPU,执行效率较高。

kqueue

与epoll类似,只能在FreeBSD系统下存在。

缺陷与弥补

对于应用程序而言,它任然只能算是一种同步,因为应用程序仍然需要等待I/O完全返回,依旧花费很多时间来等待。等待期间,CPU要么处于便利文件描述符的状态,要么处与休眠等待事件发生的状态。

通过多线程的方式来解决。让部分线程进行整个I/O操作,一个主进程负责计算处理。

libuv

封装了各个操作系统的差异,来判断当前的系统,已达到兼容的效果

二、Node.js异步I/O

几个概念:

1、事件循环

Node.js的核心是事件,他把每个任务都当做事件进行处理,然后通过事件循环来处理这些事件。

步骤:

  • 进程启动时,创建类似于while(true)的循环(每执行一次循环,称为Tick)
  • Tick查看主进程事件队列是否有事件待处理,没有就退出,有就取出事件和回调函数
  • 判断是否为I/O操作,不是就自己处理,将结果返回给上层调用;是就从线程池取出一个线程来处理这个事件,并指定回调函数,然后继续处理循环队列的其他事件。
  • I/O执行完后,将事件放回事件队列的尾部,等到再次循环到时,会判断有无相关联的回调,没有就进入第二个步骤,有就执行回调,并将结果返回给上次调用
  • 进入下一个循环,重复以上步骤,若不再有事件需处理,就退出进程。

这个图是整个 Node.js 的运行原理,从左到右,从上到下,Node.js 被分为了四层,分别是 应用层V8引擎层Node API层LIBUV层。

应用层: 即 JavaScript 交互层,常见的就是 Node.js 的模块,比如 http,fs

V8引擎层: 即利用 V8 引擎来解析JavaScript 语法,进而和下层 API 交互

NodeAPI层: 为上层模块提供系统调用,一般是由 C 语言来实现,和操作系统进行交互 。

LIBUV层: 是跨平台的底层封装,实现了 事件循环、文件操作等,是 Node.js 实现异步的核心 。

无论是 Linux 平台还是 Windows 平台,Node.js 内部都是通过 线程池 来完成异步 I/O 操作的,而 LIBUV 针对不同平台的差异性实现了统一调用。因此,Node.js 的单线程仅仅是指 JavaScript 运行在单线程中,而并非 Node.js 是单线程。

2、观察者

每个Tick过程,需要观察者来判断是否有事件需处理。

每个事件循环中有一个或者多个观察者,当事件队列为空时,事件循环会定期向观察者询问是否有事件需要处理。

Node.js中事件主要来自于网络请求、文件I/O等,然后观察者会对事件进行分类。

事件循环是一个典型的生产者/消费者模型,异步I/O、网络请求等时事件的生产者,不断地为Node.js提供不同的类型的事件,观察者拿到事件给事件循环进行处理

事件循环:Windows是基于IOCP创建,而*nux是基于多线程创建

3、请求对象

指js发起调用到内核执行完I/O操作的一个中间产物。

什么时候产生?

在应用层向操作系统发起异步调用时,node的内建模块libuv通过内部方法来创建请求对象,

整个异步I/O的流程

事件循环、观察者、请求对象、I/O线程池构造了Node.js异步I/O的基本要素。

可分为两部分:

  • 异步调用

    • 事件循环读取到一个I/O事件。
    • libuv会对我们在js层传入的参数封装到请求对象里,回调函数则封装在这个请求对象的一个属性上,然后将这个对象放入操作系统的线程池中等待执行,js调用立即返回。
    • 事件循环进入下一个循环(Tick)。
  • I/O执行完毕及后续操作

    • 线程池中有可用线程,然后取出一个线程来执行请求对象的I/O操作。
    • 等到I/O执行完后,将结果放到请求对象上,然后向epoll(windows:IOCP)提交执行状态,将线程归还给线程池。
    • 事件循环的I/O观察者在每次Tick时,都会检查线程池中是否有执行完的请求,有就放入到事件队列的尾部,当做事件处理。
    • 事件循环取到该事件,执行js传入的回调函数(存放在异步调用阶段的请求对象的一个属性里),参数为请求对象的result属性

非I/O的异步API

  • setTimeout()、setInterval()
  • process.nextTick()
  • setImmediate()
setTimeout(function () {
    console.log(1);
}, 0);
setImmediate(function () {
    console.log(2);
});
process.nextTick(() => {
    console.log(3);
});
new Promise((resovle,reject)=>{
    console.log(4);
    resovle(4);
}).then(function(){
    console.log(5);
});
console.log(6);

// 执行结果
4 6 3 5 1 2
// 优先级(setTimeout, 和setImmediate优先级不确定)
// async > process.nextTick() > primise > ( setTimeout,  setImmediate )