Node-异步IO和事件循环

43次阅读

共计 5989 个字符,预计需要花费 15 分钟才能阅读完成。

前言

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

一些概念

同步异步 & 阻塞非阻塞

查阅资料的时候,发现很多人都对 异步和非阻塞 的概念有点混淆,其实两者是完全不同的,同步异步指的是 行为即两者之间的关系 ,而阻塞非阻塞指的是 状态即某一方

以前端请求为一个例子,下面的代码很多人都应该写过

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

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

如果是异步的话,那么应该是 client 发起请求后,立即返回,而请求可能还没有到达 server 端或者请求正在处理,当然在异步情况下,client 端通常会注册事件来处理请求完成后的情况,如上面的 succeed 函数。

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

如果是阻塞的话,那么 该线程就会一直等到这个请求完成之后才能被释放用于其他请求

如果是非阻塞的话,那么 该线程就可以发起请求后而不用等请求完成继续做其他事情

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

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

正文完
 0