深入理解异步 IO 原理
fengshi123 opened this issue · 0 comments
它的优秀之处并非原创,它的原创之处并不优秀。事件循环是异步实现的核心。
1、异步 I/O 背景
单线程同步编程模型会因阻塞 I/O 导致硬件资源得不到更优的使用,多线程编程模型因为线程中的死锁、状态同步等问题让开发人员头疼;
Node 在两者之间给出方案:利用单线程,远离多线程死锁、状态同步等问题;利用 I/O,让单线程远离阻塞,以更好地使用 CPU;并且为了弥补单线程无法利用多核 CPU 的缺点,Node 提供 child_process 子进程,该子进程可以通过工作进程高效地利用 CPU 和 I/O。
2、现实的异步 I/O
基于 Windows 平台和 *nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中,架构图如下所示
- 其中,*nix 平台是自行实现线程池来完成异步 I/O;
- windows 平台的 IOCP 方案在某种程度上提供了理想的异步 I/O:调用异步方法,等待 I/O 完成之后的通知,执行回调,用户无须考虑轮询;
需要强调的是,虽然我们时常提到 Node 是单线程的,但是这里的单线程仅仅只是 Javascript 执行在单线程中。在 Node 中,无论是 *nix 还是 Windows 平台,内部完成 I/O 任务的另有线程池。
3、Node 的异步 I/O
我们继续介绍 Node 是如何实现异步 I/O 的,完成整个异步 I/O 环节的有事件循环、观察者和请求对象。
3.1、事件循环
Node 自身的执行模型 — 事件循环,它使得回调函数十分普遍。在进程启动时,Node 便会创建一个类似于 while(true) 的循环,每执行一次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不再有事件处理,就退出进程。流程图如下所示
3.2、观察者
在每个 Tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
浏览器类似的机制,事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。
事件循环是一个典型的生产者/消费者模型,异步 I/O、网络请求等则是事件的生产者,源源不断为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。
在 windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建。
3.3、请求对象
对于 Node 中的异步 I/O 调用而言,回调函数不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从 Javascript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,叫做请求对象。请求对象是异步 I/O 过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行的当前方法、参数 以及 I/O 操作完毕后的回调处理函数。
Node 的经典调用方式:从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用。至此,会将请求对象推入线程池中等待执行,Javascript 调用则立即返回,由 Javascript 层面发起的异步调用第一阶段就此结束,Javasript 线程可以继续执行当前任务的后续操作。当前的 I/O 操作在线程池中等待执行,不管它是否阻塞 I/O,都不会影响 Javascript 线程的后续执行,如此就到了异步的目的。
3.4、执行回调
组装好请求对象、送入 I/O线程池等待执行,实际上完成了异步 I/O 的第一步,回调通知是第二部分。
线程池中的 I/O 操作调用完毕之后,会讲获取的结果储存在 req->result 属性上,然后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操作已经完成,并将线程归还线程池。在每次 Tick 的执行过程中,会调用 GetQueuedCompletionStatus() 方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到 I/O 观察者的队列中,然后将其当做事件调用处理,以此达到调用 Javascript 中传入的回调函数的目的。
至此,整个异步 I/O 的流程完全结束。事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素。具体流程图如下所示
从上描述中 单线程与 I/O 线程池之间看起来自相矛盾,其实在 Node 中,除了 Javascript 运行在单线程中外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要注意的点是:除了用户代码无法并行执行外,所有的 I/O(磁盘 I/O 和网络 I/O 等)则是可以并行起来的。