浅谈Node的异步I/O

本文章是通过阅读深入浅出Node.js这本书写的总结,有什么问题,欢迎大家指出。

为什么要异步 I/O

异步I/O为何在Node里这么重要,这和Node面向网络设计有关。现如今Web应用已不再是单台服务器就能胜任的时代了,在跨网络的结构下,并发已经是现代编程中的标准配备了。具体到实处,可从用户体验和资源分配这两个方面说起。

用户体验
  1. 在现如今跨网络的结构下,并发已经是现代编程中的标配了;
  2. 在浏览器中JavaScript在单线程上执行,而且它还与UI渲染共用一个线程。这意味着JavaScript在执行的时候UI渲染和响应是处于停滞状态的;
  3. 前端通过异步可以消除掉UI阻塞的现象;
  4. 采用异步请求,可以并发的下载资源。在下载资源期间,JavaScript和UI的执行都不会处于等待状态,可以继续响应用户的交互行为,给用户一个鲜活的页面;
资源分配

假设业务场景中有一组互不相关的任务要完成,现行的主流方法有以下两种。

  • 单线程串行依次执行;
  • 多线程并行完成;
  1. 多线程的代价在于创建线程和执行期线程上下文切换的开销较大;另外,在复杂的业务中,多线程编程经常面临锁、状态同步等问题,这是多线程被诟病的主要原因。但是多线程在多核CPU上能够有效提升CPU的利用率,这个优势是毋庸置疑的。

  2. 单线程顺序执行任务的方式比较符合编程人员按顺序思考的思维方式。它依然是最主流的编程方式,因为它易于表达。但是串行执行的缺点在于性能,任意一个比较慢的任务都会导致后续执行代码被阻塞。

从上面我们知道单线程同步编程模型会因阻塞I/O导致硬件资源得不到更好的使用。多线程编程模型也因为编程中的死锁、状态同步等问题让人头疼。

Node面对上面的问题给出了它的解决方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地使用CPU。

异步I/O是Node的一大特色,它是首个大规模将异步I/O应用在应用层上的平台,它在单线程上将资源分配得更高效。为了解决单线程无法利用多核CPU的缺点,Node提供了子进程,该子进程可以通过工作进程高效地利用CPU和I/O。

我们通过一张经典图看下异步I/O的调用示意图:

在这里插入图片描述
这就是为什么异步I/O在Node中如此盛行,甚至将其作为主要理念进行设计的原因。

异步 I/O 实现现状

操作系统内核对于I/O只有两种方式阻塞与非阻塞

  1. 非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回
  2. 阻塞I/O造成CPU等待浪费
  3. 非阻塞带来的问题是需要通过轮询去确认是否完全完成进行最终数据获取。(read、select、poll、epoll、kqueue)
阻塞I/O

在调用阻塞I/O时,应用程序需要等待I/O完成才返回结果,阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。比如:我们在读取磁盘上的一段文件时,系统内核在完成磁盘寻道、读取数据、复制数据到内存中之后,这个调用才结束。

阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用。我们可以通过下面这张经典图看下:
在这里插入图片描述

非阻塞I/O

为了提高性能,内核提供了非阻塞I/O,非阻塞I/O返回之后,CPU的时间片可以用来处理其他事务,此时的性能提升是明显的。我们可以通过下面这张图看下:

在这里插入图片描述
但非阻塞I/O也存在一些问题。因为完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询

从上面我们可以看出任意技术都不是完美的。阻塞I/O造成CPU等待浪费,非阻塞带来的问题却是需要轮询去确认是否完全完成,并尽心数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。

现存的轮询技术主要有以下这些:
  • read: 它是最原始、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取。在得到最终数据前,CPU一直耗用在等待上。如下图所示:
    在这里插入图片描述
  • select:它是在read的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。select轮询有一个较弱的限制,由于它采用一个1024长度的数组来存储状态,所以它最多可以同时检查1024个文件描述符。如下图所示:
    在这里插入图片描述
  • poll:该方案比select有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。但是当文件描述符较多的时候,它的性能还是十分低下的。与select相似,但性能限制有所改善。如下图所示:
    在这里插入图片描述
  • epoll:该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。如下图所示:
    在这里插入图片描述
  • kqueue:该方案的实现方式与epoll类似,但是它仅在FreeBSD系统下存在。
理想的非阻塞异步I/O

上面的epoll已经利用了事件来降低CPU的耗用,但是休眠期间CPU几乎是闲置的,对于当前线程而言利用率不够。是否有一种理想的异步I/O呢?我们期望的完美的异步I/O应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递给应用程序即可。如下图所示:
在这里插入图片描述
我们知道在Linux下存在这样一种方式,它原生提供的一种异步I/O方式(AIO)就是通过信号或回调来传递数据的。但是,只有Linux下有,而且它还有缺陷——AIO仅支持内核I/O中的O_DIRECT方式读取,导致无法利用系统缓存。

现实的异步I/O

现实是要达成异步I/O的目标,并不是很难。前面我们限定在了单线程的状况下,那么多线程的方式通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O(它是模拟的),如下图所示:
在这里插入图片描述

这相当于是java中常用到的线程池概念。

Node 的异步 I/O

  1. 事件循环:在进程启动时,node便会创建事件循环,循环执行事件关联的回调。
  2. 观察者:每个事件循环中有一个或多个观察者,观察者决定是否要执行事件。
  3. 请求对象:从javascript发起调用到内核执行完I/O操作的过程中的中间对象。
  4. 执行回调: 组装好请求对象、送入I/O线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分。
事件循环

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

观察者

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

我们通过一个比较形象的例子来形容一下:

这个过程就如同饭馆的厨房,厨房一轮一轮地制作菜肴,但是要具体制作哪些菜肴取决于收银台收到的客人的下单。厨房每做完一轮菜肴,就去问收银台的小妹,接下来有没有要做的菜,如果没有的话,就下班打烊了。在这个过程中,收银台的小妹就是观察者,她收到的客人点单就
是关联的回调函数。当然,如果饭馆经营有方,它可能有多个收银员,就如同事件循环中有多个观察者一样。收到下单就是一个事件,一个观察者里可能有多个事件。

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。在Windows下,这个循环基于IOCP创建,而在*nix下则基于多线程创建。

请求对象

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

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

我们通过一个例子fs.open来探索Node与底层之间是如何执行异步I/O调用以及回调函数究竟是如何被调用执行的:

// 异步地打开文件
fs.open(path[, flags[, mode]], callback)

fs.open()是根据指定路径和参数去打开一个文件,从而得到一个文件描述符。这是后续所有I/O操作的初始操作。我们可以通过一张图看下fs.open的底层执行过程:
在这里插入图片描述

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

FSReqWrap->object_->Set(oncomplete_sym, callback);

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

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)

QueueUserWorkItem方法接受三个参数:

  • 第一个参数:是将要执行的方法的引用,这里引用的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的第一部分,回调通知是第二部分。

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

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

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

I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出oncomplete_sym属性作为方法,然后调用执行,至此,整个异步I/O的流程就结束了,整个流程图如下所示:
在这里插入图片描述
Windows下主要通过IOCP来向系统内核发送I/O调用和从内核获取已完成的I/O操作,加以事件循环,以此完成异步I/O的过程。在Linux下通过epoll实现这个过程,FreeBSD下通过kqueue实现,Solaris下通过Event ports实现。不同的是线程池在Windows下由内核(IOCP)直接提供,*nix系列下由libuv自行实现。

非 I/O 的异步 API

Node中还存在一些和I/O无关的异步API,它们分别是setTimeout()、setInterval()、setImmediate()和process.nextTick()。

  1. 定时器
  2. process.nextTick()
  3. setImmediate()

定时器

setTimeout()和setInterval()与浏览器中的API是一致的,它们的实现原理与异步I/O比较类似,只是不需要I/O线程池的参与调用setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行

定时器的问题在于,它不是精确的,不过是在容忍范围内。

通过下面一张经典图来看下setTimeOut行为:
在这里插入图片描述
总结:

  1. 实现原理与异步I/O比较类似,只是不需要I/O线程池的参与
  2. 定时器的问题在于,它不是精确的
  • process.nextTick()

process.nextTick()表示立即异步执行一个任务,一般情况下我们会通过setTimeout调用,来达到需要的效果,但是定时器是不精确的,采用定时器需要动用红黑树,创建定时器对象和迭代等操作,setTimeout(fn, 0)的方式比较浪费性能,相比较之下process.nextTick()方法更为轻量。

每次调用process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。定时器中采用红黑树的操作时间复杂度为O(lg(n)),nextTick()的时间复杂度为O(1)。所以,process.nextTick()更高效。

总结:

  1. setTimeout(fn, 0)的方式比较浪费性能,process.nextTick()方法更为轻量
  2. 每次调用process.nextTick()方法时,只会将回调函数放入队列中,在下一轮Tick时取出执行
  • setImmediate()

setImmediate()方法与process.nextTick()方法类似,都是将回调函数延迟执行。那他们之间有什么区别呢?我们先来看下一段代码:

process.nextTick(function () { 
 console.log('nextTick延迟执行'); 
}); 
setImmediate(function () { 
 console.log('setImmediate延迟执行'); 
}); 
console.log('正常执行'); 
// 其执行结果如下:
// 正常执行
// nextTick延迟执行
// setImmediate延迟执行

process.nextTick()中的回调函数执行的优先级要高于setImmediate()。这里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一个轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者

具体的实现上,process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。在行为上,process.nextTick()在每轮循环中会将数组中的回调函数全部执行完,而setImmediate()在每轮循环中执行链表中的一个回调函数。

总结:

  1. process.nextTick()中的回调函数执行的优先级要高于setImmediate()
  2. 原因在于事件循环对观察者的检查是有先后顺序的

事件驱动与高性能服务器

  1. 事件驱动的实质是通过主循环加事件触发的方式来运行程序
  2. 事件循环是异步实现的核心,它与浏览器中的执行模型基本保持了一致

node的异步I/O基本上就是上面这些,欢迎大家批评改正。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值