当前位置: 首页 > 科技观察

【深度探索Node】(三)《异步IO》九题

时间:2023-03-14 10:56:16 科技观察

我尝试用自问的方式写下笔记,就像面试一样,个人觉得很有意思,希望大家喜欢它也是1.为什么异步I/O?具体可以从用户体验和资源配置两个方面入手。用户体验和前端JavaScript在单线程上执行,它还与UI渲染共享一个线程。执行JavaScript时,UI渲染和响应处于停顿状态。那么,在node中,假设此时没有使用异步io,那么当一个io在执行的时候,另一个io的执行必须等待前一个io的执行完成。那么速度就会慢很多。需要意识到只有后端才能快速响应资源,才能让前端的体验更好。对于资源分配,我们首先要知道,计算机在开发过程中对组件进行了抽象,将其分为I/O设备和计算设备。如果创建多线程的开销小于并行执行,那么多线程方法是首选。多线程的代价是线程创建和执行过程中线程上下文切换的高开销。另外,在复杂的业务中,多线程编程经常面临锁、状态同步等问题,这也是多线程被诟病的主要原因。但是,多线程在多核CPU上可以有效提高CPU利用率。这个优势是毋庸置疑的。单线程任务顺序执行的方式更符合程序员顺序的思维方式。它仍然是最主流的编程风格,因为它易于表达。但是串行执行的缺点是性能。任何稍慢的任务都会导致后续代码执行受阻。在计算机资源中,通常I/O和CPU计算可以并行进行。但是同步编程模型带来的问题是I/O的进行会让后续任务等待,导致资源得不到更好的利用。由于阻塞I/O,单线程同步编程模型不会更好地利用硬件资源。多线程编程模型由于编程中的死锁和状态同步等问题也让开发者很头疼。Node给出了介于两者之间的解决方案:使用单线程避免多线程死锁和状态同步等问题;使用异步I/O让单线程远离阻塞以更好地使用CPU。异步I/O可以说是Node的一个特性,因为它是第一个将异步I/O大规模应用到应用层的平台,力求在单线程上更高效地分配资源。为了弥补单线程无法利用多核CPU的缺点,Node在前端浏览器中提供了类似于WebWorkers的子进程,可以通过工作进程高效利用CPU和I/O。异步I/O的提出是期望I/O的调用不再阻塞后续操作,将原本等待I/O完成的时间分配给其他需要的服务执行。下图是异步I/O调用的示意图。2.说到异步IO,我也经常听到非阻塞IO。两者是一回事吗?异步和非阻塞听起来是一回事。从实际效果来看,异步和非阻塞都达到了我们并行I/O的目的。但是从计算机内核I/O的角度来看,异步/同步和阻塞/非阻塞其实是两个不同的东西。操作系统内核只有两种I/O方法:阻塞和非阻塞。调用阻塞I/O时,应用程序需要等待I/O完成再返回结果,如图所示。阻塞I/O的一个特点是,调用之后,调用必须等到系统内核级别的所有操作都完成后,调用才结束。以读取磁盘上的一段文件为例。系统内核完成磁盘寻道,读取数据,并将数据复制到内存后,调用结束。阻塞式I/O导致CPU等待I/O,浪费等待时间,无法充分利用CPU的处理能力。为了提高性能,内核提供了非阻塞I/O。非阻塞I/O和阻塞I/O的区别在于调用后立即返回,如图。这让我想起了直接打印状态为pending的promise对象,也是可以打印出来的。这是异步的。虽然状态没有变成resolved或者rejected,但是也被退回了。非阻塞I/O返回后,CPU的时间片可以用来处理其他事务,此时的性能提升是很明显的。3、这种情况下,根本无法返回完整的数据。我应该怎么办?该层期望的数据只是当前调用的状态。为了获取完整的数据,应用程序需要反复调用I/O操作来确认是否完整。这种反复调用以确定操作是否完成的技术称为轮询。4.能说说轮询技术是什么吗?没有技术是完美的。阻塞I/O导致CPU等待和浪费。非阻塞带来的麻烦是需要轮询确认数据获取是否完全完成。会让CPU去处理状态判断,这是对CPU资源的浪费。轮询技术主要有这几种:read、select、poll、epoll。read是最原始的,性能也是最低的。它通过反复调用检查I/O的状态来读取完整的数据。在得到最终数据之前,CPU已经被消耗等待。下图是通过read轮询的示意图。select是在read的基础上改进的scheme,判断文件描述符上的事件状态。下图是通过select进行轮询的示意图。selectpolling有一个较弱的限制,即由于它使用1024长度的数组来存储状态,它最多可以同时检查1024个文件描述符。轮询是对选择的改进。它使用链表来避免数组长度的限制,二来可以避免不必要的检查。但是当文件描述符很多的时候,它的性能还是很低的。下图是通过poll进行轮询的示意图,和select类似,但是提高了性能限制。epoll方案是Linux下最高效的I/O事件通知机制。如果在进入轮询时没有检测到I/O事件,它将休眠,直到有事件发生将其唤醒。它实际上是采用了事件通知和回调执行的方式,而不是遍历查询,所以不会浪费CPU,执行效率高。下图是通过epoll进行轮询的示意图。轮询技术满足了非阻塞I/O的要求,保证数据获取完整,但是对于应用来说,仍然只是一种同步,因为应用还需要等待I/O完全返回,这还是需要很多时间时间来等待。在等待期间,CPU要么用来遍历文件描述符的状态,要么用来休眠等待事件的发生。结论是还不够好。5、epoll虽然使用了事件来降低CPU消耗,但是在sleep的时候CPU几乎是空闲的,对于当前线程来说利用率是不够的。那么,有没有理想的异步I/O呢?是的。我们期望的完美异步I/O应该是应用程序发起一个非阻塞的调用。不需要通过遍历或事件唤醒进行轮询,可以直接处理下一个任务。它只需要在I/O完成后通过信号或回调发送数据。只需将其传递给应用程序即可。幸运的是,Linux下有这样一种方式,它提供了一种异步I/O方法(AIO),通过信号或回调来传输数据。但遗憾的是,它只能在Linux下使用,而且它有缺陷——AIO在内核I/O中只支持O_DIRECT模式读取,导致无法使用系统缓存。6、异步I/O在现实中是如何实现的?现实比理想要骨感一点,但实现异步I/O的目标并不难。前面我们把场景限制在单线程的情况下,多线程的做法会是另外一种场景。通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术完成数据的获取,让一个线程进行计算处理,通过线程间的通信传递I/O得到的数据,很容易实现异步I/O是实现的(虽然是模拟的),如原理图所示。还有一点需要强调的是,我们经常提到Node是单线程的。这里的单线程只是JavaScript在单线程中执行。在Node中,无论是*nix还是Windows平台,都有另外一个线程池用于内部完成I/O任务。7、以上是系统对异步IO的实现,如何在node中实现异步IO来完成整个异步IO环节,包括事件循环、观察者、线程池、请求对象。事件循环首先,让我们重点介绍一下Node自己的执行模型,即事件循环,它使回调变得如此普遍。当进程启动时,Node会创建一个类似于while(true)的循环,每次执行循环体的过程称为Tick。每个Tick的过程就是检查是否有事件需要处理,如果有则取出事件及其相关的回调函数。如果有关联的回调函数,则执行它们。然后进入下一个循环,如果没有更多的事件处理,则退出流程。流程图如图所示。在每个Tick的过程中,观察者如何判断是否有事件需要处理?这里必须要介绍的概念就是观察者。每个事件循环中都有一个或多个观察者,判断是否有事件需要处理的过程就是向这些观察者询问是否有事件需要处理。这个过程就像餐厅的厨房。厨房一一做菜,但做哪些菜要看收银员接到的订单。厨房每做完一轮菜,我就会问收银妹接下来有没有菜做,如果没有,下班就关门了。在这个过程中,收银台的小姐姐就是观察者,她收到顾客的订单就是关联的回调函数。当然,如果餐厅经营得好,它可能会有多个收银员,就像事件循环中有多个观察者一样。接收订单是一个事件,一个观察者中可能有多个事件。浏览器采用类似的机制。事件可能来自用户点击或某些文件加载??时,这些产生的事件有相应的观察者。在Node中,事件主要来自网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察员对事件进行了分类。事件循环是典型的生产者/消费者模型。异步I/O、网络请求等都是事件的生产者,源源不断地为Node提供不同类型的事件,这些事件被传递给相应的观察者,事件循环从观察者那里获取事件并进行处理。请求对象我们可以先看这张图,对node中异步io的实现有一个大概的了解,再看下面的分析。由于后面的讲解会涉及到一些内部方法,这些方法是很难记住的,所以建议大家不要去深究这些方法是怎么写的,只要能搞清楚这张图的流程即可,我们将通过一个Windows下异步I/O(使用IOCP实现)的简单例子来解释,来探究JavaScript代码和系统内核之间发生了什么。对于一般的(非异步的)回调函数,函数由我们调用,如下:varforEach=function(list,callback){for(vari=0;iobject_->Set(oncomplete_sym,callback);对象被包装后,在Windows下,调用QueueUserWorkItem()方法将FSReqWrap对象推入线程池执行。该方法的代码如下:QueueUserWorkItem(&uv_fs_thread_proc,req,WT_EXECUTEDEFAULT)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操作完成后的回调处理。8.所以嘎,上面说的是异步方法的调用,也就是fs.open方法的调用。后续的io操作和回调函数的执行呢?简单的回答是:调用fs.open方法后,会获取一个ioread操作,然后把这个操作放到线程池中,等待空闲线程执行ioread操作,然后获取结果,传递数据到回调函数,执行它,然后执行回调。如下所示。下面是详细的解释:将请求对象组装起来,送到I/O线程池中执行。其实异步I/O的第一部分就完成了,回调通知才是第二部分。调用线程池中的I/O操作后,将得到的结果存放在req->result属性中,然后调用PostQueuedCompletionStatus()通知IOCP当前对象操作已经完成:PostQueuedCompletionStatus((loop)->iocp,0,0,&((req)->overlapped))PostQueuedCompletionStatus()方法的作用是将执行状态提交给IOCP,将线程返回线程池。PostQueuedCompletionStatus()方法提交的状态可以通过GetQueuedCompletionStatus()检索。在这个过程中,我们实际上使用了事件循环的I/O观察器。在每次Tick执行时,都会调用IOCP相关的GetQueuedCompletionStatus()方法来检查线程池中是否有完成的请求。如果存在,它会将请求对象添加到I/O观察者的队列中,然后将其作为事件发送。I/O观察者回调函数的行为是将request对象的result属性作为参数,将oncomplete_sym属性作为方法,然后调用执行,从而达到调用回调函数的目的在JavaScript中传递。至此,整个异步I/O过程就彻底结束了。事件循环、观察者、请求对象和I/O线程池共同构成了Node异步I/O模型的基本元素。9、setTimeout()、setInterval()、setImmediate()和process.nextTick()也是异步IO吗?不,这些是异步API。这部分也值得关注。定时器setTimeout()和setInterval()与浏览器中的API一致,分别用于单次和多次定时执行任务。它们的实现原理类似于异步I/O,但不需要I/O线程池的参与。通过调用setTimeout()或setInterval()创建的计时器将被插入到计时器观察器内部的红黑树中。每执行一次Tick,就会迭代地从红黑树中取出定时器对象,检查是否超时。超过则形成事件,立即执行其回调函数。计时器的问题在于它们不准确(在公差范围内)。事件循环虽然很快,但是如果一次循环耗时很多,下次再循环的时候,可能早就超时了。比如通过setTimeout()设置一个任务在10毫秒后执行,但是9毫秒后,一个任务占用了5毫秒的CPU时间片,等到再次轮到定时器执行时,时间已经超时4毫秒。process.nextTick()在知道process.nextTick()之前,很多人可能会调用setTimeout()来达到想要的效果,以便立即异步执行一个任务:setTimeout(function(){//TODO},0);由于事件循环本身的特点,定时器的精度不够。事实上,使用定时器需要使用红黑树,创建定时器对象并迭代,setTimeout(fn,0)的方法很浪费性能。其实process.nextTick()方法的操作比较轻,具体代码如下:process.nextTick=function(callback){//onthewayout,don'tbother._exiting)return;if(tickDepth>=process.maxTickDepth)maxTickWarn();vartock={callback:callback};if(process.domain)tock.domain=process.domain;nextTickQueue.push(tock);if(nextTickQueue.长度){过程。_needTickCallback();}};每次调用process.nextTick()方法时,只会将回调函数放入队列,在下一轮Tick时执行。定时器中使用红黑树的操作时间复杂度为O(lg(n)),nextTick()的时间复杂度为O(1)。相比之下,process.nextTick()效率更高。setImmediate()setImmediate()方法与process.nextTick()方法非常相似,延迟回调函数的执行。在Nodev0.9.1之前,setImmediate()还没有实现。当时类似的功能主要是通过process.nextTick()实现的。该方法的代码如下:process.nextTick(function(){console.log('延迟执行');});console.log('正常执行');上述代码的输出结果如下:当用setImmediate()实现时,相关代码如下:setImmediate(function(){console.log('延迟执行');});console.log('正常执行');结果完全一样:但实际上两者之间存在细微差别。将它们放在一起时的优先级是什么。示例代码如下:process.nextTick(function(){console.log('nextTick延迟执行');});setImmediate(function(){console.log('setImmediate延迟执行');});console.log('正常执行');执行结果如下:从结果可以看出,process.nextTick()中回调函数的执行优先级高于setImmediate()。这里的原因是事件循环顺序检查观察者,process.nextTick()属于空闲观察者,setImmediate()属于检查观察者。在每个检查周期中,空闲观察者在I/O观察者之前,I/O观察者在检查观察者之前。具体实现上,process.nextTick()的回调函数存储在一个数组中,setImmediate()的结果存储在一个链表中。在行为上,process.nextTick()会在每个循环中执行数组中的所有回调函数,而setImmediate()会在每个循环中执行链表中的一个回调函数。下面的示例代码可以证明://增加两个nextTick()回调函数process.nextTick(function(){console.log('nextTickdelayexecution1');});process.nextTick(function(){console.log('nextTick延迟执行2');});//添加两个setImmediate()回调函数setImmediate(function(){console.log('setImmediate延迟执行1');//进入下一个循环流程.nextTick(function(){console.log('强插入');});});setImmediate(function(){console.log('setImmediate延迟执行2');});console.log('正常执行');执行结果如下:从执行结果可以看出,当第一个setImmediate()的回调函数执行到的时候,第二个并没有立即执行,而是进入下一个循环,再次按下process.nextTick()优先,按照setImmediate()次后的顺序执行。之所以这样设计,是为了保证每一轮循环都能快速执行,防止后续的I/O调用因为CPU占用率过高而被阻塞。