草庐IT

搞懂EventLoop机制

没名字的某某人 2023-09-13 原文

写在最前:本文转自掘金

# JavaScript是单线程的语言

事件循环Event Loop,这是目前浏览器和NodeJS处理JavaScript代码的一种机制,而这种机制存在的背后,正是因为JavaScript是单线程语言。

单线程和多线程最简单的区别就是:单线程同一个事件只能做一件事情,而多线程同一个时间能做多件事情。

调用栈Call Stack

在JavaScript运行的时候,主线程会形成一个栈,这个栈主要是解释器用来最终函数执行流程的一种机制。通常这个栈被称为调用栈Call Stack或者执行栈。
调用栈,顾名思义是具有LIFO(后进先出,Last in First Out)的结构。调用栈内存放的是代码执行期间的所有执行上下文。

  • 每调用一个函数,解释器就会把该函数的执行上下文添加到调用栈并开始执行
  • 正在调用栈中执行的函数,如果还调用了其他函数,那么新函数也会被添加到调用栈,并立即执行
  • 当前函数执行完毕后,解释器会将其执行上下文清除调用栈,继续执行剩余执行上下文中的剩余代码
  • 但分配的调用栈空间被沾满,会引发“堆栈溢出”的报错

现在用一个小案例来演示一下调用栈

function a(){
  console.log(a);
}
function b(){
  console.log(b);
}
function c(){
  console.log(c);
  a();
  b();
}
c();
// 输出结果 c a b

执行这段代码的时候,首先调用的函数是c()。因此执行上下文就会被放入调用栈中。

然后开始执行函数c,执行第一个语句。因此解释器也会将其放入调用栈。

console.log('c')方法执行完后,控制台打印了'c',调用栈就会将其移除。

接着就是执行a()函数。
解释器就将function a() {}的执行上下文放入调用栈中


紧接着就执行a()中的语句——console.log('a')
当函数a执行结束后,调用栈就将执行上下文移除。

然后接着执行c()函数剩下的语句,也就是执行b()函数,因此它的执行上下文就加入调用栈中。同a()

b()执行完后,调用栈就将其移出。

这时c()也执行结束了,调用栈也将其移出栈。

这时候,我们这段语句就执行结束了。

#任务列队

上面的案例简单的介绍了关于JavaScript单线程的执行方式。
但这其中会存在一个问题,例如当中一个语句需要执行很长时间的话,后面的语句就会已知等待。显而易见,这是不可取的。

同步任务和异步任务

因此,JavaScript将所有执行任务分为了同步任务和异步任务。

其实我们的每个任务都是在做两件事,发起调起得到结果

而同步任务和异步任务最主要的区别就是,同步任务发起调用后便可以得到结果,而异步任务是无法立即得到结果,例如定时器。

对于同步任务和异步任务的执行机制也不同

同步任务的执行,其实就是跟前面的那个案例一样,按照代码顺序和调用顺序,支持进入调用栈并执行,执行结束后就移除调用栈。

而异步任务的执行,首先它依旧进入调用栈,然后发起调用,解释器会将其相应回调任务放入一个任务队列,紧接着调用栈会将这个任务移除。当主线程清空后,即所有同步任务结束后,解释器会读取任务列队,并以此将已完成的异步任务加入调用栈中并执行。

这里有个重点,就是异步任务不是直接进入任务队列的。

这里举个简单的例子。

console.log(1);

fetch('https://jsonplaceholder.typicode.com/todos/1')
    .then(response => response.json())
    .then(json => console.log(json))

console.log(2);

很显然,fetch()就是一个异步任务
但执行到 console.log(2)之前,其实fetch()已经被调用且发起请求了,但是还未响应数据。而响应数据和处理的函数then()此时已经在任务队列中,等候console.log(2)执行结束后,所以同步任务清空后,再进入调用栈执行响应动作。

宏任务和微任务

前面提到任务队列,其实任务队列还分为宏任务队列(Task Queue)和微任务列队(Microtask Queue),对应的里面存放的就是宏任务微任务

首先,宏任务和微任务都是异步任务

而宏任务和微任务的区别,就是它们执行的顺序,这也是为什么要区分宏任务和微任务。

在同步任务中,任务的执行都是按照代码顺序执行的,而异步任务的执行也是需要按顺序,队列的属性就是先进先出(FIFO,First in First Out),因此异步任务会按照进入队列的顺序依次执行。

但在一些场景下,如果只按照进入队列的顺序依次执行的话,也会出问题。比如队列进入一个一小时的定时器,接着再进入一个请求接口函数,而如果根据进入队列的顺序执行的话,请求接口函数可能需要一个小时后才会响应数据。

因此浏览器就会将异步任务分为宏任务和微任务,然后按照事件循环的机制去执行,因此不同的任务会有不同的执行优先级,具体会在事件循环讲到。

任务入队

这里还有个知识点,就是关于任务入队。
任务进入任务队列,其实会利用到浏览器的其他线程。虽然说JavaScript是单线程语言,但是浏览器不是单线程的。而不同的线程就会对不同的事件进行处理,当对应事件可以执行的时候,对应线程就会将其放入任务队列。

  • js引擎线程:用于解释执行js代码、用户输入、网络请求等
  • GUI渲染线程:绘制用户界面,与JS主线程互斥(因为js可以操作DOM,进而影响到GUI的渲染结果)
  • http异步网络请求:处理用户的get、post等请求,等待返回结果后将回调函数推入到任务队列
  • 定时器触发线程: setIntervalsetTimeout等待时间结束后,会把执行函数推入任务队列中
  • 浏览器事件处理线程:将clickmouse等UI交互事件发生后,将要执行的回调函数放入事件队列中。

这个其实就可以解释了下列代码为什么后面的定时器会比前面的定时器先执行。因为后者的定时器会先被推进宏任务列表,而前者之后到点了再被推入宏任务列表。

setTimeout(() => {
    console.log('a');
}, 10000);

setTimeout(() => {
    console.log('b');
}, 100);
宏任务
浏览器 Node
整体代码(script)
UI交互事件
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame
微任务
浏览器 Node
process.nextTick
MutationObserver
Promise.then catch finally

# 事件循环 Event Loop

其实宏任务队列和微任务队列的执行,就是事件循环的一部分,所以放在这里一起说了。

事件循环的具体流程如下:

  1. 从宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行
  2. 执行完该宏任务下所有同步任务后,即调用栈清栈后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空位置
  3. 当微任务队列清空后,一个事件循环结束
  4. 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。

这里有几个重点:

  • 当我们第一次执行的时候,解释器会将整体代码script放入宏任务队列中,因此事件循环是从第一个宏任务开始的
  • 如果在执行微任务的过程中,产生新的微任务添加到微任务列队中,也需要一起清空;微任务队列没清空之前,是不会执行下一个宏任务的

接下来,通过一个例子来模拟一下事件循环。

console.log("a");

setTimeout(funtion () {
    console.log("b");
}, 0);

new Promise(resolve =>{
    console.log("c");
    resolve();
}).then(_=>{
  console.log("d");
}).then(_=>{
  console.log("e");
})

console.log("f");

// 输出结果:a c f d e b

首先,当代码执行的时候,整体代码script被推入宏任务队列汇总,并开始执行宏任务。


按照代码顺序,首先执行console.log("a")
该函数上下文被推入调用栈,执行完后,即移除调用栈。

接下来执行 setTimeout(),该函数上下文也进入调用栈中。
因为setTimeout是宏任务,因此将其callback函数推入宏任务队列中,然后该函数就被移除调用栈,继续往下执行。

紧接着是Promise语句,先将其放入调用栈,然后接着往下执行。执行了console.log("c")resolve(),这里就不多说了。

接着来到 new Promise().then()方法,推入到微任务队列中
这时候 new Promise 语句已经执行结束了,就被移除调用栈。
接着做执行console.log("f")。完成后script宏任务已经执行结束了,因此被推出宏任务队列。

宏任务完成后清空微任务队列了,首先执行的是Promise then
然后开始执行其中的console.log("d")

执行结束后,检测到后面还有一个then()函数,因此将其推入微任务队列中。
此时第一个then()函数已经执行结束了,就会移除调用栈和微任务队列。

此时微任务队列还没被清空,因此继续执行下一个微任务。
执行过程跟前面差不多,就不多说了

微任务队列已经清空了,第一个事件循环已经结束了。
接下来执行下一个宏任务,即setTimeout callback

执行结束后,它也被移除宏任务队列和调用栈。
这时候微任务队列里面没有任务,因此第二个事件循环也结束了。
宏任务也被清空了,因此这段代码已经执行结束了。

# await

ECMAScript2017中添加了 async functionsawait
Async关键词是将一个同步函数变成一个异步函数,并将返回变为promise
await可以放在任何异步的、基于promise的函数之前。在执行过程中,它会暂停代码在该行上,直到promise完成,然后返回结果值。而在暂停的同时,其他正在等待执行的代码就有机会执行了。
下面通过一个例子

async function async1() {
    console.log("a");
    const res = await async2();
    console.log("b");
}

async function async2() {
    console.log("c");
    return 2;
}

console.log("d");

setTimeout(() => {
    console.log("e");
}, 0);

async1().then(res => {
    console.log("f")
})

new Promise((resolve) => {
    console.log("g");
    resolve();
}).then(() => {
    console.log("h");
});

console.log("i");

// 输出结果:d a c g i b h f e 

首先,开始执行钱,将整体代码 script 放入宏任务队列中,并开始执行。
第一个执行的是console.log('d')

紧接着是 setTimeout放入宏任务中,继续执行

调用async1() 函数,因此将其函数的上下文放置到调用栈
开始执行async1中的console.log('a')
接下来就是await关键字语句,调用async2函数,因此我们将其放入调用栈
开始执行async2中的console.log('c'),并return一个值
执行完成后,async2就会被移除调用栈

这时候await会阻塞async2的返回值,先跳出async1往下执行
需要注意的是,现在async1中的res变量还是undefined,没有赋值

下面执行 new Promise推入then()微任务
执行console.log('i')

这时,async1外面的同步任务都执行完成了,重新回到前面阻塞的位置,往下执行。
res被成功赋值后,执行console.log('b')async1执行完成后将其后then()函数放入微任务队列中

届时 script 中宏任务已经全部执行完成,开始准备清空微任务队列了

第一个被执行的微任务队列是 promise then 也就是执行console.log('h')
执行完 promise then微任务后,是async1promise then微任务

届时微任务队列已清空,开始执行下一个

# 页面渲染

最后来讲讲事件循环中的页面更新渲染,这也是vue中异步更新的逻辑所在。

每次当一次事件循环结束后,即一个宏任务执行完成后以及微任务队列被清空后,浏览器就会进行一次页面更新渲染。

通常我们的浏览器页面刷新频率是 60fps,也就意味着16.67ms要刷新一次,因此我们也要尽量保证一次事件循环控制在16.67ms之内。

接下来通过一个案例来看一下。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Event Loop</title>
</head>
<body>
    <div id="demo"></div>
    
    <script src="./src/render1.js"></script>
    <script src="./src/render2.js"></script>
</body>
</html>
// render1
const demoEl = document.getElementById('demo');

console.log('a');

setTimeout(() => {
    alert('渲染完成!')
    console.log('b');
},0)

new Promise(resolve => {
    console.log('c');
    resolve()
}).then(() => {
    console.log('d');
    alert('开始渲染!')
})

console.log('e');
demoEl.innerText = 'Hello World!';
// render2
console.log('f');

demoEl.innerText = 'Hi World!';
alert('第二次渲染!');

根据HTML的执行顺序,第一个被执行的JavaScript代码是render1.js,因此解释器将其推入宏任务队列,并开始执行。

第一个被执行的是console.log('a')
其后是setTimeout,并将其回调加入宏任务队列中。

紧接着执行new Promise。之后将then()推入微任务队列中去
下一个执行console.log('e')
最后,修改DOM节点的文本内容,但是这个时候页面还不会更新渲染
届时script宏任务也就执行结束了

开始清空微任务,执行Promise then
alert一个通知后,微任务队列清空,代表一个事件循环结束,即将要开始渲染页面了。当点击关闭alert后,事件循环结束,页面也开始渲染

渲染结束后,就开始执行下一个宏任务,即setTimeout callback
届时宏任务队列已清空,但是html文件还没执行结束,因此进入render2.js继续执行。

首先执行console.log('f')。紧接着,再次修改节点的文本信息,此时依旧不会更新页面渲染。接着执行alert语句,当关闭alert通知后,该宏任务结束,微任务队列也为空,因此该事件循环也结束了,这时候就开始第二次页面更新

如果将所有JavaScript代码使用内嵌方式的话,浏览器会先把两个script丢到宏任务队列中去,因此执行的顺序也会不一样

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Event Loop</title>
</head>
<body>
    <div id="demo"></div>

    <script>
        const demoEl = document.getElementById('demo');

        console.log('a');

        setTimeout(() => {
            alert('渲染完成!')
            console.log('b');
        },0)

        new Promise(resolve => {
            console.log('c');
            resolve()
        }).then(() => {
            console.log('d');
            alert('开始渲染!')
        })

        console.log('e');
        demoEl.innerText = 'Hello World!';
    </script>
    <script>
        console.log('f');

        demoEl.innerText = 'Hi World!';
        alert('第二次渲染!');
    </script>
</body>
</html>
// 输出:a c e d "开始渲染!" f "第二次渲染!" "渲染完成!" b

有关搞懂EventLoop机制的更多相关文章

  1. ruby - Ruby 是否提供响应 OS X 上的 Apple 事件的机制? - 2

    我正在使用Ruby-Tk为OSX开发一个桌面应用程序,我想为该应用程序提供一个AppleEvents接口(interface)。这意味着应用程序将定义它将响应的AppleScript命令的字典(对应于发送到应用程序的Apple事件),并且用户/其他应用程序可以使用AppleScript命令编写Ruby-Tk应用程序的脚本。其他脚本语言支持此类功能——Python通过位于http://appscript.svn.sourceforge.net/viewvc/appscript/py-aemreceive/的py-aemreceive库和Tcl通过位于http://tclae.source

  2. ruby - Ruby 的方法解除绑定(bind)机制有什么意义? - 2

    Method#unbind返回对该方法的UnboundMethod引用,稍后可以使用UnboundMethod#bind将其绑定(bind)到另一个对象.classFooattr_reader:bazdefinitialize(baz)@baz=bazendendclassBardefinitialize(baz)@baz=bazendendf=Foo.new(:test1)g=Foo.new(:test2)h=Bar.new(:test3)f.method(:baz).unbind.bind(g).call#=>:test2f.method(:baz).unbind.bind(h).

  3. Selenium等待机制之显示等待 - 2

    显示等待需要用到两个类:WebDriverWait和expected_conditions两个类WebDriverWait:指定轮询间隔、超时时间等expected_conditions:指定了很多条件函数(也可以自定义条件函数)具体可以参考官网:selenium.webdriver.support.expected_conditions—Selenium4.5documentationfromseleniumimportwebdriverfromselenium.webdriver.common.byimportByfromselenium.webdriver.support.uiimpor

  4. ruby - 不支持您提供的授权机制。请使用 AWS4-HMAC-SHA256 - 2

    我收到错误AWS::S3::Errors::InvalidRequest不支持您提供的授权机制。请使用AWS4-HMAC-SHA256.当我尝试将文件上传到新法兰克福地区的S3存储桶时。所有适用于USStandard区域。脚本:backup_file='/media/db-backup_for_dev/2014-10-23_02-00-07/slave_dump.sql.gz's3=AWS::S3.new(access_key_id:AMAZONS3['access_key_id'],secret_access_key:AMAZONS3['secret_access_key'])s3_

  5. Qt 中的信息输出机制:QDebug、QInfo、QWarning、QCritical 的简单介绍和用法 - 2

    Qt中的信息输出机制介绍QDebug在Qt中使用qDebug输出不同类型的信息浮点数:使用%!f(MISSING)格式化符号输出浮点数布尔值:使用%!(MISSING)和%!(MISSING)格式化符号输出布尔值对象:使用qPrintable()函数输出对象的信息qInfoqWarningqCritical自定义信息输出格式不同输出方式的区别和底层逻辑总结介绍在Qt中,信息输出机制用于在程序运行时输出各种信息,包括调试信息、提示信息、警告信息和错误信息等。Qt提供了多种信息输出机制,主要包括以下几种:qDebug:最常用的信息输出机制,用于输出各种调试信息,例如变量的值、函数的返回值和对象的状

  6. javascript - 如何有效地使用日志记录机制? - 2

    我正在使用log4javascript来记录和跟踪我的JavaScript代码中的问题。我以前见过类似的日志记录辅助工具,但我很难理解应该如何使用这些日志级别中的每一个才能更有用和更有成效。大多数时候,我最终会记录调试、信息或跟踪,但并没有真正意识到它们各自的效率如何。随着代码变得越来越大,它变得越来越困难,我觉得日志麻烦多于帮助。有人可以给我一些指南/帮助,以便我可以很好地使用日志记录机制。以下是log4javascript支持的不同日志级别:log4javascript.Level.ALLlog4javascript.Level.TRACElog4javascript.Level.

  7. Cookie/Session 的机制与安全 - 2

    文章目录Cookie的实现机制Cookie的安全隐患Cookie防篡改机制Session的实现机制Cookie和Session是为了在无状态的HTTP协议之上维护会话状态,使得服务器可以知道当前是和哪个客户在打交道。本文来详细讨论Cookie和Session的实现机制,以及其中涉及的安全问题。因为HTTP协议是无状态的,即每次用户请求到达服务器时,HTTP服务器并不知道这个用户是谁、是否登录过等。现在的服务器之所以知道我们是否已经登录,是因为服务器在登录时设置了浏览器的Cookie!Session则是借由Cookie而实现的更高层的服务器与浏览器之间的会话。Cookie是由网景公司的前雇员Lo

  8. javascript - node.js 中是否存在超时事件的通用机制? - 2

    我正在学习node.js,我能找到的大多数示例都是处理简单示例的。我更感兴趣的是构建真实世界的复杂系统,并评估node.js基于事件的模型如何处理真实应用程序的所有用例。我想应用的一个常见模式是让阻塞执行超时,如果它没有在特定超时时间内发生。例如,如果执行一个数据库查询需要超过30秒,那么对于某些应用程序来说可能太多了。或者如果读取一个文件需要超过10秒。对我来说,带超时的理想程序流与带异常的程序流类似。如果某个事件没有在某个预定义的超时限制内发生,那么事件监听器将从事件循环中清除,并且会生成一个超时事件。此超时事件将有一个备用监听器。如果事件被正常处理,那么超时监听器和事件监听器都会

  9. javascript - JavaScript WebSockets API 的机制 - 2

    我一直在尝试理解一些用于打开websocket的代码:varws=newWebSocket('ws://my.domain.com');ws.onopen=function(event){...}我的问题是握手是如何开始的?如果它是在WebSocket构造函数中启动的,那么如果到那时还没有设置,如何调用onopen呢?如果WebSocket构造函数创建一个执行握手的线程,那么在握手结束之前是否必须足够快地定义onopen?如果是这样,那听起来有点危险,因为如果JS虚拟机变慢,握手可能会在定义onopen之前完成,这意味着事件没有得到处理。还是设置onopen函数触发握手?有人可以向我解

  10. javascript - Java/SpringMVC/Maven元素的缓存清除机制 - 2

    关于我正在使用的应用程序的一些背景知识:SpringMVC、JavaEEWeb应用程序、Maven。基本上,我正在寻找的是一种在每次部署我们的应用程序时刷新所有JS和CSS文件的机制。就目前而言,应用程序正在引用静态文件(例如“js/app.js”)。每次这个文件有变化,在本地重新部署后,当浏览器去下载它时,我们会得到一个304(文件没有被修改)。我的问题是:1)在重新部署应用程序时再次提供这些文件并使用应用程序版本作为缓存清除机制(例如“js/v1.0.0/app.js”)会更好吗?这可以通过servlet过滤器来完成。2)由于该元素是一个Maven动态Web元素,是否有某种插件可以

随机推荐