理解事件循环

153
我想了一下,得出了以下结论:
让我们来看看下面的代码:
console.clear();
console.log("a");
setTimeout(function(){console.log("b");},1000);
console.log("c");
setTimeout(function(){console.log("d");},0);

一份请求进来后,JS引擎开始逐步执行上面的代码。前两个调用都是同步调用。但是,当到达setTimeout方法时,它变成了异步执行。但是JS立即从中返回并继续执行,这称为非阻塞或异步执行。然后它继续在其他等待操作工作。其执行结果如下:
"a c d b"
因此,基本上第二个setTimeout先完成,并且它的回调函数比第一个更早地被执行,这很有意义。
我们正在谈论单线程应用程序。JS引擎保持执行此操作,除非完成第一个请求,否则它不会转向第二个请求。但是好处是它不会等待像setTimeout这样的阻塞操作解决,因此它将更快,因为它接受新的传入请求。
但我的问题围绕以下项目产生:
#1: 如果我们谈论单线程应用程序,那么在JS引擎接受更多请求并执行它们时,什么机制处理setTimeouts?单线程如何继续处理其他请求?当其他请求不断到来并得到执行时,setTimeouts如何工作。
#2: 如果这些setTimeout函数在更多请求进来并被执行的同时在后台执行,那么是什么执行异步操作的?我们所说的EventLoop是什么?
#3: 但是整个方法不应该放在EventLoop中,以便整个过程得到执行并调用回调方法吗?当谈论回调函数时,这就是我的理解:
function downloadFile(filePath, callback)
{
   blah.downloadFile(filePath);
   callback();
}

但在这种情况下,JS引擎如何知道它是异步函数,以便将回调放入EventLoop中?也许类似于C#中的async关键字或某种属性,指示JS引擎将采用的方法是异步方法,并应相应处理。

#4:但一篇article与我猜测的工作方式完全相反:

事件循环是回调函数的队列。当异步函数执行时,回调函数被推入队列中。JavaScript引擎直到异步函数后的代码执行后才开始处理事件循环。

#5:还有这张图片可能有所帮助,但图像中的第一个解释与问题4中提到的完全相同:

enter image description here

所以我的问题是关于上面列出的项目需要一些澄清吗?

1
线程不是处理这些问题的正确隐喻。考虑事件。 - Denys Séguret
1
@dystroy:展示JavaScript中事件隐喻的代码示例会很不错。 - Tarik
1
@dystroy:我在这里的问题是想要对上面列出的项目进行一些澄清。 - Tarik
2
Node.js不是单线程的,但这对你来说并不重要(除了它能够在你的用户代码执行时完成其他任务)。在你的用户代码中最多只有一个回调会同时执行。 - Denys Séguret
@alex 不是这样的。如果你对此感到困惑,请阅读答案。 - Denys Séguret
显示剩余9条评论
4个回答

98
1: 如果我们正在谈论单线程应用程序,那么当JS引擎接受更多请求并执行它们时,谁会处理setTimeout呢?难道这个单线程将继续处理其他请求吗?那么在其他请求不断到来并得到执行的同时,谁将继续处理setTimeout呢?
在node进程中只有1个线程会实际执行你程序的JavaScript。然而,在node内部,实际上有几个线程来处理事件循环机制的操作,包括一个IO线程池和一些其他线程。关键是,这些线程的数量不像线程-每-连接并发模型中那样与处理的并发连接数相对应。
至于“执行setTimeout”,当你调用setTimeout时,node所做的就是基本上更新一个数据结构,其中包含要在未来某个时间执行的函数。它基本上有一堆需要完成的任务队列,每个事件循环“滴答”时选择一个,从队列中删除它并运行它。
一个重要的理解事项是,node大部分工作都依赖于操作系统。因此,传入的网络请求实际上由操作系统本身跟踪,在node准备处理一个网络请求时,它只需使用系统调用请求OS提供待处理数据的网络请求。所以 node 所做的大部分IO“工作”都是“嘿,操作系统,有一个网络连接可读吗?”或“嘿,操作系统,我这些未完成的文件系统调用中是否有待处理数据?”根据其内部算法和事件循环引擎设计,node将选择要执行的JavaScript的下一个“滴答”,然后重复整个过程。这就是事件循环的含义。Node在基本上确定“下一点应运行哪个JavaScript?”,然后运行它。这因素包括操作系统已完成的IO和通过调用setTimeout或process.nextTick在JavaScript中排队的东西。
2: 如果这些setTimeout会在更多请求进来并被执行时在后台执行,那么执行异步操作的背后是我们所说的EventLoop吗?
没有JavaScript在幕后执行。你程序中所有的JavaScript都是一次只能运行一个。在幕后发生的事情是操作系统处理IO,node等待IO准备好,并管理其等待执行JavaScript的队列。
3: JS 引擎如何知道它是一个异步函数,以便将其放入EventLoop中?
在node核心中有一组固定的函数是异步的,因为它们进行系统调用,而node知道这些是因为它们必须调用操作系统或C ++。基本上,所有网络和文件系统 IO 以及���进程交互都将是异步的,JavaScript唯一可以让node异步运行某些内容的方式是通过调用node核心库提供的其中一个异步函数。即使你使用了定义自己API的npm包,为了产生事件循环(yield the event loop),最终该npm包的代码也将调用node核心的某个异步函数,这时node就知道tick已经完成,可以重新启动事件循环算法。
4: 事件循环是一个回调函数队列。当异步函数执行时,回调函数被推入队列中。JavaScript引擎不会在异步函数的代码后开始处理事件循环。
是的,这是正确的,但有些误导。关键是正常模式如下:
//Let's say this code is running in tick 1
fs.readFile("/home/barney/colors.txt", function (error, data) {
  //The code inside this callback function will absolutely NOT run in tick 1
  //It will run in some tick >= 2
});
//This code will absolutely also run in tick 1
//HOWEVER, typically there's not much else to do here,
//so at some point soon after queueing up some async IO, this tick
//will have nothing useful to do so it will just end because the IO result
//is necessary before anything useful can be done

是的,你完全可以通过在同一次tick中同步地计算所有Fibonacci数字并将它们全部存储在内存中来阻塞事件循环,并且是的,这将完全冻结你的程序。这是协作式并发。JavaScript的每个tick都必须在合理的时间内让事件循环屈服,否则整个架构将失败。


1
假设我有一个队列,需要服务器执行1分钟,而第一件事是某个异步函数,在10秒钟后完成。它会排到队列的末尾还是在准备就绪时立即将自己推入队列? - ilyo
4
通常情况下,它会进入队列的末尾,但是 process.nextTicksetTimeoutsetImmediate 的语义略有不同,尽管你实际上并不需要太在意。我有一篇关于 setTimeout 等的博客文章,名为 setTimeout and friends,其中详细讲述了这方面的内容。 - Peter Lyons
1
@SheshPai 当代码以英语段落的形式书写时,讨论起来对大家来说太混乱了。请发布一个带有代码片段的新问题,这样人们可以根据代码回答,而不是描述代码,这会留下很多歧义。 - Peter Lyons
@PeterLyons:有趣!但是在技术层面上,我不太理解什么是“tick”? - darKnight
我认为“tick”的意思是事件循环运行JavaScript代码部分的单个实例。我怀疑其他一些人认为它是事件循环代码的单个迭代(这可能包括像当没有JS准备运行时那样的空操作)。 - Peter Lyons
显示剩余4条评论

12

不要认为主机进程是单线程的,它们并不是。单线程的是主机进程中执行您的JavaScript代码的部分。

除了后台工作者,但这些会使场景复杂化......

因此,所有的js代码都在同一个线程中运行,你不可能同时运行两个不同部分的js代码(所以,你不用处理并发噩梦)。

正在执行的js代码是主机进程从事件循环中选择的最后一段代码。 在您的代码中,基本上可以做两件事:运行同步指令,并安排函数在将来某些事件发生时执行。

这是我对您示例代码的心理表示(请注意:它只是这样,我不知道浏览器的实现细节!):

console.clear();                                   //exec sync
console.log("a");                                  //exec sync
setTimeout(                //schedule inAWhile to be executed at now +1 s 
    function inAWhile(){
        console.log("b");
    },1000);    
console.log("c");                                  //exec sync
setTimeout(
    function justNow(){          //schedule justNow to be executed just now
        console.log("d");
},0);       

在您的代码运行时,主进程中的另一个线程会跟踪发生的所有系统事件(UI上的点击、文件读取、网络数据包接收等)。

当您的代码完成时,它将从事件循环中移除,主进程则返回检查它,以查看是否有更多要运行的代码。事件循环中还包含了两个事件处理程序:一个立即执行(justNow函数),另一个在一秒钟后执行(inAWhile函数)。

现在主进程尝试匹配所有已发生的事件,以查看是否有针对它们注册的处理程序。它发现justNow正在等待的事件已经发生,因此开始运行其代码。当justNow函数退出时,它再次检查事件循环,搜索事件处理程序。假设1秒已过去,则运行inAWhile函数,以此类推......


setTimeout虽然是在主线程中实现的,但你的示例中没有任何需要单独线程的内容。事实上,在浏览器中,只有选项卡是在多个线程中实现的。在单个选项卡中,包括建立多个并行网络连接、等待鼠标点击、setTimeout、动画等所有进程都在同一个线程中完成。 - slebetman

4
事件循环(Event Loop)只有一个简单的工作,就是监控调用栈(Call Stack)、回调队列(Callback Queue)和微任务队列(Micro task queue)。如果调用栈为空,事件循环将首先从微任务队列中获取第一个事件,然后再从回调队列中获取第一个事件,并将其推送到调用栈中,从而有效地运行它。这样的迭代在事件循环中被称为tick。
正如大多数开发人员所知道的,JavaScript是单线程的,这意味着JavaScript中的两个语句不能并行执行,这是正确的。执行是逐行进行的,这意味着每个JavaScript语句都是同步且阻塞的。但是,如果使用setTimeout()函数(一个由浏览器提供的Web API),就可以异步运行代码,该函数确保您的代码在指定的时间(以毫秒为单位)后执行。
例如:
console.log("Start");

setTimeout(function cbT(){
console.log("Set time out");
},5000);

fetch("http://developerstips.com/").then(function cbF(){
console.log("Call back from developerstips");
});

// Millions of line code
// for example it will take 10000 millisecond to execute

console.log("End");

setTimeout函数的第一个参数是回调函数,第二个参数是以毫秒为单位的时间。 在浏览器控制台执行上述语句后,它将被打印出来。

Start
End
Call back from developerstips
Set time out

注意:异步代码在所有同步代码执行完毕后才会运行。

了解代码逐行执行的方式

JS 引擎执行第一行代码并在控制台打印 "Start"。

在第二行代码中,它看到了名为 cbT 的 setTimeout 函数,并将 cbT 函数推入回调队列。

在此之后,指针将直接跳转到第 7 行,在那里它将看到 Promise 并将 cbF 函数推入微任务队列。

然后,它将执行数百万行代码,并在结束时打印 "End"。

在主线程执行结束后,事件循环将首先检查微任务队列,然后是回调队列。在我们的情况下,它从微任务队列中取出 cbF 函数并将其推入调用栈,然后从回调队列中选择 cbT 函数并将其推入调用栈。

enter image description here


事件循环最初不会从微队列中获取任务。实际上,它会从任务队列中获取第一个任务,如果为空,则对微队列进行检查。无论哪种方式,任务队列中最旧的任务都会首先执行。例如,如果您设置了一个setTimeout为0和一个Promise...则Promise回调将首先执行,因为setTimeout为0意味着最少4ms,并且仅在时间到达后才将其作为任务排队。这是模型:https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model - nullspace
1
嗨@nullhook,微任务队列将始终获得第一优先级。你可以看一下https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide上面的内容。其中有两个关键区别…… - Srikrushna
你尝试过阅读事件循环模型吗? - nullspace
1
你尝试过将超时延迟设置为0ms(对于cbT回调)吗?这难道不证明了微任务队列比回调队列具有更高的优先级吗?当您使用较长的超时时,我认为只是setTimeout API在此大超时后将回调放入回调队列中,而此时事件循环已经通过微任务队列了。有道理吧?也许证据就在这里(消除了关于fetch实现、缓存等的具体细节): https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide - MadJoRR
1
@Srikrushna,关于可用队列,addEventListener()如何工作?例如,当单击事件发生或模拟单击的dispatchEvent时,如果您为“click”添加事件侦听器回调函数。 - Fernando Gabrieli
嗨@FernandoGabrieli,如果您向事件添加了任何addEventListener(),那么它应该直接被调用(由任何其他事件)或间接地(在某些其他事件中)。 - Srikrushna

-2

JavaScript 是一种高级、单线程的解释型语言。这意味着它需要一个解释器将 JS 代码转换为机器码。解释器也称为引擎。Chrome 使用 V8 引擎,Safari 使用 WebKit 引擎。每个引擎都包含内存、调用栈、事件循环、计时器、Web API、事件等。

事件循环:微任务和宏任务

事件循环的概念非常简单。有一个无限循环,在其中 JavaScript 引擎等待任务,执行它们,然后休眠,等待更多任务。

任务被设置 - 引擎处理它们 - 然后等待更多任务(同时休眠并消耗接近零的 CPU)。可能会发生这样的情况,即当引擎忙于处理任务时,另一个任务到达,则该任务将被排队。任务形成一个队列,称为“宏任务队列”。

微任务仅来自我们的代码。它们通常由 Promise 创建:.then/catch/finally 处理程序的执行变为微任务。微任务也在 await 的“幕后”使用,因为它是 Promise 处理的另一种形式。在每个宏任务之后,引擎会先执行微任务队列中的所有任务,然后再运行其他宏任务、渲染或其他任何操作。

enter image description here


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接