由浅入深彻底理解JS事件循环Event Loop

2,486 阅读12分钟

初识 事件循环 - Event Loop

Event Loop 的流程

事件循环 Event Loop是JS实现异步任务执行的一个机制。

JS是单线程的脚本语言。
只有一个线程,我们称其为主线程

代码在主线程运行,会产生堆(heap)和栈(stack)。
中存的是引用数据类型,
中存的是基本数据类型 以及 函数执行时的运行空间。

任务分为同步、异步,宏任务/微任务都属于异步任务。
同步任务,指从上到下依次执行,中间有地方卡住了,就会阻塞,后面的代码就会加载不了。
就像上学时课间操齐步走,一个班的人,都要一个步调,前面的人快后面的人跟着快,前面的人慢下来,后面的人也不能超过去,只能【阻塞】在后面。
异步任务,指各执行各的,互不相关,什么时候执行完了,返回一个结果,或者不返回结果,都可以。
就像晚上操场,有人散步,有人跑步,两种人步调必定不同,也无需相同,你走你的我跑我的,你走不走跟我跑不跑没什么关系。

JS是单线程语言,即同一时间只能执行一个任务, 也就是说代码执行是同步并且阻塞的,
同步执行的弊端不必多言,所以避免不开需要异步任务的需求。

浏览器读取代码,同步代码直接放入调用栈中,然后进入主线程执行。
遇到异步任务则放入任务队列——异步代码不进入调用栈。

调用栈 Call stack ,主线程执行调用栈中的任务。
调用栈最经常被用于存放子程序的返回地址。
举个例子,我的理解是,函数是引用数据类型,在堆中存放内容,在栈中存放引用地址,调用栈则保存这个引用地址,当执行到这个函数的时候,就会读取这个引用地址,然后执行堆中内容。

任务队列 task queue ,任务队列是在主线程上对(异步)任务的一切调用。
更通俗一点说,同步任务放入调用栈,按顺序直接被主线程调用;异步任务则是先放入任务队列,等到调用栈空了(本轮同步任务执行完毕),主线程就会执行任务队列中的任务。
An event loop has one or more task queues. A task queue is a set of tasks.【事件循环中有一个或多个任务队列,任务队列中都是一组任务】

上面说宏任务/微任务都属于异步任务的范畴。
宏任务 Macrotask 这个词的出处不可考,官方管宏任务叫任务 task,我猜大家可能为了不引起混淆(?)。
宏任务 Macrotask,例如:script(整体代码),I/O, setTimeout,setInterval等
微任务 Microtask每个事件循环都有一个微任务队列,它是一个微任务队列,最初是空的。例如:Promise等
一组事件循环中,先执行同步任务,然后执行异步任务
在进入下一个宏任务之前,会将清空在这个宏任务中产生的微任务

这里我不说哪些任务算是宏任务、微任务,因为重点不在这,这篇文章只为了搞清楚其中的机制,而不关心具体。

异步任务分为宏任务、微任务,异步任务进入任务队列,
任务队列自然分宏任务队列、微任务队列。

例子🌰

讲到这儿,已经可以看看例子了:


    console.log(1)
    
    setTimeout(function() {
        console.log(2)
    },0)
    
    // 注:new promise任务同步,then()中任务异步-微任务
    new Promise(resolve=>{
        console.log(3)
        resolve()
    }).then(()=>{
        console.log(4)
    })
    
    console.log(5)
    

输出:1,3,5,4,2

  1. console.log(1)放入调用栈,主线程执行,输出1
  2. setTimeout(...)是宏任务,放入宏任务队列
  3. new Promise(resolve=>{ console.log(2); resolve() }).放入调用栈,主线程执行,输出2
  4. then(...)是微任务,放入微任务队列
  5. console.log(5)放入调用栈,主线程执行,输出5
  6. 此时,同步任务执行完毕,调用栈为空,轮询任务队列,执行微任务队列中任务。
  7. then(...)中的console.log(3)放入调用栈,主线程执行,输出3
  8. 此时,微任务队列清空,调用栈为空,轮询任务队列,执行宏任务队列中任务。
  9. setTimeout(...)中的console.log(2)放入调用栈,主线程执行,输出2

至此,算是事件循环的一套流程。当然,也有说的不完善不到位的地方,比如调用栈和任务队列中见还有一个步骤,下面让我们接着讲。

补充

在【初识 事件循环 - Event Loop】中,我几乎没有问「为什么」,只说「是什么」,也没有旁征博引去思辨。
这是因为我怕以我的写作能力,写的太多,反而写的没有重点,乱七八糟。
那在这个「补充」里,来说说上面没有提到的东西吧。

先放一张图:

image.png

⬇️ 其中

红色圆圈内是堆和栈,
中存的是引用数据类型,
中存的是基本数据类型 以及 函数执行时的运行空间。

蓝色圆圈内是WebAPIs

绿色圆圈内是callback queue

整个事件循环就是红-->蓝-->绿-->红-->蓝-->绿这样一直循环。

堆栈-->WebAPIs-->callback queue-->堆栈-->WebAPIs-->callback queue image.png

说起事件循环,总会有上图的身影出现,套用我上面写的东西,似乎跟这个图契合不上,我说的是错的吗吗?

从图中 callback queue 说起

我认为回调队列 callback queue与我上文说的任务队列 task queue 是一个东西的不同说法。
也有人叫它事件队列 event queue消息队列
总的来说是调用栈为空时,主线程轮询的队列。

轮询,是一个动作。
之前说,主线程执行调用栈中的任务,当调用栈为空,就会执行任务队列中的任务。
当调用栈为空,JS的运行时环境就会持续查看任务队列中是否有任务,这个反复查看的动作就是轮询

再来看到底应该怎么称呼这个队列,引用这篇文章中的话

任务队列既不是事件的队列,也不是消息的队列。

任务队列就是你在主线程上的一切调用。

所谓的事件驱动,就是将一切抽象为事件。IO操作完成是一个事件,用户点击一次鼠标是事件,Ajax完成了是一个事件,一个图片加载完成是一个事件

一个任务不一定产生事件,比如获取当前时间。

当产生事件后,这个事件会被放进队列中,等待被处理

由此可见,事件队列、消息队列的称呼似乎站不住脚。

而「回调队列」的说法,他们是这么说的:

所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当异步任务从"任务队列"回到执行栈,回调函数就会执行。

再看看刚才那篇文章作者所言:

他们压根就没有被执行过,何来挂起之说?

异步任务不一定要回调函数。

从来就没有什么执行栈。主线程永远在执行中。主线程会不断检查事件队列

注:他似乎反驳「回调队列」的说法的同时,也否定了「执行栈」
但我以为,执行栈(调用栈)的说法可以存在,代码解析成变量和指令保存在内存,CPU从上至下读取执行,私以为这个保存在内存中的指令就是执行栈。

综上,我认为任务队列的说法比较靠谱。并且HTML Standard中也用的这个词。
其他说法只会混淆视听,增加学习难度。

再说图中 Web APIs

在【初识 事件循环 - Event Loop】中说异步代码不进入调用栈,异步任务则放入任务队列。
但没有提到的一点是:
异步任务进入任务队列之前,还会有一个步骤。

这个步骤有人会经过Event Table,有人说经过Web APIs。

先说 Event Table

关于事件表 Event Table这个概念,
有人说

Event Queue 简单理解就是 回调函数 队列,所以它也叫Callback Queue
当 Event Table 中的事件被触发,事件对应的 回调函数 就会被 push 进这个 Event Queue,然后等待被执行

也有人说

Event Table 就是个注册栈:调用栈让Event Table注册一个函数,该函数会在5秒之后被调用。当指定的事情发生时,Event Table会将这个函数移到Event Queue

这句话指的是这句代码:setTimeout(firstFunction, 5000);

私以为两个表达的意思都差不多:
异步代码先进入「Event Table」注册,
如果是定时器5000ms,那就5秒后将定时器内的回调函数中的任务放入任务队列。
如果是click事件,那就等到触发click事件后将回调函数中的任务放入任务队列。

看一下这篇文章最后几行所言

准确讲,使用事件驱动的系统中,必然有非常非常多的事件。如果事件都产生,都要主循环去处理,必然会导致主线程繁忙。那对于应用层的代码而言,肯定有很多不关心的事件(比如只关心点击事件,不关心定时器事件)。这会导致一定浪费。

这篇文章里没有讲到的一个重要概念是watcher。观察者。

事实上,不是所有的事件都放置在一个队列里。

不同的事件,放置在不同的队列。

当我们没有使用定时器时,则完全不用关心定时器事件这个队列

当我们进行定时器调用时,首先会设置一个定时器watcher。事件循环的过程中,会去调用该watcher,检查它的事件队列上是否产生事件(比对时间的方式)

当我们进行磁盘IO的时候,则首先设置一个io watcher,磁盘IO完成后,会在该io watcher的事件队列上添加一个事件。事件循环的过程中从该watcher上处理事件。处理完已有的事件后,处理下一个watcher

检查完所有watcher后,进入下一轮检查

对某类事件不关心时,则没有相关watcher

再说 Web APIs

Web API容器

调用栈内的Web API调用会被分发到Web API容器内,比如事件监听函数、HTTP/AJAX请求、或者是定时器函数,这些事件会在该容器内直到达到触发条件。要么是一个点击事件被触发、或者是HTTP请求完成从数据源获取数据、或者是定时器达到触发的时间点,一旦达到触发条件,一个回调函数就会被推入第四个也是最后一个容器: 回调队列。

在说说什么是web api
我认为它是浏览器对外暴露的函数、接口。
举个例子,下面这段代码,add就是暴露出来的函数、接口,外面只能读取,使用,但是不知道内部如何实现的,也不能改变。

let mywindow = function(){
    let obj = {
        add: function(a, b){
            return a+b
        }
    }
    return obj
}

mywindow().add(1,2) // 3

小结

本质来讲,我认为这event table 和 web apis也是同一个东西的两种说法。
仁者见仁智者见智吧。

2022-02-14,补记:

看了大佬的一套课程,才发觉,不管是事件表还是web APIs,都是对的,是两种实现异步的方式。

先说event table,他也可以叫做延迟队列,但实际上是一个哈希实现的一张表,比较典型的就是setTimeout,定时之后进入延迟队列,当任务队列为空的时候,就看延迟队列里面有没有到期的任务,有的话就去执行,没有就算了。

再说web APIs,比较典型的是XMLHttpRequest,它会调用其他进程去执行内容,比如请求服务器,然后通过IPC(进程间通信)把结果交给主线程,放进任务队列去执行。

碎碎念

这篇文章断断续续写了两三天,本意是看了这么多文章,想看看我自己能不能说好什么是事件循环,
同时看了这么多文章,那么多概念实在是容易混淆了,所以希望能写出一篇文章对大家有所帮助。

之前看文章的时候,看一篇信一篇,以此为基调,看到说法不同的就会云里雾里,
但是实际上呢,都是一个东西。
可我上面写的真的是对的吗?不一定,大家还是要有自己的思考
上面都是我自己的想法,我自己的总结,他们真的是一个东西吗?会不会有什么不一样的?

关于任务队列,
回调队列的说法真的不对吗?
什么是异步任务?不会立即执行的任务。
既然不会立即执行,那执行的时候必然调用代码,这串代码不可以叫做函数吗?

讲了一些乱七八糟的东西,如有错漏,敬请指摘。

参考链接: