前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入浅出 Nodejs(四):Nodejs 异步 I/O 机制

深入浅出 Nodejs(四):Nodejs 异步 I/O 机制

原创
作者头像
serena
修改2021-08-03 14:56:07
2.1K0
修改2021-08-03 14:56:07
举报
文章被收录于专栏:社区的朋友们社区的朋友们

作者:郭泽豪

本篇教程关于Nodejs的异步I/O,具体讲异步I/O的实现现状、非I/O的异步API、事件驱动与高性能服务器。

本章的重点内容

  • 从事件循环、观察者、请求对象以及执行回调来理解异步I/O的机制
  • 了解setTimeOut()、setInterval()、process.nextTick()、setImmediate()这些非I/O的异步API
  • 理解Nodejs的事件驱动以及高性能的原因

一、异步I/O实现现状

1.1 异步I/O与非阻塞I/O

在听到Node的介绍时,我们时常会听到异步、非阻塞、回调以及事件这些词语混合在一起推介出来,其中非阻塞和异常听起来似乎是同一件事。从实际效果上来看,异步和非阻塞都达到我们并行I/O的目的。但是从操作系统内核来说,同步/异步和阻塞/非阻塞实际上是两回事。

阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成I/O操作,调用才结束。以读取磁盘上的一个文件为例,系统内核在完成磁盘寻道、读取数据、复制数据到内存之后,这个调用才结束。

阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用。为了提高性能,内存提供了非阻塞I/O。非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回,如图1所示。

这里,我们先提一下文件描述符的概念。操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核在进行文件I/O操作时,通过文件描述符进行管理,而文件描述符类似于应用程序和系统内核之间的凭证。应用程序如果需要进行I/O调用,需要先打开文件描述符,然后再根据文件描述符去完成文件的数据读写。

非阻塞I/O返回之后,CPU的时间片可以用来处理其他事务,此时的性能提升是明显的。

但非阻塞I/O也存在一些问题。因为调用非阻塞I/O立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询,下面我们简要说明这种技术,包括read、select、poll、epoll以及kqueue。

任何技术都不是完美的。阻塞I/O会造成CPU等待浪费,非阻塞需要轮询去确认是否完全完成数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。这里我们且看轮询技术是如何演进的,以减少I/O状态判断带来的CPU损耗。

现存的轮询技术主要有以下几种。

  • read。它是一种最原始、性能最低的一种,它会重复检查I/O的状态来完成数据的完整读取。在得到最终数据前,CPU一直耗用在I/O状态的重复检查上。图1是通过read进行轮询的示意图。

图1 通过read进行轮询的示意图

  • select。它是在read的基础上改进的一种方案,通过对文件描述符上的事件状态进行判断。图2是通过select进行轮询的示意图。select轮询具有一个较弱的限制,那就是由于它采用一个1024长度的数组来存储状态,也就是说它最多可以同时检查1024个文件描述符。

图2 通过select进行轮询的示意图

  • poll。poll比select有所改进,采用链表的方式避免数组长度的限制,其次它可以避免不必要的检查。但是文件描述符较多的时候,它的性能是十分低下的。图3为通过poll实现轮询的示意图。

图3 通过poll进行轮询的示意图

  • epoll。该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知,执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。图4是通过epoll实现轮询的示意图。

图4 通过epoll进行轮询的示意图

  • kqueue。该方案的实现方式与epoll类似,不过它仅在FreeBSD系统下存在。

轮询技术满足了非阻塞I/O确保获取完整数据的需求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待I/O完全返回,依旧花费了很多时间来等待。等待期间,CPU要么是遍历文件描述符的状态,要么用于休眠等待事件发生。结论是它不够好。

1.2 理想的非阻塞异步I/O

尽管epoll已经利用了事件来降低CPU的耗用,但是休眠期间CPU几乎是闲置的,对于当前线程而言利用率不够。那么,是否有一种理想的异步I/O呢?

我们期望的完美的异步I/O应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一任务,只需在I/O完成后通过信号或回调将数据传递给应用程序即可。图5为理想中的异步I/O示意图。

图5 理想中的异步I/O示意图

幸运的是,在Linux下存在这样一种方式,它原生提供的一种异步I/O方式(AIO)就是通过信号或回调来传递数据的。

但不幸的是,只有Linux下有,而且它还有缺陷——AIO仅支持内核I/O中的O_DIRECT方式读取,导致无法利用系统缓存。

1.3 现实的异步I/O

现实比理想要骨感一些,但是要达到异步I/O的目标,并非难事。前面我们将场景限定在单线程的状况下,多线程的方式会是另一番风景。通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O,示意图如图6所示。

图6 异步I/O

glibc的AIO便是典型的线程池模拟异步I/O。然而遗憾的是,它存在一些难以忍受的缺陷和bug,不推荐使用。Libev的作者Marc Alexander Lehmann重新实现了一个异步I/O的库:libeio。libeio实质上依然是采用线程池与阻塞I/O模拟异步I/O。最初,Node在*nix平台下采用了libeio配合libev实现I/O部分,实现了异步I/O。在Node v0.9.3中,自行实现了线程池来完成异步I/O。

另一种异步I/O方案是windows下的IOCP,它在某种程度上提供了理想的异步I/O:调用异步方法,等待I/O完成之后的通知,执行回调,用户无须考虑轮询。但是它的内部其实仍然是线程池原理,不同之处在于这些线程池由系统内核接手管理。

IOCP的异步I/O模型与Node的异步调用模型十分近似。在windows平台下采用了IOCP实现异步I/O。

由于windows平台和*nix平台的差异,Node提供了libuv作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的Node与下层的自定义线程池及IOCP之间各自独立,Node在编译期间会判断平台条件,选择性编译unix目录或是win目录下的源文件到目标程序中,其架构如图7所示。

图7 基于libuv的架构示意图

需要强调一点的是,这里的I/O不仅仅只限于磁盘文件的读写。*nix将计算机抽象了一番,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件,因此这里描述的阻塞和非阻塞的情况同样能适合于套接字等。

另一个需要强调的地方在于我们时常提到单线程的,这里的单线程仅仅只是JavaScript执行在单线程罢了。在Node中,无论是*nix还是windows平台,内部完成I/O任务的另有线程池。

1.4 Node的异步I/O

介绍完系统对异步I/O的支持后,我们将继续介绍Node是如何实现异步I/O的。这里我们除了介绍异步I/O的实现外,还将讨论Node的执行模型。完成整个异步I/O环节的有事件循环、观察者和请求对象等。

1.4.1 事件循环

首先,我们着重强调一下Node的自身的执行模型——事件循环,正是它使得回调函数十分普遍。

在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程我们成为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件以及相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不在有事件处理,就退出进程。流程图如图8所示。

图8 Tick流程图

1.4.2 观察者

在每个Tick的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。

事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的产生者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

1.4.3 请求对象

我们将通过解释window下异步I/O(利用IOCP实现)的简单例子来探寻从JavaScript代码到系统内核之间都发生了什么。

对于Node中的异步I/O调用而言,回调函数却不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么?事实上,从JacaScript发起调用到内核执行完I/O操作的过渡过程中,它叫做请求对象。

下面我们以最简单的fs.open()方法来作为例子,探索Node到底层之间是如何执行异步I/O调用以及回调函数究竟是如何被调用执行的:

代码语言:javascript
复制
fs.open = function(path, flags, mode, callback){

//...

binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);

}

fs.open()的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O操作的初始操作。从前面的代码中可以看到,JavaScript层面的代码通过调用C++核心模块进行下层的操作。图9为调用示意图。

图9 fs.open()调用示意图

从JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用,这是Node里经典的调用方式。这里libuv作为封装层,有两个平台的实现,实质上是调用了uv_fs_open()方法。在uv_fs_open()的调用过程中,我们创建了一个FSReqWrap请求对象。从JavaScript层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的oncomplete_sym属性上:

代码语言:javascript
复制
req_wrap->object_->Set(oncomplete_sym, callback);

对象包装完毕后,在windows下,则调用QueueUserWorkItem()方法将这个FSReqWrap对象推入线程池中等待执行,该方法的代码如下所示:

代码语言:javascript
复制
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);

QueueUserWorkItem()方法接受3个参数:第一个参数是将要执行的方法的引用,这里引用的uv_fs_thread_proc;第二个参数是uv_fs_thread_proc方法运行时所需要的参数;第三个参数是执行的标志。当线程池中有可用线程时,我们会调用uv_fs_thread_proc()方法。uv_fs_thread_proc()方法会根据传入参数的类型调用相应的底层函数。以uv_fs_open()为例,实际上调用fs_open()方法。

至此,JavaScript调用立即返回,由JavaScript层面发起的异步调用的第一阶段就此结束。JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行,如此就达到了异步的目的。

请求对象是异步I/O过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。

1.4.4 执行回调

组装好请求对象,送入线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。

线程池中的I/O操作调用完毕之后,会将获取的结果存储在req->result属性上,然后调用PostQueueCompletionStatus()通知IOCP,告知当前对象操作已经完成。

代码语言:javascript
复制
PostQueueCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped));

PostQueueCompletionStatus()方法的作用是向IOCP提交执行状态,并将线程归还线程池。通过PostQueueCompletionStatus()方法提交的状态,可以通过GetQueuedCompletionStatus()提取。

在这个过程中,我们其实还动用了事件循环的I/O观察者。在每次Tick的执行中,它会调用IOCP相关的GetQueuedCompletionStatus()方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当做事件处理。

I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,以此达到调用JavaScript中传入的回调函数的目的。

至此,整个异步I/O的流程完全结束,如图10所示。

图10 整个异步I/O的流程

事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。windows下主要通过IOCP来向系统内核发送I/O调用和从内核获取已完成的I/O操作,配以事件循环,以此完成异步I/O的过程。在Linux下通过epoll实现这个过程,FreeBSD下通过kqueue实现,Solaris下通过Event ports实现。不同的是线程池在windows下由内核(IOCP)直接提供,*nix系列有libuv自行实现。

未完待续...

作者:MIG无线合作开发部实习生marcozhguo

电子邮箱:446882229@qq.com

参考资料:

《深入浅出Nodejs》

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 本章的重点内容
  • 一、异步I/O实现现状
    • 1.1 异步I/O与非阻塞I/O
      • 1.2 理想的非阻塞异步I/O
        • 1.3 现实的异步I/O
          • 1.4 Node的异步I/O
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档