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

setTimeout和setImmediate哪个先执行?这篇文章将让你全面了解EventLoop

时间:2023-04-03 12:24:52 Node.js

在之前的采访中,笔者经常遇到写一堆setTimeout和setImmediate来询问先执行哪个。这篇文章主要讲的就是这个问题,但不是简单的先到后到。仅仅知道setImmediate先于setTimeout(fn,0)执行是不够的,因为在某些情况下setTimeout(fn,0)会先于setImmediate执行。要想彻底理解这个问题,就需要系统地研究一下JS的异步机制和底层原理。本文将从异步的基本概念出发,讲讲EventLoop的底层原理,让你彻底理解setTimeout、setImmediate、Promise、process.nextTick谁先谁后。synchronous和asynchronousasynchronous简单理解就是同步代码按照写的先后顺序执行,而异步代码可能写的顺序不一样,后面写的可能先执行。让我们看一个例子:constsyncFunc=()=>{consttime=newDate().getTime();while(true){if(newDate().getTime()-time>2000){break;}}console.log(2);}console.log(1);syncFunc();console.log(3);上面的代码会先打印出1,然后调用syncFunc,syncFunc中的while循环会运行2seconds,然后输出print2,最后打印3。所以这里代码的执行顺序和我们写的顺序是一样的。这是一个同步代码:让我们看一个异步示例:constasyncFunc=()=>{setTimeout(()=>{console.log(2);},2000);}console.log(1);asyncFunc();console.log(3);上面代码的输出是:可以看到中间我们调用的asyncFunc中的2是最后一个输出,因为setTimeout是异步方法。它的作用是设置一个定时器,然后在定时器超时的时候执行回调中的代码。所以异步相当于做一件事情,但不是马上就做,而是你先跟别人打个招呼,说满足xxx条件的时候怎么办。就像你在手机上设置了一个7天的闹钟,第二天早上晚上睡觉前,就相当于给手机一个异步事件,触发条件是时间到了晚上7点早上。使用异步的好处是只需要设置好异步的触发条件就可以做其他的事情,所以异步不会阻塞trunk上事件的执行。尤其是像JS这种只有一个线程的语言,如果你像我们第一个例子那样去while(true),那么浏览器只会一直卡住,直到循环运行完才会有反应。JS异步是如何实现的?我们都知道JS是单线程的,那么单线程是如何实现异步的呢?其实,所谓“JS是单线程的”只是说JS只有一个主运行线程,并不是说整个运行环境都是单线程的。JS的运行环境主要是浏览器。以大家熟悉的Chrome内核为例,它不仅是多线程的,而且是多进程的:上图只是一个大致的分类,也就是说Chrome有这几种进程和线程,有不仅每一种都有一个,比如有多个渲染进程,每个tab都有自己的渲染进程。有时当我们使用Chrome时,某个选项卡会崩溃或没有响应。该tab对应的渲染进程可能会crash,但其他tab不使用该渲染进程。它们有自己的渲染过程,因此其他选项卡不受影响。这也是为什么在Chrome中单个页面崩溃不会导致浏览器崩溃,而不是像旧版IE那样一个页面卡住导致整个浏览器卡死的原因。对于前端工程师来说,主要关注的是渲染过程。让我们看看每个线程做了什么。GUI线程GUI线程负责渲染页面。它解析HTML和CSS,然后将它们构建成DOM树和渲染树。本帖负责。JS引擎线程该线程是负责执行JS的主线程。上面说的“JS是单线程的”指的就是这个线程。著名的ChromeV8引擎就运行在这个线程上。需要注意的是,这个线程与GUI线程是互斥的。互斥的原因是JS也可以操作DOM。如果JS线程和GUI线程同时操作DOM,结果会很混乱,不知道渲染哪个结果。这样做的后果是,如果JS运行时间长了,GUI线程就执行不下去了,整个页面有卡顿的感觉。所以像我们最初例子中的while(true)这样的长期同步代码在实际开发中是绝对不允许的。之前定时器线程的异步例子中的setTimeout其实就是运行在这里的。它和JS主线程根本不在同一个地方,所以“单线程JS”可以实现异步。JS的timer方法和setInterval也在这个线程中。事件触发线程定时器线程其实只是一个计时函数。当时间到了时,它实际上并不执行回调。真正执行回调的是JS主线程。所以当时间到了,定时器线程会把这个回调事件交给事件触发线程,然后事件触发线程会把它加入到事件队列中。最后,JS主线程从事件队列中取出这个回调并执行。事件触发线程不仅将定时器事件放入任务队列,还会将其他满足条件的事件放入任务队列。异步HTTP请求线程该线程负责处理异步ajax请求。当请求完成后,也会通知事件触发线程,然后事件触发线程会将这个事件放入事件队列中,供主线程执行。所以,JS异步的实现依赖于浏览器的多线程。当它遇到异步API时,它会将任务交给相应的线程。当异步API满足回调条件时,相应的线程会触发该线程将该事件放入任务队列,然后主线程从任务队列中取出该事件继续执行。这个过程中我们已经多次提到了任务队列,它其实就是EventLoop,下面我们会详细解释。EventLoop所谓EventLoop就是事件循环。其实就是一个JS管理事件执行的过程。具体的管理方式由其具体的运行环境决定。目前JS主要有两种运行环境,浏览器和Node.js。这两种环境的EventLoop还是有些不同的,我们会分开说。浏览器的EventLoop事件循环是一个循环,是各种异步线程用来进行通信和协同执行的一种机制。为了交换消息,每个线程还有一个公共数据区,就是事件队列。每个异步线程执行完毕后,通过事件触发线程将回调事件放入事件队列。主线程每次做完手头的工作,就会检查队列中是否有新的工作,如果有,就取出来执行。画个这样的流程图:流程解释如下:每次主线程执行时,先检查要执行的是同步任务还是异步API同步任务,然后继续执行,交给主线程执行后的异步API给对应的异步线程,继续执行同步任务。异步线程执行异步API。执行完成后,将异步回调事件放入事件队列。当线程发现事件队列中有任务时,将其中的任务取出,执行主线程,不断循环上述过程。然后在事件队列中执行回调。这个特性直接影响定时器的执行。想一下我们启动的2秒定时器的执行过程:主线程执行同步代码遇到setTimeout,交给定时器线程处理。定时器线程开始计时,2秒到。Notificationeventtriggerthread事件触发线程将定时器回调放入事件队列,异步流程到此结束。如果主线程空闲,它会取出定时器回调并执行。如果不是空闲的,回调将一直放在队列中。从上面的流程我们可以看出,如果主线程长时间阻塞,定时器回调将没有机会执行。即使执行了,时间也会不准确。我们结合开头的两个例子就可以看出这个效果:constsyncFunc=(startTime)=>{consttime=newDate().getTime();while(true){if(newDate().getTime()-time>5000){break;}}constoffset=newDate().getTime()-开始时间;console.log(`syncFuncrun,timeoffset:${offset}`);}constasyncFunc=(startTime)=>{setTimeout(()=>{constoffset=newDate().getTime()-startTime;console.log(`asyncFuncrun,timeoffset:${offset}`);},2000);}conststartTime=newDate().getTime();asyncFunc(startTime);syncFunc(开始时间);执行结果如下:从结果可以看出,虽然我们先调用了asyncFunc,虽然asyncFunc写的是2秒后执行,但是syncFunc的执行时间太长,达到了5秒,虽然asyncFunc是在执行2秒已经进入了事件队列,但是主线程一直在执行同步代码,没有时间,所以要等5秒,直到同步代码执行完,才有机会执行定时器回调。所以还是那句话,写代码的时候一定不要长时间占用主线程。在介绍microtasks之前介绍流程图,为了便于理解,我简化了事件队列。实际上,事件队列中的事件可以分为两大类:macrotasks和microtasks。微任务具有更高的优先级。当事件循环遍历队列时,首先检查微任务队列。如果里面有任务,就会全部执行。执行后,会执行一个宏任务。在执行每一个macrotask之前,先检查microtask队列中是否有任务,如果有则先执行microtask队列。所以完整的流程图如下:上图中需要注意以下几点:一个EventLoop可以有一个或多个eventqueue,但是只有一个microtaskqueue。microtask队列会在每个macrotask全部执行完后重新渲染。requestAnimationFrame处于渲染阶段,既不在微任务队列中,也不在宏任务队列中。所以如果我们想知道一个异步API执行在哪个阶段,我们需要知道它是宏任务还是微任务。常见的宏任务包括:script(可以理解为外层同步代码)setTimeout/setIntervalsetImmediate(Node.js)I/OUIeventpostMessage常见的微任务包括:Promiseprocess.nextTick(Node.js)Object.observeMutaionObserver注意Promise是一个microtask,这意味着它将在计时器之前运行。让我们看一个例子:console.log('1');setTimeout(()=>{console.log('2');},0);Promise.resolve().then(()=>{console.log('5');})newPromise((resolve)=>{console.log('3');resolve();}).then(()=>{console.log('4');})上面代码的输出是1,3,5,4,2。因为:先输出1,这个没啥好说的,同步代码执行console.log('2');在setTimeout中,setTimeout是宏任务,“2”进入宏任务队列console.log('5');在Promise.then中,进入微任务队列console.log('3');在Promise构造函数的参数中,这其实是一段同步代码,直接输出console.log('4');inthen,他会进入microtask队列,在检查event队列的时候先执行microtask同步代码,结果是“1,3”,然后检查microtask队列,输出“5,4”最后执行macrotask队列,output"2"Node.jsEventLoopNode.js是运行在服务端的js。虽然同样使用V8引擎,但是其服务目的和环境不同,这使得它的API与原生JS不同。它的EventLoop还需要处理一些I/O,比如Newnetworkconnections等,所以和浏览器的EventLoop不同。Node的EventLoop是staged的,如下图所示:timers:执行setTimeout和setInterval回调pendingcallbacks:执行延迟到下一次循环迭代的I/O回调idle,prepare:仅供系统内部使用poll:retrievenew一个I/O事件;执行I/O相关的回调。其实除了其他几个阶段处理的东西,几乎所有其他的异步过程都是在这个阶段处理的。check:setImmediateexecutesclosecallbackshere:一些关闭回调函数,如:socket.on('close',...)每个阶段都有自己的先进先出队列,只有当这个队列的事件是执行完成该阶段或达到该阶段上限时,才会进入下一阶段。在每个事件循环之间,Node.js检查它是否正在等待任何I/O或计时器,如果没有,程序将关闭并退出。我们的直觉是,如果一个Node程序只有同步代码,你在控制台运行完它就会自己退出。另一件需要注意的事情是轮询阶段,它并不总是跟在检查阶段之后。poll队列执行完后,如果没有setImmediate但是有一个timer超时,会回头执行timer阶段:setImmediate和setTimeout上面的过程简单的说,在异步过程中,setImmediate会在timer之前执行.让我们写一些代码来尝试:console.log('outer');setTimeout(()=>{setTimeout(())=>{console.log('setTimeout');},0);setImmediate(()=>{console.log('setImmediate');});},0);上面的代码是这样工作的:一样的,先执行setImmediate。我们看一下这个过程:外层是一个setTimeout,所以在执行它的回调的时候,已经在timers阶段去处理里面的setTimeout了,因为这个循环的timer都在执行,所以它的回调其实是添加到下一个定时器阶段处理里面的setImmediate,将其回调添加到检查阶段的队列中。outertimers阶段执行完后,进入pendingcallbacks,idle,prepare,poll。这些队列都是空的,继续往下看check阶段,找出setImmediate的回调,取出执行关闭回调,队列为空,再次跳过timers阶段,执行我们的console,但请注意把我们上面的console.log('setTimeout')和console.log('setImmediate')都包裹在一个setTimeout里面,如果直接写在最外层呢?代码改写如下:console.log('outer');setTimeout(()=>{console.log('setTimeout');},0);setImmediate(()=>{console.log('setImmediate');});运行一下看看效果:好像先输出了setTimeout,再运行几次看看:为什么setImmediate又先出来了,这段代码是坑爹还是什么?这个世界上没有鬼,所以一切都是有原因的,我们就按照前面的EventLoop来处理吧。在此之前,我需要告诉你一件事,node.js中的setTimeout(fn,0)会被强行改为setTimeout(fn,1),官方文档对此有说明。(顺便说一下,HTML5中setTimeout的最小时间限制是4ms)。原理都有了,我们来看看流程:外层的同步代码一次性全部执行完,遇到异步API就塞到相应的stage中。遇到setTimeout时,虽然设置为0毫秒触发,但被node.js强制改为1毫秒,插入times阶段,setImmediate插入check阶段时,执行同步代码。进入EventLoop,首先进入times阶段,查看当前时间是否超过1毫秒。如果经过1毫秒,则满足setTimeout条件,并执行回调,如果还没有超过1毫秒,则跳过空阶段,进入检查阶段,执行setImmediate回调。通过以上过程的梳理,我们发现关键就在这1毫秒。如果同步代码执行时间长,进入EventLoop1毫秒后,执行setTimeout,未到1毫秒,先执行setImmediate。我们每次运行脚本,机器的状态可能不同,导致运行时有1毫秒的间隙,先执行setTimeout,先执行setImmediate。但是这种情况只有在还没有进入timers阶段的时候才会出现。像我们第一个例子,因为已经在timers阶段,里面的setTimeout只能等待下一个周期,所以必须先执行setImmediate。poll阶段的其他API也是如此,比如:varfs=require('fs')fs.readFile(__filename,()=>{setTimeout(()=>{console.log('setTimeout');},0);setImmediate(()=>{console.log('setImmediate');});});这里setTimeout和setImmediate是在readFile的回调中,由于readFile回调是一个I/O操作,它本身是在poll阶段,所以里面的timer只能进入下一个timers阶段,但是setImmediate可以运行在下一个检查阶段,所以setImmediate必须先运行。运行后,在运行setTimeout之前检查计时器。同样,我们再来看一段代码。如果他们两个不在最外层,而是在setImmediate的回调中,情况和外层一样,结果也是随机的。请参见以下代码:console.log('outer');setImmediate(()=>{setTimeout(()=>{console.log('setTimeout');},0);setImmediate(()=>{console.log('setImmediate');});});原因和最外层写的差不多,因为setImmediate已经在check阶段,内层循环会从timers阶段开始,会先看setTimeout的回调。如果此时已经过去了1毫秒,它将被执行。如果没有则执行setImmediate。process.nextTick()process.nextTick()是一个特殊的异步API,不属于任何事件循环阶段。事实上,当Node遇到这个API时,EventLoop根本不会继续下去,会立马停下来执行process.nextTick(),这次执行完EventLoop会继续。下面写个例子看看:varfs=require('fs')fs.readFile(__filename,()=>{setTimeout(()=>{console.log('setTimeout');},0);setImmediate(()=>{console.log('setImmediate');process.nextTick(()=>{console.log('nextTick2');});});process.nextTick(()=>{console.日志('nextTick1');});});这段代码打印如下:看一下流程:我们的代码基本都在readFile回调中,他自己执行的时候,遇到过poll阶段的setTimeout(fn,0)其实就是setTimeout(fn,1).插入timers阶段遇到setImmediate,插入后续check阶段遇到nextTick,立即执行,输出'nextTick1'。到了check阶段,输出'setImmediate'',遇到另一个nextTick,立即输出'nextTick2'给下一个timers阶段,输出'setTimeout'的机制其实和我们前面说的microtask类似,但不完全相同的,比如同时有nextTick和Promise,此时nextTick必须先执行,因为nextTick队列的优先级高于Promise队列。让我们看一个例子:constpromise=Promise.resolve()setImmediate(()=>{console.log('setImmediate');});promise.then(()=>{console.log('promise')})process.nextTick(()=>{console.log('nextTick')})代码运行结果如下:小结本文从异步的基本概念开始,到浏览器和Node.js的EventLoop,下面开始总结:JS所谓的“单线程”只是主线程只有一个,并不是整个运行环境都是单线程的。是EventLoop异步线程在完成任务后将其放入任务队列。主线程不断轮询任务队列,取出任务执行任务队列。宏任务队列和微任务队列是有区别的。微任务队列的优先级比较高,所有的微任务和宏任务都处理完了才会去处理。Promise是一个微任务。Node.js的事件循环不同于浏览器的事件循环。它是分阶段的setImmediate和setTimeout(fn,0),首先执行回调。看自己在哪个stage注册了,如果是在timercallback或者I/Ocallback中,必须先执行setImmediate。如果是在最外层还是在setImmediate回调中,先执行哪个取决于当前机器情况。process.nextTick不在EventLoop的任何阶段,它是一个特殊的API,会立即执行,然后继续执行EventLoop。文末,感谢您抽出宝贵的时间阅读本文,如果本文对您有一点帮助或启发,请不要吝啬您的点赞和GitHubstar,您的支持就是作者前进的动力继续创作。欢迎关注我的公众号进取大前端第一时间获取优质原创~《前端进阶知识》系列文章源码地址:https://github.com/dennis-jiang/前端知识