异步I/O

为什么要异步 I/O

用户体验

只有后端能够快速响应资源,才能让前端的体验变好

资源分配

利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地使用 CPU

异步 I/O 实现现状

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

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

理想的非阻塞异步 I/O

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

现实的异步 I/O

通过让部分现成进行阻塞 I/O 或者非阻塞 I/O 加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O(尽管它是模拟的)


基于libuv的架构示意图.png

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

Node 的异步 I/O

事件循环——Node 自身的执行模型

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

Tick流程图.png

观察者——在每个 Tick 的过程中,判断是否有事件需要处理

每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件

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

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

在 Windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建

请求对象——从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中的中间产物

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

对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行。

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

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

执行回调

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

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

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

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

整个异步IO的流程.png

事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素

在 Node 中,除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的 I/O(磁盘 I/O 和网络 I/O 等)则是可以并行起来的。

非 I/O 的异步 API

定时器

setTimeout() 和 setInterval() 与浏览器中的 API 是一致的,分别用于单次和多次定时执行任务。它们的实现原理和异步 I/O 比较类似,只是不需要 I/O 线程池的参与。定时器的问题在于,它并非精确的(容忍范围内)。尽管事件循环十分快,但是如果某一次循环占用的事件较长,那么下次循环时,它也许已经超时很久了。


setTimeout()的行为.png

process.nextTick()

采用定时器需要动用红黑树,创建定时器对象和迭代等操作,而 setTimeout(fn, 0) 的方式较为浪费性能。实际上 process.nextTick() 的方法的操作相对较为轻量。

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

setImmediate()

setImmediate() 方法与 process.nextTick() 方法十分类似,都是将回调函数延迟执行

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

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

// 加入两个nextTick()de 回调函数
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() 的回调函数执行后,并没有立即执行第二个,而是进入了下一轮循环,再次按 process.nextTick() 优先、setImmediate() 次后的顺序执行。之所以这样设计,是为了保证每轮循环能够较快地执行结束,防止 CPU 占用过多而阻塞后续 I/O 调用的情况。

事件驱动与高性能服务器

Node 通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这使得服务器有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是 Node 高性能的一个原因。

你可能感兴趣的:(异步I/O)