JavaScript中的事件循环如何工作?
尽管编写完整的生产代码可能需要对诸如C++和C之类的语言有深入的了解,但JavaScript通常只需要基本了解可以用该语言做什么就可以编写。
像将回调传递给函数或编写异步代码这样的概念通常并不难实现,这使得大多数JavaScript开发人员对底层发生的事情不太在意。他们并不关心被语言深度抽象化的复杂性。
作为JavaScript开发人员,了解底层发生的事情以及这些抽象化复杂性是如何工作真的变得越来越重要。这有助于我们做出更明智的决策,从而可以大大提升我们的代码性能。
本文重点介绍了JavaScript中非常重要但很少被理解的概念或术语之一。事件循环!
在JavaScript中,无法避免地编写异步代码,但是为什么异步运行的代码会真正意味着什么呢?即事件循环
在我们理解事件循环如何工作之前,我们首先必须了解JavaScript本身是什么以及它是如何工作的!
JavaScript是什么?
在我们继续之前,我想让我们退回到最基本的层面。JavaScript到底是什么?我们可以这样定义JavaScript;
JavaScript是一种高级、解释型、单线程、非阻塞、并发语言。
等等,这是什么?书面上的定义? 🤔
让我们分解一下!
就本文而言,关键词是单线程、非阻塞、并发和异步。
单线程
执行线程是可由调度程序独立管理的最小程序指令序列。单线程的编程语言意味着它一次只能执行一个任务或操作。这意味着它会从开始到结束执行整个过程,而不会中断或停止线程。
与多线程语言不同,多线程语言可以在多个线程上同时运行多个进程,而不会相互阻塞。
JavaScript如何既是单线程又是非阻塞的?
但是阻塞是什么意思?
非阻塞
阻塞没有一个确定的定义;它只是指在线程上运行缓慢的事物。因此,非阻塞就意味着在线程上运行速度不慢的事物。
但是等等,我说过JavaScript在单线程上运行吗?我也说过它是非阻塞的,这意味着任务在调用栈上运行得很快?但是如何???当我们运行计时器时呢?循环呢?
放松!我们一会儿会找出来的 😉。
并发
并发意味着代码正在由多个线程同时执行。
好吧,事情现在变得更weird了,JavaScript如何既是单线程又同时是并发的?即使用多个线程执行其代码?
异步
异步编程意味着代码在事件循环中运行。当有阻塞操作时,事件会启动。阻塞代码会持续运行而不阻塞主执行线程。当阻塞代码运行完成时,它会将阻塞操作的结果排队并将其推回到堆栈。
但是JavaScript只有一个线程?那么在让线程中的其他代码执行的同时,是什么执行了这个阻塞代码呢?
在我们继续之前,让我们回顾一下上面的内容。
- JavaScript 是单线程的
- JavaScript 是非阻塞的,即慢速进程不会阻塞其执行
- JavaScript 是并发的,即它同时在多个线程中执行其代码
- JavaScript 是异步的,即它在其他地方运行阻塞代码
但以上说法似乎并不完全符合,一个单线程的语言如何同时是非阻塞、并发和异步的呢?
让我们深入一点,来了解一下 JavaScript 运行时引擎 V8,也许它有一些我们不知道的隐藏线程。
V8 引擎
V8 引擎是由谷歌用 C++ 编写的高性能、开源的 JavaScript Web 汇编运行时引擎。大多数浏览器都使用 V8 引擎来运行 JavaScript,甚至流行的 Node.js 运行环境也使用它。
简单来说,V8 是一个 C++ 程序,它接收 JavaScript 代码,编译并执行它。
V8 主要做了两件事:
- 堆内存分配
- 调用栈执行上下文
不幸的是,我们的猜测是错误的。V8 只有一个调用栈,将调用栈看作线程。
一个线程 === 一个调用栈 === 一次只能执行一个任务。
由于 V8 只有一个调用栈,那么 JavaScript 如何在不阻塞主执行线程的情况下并发和异步运行呢?
让我们通过编写一个简单但常见的异步代码并一起分析来找出答案。
JavaScript 逐行运行每一行代码,一个接一个地(单线程)。如预期的那样,第一行在控制台中被打印出来,但为什么最后一行在超时代码之前被打印出来呢?为什么执行过程不等待超时代码(阻塞)之后再继续运行最后一行呢?
似乎有另一个线程帮助我们执行了那个超时代码,因为我们非常确定一个线程一次只能执行一个任务。
让我们偷偷看一下 V8 Source Code。
等等…什么?!V8 中没有计时器函数,没有 DOM?没有事件?没有 AJAX?….是的!
事件、DOM、计时器等不是 JavaScript 核心实现的一部分,JavaScript 严格遵守 Ecma Scripts 规范,并且不同版本的 JavaScript 常根据其 Ecma Scripts 规范(ES X)来引用。
执行流程
事件、计时器、Ajax 请求等都由浏览器在客户端提供,并通常被称为 Web API。它们使单线程的 JavaScript 变成了非阻塞、并发和异步的!但是如何实现的呢?
任何 JavaScript 程序的执行流程分为三个主要部分:调用栈、Web API 和任务队列。
调用栈
栈是一种数据结构,其中最后添加的元素总是最先从栈中移除,可以将其想象为一堆盘子的堆栈,只有最后添加的盘子可以最先被移除。调用栈只是一个堆栈数据结构,其中任务或代码按顺序执行。
让我们看下面的例子;
在调用函数printSquare()
时,它被推入调用堆栈,printSquare()
函数调用了square()函数。将square()
函数推入堆栈,并调用multiply()
函数。multiply函数被推入堆栈。由于multiply函数返回并且是最后推入堆栈的内容,所以它首先被解决并从堆栈中移除,然后是square()
函数,最后是printSquare()
函数。
Web API
这是在V8引擎无法处理的代码执行的地方,以便不阻塞主执行线程。当调用堆栈遇到Web API函数时,该过程立即交给Web API处理,以便执行并释放调用堆栈,在执行期间执行其他操作。
让我们回到上面的setTimeout
示例;
当我们运行代码时,第一个console.log行被推入堆栈,我们几乎立即得到输出,在超时时,定时器由浏览器处理,并不是V8的核心实现的一部分,它被推到Web API中,从而释放堆栈以便执行其他操作。
在超时仍在运行时,堆栈继续进行下一行操作并运行最后一个console.log,这解释了为什么在定时器输出之前我们得到那个输出。一旦计时器完成,就会发生一些事情。定时器中的console.log会在调用堆栈中以神奇的方式再次出现!
怎么样?
事件循环
在讨论事件循环之前,让我们先了解任务队列的功能。
回到我们的超时示例,一旦Web API完成任务执行,它不会自动将其推回到调用堆栈中。它进入任务队列
。
队列是一种按照先进先出原则工作的数据结构,因此当任务被推入队列时,它们以相同的顺序出队。已由Web API执行的任务被推入任务队列,然后返回到调用堆栈以打印其结果。
但是等等,事件循环到底是什么???
事件循环是一种在将回调从任务队列推入调用堆栈之前等待调用堆栈清空的过程。一旦堆栈清空,事件循环会触发并检查任务队列中是否有可用的回调函数。如果有,它将其推入调用堆栈,等待调用堆栈再次清空,并重复相同的过程。
上述图示演示了事件循环和任务队列之间的基本工作流程。
结论
虽然这只是一个非常基本的介绍,但JavaScript中的异步编程概念足以清楚地了解底层的运行情况,以及JavaScript如何能够在单个线程上并发和异步运行。
JavaScript始终是按需执行的,如果你有兴趣学习,我建议你查看这个Udemy course。