Node 是如何实现异步 I/O 的
完成整个异步 I/O 环节的有事件循环、观察者和请求对象等。
事件循环
首先,我们着重强调一下 Node 自身的执行模型——事件循环,正是它使得回调函数十分普遍。在进程启动时,Node 便会创建一个类似于 while(true)
的循环,每执行一次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不再有事件处理,就退出进程。流程图如图所示。
观察者
在每个 Tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。
浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。观察者将事件进行了分类。
事件循环是一个典型的生产者/消费者模型。异步 I/O、网络请求等则是事件的生产者,源源不断为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。
在 Windows 下,这个循环基于 IOCP 创建,而在*nix 下则基于多线程创建。
请求对象
在这一节中,我们将通过解释 Windows 下异步 I/O(利用 IOCP 实现)的简单例子来探寻从 JavaScript 代码到系统内核之间都发生了什么。对于一般的(非异步)回调函数,函数由我们自行调用,如下所示:
var forEach = function (list, callback) { for (var i = 0; i < list.length; i++) { callback(list[i], i, list); } };
对于 Node 中的异步 I/O 调用而言,回调函数却不由开发者来调用。那么从我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,它叫做请求对象。
下面我们以最简单的 fs.open()
方法来作为例子,探索 Node 与底层之间是如何执行异步 I/O 调用以及回调函数究竟是如何被调用执行的:
fs.open = function(path, flags, mode, callback) {})
fs.open()
的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有 I/O 操作的初始操作。从前面的代码中可以看到,JavaScript 层面的代码通过调用 C++核心模块进行下层的操作。图为调用示意图。
从 JavaScript 调用 Node 的核心模块,核心模块调用 C++内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里经典的调用方式。这里 libuv 作为封装层,有两个平台的实现,实质上是调用了 uv_fs_open()
方法。在 uv_fs_open()
的调用过程中,我们创建了一个 FSReqWrap 请求对象。从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中,其中我们最为关注的回调函数则被设置在这个对象的 oncomplete_sym
属性上:
req_wrap->object_->Set(oncomplete_sym, callback);
对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem()
方法将这个 FSReqWrap 对象推入线
程池中等待执行,该方法的代码如下所示:
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 操作完毕后的回调处理。
执行回调
组装好请求对象、送入 I/O 线程池等待执行,实际上完成了异步 I/O 的第一部分,回调通知是第二部分。
线程池中的 I/O 操作调用完毕之后,会将获取的结果储存在 req->result
属性上,然后调用 PostQueuedCompletionStatus()
通知 IOCP,告知当前对象操作已经完成:
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus()
方法的作用是向 IOCP 提交执行状态,并将线程归还线程池。通过 PostQueuedCompletionStatus()
方法提交的状态,可以通过 GetQueuedCompletionStatus()
提取。
在这个过程中,我们其实还动用了事件循环的 I/O 观察者。在每次 Tick 的执行中,它会调用 IOCP 相关的 GetQueuedCompletionStatus()
方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到 I/O 观察者的队列中,然后将其当做事件处理。
I/O 观察者回调函数的行为就是取出请求对象的 result
属性作为参数,取出 oncomplete_sym
属性作为方
法,然后调用执行,以此达到调用 JavaScript 中传入的回调函数的目的。
至此,整个异步 I/O 的流程完全结束,如图所示。
事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素。
不同的是线程池在 Windows 下由内核(IOCP)直接提供,*nix 系列下由 libuv 自行实现。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

上一篇: 10 分钟学会 Vuex
下一篇: 不要相信一个熬夜的人说的每一句话
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论