Node - 异步IO和事件循环

前言

学习Node就绕不开异步IO, 异步IO又与事件循环息息相关, 而关于这一块一直没有仔细去了解整理过, 恰好最近在作项目的时候, 有了一些思考就记录了下来, 但愿能尽可能将这一块的知识整理清楚, 若有错误, 请指点轻喷~~html

一些概念

同步异步 & 阻塞非阻塞

查阅资料的时候, 发现不少人都对异步和非阻塞的概念有点混淆, 其实二者是彻底不一样的, 同步异步指的是行为即二者之间的关系, 而阻塞非阻塞指的是状态即某一方前端

之前端请求为一个例子,下面的代码不少人都应该写过node

$.ajax(url).succedd(() => {
    ......
    // to do something
})

同步异步
若是是同步的话, 那么应该是client发起请求后, 一直等到serve处理请求完成后才返回继续执行后续的逻辑, 这样client和serve之间就保持了同步的状态nginx

若是是异步的话, 那么应该是client发起请求后, 当即返回, 而请求可能尚未到达server端或者请求正在处理, 固然在异步状况下, client端一般会注册事件来处理请求完成后的状况, 如上面的succeed函数。ajax

阻塞非阻塞
首先须要明白一个概念, Js是单线程, 可是浏览器并非, 事实上你的请求是浏览器的另外一个线程在跑。 segmentfault

若是是阻塞的话, 那么该线程就会一直等到这个请求完成以后才能被释放用于其余请求数组

若是是非阻塞的话, 那么该线程就能够发起请求后而不用等请求完成继续作其余事情浏览器

总结
之因此常常会混乱是由于没有说清楚讨论的是哪一部分(下面会提到), 因此同步异步讨论的对象是双方, 而阻塞非阻塞讨论的对象是自身服务器

IO和CPU

Io和Cpu是能够同时进行工做的网络

IO:

I/O(英语:Input/Output),即输入/输出,一般指数据在内部存储器和外部存储器或其余周边设备之间的输入和输出。

cpu

解释计算机指令以及处理计算机软件中的数据。

Node中的异步IO模型

IO分为磁盘IO和网络IO, 其具备两个步骤

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

Node中的磁盘Io

如下的讨论基于*nix系统。
理想的异步Io应该像上面讨论的同样, 如图:

而实际上, 咱们的系统并不能完美的实现这样的一种调用方式, Node的异步IO, 如读取文件等采用的是线程池的方式来实现, 能够看到, Node经过另一个线程来进行Io操做, 完成后再通知主线程:

而在window下, 则是利用IOCP接口来完成, IOCP从用户的角度来讲确实是完美的异步调用方式, 而实际也是利用内核中的线程池, 其与nix系统的不一样在于后者的线程池是用户层提供的线程池。

Node中的网络Io

在进入主题以前, 咱们先了解下Linux的Io模式, 这里推荐你们看这篇文章, 大体总结以下:

阻塞 I/O(blocking IO)

因此,blocking IO的特色就是在IO执行的两个阶段都被block了。

非阻塞 I/O(nonblocking IO)

当用户进程发出read操做时,若是kernel中的数据尚未准备好,那么它并不会block用户进程,而是马上返回一个error。从用户进程角度讲 ,它发起一个read操做后,并不须要等待,而是立刻就获得了一个结果。用户进程判断结果是一个error时,它就知道数据尚未准备好,因而它能够再次发送read操做。一旦kernel中的数据准备好了,而且又再次收到了用户进程的system call,那么它立刻就将数据拷贝到了用户内存,而后返回。

I/O 多路复用( IO multiplexing)

因此,I/O 多路复用的特色是经过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就能够返回。

异步 I/O(asynchronous IO)

用户进程发起read操做以后,马上就能够开始去作其它的事。而另外一方面,从kernel的角度,当它受到一个asynchronous read以后,首先它会马上返回,因此不会对用户进程产生任何block。而后,kernel会等待数据准备完成,而后将数据拷贝到用户内存,当这一切都完成以后,kernel会给用户进程发送一个signal,告诉它read操做完成了。

而在Node中, 采用的是I/O 多路复用的模式, 而在I/O多路复用的模式中, 又具备read, select, poll, epoll等几个子模式, Node采用的是最优的epoll模式, 这里简单说下其中的区别, 而且解释下为何epoll是最优的。

read
read。它是一种最原始、性能最低的一种,它会重复检查I/O的状态来完成数据的完整读取。在获得最终数据前,CPU一直耗用在I/O状态的重复检查上。图1是经过read进行轮询的示意图。

select
select。它是在read的基础上改进的一种方案,经过对文件描述符上的事件状态进行判断。图2是经过select进行轮询的示意图。select轮询具备一个较弱的限制,那就是因为它采用一个1024长度的数组来存储状态,也就是说它最多能够同时检查1024个文件描述符。

poll
poll。poll比select有所改进,采用链表的方式避免数组长度的限制,其次它能够避免没必要要的检查。可是文件描述符较多的时候,它的性能是十分低下的。

epoll
该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候若是没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知,执行回调的方式,而不是遍历查询,因此不会浪费CPU,执行效率较高。

除此以外, 另外的poll和select还具备如下的缺点(引用自文章):

  1. 每次调用select,都须要把fd集合从用户态拷贝到内核态,这个开销在fd不少时会很大
  2. 同时每次调用select都须要在内核遍历传递进来的全部fd,这个开销在fd不少时也很大
  3. select支持的文件描述符数量过小了,默认是1024

epoll对于上述的改进

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此以前,咱们先看一下epoll和select和poll的调用接口上的不一样,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是建立一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
  对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把全部的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每一个fd在整个过程当中只会拷贝一次。
  对于第二个缺点,epoll的解决方案不像select或poll同样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每一个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工做实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是相似的)。
  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大能够打开文件的数目,这个数字通常远大于2048,举个例子,在1GB内存的机器上大约是10万左右,通常来讲这个数目和系统内存关系很大。

Node中的异步网络Io就是利用了epoll来实现, 简单来讲, 就是利用一个线程来管理众多的IO请求, 经过事件机制实现消息通信。

事件循环

理解了Node中磁盘IO和网络IO的底层实现后, 基于上面的代码, 能够看出Node是基于事件注册的方式在完成Io后进行一系列的处理, 其内部是利用了事件循环的机制。

关于事件循环, 是指JS在每次执行完同步任务后会检查执行栈是否为空, 是的话就会去执行注册的事件列表, 不断的循环该过程。Node中的事件循环有六个阶段:

其中的每一个阶段都会处理相关的事件:

  • timers: 执行setTimeout和setInterval中到期的callback。
  • pending callback: 执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • poll:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎全部状况下,除了关闭的回调函数,它们由计时器和 setImmediate() 排定的以外),其他状况 node 将在此处阻塞。(即本文的内容相关))
  • check: setImmediate() 回调函数在这里执行。
  • close callbacks: 执行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)。

ok, 这样就解释了Node是如何执行咱们注册的事件, 那么还缺乏一个环节, Node又是怎么把事件和IO请求对应起来呢? 这里涉及到了另一种中间产物请求对象。
以打开一个文件为例子:

fs.open = function(path, flags, mode, callback){

//...

binding.open(pathModule._makeLong(path), stringToFlags(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);
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操做完毕后的回调处理。
关于这一块其实我的认为不用过于细究, 大体上知道有这么一个请求对象便可, 最后总结一下整个异步IO的流程:

图引用自深刻浅出NodeJs

至此, Node的整个异步Io流程都已经清晰了, 它是依赖于IO线程池epoll、事件循环、请求对象共同构成的一个管理机制。

Node为何更适合IO密集

Node为人津津乐道的就是它更适合IO密集型的系统, 而且具备更好的性能, 关于这一点其实与它的异步IO息息相关。

对于一个request而言, 若是咱们依赖io的结果, 异步io和同步阻塞io(每线程/每请求)都是要等到io完成才能继续执行. 而同步阻塞io, 一旦阻塞就不会在得到cpu时间片, 那么为何异步的性能更好呢?

其根本缘由在于同步阻塞Io须要为每个请求建立一个线程, 在Io的时候, 线程被block, 虽然不消耗cpu, 可是其自己具备内存开销, 当大并发的请求到来时, 内存很快被用光, 致使服务器缓慢, 在加上, 切换上下文代价也会消耗cpu资源。而Node的异步Io是经过事件机制来处理的, 它不须要为每个请求建立一个线程, 这就是为何Node的性能更高。

特别是在Web这种IO密集型的情形下更具优点, 除开Node以外, 其实还有另一种事件机制的服务器Ngnix, 若是明白了Node的机制对于Ngnix应该会很容易理解, 有兴趣的话推荐看这篇文章

总结

在真正的学习Node异步IO以前, 常常看到一些关于Node适不适合做为服务器端的开发语言的争论, 固然也有不少片面的说法。
其实, 关于这个问题仍是取决于你的业务场景。

假设你的业务是cpu密集型的, 那你采用Node来开发, 确定是不适合的。 为何不适合? 由于Node是单线程, 你被阻塞在计算的时候, 其余的事件就作不了, 处理不了请求, 也处理不了回调。

那么在IO密集型中, Node就比Java好吗? 其实也不必定, 仍是要取决于你的业务。 若是你的业务是很是大的并发, 可是你的服务器资源又有限, 就比如如今有个入口, Node能够一次进10我的, 而Java依次排队进一我的, 若是是10我的同时进, 固然是Node更具备优点, 可是假设有100我的(如1w个异步请求之类)的话, 那么Node就会由于它的异步机制致使应用被挂起,内存狂飙,IO堵塞,并且不可恢复,这个时候你只能重启了。而Java却能够有序的处理, 虽然会慢一点。 而一台服务器挂了形成的线上事故的损失更是不可衡量的。(固然, 若是服务器资源足够的话, Node也能处理)。

最后, 事实上Java也是具备异步IO的库, 只是相对来讲, Node的语法更天然更贴近, 也就更适合。

参考&引用

怎样理解阻塞非阻塞与同步异步的区别?
Linux epoll & Node.js Event Loop & I / O复用
node.js应用高并发高性能的核心关键本质是什么?
Linux IO模式及 select、poll、epoll详解
异步IO比同步阻塞IO性能更好吗?为何?
深刻浅出Nodejs