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

Nodejs深入探索:EventLoop的本质和异步代码中的Zalgo问题

时间:2023-03-17 20:54:44 科技观察

Nodejs是一个高效的异步服务平台,因此非常适合开发高并发的后台服务。为了满足高并发,后台服务要做的就是能够及时响应客户端发送的请求。这里要注意的是“响应”而不是“完成”。客户端可能会要求后台从数据库中查询特定的数据。后台收到请求后,会告诉客户端“我已经收到你的请求,正在处理中,处理完后会发送给客户端”。通知你”。所以,NodeJS之所以能做到高并发,是因为它会把那些耗时的进程提交给线程池处理,它的主线程会一直响应客户端的请求。当线程池完成的时候-消费任务,主线程获取结果后发送给对应的客户,因此NodeJS的基本模式是一个主线程不断接收客户请求,如果请求需要一定的时间才能完成,主线程会将任务丢到线程池中,然后继续处理其他客户端请求,在主线程的循环中,会不断轮询特定的队列,看是否有可以处理的数据。所以,它会把它从队列中取出,然后处理数据发送给需要的客户端。由于主线程不需要长时间阻塞,它可以响应大量客户端请求。给entime,这也是它能做到高并发的原因。主线程不断轮询特定队列以获取数据的过程也称为事件循环。基本流程如下:NodeJS代码的特点是我们自己写的任何代码执行时都必须在主线程中,不用担心多线程导致的重入。在NodeJS代码中,一旦产生异步调用,执行流程会将调用提交到它的线程池,然后直接指向异步调用背后的代码,例如:console.log(1)setTimer(()=>{console.log(2),0)console.log(3)上面代码运行时,输出结果为1,3,2,这是因为setTimer是异步函数,不会在主线程中执行.主线程会将设置这个时钟的任务交给线程池。clock结束后,里面的callback会放到上图中的clock队列中,所以主线程会跳过setTimer直接指向后面的语句,一直等到主线程循环到clock中的clock下次上图。setTimer设置的回调函数只有在到达队列位置时才会执行。因此,对于NodeJS的事件循环来说,它包含了几个阶段,每个阶段对应上图中的一个block。在每个阶段,主线程都会从对应的队列中获取数据返回给客户端,或者执行存储在队列中的回调函数。当队列清空,或者访问的队列元素超过给定值时,进入下一阶段。舞台。从上图可以看出,所有时钟相关的回调都是在Timer阶段执行的。例如,当代码使用setTimer、setInterval等接口时,NodeJS会向操作系统提交时钟请求。一旦时钟结束,操作系统会通知NodeJS,后者会把时钟对应的回调挂到Timer阶段对应的队列中。第二阶段是操作系统在某些情况下需要将特定的事件通知NodeJS,比如TCP连接请求被拒绝、数据库连接失败等;idle阶段是nodejs内部使用的,主线程会在nodejs内部执行一些特定的回调函数。内部事务,这部分通常与我们的发展无关;poll阶段应该是nodejs主线程的主要工作。当文件打开成功,从文件中读取数据,或者向文件中写入数据等相应的IO事件发生时,相应的回调函数会在这个阶段存储到队列中。对于典型的fs.writeFile(p,(err,data)=>{})调用,其对应的回调函数只能在这个阶段执行。检查阶段执行setImmediate提交的回调函数。setImmediate和setTimeout(callback,0)其实本质上是一样的,只是这两个异步函数对应的回调是在不同的阶段执行的。如果我们同时执行setImmediate和setTimeout(callbackinthecode,0),那么先执行哪个回调取决于主线程当前处于哪个阶段。我们可以做一个实验,创建一个文件如hello.txt在本地,然后创建index.js,在里面添加如下代码:setTimeout(function(){console.log('setTimeout')},0)setImmediate(function(){console.log('setImmediate')})多次运行index.js时,有时setTimeout先打印,有时setImmediate先打印,这要看主线程在哪个阶段,如果主线程执行时已经跨过check阶段,则setTimeout先打印,反之反之亦然。如果我们在IO回调中执行上面的代码,例如:fs.readFile('./hello.txt',()=>{setTimeout(function(){console.log('setTimeoutinreadfile')},0)setImmediate(function(){console.log('setImmediateinreadfile')})})然后setImmediateinreadfile会先打印出来,因为readFile的回调是在poll阶段执行的,check阶段是其次是poll,所以read的filefetch回调执行完后,主线程进入check阶段,所以setImmediate设置的回调必须先执行。上图中还有一个process.nextTick,也是一个异步函数,但不属于事件循环的任何阶段。当当前eventloop阶段结束,回到timer阶段,主线程会先检查是否有nextTick提供的Callback,如果有则执行给定的callback,然后进入timer阶段。本质上和setImmediate没有区别,只是后者属于事件循环的特定阶段,前者不属于事件循环,所以它最大的作用就是让代码在主线程进入之前做一些操作下一个循环,比如释放一些无用的资源。由于nodejs的异步模式,有些错误可能难以处理。这种类型的问题称为Zalgo问题。它们的特点是同步逻辑和异步逻辑的结合导致了难以重现和调试的错误。示例如下:import{readFile}from'fs'constcache=newMap()functionproblemRead(filename,cb){if(cache.has(filename)){cb(cache.get(filename))}else{readFile(filename,'utf8',(err,data)={cache.set(filename,data)cb(data)})}}上面代码中problemRead有两种模式,一种是如果缓存不存在,然后使用readFile进行异步Read,如果缓存已经存在,会直接执行cb对应的回调函数,所以cb在执行过程中可能有不同的上下文,容易导致代码问题,比如创建一个文件zalgo.mjs,实现代码如下:functioncreateFileReader(filename){constlisteners=[]problemRead(filename,value=>{listeners.forEach(listener=>listener(value))})return{onDataReady:listener=>listeners.push(listener)}}constreader1=createFileReader('./hello.txt')reader1.onDataReady(data=>{console.log("callingfromreader1:",data)constreader2=createFileReader('./hello.txt')reader2.onDataReady(data=>{//这里的回调不会被调用console.log('callingfromreader2:',data)})})上面代码执行时只会输出:callingfromreader1:helloworld!也就是read2对应的callback没有调用它的原因是这样的。第一次调用createFileReader时,由于没有缓存数据,所以代码调用了异步接口readFile。前面我们说过,任何异步调用都会提交到内部线程池,永远不会在主线程中运行。因此,readFile收到后,下面的代码会直接运行,这样我们就有机会把reader1对应的回调加入到listeners队列中。回调完成后,reader1的回调函数已经存储在listeners中,所以我们在callback中遍历listeners队列,取出回调函数Exec??ute,这样reader1指定的回调就可以执行了。reader2对应的createFileReader函数执行后,缓存中已经存入了对应的数据,所以代码直接执行listener2队列中的回调元素。注意此时reader2.onDataReady对应的代码还没有执行,所以reader2对应的回调函数还没有来得及放入listeners队列,也就没有机会执行了。这种问题很难调试。首先,它不容易复制。如果继续调用createReader,则可以执行reader2对应的回调。同时,上述代码中reader2的回调并没有执行,代码也没有产生任何异常或错误,这使得定位问题变得非常困难。nodejs社区把这种问题称为unleasingzalgo,这是一个具体的典故。这给我们的教训是,我们必须在所有代码中要么使用异步模式,要么使用同步模式,不能将两者混用。