《深入浅出nodejs》学习总结之异步I/O

632 阅读7分钟

异步I/O和事件驱动是Nodejs的主要特点。正如它的名字一样,Nodejs是网络中灵活的一个 节点,既可以作为服务器端起处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。

1 系统对异步I/O的支持

1.1 异步I/O的必要性

要处理一堆任务,现行的主流方法有两种:

  • 多线程:并行执行。多线程的优势在于在多核CPU上能有效提升CPU的利用率,但代价就上创建线程和线程上下文切换的开销较大。在复杂的业务中,还要面临锁、状态同步等问题。

  • 单线程:串行执行。单线程的顺序执行任务方式更易于表达,但缺点在于性能。串行执行会造成 阻塞,造成资源不能更好地被利用。如何让单线程远离阻塞?一种办法是通过启动多个工作进程(子进程)来工作,占用更多资源来提升服务速度,但并没有真正改善问题;另一种就是异步I/O。

Nodejs在两者中给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O远离阻塞问题,以更好地利用CPU。

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

首先,从实际效果来看,异步I/O与非阻塞I/O都达到了并行I/O的目的。

  • 非阻塞I/O:进行I/O调用时,阻塞I/O完成整个获取数据的过程才返回,而非阻塞I/O则不带数据直接返回,要获取数据,还需要通过 轮询 主动获取完成状态,得到任务完成的状态后才获取数据。轮询期间,CPU要么遍历文件描述符的状态,要么用于休眠等待事件发生。
  • 异步I/O:进行I/O调用时,异步I/O也是不带数据直接返回。与非阻塞I/O不同的是,应用程序发起非阻塞调用,无须遍历或事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递给应用程序即可。 [P54图]

1.3 现实中的异步I/O

上述的非阻塞异步I/O是理想的异步I/O。实际上,虽然Linux有原生提供一种异步I/O方式(AIO),但其他平台没有。

目前一般的做法是:通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程(主线程)来完成计算处理,通过线程之间的通信将I/O得到的数据进行传递,从而模拟异步I/O。即通过线程池模拟异步I/O。windows下的IOCP在某种程度上提供了理想的异步I/O,其内部就是线程池原理。

Nodejs在windows平台下采用IOCP实现异步I/O,在*nix平台采用自行实现的线程池(v0.9.3),并提供了libuv作为抽象封装层以兼容不同平台。

2 Node的异步I/O执行模型

2.1 事件循环

在进程启动时,Node会创建一个类似于while(true)的循环,每执行一次循环称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有就取出事件及其相关的回调函数并执行。

具体每个Tick中的回调函数执行的优先级可以看之前的总结理解浏览器与Nodejs中的event loop

2.2 观察者

事件是从哪里来的呢?在Nodejs中,网络请求、文件I/O等事件都有对应的网络I/O观察者、文件I/O观察者。

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

2.3 请求对象

从调用I/O操作到回调函数被执行发生了什么?

从JS发起调用到内核执行完I/O操作 的过渡过程中,存在一种中间产物,即__请求对象__。下面以windows平台上调用fs.open()为例说明。

文件模块中调用对应的js核心模块 => 核心模块调用对应的C/C++内建模块 => 内建模块通过libuv进行平台判断(windows/*nix),实质上调用uv_fs_open()方法。

uv_fs_open()调用过程中,创建了一个FSReqWrap请求对象。js层传入的参数和当前调用的方法都被封装到这个对象中,其中有一个属性oncomplete_sym就是回调函数。封装后,系统把这个 请求对象 推入线程池中等待执行。

2.4 执行回调

推入线程池后,等到线程池中操作完毕后,会将结果储存在 请求对象result属性上,然后调用IOCP相关的方法通知IOCP对象操作已经完成。

在这个过程中,每次Tick的执行中,事件循环的 I/O观察者(poll阶段)会调用IOCP相关的方法检查线程池是否有执行完的请求,如果有则将请求对象加入到I/O观察者的 队列(poll队列)中。

需要执行时,取出请求对象的result属性(结果)作为参数,传递给请求对象的oncomplete_sym属性(回调函数),以达到调用js中传入的回调函数的目的。

至此,整个异步I/O流程就结束了。

异步非I/O的流程有点相似,这里不详细说了。比如setTImeout()setInterval()不需要I/O线程池的参与,而是利用定时器处理调用。

3 与浏览器中的事件循环对比

3.1 浏览器内核

浏览器的内核是 多线程 的。除了我们普遍知道的渲染引擎和js引擎外还有其他线程。 这些线程在内核控制下各线程相互配合以保持同步。

一个浏览器通常由以下常驻线程组成:

  • GUI 渲染线程(渲染引擎):负责取得网页的内容(HTML、XML、图像等等)、整理讯息(例如加入 CSS 等),以及计算网页的显示方式,将页面内容和排版代码转换为用户所见的视图。
  • JavaScript引擎线程(JS引擎):解析 Javascript 语言
  • 定时触发器线程:通过单独线程来计时并触发定时,把回调函数放到异步事件队列中
  • 事件触发线程:当一个事件被触发时该线程会把回调函数放到异步事件队列中
  • 异步http请求线程:在XMLHttpRequest在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到异步事件队列中

其中,GUI 渲染线程 与 JavaScript引擎线程是 互斥 的!

因为JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JavaScript线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。所以JavaScript文件加载会阻塞DOM树的构建

3.2 与浏览器中的事件循环对比

在浏览器的事件循环中,js引擎是单线程的,如鼠标点击、AJAX异步请求等这些事件都得排队等待js引擎处理。定时触发器线程、事件触发线程和异步http请求线程各自处理后会把把回调函数放到宏任务队列/微任务队列当中。js引擎会按照宏任务->当前微任务->下一个宏任务的顺序循环执行。

浏览器的事件循环
如上所述,nodejs则是交给线程池中的各个线程(或定时器等)处理,处理后会把回调函数放到各个阶段的任务队列中。v8引擎按各个阶段顺序循环执行。

nodejs的事件循环
具体可以看之前的总结:理解浏览器与Nodejs中的event loop

4 参考

  • 《深入浅出nodejs》

因为疫情在家休息,继续看看书……