前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Node理论笔记:异步I/O

Node理论笔记:异步I/O

原创
作者头像
Ashen
修改2020-06-01 14:41:05
7140
修改2020-06-01 14:41:05
举报
文章被收录于专栏:Ashenの前端技术Ashenの前端技术

”异步“对于前端已经非常熟悉了,ajax、事件都是异步的。但在绝大多数高级编程语言中,异步并不多见,主要原因是:程序员不太适合通过异步来进行程序设计。

PHP语言从头到尾都是以同步阻塞的方式来执行的,优点是利于程序员顺序编写业务逻辑,缺点在小规模站点中基本不存在,但在复杂的网络应用中,阻塞导致无法更好的并发。

伴随异步的有事件驱动和单线程。与node的事件驱动、异步I/O设计理念比较相近的一个知名产品就是Nginx,Nginx具备面向客户端管理连接的强大能力,但是背后依然受限于各种同步方式的编程语言。而node既可以作为服务器端处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。

一、为什么要异步I/O

用户体验自不必说,还有一点就是资源分配。

假设业务场景中有一组互不相关的任务需要完成。那么有2种方法:

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

多线程的代价在于创建线程和执行线程上下文切换的开销较大。在复杂的业务中,多线程经常面临锁、状态同步等问题,但多线程在CPU上能够有效的提升CPU的利用率,这个优势毋庸置疑。

单线程顺序执行比较符合编程人员按顺序思考的思维方式,但串行执行的缺点在于性能,任何一个略慢的任务都会导致后续执行代码被阻塞。在计算机资源中,通常I/O与CPU计算之间是可以并行进行的,但同步的编程模型中,I/O的进行会让后续任务等待,这造成资源不能更好的被利用。

操作系统会将CPU的时间片分配给其余进程,有的服务器为了提升响应能力,会通过启动多个工作进程来为更多的用户服务。但就一组任务而言,它无法分发任务到多个进程上,所以依然无法高效利用资源,结束所有任务所需的时间会变长。这种模式类似于加三倍服务器,达到占用更多资源来提升服务速度,但并没能真正改善问题,

单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用,多线程编程模型也因为死锁、状态同步让开发人员头疼。

node在二者之间给出了方案:利用单线程远离死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以便更好利用CPU。

为了弥补单线程无法利用多核CPU的特点,node提供了类似web workers的子进程,该子进程可以通过工作进程高效利用CPU和I/O。异步I/O提出是期望I/O的调用不再阻塞后续运算,将原有等待I/O完成的这段时间分配给其余需要的业务去执行。

二、异步I/O实现现状

”它的优秀之处并非原创,它的原创之处并非优秀“,同JavaScript一样,node的优秀之处也并非原创。

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

从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。

操作系统对于I/O只有两种:阻塞和非阻塞。

调用阻塞I/O时,应用程序需要等待I/O完成才返回。阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。如读取磁盘上的一个文件,系统内核完成磁盘寻道、读取数据、复制数据到内存之后,这个调用才结束。

阻塞I/O导致CPU等待I/O,浪费时间,CPU的处理能力得不到充分利用。为了提升性能,内核提供了非阻塞I/O,相比阻塞I/O,非阻塞I/O会在调用之后立即返回。非阻塞I/O返回后,CPU的时间片可以用来处理其它事物。

但非阻塞I/O也有个问题,就是事物立即返回的并不是业务期望的数据,仅仅是当前调用的状态,为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术称之为轮询。

任何技术都不是完美的,阻塞I/O会造成CPU等待浪费,非阻塞I/O会需要轮询去确认是否完成数据获取。现存轮询技术有:

  • read
  • select
  • poll
  • epoll
  • kqueue

2.2 理想的非阻塞异步I/O

轮询技术的结论是,它不够好。

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

Linux操作系统提供了AIO。

2.3 现实的异步I/O

现实是,让部分线程进行阻塞I/O或非阻塞I/O加轮询技术完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递。

node提供libuv作为封装抽象层,对于*nix采用自定义线程池的方式,对于windows采用IOCP的方式解决这些问题。

一个很重要的点,经常提到node是单线程的,这里的单线程仅仅是JavaScript执行在单线程中而已,在node中,无论是*nix还是windows,内部完成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操作的过渡过程中,存在一种中间产物,称为请求对象。

以fs.open()为例,它的作用是根据请求路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O的初始操作。JavaScript调用核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用,这是node里经典的调用方式。libuv作为封装层,实质上调用了uv_fs_open()方法。在uv_fs_open()的调用过程中,会创建一个FSReqWrap请求对象。从JavaScript层传入的参数和当前方法都被封装在这个请求对象上,最关注的回调函数则被设置在这个对象的oncomplete_sym属性上。

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

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

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

3.4 执行回调

回调通知是第二部分。

线程池中的I/O操作调用完毕之后,将获取的结果存储在req->result属性上,然后调用PostQueuedCompletionStatus()通知IOCP,告知当前对象操作已经完成。该方法用于向IOCP提交执行状态,并将线程归还线程池。

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

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

至此,整个异步I/O的流程完全结束。

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

在node中,除了JavaScript是单线程外,node自身是多线程的,只是I/O线程使用的CPU较少。另一点就是,除了用户代码无法并行执行外,所有的I/O则是可以并行执行的。

四、非I/O的异步API

node其实还存在一些与I/O无关的异步API,分别是setTimeout()、setInterval()、setImmediate()、process.nextTick()。

4.1 定时器

setTimeout()和setInterval()与浏览器的API是一致的,分别用于单次和多次定时执行任务。实现原理与异步I/O类似,只是不需要I/O线程池的参与。调用这两了方法创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过就形成一个事件,它的回调函数将立即执行。

setInterval()与setTimeout()类似,区别在于前者是重复性的检测和执行。

定时器的问题是,它并非是精确的。尽管事件循环很快,但如果某一次循环占用的时间较长,那么下次循环时也许已经超时很久了。比如setTimeout()设定一个任务在10ms后执行,但在9ms后,有一个任务占用了5ms的CPU时间片,再次轮到定时器执行,时间已经过期4ms了。

4.2 process.nextTick()

如果立即执行一个异步任务,可能会这样调用:

代码语言:javascript
复制
setTimeout(()=>{
  console.log("hello world");
},0);

由于时间循环的特点,定时器的精度不够。事实上,采用定时器需要动用红黑树,创建定时器对象和迭代操作,而setTimeout的方式较为浪费性能。所以使用process.nextTick()方法的操作相对较为轻量。

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

4.3 setImmediate()

setImmediate()和process.nextTick()很相似,但还是有细微区别的。看这段代码:

代码语言:javascript
复制
setTimeout(()=>{
  console.log("setTimeout延迟执行");
},0);
setImmediate(()=>{
  console.log("setImmediate延迟执行");
});
process.nextTick(()=>{
  console.log("nextTick延迟执行");
});
console.log("立即执行");

//打印结果:

//立即执行
//nextTick延迟执行
//setTimeout延迟执行
//setImmediate延迟执行

process.nextTick()优先于setImmediate()。原因在于事件循环对观察者的检查是有先后的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。每一轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。

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

五、事件驱动与高性能服务器

经典服务器模型:

  • 同步式。一次只能处理一个请求,其余请求都处于等待状态。
  • 每进程/每请求。为每一个请求启动一个进程,可以处理多个请求,但不具备扩展性,因为资源就那么多。
  • 每线程/每请求。为每一个请求启动一个线程,线程相对进程轻量,但是每个线程都会占用一定内存,大并发请求到来后,内存很快就会被吃光。

每线程/每请求的方式被Apache所采用。

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

Nginx也采用了事件驱动的方式,有纯C语言编写,在web服务器方面性能较强,node是一个高性能的平台,所做的事情不限于web服务器,各有优势。

node的事件驱动并非首创,但却是第一个成功的平台。

JavaScript的作用域和函数在浏览器已经有成熟的应用,同时在服务器端又是空白,使得node没有任何历史包袱,且性能又非常优异,所以node在社区便迅速流行起来。

六、总结

事件驱动是异步的核心,与浏览器中的执行模型基本保持一致。其它语言一般采用同步I/O作为主要模型,node正是依靠构建了一套完善的高性能异步I/O框架,打破了JavaScript在服务器端止步不前的局面。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、为什么要异步I/O
  • 二、异步I/O实现现状
    • 2.1 异步I/O与非阻塞I/O
      • 2.2 理想的非阻塞异步I/O
        • 2.3 现实的异步I/O
        • 三、node的异步I/O
          • 3.1 事件循环
            • 3.2 观察者
              • 3.3 请求对象
                • 3.4 执行回调
                • 四、非I/O的异步API
                  • 4.1 定时器
                    • 4.2 process.nextTick()
                      • 4.3 setImmediate()
                      • 五、事件驱动与高性能服务器
                      • 六、总结
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档