Node中的异步I/O

302 阅读7分钟

前言:node有四个特点,分别是异步I/O,事件与回调函数,单线程,跨平台

为什么需要异步I/O?

  1. 用户体验

采用异步的方式使得第一个资源的获取不会阻塞第二个资源的获取,达到并发的优势,增加用户的体验感。

  1. 资源的分配

假设业务场景中有一组互不相关的任务需要完成,主流的方式:单线程串行依次执行 多线程并行完成

  • 单线程串行依次执行:执行的方式符合编程人员的思维方式,但是会导致代码的阻塞。
  • 多线程并行完成:在多核cpu上面能够有效提升cpu的利用率,但是代价自安于创建线程和执行期线程上下文切换的开销较大。另一方面,多线程经常面临锁,状态同步等问题。
  • Node在两者之间给出了它的方案:单线程异步I/O(为了弥补单线程无法利用多核cpu的缺点,Node提供类似前端浏览器中Web Workers的子进程)

异步I/O和非阻塞I/O

异步和非阻塞都达到了并行I/O的目的,但是从计算机内核I/O而言,异步/同步和阻塞/非阻塞是两回事。操作系统内核对于I/O只有两种方式:阻塞和非阻塞,它们两者的主要区别是阻塞I/O完成整个获取数据的过程,而非阻塞I/O则不带数据直接返回,要获取数据还需要通过文件描述符再次读取。

  • avatar
    avatar

非阻塞I/O存在的问题:立即返回的不是业务层期望的数据,而是当前调用的状态,为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成,这种重复调用的判断操作是否完成的技术是轮询。主要的轮询技术:

  • avatar
  • avatar
  • avatar
  • avatar
  • avatar

理想的异步I/O:尽管epoll已经利用时间来降低cpu的耗用,但是休眠期间cpu几乎是闲置,对于当前线程而言的利用率是不够的。完美的异步I/O应该是应用程序发起非阻塞调用,无需通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需要I/O完成后通过信号或者回调函数将数据传递给应用程序即可。

  • avatar

现实的异步I/O:在多线程的情况下,通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据的获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,就能实现异步I/O(模拟的)。这便是典型的线程池来模拟异步I/O,其中node在*nix平台中,就是自行实现线程池来完成异步I/O。在Windows下采用的方案是IOCP,其内部原理仍然是线程池的原理。注意:我们平常指的node是单线程,这里的单线程是指JavaScript执行在单线程中,在node中,无论是什么平台,内部完成I/O任务的另外有线程池。

  • avatar

异步/同步和阻塞/非阻塞的区别

I/O的阻塞与非阻塞:阻塞模式的I/O会造成应用程序等待,直到I/O完成。同时操作系统也支持将I/O操作设置为非阻塞模式,这时应用程序的调用将可能在没有拿到真正数据时就立即返回了,为此应用程序需要多次调用才能确认I/O操作完全完成。 I/O的同步与异步 I/O的同步与异步出现在应用程序中。如果做阻塞I/O调用,应用程序等待调用的完成的过程就是一种同步状况。相反,I/O为非阻塞模式时,应用程序则是异步的。

Node的异步I/O

事件循环:在进程启动的时,Node变回创建一个类似while(true)的循环,每执行一次循环体的过程被称为Tick。每个Tick的过程中就是查看是否有事件需要处理。如果有,取出事件以及相关的回调函数。如果存在关联的回调函数,就执行它们。然后就进行下一次循环,如果不再有事件处理就退出流程。

  • avatar

观察者:在每个Tick的过程中,如何判断是否有事件需要处理呢?这需要引入观察者的概念。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。在Node中,事件主要来源于网络请求,文件I/O等,这些事件对应的观察者有文件I/O观察者,网络I/O观察者。异步I/O,网络请求等是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到观察者那里,事件循环则从观察者那里取出事件并处理。

请求对象:对于Node中的异步I/O而言,回调函数不由开发者来调用。那么从我们发出调用之后,到回调函数被执行的过程中,发生了什么?其实,从JavaScript发起调用到内核执行完I/O操作的过渡过程中,存在“请求对象”的中间产物。从JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用。libuv作为封装层,有两个平台的实现,实质上是调用了uv_fs_open()方法。在uv_fs_open()调用的过程中,创建了一个FSReqWrap请求对象。从JavaScript层传入的参数和当前的方法都被封装到这个请求对象中,其中回调函数则被设置在这个对象的oncomplete_sym属性上。对象包装完毕以后,在Windows下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中等待执行。至此JavaScript调用立即返回,由JavaScript层面发起的异步调用的第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管他是否阻塞I/O,都不会影响JavaScript线程的后续操作,就此达到异步目的。请求对象是异步I/O过程中重要的中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。

  • avatar

执行回调:组装好请求对象,送入I/O线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。线程池中的I/O操作调用完毕以后,会将获取的结果储存在req->result属性上面,然后调用PostQueueCompletionStatus()通知IOCP,告知当前对象操作已经完成。PostQueueCompletionStatus()方法的作用是向IOCP提交执行状态,并将线程归还线程池。通过PostQueueCompletionStatus()提交的状态,可以通过GetQueueCompletionStatus()提取,I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,以此达到调用JavaScript传入的回调函数的目的。

  • avatar

总结:事件循环,观察者,请求对象,I/O线程池这四者共同构成了Node异步I/O模型的基本要素。在Node中,除了JavaScript是单线程以外,Node本身是多线程,只是I/O线程使用的少。