浅谈NodeJS的异步I/O

浏览器和NodeJS均使用JavaScript作为其编程语言。准确来说,是NodeJS的作者选择了用JavaScript作为应用层的语言,底层则是基于V8引擎。
尽管浏览器应用和NodeJS都是使用JavaScript进行编程,但一些关键的差异使体验相当不同。
而NodeJS的核心,就是异步I/O。

为什么NodeJS选择异步I/O?

在Node中,绝大多数的操作都是以异步的方式进行调用,每一个调用之间不需要等待前一个调用结束,比如说两个请求进行异步I/O,那么耗时则取决于最慢的那个请求。换句话说,第一个请求耗时x,第二个请求耗时y,则总耗时=max(x,y)。对比于同步I/O,总耗时=x+y,优势就很明显了。
Node,可以说是第一个把异步作为主要编程方式和设计理念的高级编程语言,那么为什么要使异步I/O呢?有如下的原因:

用户至上的原则

我们都知道,当前的时代是体验至上的时代。同类竞争的软件成千上万,要如何脱颖而出,体验就是一个非常重要的因素。
随着APP功能逐渐繁杂,每一个操作需要的请求也越来越多。在以前,或许我们会认为相应的速度就交给后台吧,和前端没有太大的关系,但现在不一样,前端承担着越来越重要的责任。
JavaScript是单线程执行的,它不仅仅负责着文档对象模型的一系列动作,还负责UI的渲染。单线程模式也就意味着这一连串的操作使用一个线程,这又要回到我们前面说的同步I/O,JS在执行的时候会等待一个个任务结束之后再继续,这无疑是十分低效的。而这个时候如果采用异步任务,JS的执行就不会处于等待的状态,可以完成交互。
有一个观点就是,当脚本执行的时间超过100ms,用户就会感到卡顿。当年Ajax的出现,改变了这一困境,开启了新的篇章、

更好地进行资源分配

异步I/O的提出,是为了让I/O的调用不会阻塞到后续的运算,将原有的等待时间分配给其余需要执行的进程。
我们做一个假设,有一个需求,要求有多组不相关的任务需要进行,无非就是两种解决方案:

  • 单线程串行依次解决
  • 多线程并行完成

按照我们前面所说,多线程似乎是不错的选择。但是我们别忘了,任何一项技术都是有缺陷的,多线程的方案在创建线程和执行期线程上下文切换时会产生较大的开销,而且在业务较为繁杂的情况下,多线程编程会面临状态同步等问题,因此多线程也并非是完美的。
单线程的编程模式应该是比较符合我们的思维的,一件事情做完再去做下一件,流水线般,易于理解。正如我们第一门学习的语言,C语言就是一个典型的面向过程编程语言。但是它的缺点很明显,当线程中有一个进程稍慢,就会影响后续进程的时间,发生代码阻塞。更严重的是,假如一个进程出错,整个脚本可能就出问题。
面对单线程和多线程的缺点,Node给出了一个解决方案:利用单线程,远离多线程死锁等问题;利用异步I/O,避免单线程阻塞,以便更好地利用CPU资源。
其实在NodeJS中,只有JavaScript的执行是单线程的,因此并发性是指事件循环在完成其他工作后执行JavaScript 回调函数的能力。任何希望异步执行的代码必须让事件循环能够在非 JavaScript 操作(比如 I/O )执行的同时继续运行。

NodeJS的异步I/O

事件循环

JavaScript是一门单线程语言,在同一个时间里,单线程的执行栈只能执行一个任务,也就是阻塞的。但是现代的应用是非常复杂的,异步解决问题避免不了。JavaScript就是通过时间循环+任务队列相结合的方式,解决异步调用的问题。在浏览器的环境下,单线程调用任务进执行栈的时候,假如遇到一个异步任务,则会将其交给浏览器的相关模块处理,自己则继续处理非异步任务。当主线程中的任务处理完毕,则会去看任务队列中有没有可以执行的异步任务,若有,则提取队列的第一个到执行栈中执行。
异步任务不会影响主线程的任务,相当于它在一边进行,当得到结果并可以执行回调方法的时候,则会将回调方法放至消息队列中,等待主线程的调用。
而JavaScript中的任务队列还分为微任务队列和宏任务队列,当有微任务的时候,会优先执行完所有的微任务,再去宏任务队列中提取一个任务执行,紧接着看有没有新的微任务,有则全部执行,继续调用一个宏任务,以此类推。这就是事件循环的基本流程,也就是说,事件循环必须同时拥有微任务和宏任务才能够体现出来。
在这里插入图片描述

常见的宏任务有点击事件,键盘事件,setTimeout等,而常见的微任务有Promise等等。
而我们今天说到的NodeJS,其实就是一个基于V8解析引擎的JS运行环境,事件循环机制和浏览器中基本一致。在NodeJS中,单线程仅仅是JS执行任务的单线程,背后执行I/O任务则为线程池提供的多线程执行。
NodeJS中的事件循环

观察者

每一个循环体我们称之为Tick,在Tick中,判断当前是否有事件需要处理本质上就是向这些观察者进行询问。浏览器中,用户通过点击或者请求数据产生事件,这些事件都有其对应的观察者。同理,在NodeJS的世界中,事件主要来源则为网络的请求,文件I/O等等。不同的事件类型有不同的观察者。
事件循环是一个典型的生产者/消费者模型,异步I/O、网络请求是事件的生产者,这些生产者会生产出事件,事件则会被对应的观察者观察,事件循环按照相关的机制,从观察者取出事件并处理。

请求对象

对于NodeJS,异步I/O的回调函数并不是由开发者决定,当我们的代码接受了前端的请求,会生产一种用于JS到内核执行I/O任务的过渡产物——请求对象。所有的请求状态都被保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。
JavaScript通过调用C++核心模块进行下层的操作。在NodeJS的核心实现中,大部分模块是基于C++实现的,而这些内建模块通过libuv进行系统调用。libuv可以看做是一个封装层。
在经过底层的实现之后,JavaScript调用立即返回,接着JS会根据事件循环的机制,将消息返回给前端。在JS的单线程调用过程中,由于异步I/O的存在,不影响线程池的执行机制,实现了异步。

执行回调

在上一个板块中,我们介绍了请求对象,那么当请求对象封装好之后,送入线程池执行,也就是完成了整个异步I/O的第一步。在线程池中的I/O操作调用完毕之后,会将获取的结果储存在req的result属性上,然后调用PostQueuedCompletionStatus()通知IOCP,告知当前对象操作已完成,而这个方法的作用就是向IOCP提交执行状态,并将线程归还给线程池。而被提交的执行状态,可以通过GetQueuedCompletionStatus()获取。
NodeJS在处理这个流程,其实也是用事件循环I/O观察者,在每一个循环体中,观察者会调用IOCP相关的GetQueuedCompletionStatus()方法来检查线程池中是否有执行完毕的请求,如果存在,会将请求对象加入到I/O观察者的队列中。
而回调函数的目的就是取出请求对象的result属性作为参数,取出oncomplete_sync属性作为方法,然后调用执行,以此达到调用JS中传入回调函数的目的。
在这里插入图片描述


以下是官方文档中,对NodeJS事件循环操作顺序的简易图:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  • 定时器:本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。
  • 待定回调:执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • 轮询:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计>+ 时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 检测:setImmediate() 回调函数在这里执行。
  • 关闭的回调函数:一些关闭的回调函数,如:socket.on(‘close’, …)。

小结

NodeJS异步I/O有四大要素,也就是我们上文提到的事件循环、观察者、请求对象和I/O线程池。在Windows的环境下,主要通过IOCP来向系统内核发送I/O调用和从内核中获取已完成的I/O任务,结合JavaScript的时间循环机制,完成异步I/O。
我们可能会诧异,JS的单线程似乎和I/O线程池有些矛盾,实际上,对于NodeJS而言,开发者是使用了JS,实际上其自身底层是多线程的。换言之,只有开发者所敲的代码是单线程,所有的I/O操作均可同时执行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值