Node.js中的异步I/O

146 阅读7分钟

一、异步I/O

完成整个异步I/O环节的有事件循环,观察者和请求对象等。

  • 事件循环

首先介绍下Node自身的执行模型——事件循环,正是它的存在,才让回调函数十分普遍。

在线程启动时,node便会创建一个类似while(true)的循环,每执行一次循环体的过程我们称为Tick,每个Tick的过程就是查看是否有事件待处理,如果有,就取出时间以及相关的回调函数并执行。然后进入下一个循环。如果不再有事件处理就退出进程。

  • 观察者

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

事件循环是一个典型的生产者/消费者模型,异步I/O,网络请求是事件的生产者,而时间被传播到对应的观察者哪里,事件循环从观察者取出事件并处理。

  • 请求对象

这里以windows下的异步I/O的简单例子来看一下JS代码到系统内核之间都发生了什么。

对于一般的同步回调函数,函数由我们自己处理:

对于node中的异步I/O调用而言,回调函数却不由我们来调用。事实上从JS发起调用到内核执行完I/O操作的过渡过程中有一种中间产物,被称为请求对象

下面以fs.open()方法作为例子:

fs.open = function(path, flags, mode, callback) { 
  // ...
  binding.open(pathModule._makeLong(path), stringToFlags(flags),
  mode,
  callback); 
};

fs.open()的作用是根据制定的路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O的初始操作,从代码可以看到,这里通过调用c++核心模块进行下层的操作:

从JS调用node的核心模块,核心模块在调用C++内建模块,内建模块通过libuv进行系统调用,这是Node的经典调用方式。

这里libuv作为封装层,有两个平台的实现,实质上是调用uv_fs_open()方法。在调用uv_fs_open()的过程中,我们封装了一个FSReqWrap请求对象,

从JS层传入的参数和当前方法都被封装在这个对象中,我们关心的回调函数则被设置在这个对象上的oncomplete_sym属性上。

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

对象包装完毕之后,在windows下调用QueueUserWorkItem()方法将请求对象推入到线程池等待执行,

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()方法。

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

执行回调:

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

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

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

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

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

至此,整个异步I/O的流程完全结束。

需要注意的是,JS虽然是单线程的,但是Node自身其实是多线程的,只是I/O线程使用的CPU较少。另一个要重视的观点是,除了用户代码无法并行执行外,所有的I/O则是可以并行的。

二、 非I/O 的异步API

除了异步I/O之外,node中还存在一些异步API,他们分别是setTimeout()、setInterval()、setImmediate()和process.nextTick()。

  • 定时器

调用定setTimeout()或者setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次Tick执行,他都会到该红黑树中取出定时器对象,检查是否超时,如果超时,则会形成一个时间,他的回调函数将会执行。

  • process.nextTick()

有些场景下,想要得到异步执行的效果,调用定时器比较浪费性能(需要动用红黑树,创建定时器对象和迭代等操作),可以采用比较轻量的process.nextTick():

process.nextTick = function(callback) { 
  // on the way out, don't bother.
  // it won't get fired anyway
  if (process._exiting) return;
  if (tickDepth >= process.maxTickDepth) maxTickWarn();
  var tock = { callback: callback };
  if (process.domain) tock.domain = process.domain; nextTickQueue.push(tock);
  if (nextTickQueue.length) {
  process._needTickCallback(); }
};

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

  • setImmediate()

setImmediate()方法与process.nextTick()方法十分相似,都是将回调函数延迟执行。但两者之间还是有细微差别的,主要体现在执行的优先级上。

process.nextTick(function () { console.log('nextTick执行');
});
setImmediate(function () {
console.log('setImmediate执行'); });
console.log('正常执行'); 

其执行结果如下:

正常执行

nextTick执行

setInmediate执行

从结果中可以看出,process.nextTick()中的回调函数执行的优先级要高于setImmediate(),

这是因为在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者,在每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者下于check检查者。

还有一个比较有差异的点,process.nextTick()的回调函数保存在数组中,setImmediate()保存在链表中,在行为上,process.nextTick()在每轮循环中回奖数组中的回调函数全部执行完,而setImmediate()在每轮循环中执行链表的一个回调函数:

// 加入两个nextTick()的回调函数
process.nextTick(function () { 
  console.log('nextTick延迟执行1'); 
});
 process.nextTick(function () { 
   console.log('nextTick延迟执行2');
});  // 加入两个setImmediate()的回调函数
setImmediate(function () {
  console.log('setImmediate延迟执行1'); 
  // 进入下次循环 
  process.nextTick(function () {
		console.log('强势插入'); });
	});
setImmediate(function () {
	console.log('setImmediate延迟执行2'); 
});
console.log('正常执行');

执行结果如下:

nextTick延迟执行1

nextTick延迟执行2

setImmediate延迟执行1

强势插入

setImmediate延迟执行2

从执行结果上可以看出,当第一个setImmediate执行完之后,没有立即执行第二个setImmediate,而是进入了下一个循环,再次按照nextTick先执行,

setImmediate后执行的顺序执行。

之所以这样设计是为了保证每轮循环能够尽快结束,防止CPU占用过多而阻塞后续I/O调用。

三、Node服务器