Node.js异步IO

异步早就存在于操作系统的底层,通过信号量、消息等方式应用。

与Node的事件驱动、异步IO设计理念比较相近的一个产品是Nginx。

为什么要异步IO

原因:Node是面向网络设计的。

网络请求是一个耗时操作,同步网络请求会导致程序处于等待卡死状态,用户体验差。

计算机将组件进行了抽象,分为IO设备和计算设备。

处理一组任务的方案:

  • 单线程串行执行:易于编写,但是性能差。

  • 多线程并发执行:性能一般较好;但多线程开销大,且需要处理锁、状态同步等问题,编写难度大。

Node的方案是:利用单线程避免死锁、同步等问题;利用异步IO让单线程不会阻塞,更好地利用CPU。

异步IO的现状

异步IO不是Node首创。

异步/同步和阻塞/非阻塞是两回事。

操作系统内核对于IO只有两种方式:阻塞与非阻塞。

阻塞IO:操作系统处理IO时,会等待IO结束,获得数据后再返回。

非阻塞IO:操作系统启动IO后,立即无数据返回。

操作系统把输入输出设备抽象为文件,在进行IO操作时,通过文件描述符进行管理,先打开文件描述符,在根据文件描述符进行数据的读写。

非阻塞IO返回后,CPU的时间片段可以用来处理其他事务,性能更好,但又一个问题:如何获取IO的完整数据?应用程序需要重复调用IO操作来确认IO是否完成,称为“轮询”技术。这也会浪费CPU资源。

现存的轮询技术:

  • read

  • select

  • poll

  • epoll:Linux系统,IO事件通知机制

  • kqueue:FreeBCD系统

理想的非阻塞异步IO

应用程序发起非阻塞调用,无需轮询,IO完成后,系统内核通过事件或信号将数据传递给应用程序。

现实的异步IO

现实:通过线程池模拟异步IO。让一个线程进行计算处理,让部分其他线程进行阻塞IO或非阻塞IO加轮询技术来完成数据,通过线程间通信将IO得到的数据传递给计算线程。

Windows系统:IOCP

Linux系统:自定义线程池

IO包括:文件读写、硬件、套接字等。

在Node中,所谓的单线程只是JS执行在单线程,完成IO任务的另有线程池。

Node的异步IO

Node的异步IO模型:

  • 事件循环

  • 观察者

  • 请求对象

  • IO线程池

事件循环

在进程启动时,Node就会创建一个死循环,每执行一次循环称为“Tick”。

观察者

在每个Tick中,判断是否有事件需要处理。每个循环中有一个或多个观察者,观察者收集事件,对事件进行分类,事件循环则从观察者中获取事件并处理。

请求对象

当JS发起一个异步调用时,Node会创建一个“请求对象”,封装参好数和回调,然后推入线程池中等待执行,至此,JS调用返回,继续执行后续JS代码。

线程池中的IO操作调用完毕后,会把获得的数据存储到请求对象的result属性中,然后通知IOCP记录状态,并释放线程。

在每次Tick时,事件循环的IO观察者会检查IOCP的状态,如果有已完成的请求,就会把这些请求对象加入到观察者的队列中,等待被取出当做事件处理。

回调函数的执行:取出请求对象,提取请求对象的result和oncomplete_sym属性,执行oncomplete_sym(result)

非IO的异步API

Node中存在一些与IO无关的异步API。

定时器

  • setTimeout()

  • setInterval()

不需要IO线程池参与。

流程:创建定时器,插入到“定时器观察者”内部的红黑树中,每次Tick,都会检查定时器对象是否超时,超时就会调用定时器的回调函数。

存在的问题:不精确。

process.nextTick()

调用定时器需要动用红黑树、创建对象、迭代等操作,比较浪费性能。

想要立即异步执行一个任务,使用 process.nextTick() 方法。该方法只会将回调函数放入队列中,在下一次Tick中取出执行。

setImmediate()

延迟执行,和 process.nextTick() 类似。但 process.nextTick() 会优先执行。

process.nextTick() 的回调保存在一个数组中,每轮Tick会全部调用。

setImmediate() 的回调则是保存在一个链表中,每轮Tick只会调用一个。