当前位置: 首页 > 后端技术 > Node.js

Node-异步IO和EventLoop

时间:2023-04-03 20:17:11 Node.js

前言学习Node离不开异步IO,而异步IO又和EventLoop息息相关,不过这部分我还没有仔细了解和整理,只是最近在做一个项目中,我曾经记录了一些想法,希望自己能尽量梳理一下这方面的知识。如有错误还请指点~~一些概念是同步、异步&阻塞非阻塞。的概念有点混乱。事实上,两者是完全不同的。同步和异步指的是行为,也就是两者的关系,而阻塞和非阻塞指的是状态,也就是某一方。以前端请求为例,很多人应该写过如下代码$.ajax(url).succedd(()=>{......//todosomething})synchronousasynchronous如果是synchronous,那么应该是client发起请求后,等到server处理完请求后才返回继续执行后面的逻辑,让client和server保持同步状态。如果是异步的,那么应该在客户端发起请求后立即返回,可能请求还没有到达服务器或者正在处理请求。当然,在异步情况下,客户端通常会注册一个事件来处理请求完成后的情况,比如上面的succeed函数。阻塞与非阻塞首先需要了解一个概念。js是单线程的,浏览器不是。实际上,您的请求是在浏览器的另一个线程中运行的。如果它被阻塞,那么线程将等待直到请求完成,然后才被释放用于其他请求。如果是非阻塞的,那么线程可以发起请求,而不用等待请求完成,继续做其他事情。总结之所以经常出现混淆,是因为不清楚讨论的是哪一部分(下文会提到),所以同步和异步讨论的对象都是双方,而阻塞和非阻塞讨论的对象是自己。IO和CPUIo和Cpu可以同时工作。IO:I/O(英文:Input/Output),即输入/输出,通常是指内部存储器与外部存储器或其他外围设备之间的数据输入输出。CPU解释计算机指令并处理计算机软件中的数据。Node中的异步IO模型IO分为磁盘IO和网络IO,分为两个步骤:等待数据准备好(Waitingforthedatatobeready)和从内核拷贝数据到进程(Copyingthedatafromthekerneltotheprocess)DiskIoinNode下面的讨论都是基于*nix系统。理想的异步IO应该是上面讨论的,如图:其实我们的系统是无法完美实现这样的调用方式的。Node的异步IO,比如读取文件,使用的是线程池的方式可以看出,Node是通过另一个线程进行Io操作,完成后再通知主线程:而在window下,则是使用IOCP接口来完成。从用户的角度来看,IOCP确实是一个完美的异步调用。该方法实际上使用了内核中的线程池,与nix系统的区别在于后者的线程池是用户层提供的线程池。在进入Node中网络Io这个话题之前,我们先来了解下Linux的Io模式。在这里我推荐你阅读这篇文章。大体总结如下:阻塞式I/O(blockingIO)因此,阻塞式IO的特点是在执行IO的两个阶段都是阻塞的。非阻塞I/O(nonblockingIO)当用户进程发出读操作时,如果内核中的数据还没有准备好,它不会阻塞用户进程,而是立即返回错误。从用户进程的角度来看,它发起读操作后,不需要等待,而是立即得到结果。当用户进程判断结果为错误时,就知道数据没有准备好,可以再次发送读操作。一旦内核中的数据准备好,再次收到用户进程的系统调用,就立即将数据拷贝到用户内存中,然后返回。I/O多路复用(IOmultiplexing)因此,I/O多路复用的特点是一个进程可以通过一种机制同时等待多个文件描述符,而这些文件描述符(套接字描述符)中的任意一个进入读-就绪状态,并且select()函数可以返回。异步I/O(asynchronousIO)用户进程发起读操作后,可以马上开始做其他事情。另一方面,从内核的角度来看,当它接收到一个异步读取时,它会先立即返回,因此它不会为用户进程产生任何块。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存中。当这一切完成后,内核会向用户进程发送一个信号,告诉它读操作已经完成。在Node中,采用的是I/O多路复用的模式,在I/O多路复用的模式中,有read、select、poll、epoll等几种子模式,Node采用了最优秀的epoll模式,这里简单描述一下区别,并解释为什么epoll是最优的。读读。它是最原始、性能最低的一种,通过反复检查I/O的状态来完成对数据的完整读取。直到得到最终的数据,CPU一直消耗在反复检查I/O状态。图1是read轮询的示意图。选择选择。是在read的基础上改进的方案,通过判断文件描述符上的事件状态。图2是通过select进行轮询的示意图。Selectpolling有一个较弱的限制,因为它使用1024长度的数组来存储状态,这意味着它最多可以同时检查1024个文件描述符。民意调查。轮询是对选择的改进。它使用链表来避免数组长度的限制,二来可以避免不必要的检查。但是当文件描述符很多的时候,它的性能就很低了。epoll这种方案是Linux下最高效的I/O事件通知机制。如果在进入轮询时没有检测到I/O事件,它将休眠,直到有事件发生将其唤醒。它实际上使用事件通知和执行回调而不是遍历查询,所以不会浪费CPU,执行效率高。另外,其他的poll和select也有如下缺点(引用自文章):每次调用select时,都需要将fd集合从用户态复制到内核态。每次调用select需要遍历内核传入的所有fd。这个开销在fd很多的时候也是非常大的。select支持的文件描述符数量太少。默认是1024.,应该可以避免以上三个缺点。那么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),所有的fds都会被复制到内核中,而不是在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中的事件循环有六个阶段:每个阶段都会处理相关的事件:定时器:在setTimeout和setInterval中执行过期回调。挂起回调:I/O回调,其执行被推迟到下一个循环迭代。空闲,准备:仅供系统内部使用。poll:检索新的I/O事件;执行I/O相关的回调(几乎所有情况下,关闭的回调函数除外,通过定时器和setImmediate()来调度),其余的节点都会在Block这里。(即本文内容相关))检查:这里执行了setImmediate()回调函数。closecallbacks:执行关闭事件的回调,比如socket.on('close'[,fn])或者http.server.on('close,fn)。好了,到此就解释了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层传入的参数和当前方法都封装在这个request对象中,而我们最关心的回调函数是在这个对象的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过程:它依赖于一个由IO线程池epoll、事件循环、请求对象组成的管理机制。为什么Node更适合IO密集型Node更适合IO密集型系统,性能更好,这与其异步IO密切相关。对于一个请求,如果我们依赖io的结果,异步io和同步阻塞io(perthread/perrequest)必须等到io完成后才能继续执行。同步阻塞io,一旦阻塞,就得不到cpu时间那么为什么异步性能更好呢?根本原因是同步阻塞Io需要为每个请求创建一个线程。I期间,线程被阻塞。虽然不消耗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的语法更自然、更接近,所以更适合。Reference&reference如何理解阻塞非阻塞和同步异步的区别?Linuxepoll&Node.jsEventLoop&I/Omultiplexingnode.js应用高并发高性能的核心本质是什么?LinuxIO模式及select、poll、epoll详解异步IO比同步阻塞IO好吗?为什么?用简单的术语解释Nodejs