事件循环

本文最后更新于:29 分钟前

事件循环

JavaScript 引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是 Web 浏览器。经过最近几年(不仅于此)的发展,JavaScript 已经超出了浏览器的范围,进入了其他环境,比如通过像 Node.,js 这样的工具进入服务器领域。实际上,JavaScript 现如今已经嵌入到了从机器人到电灯泡等各种各样的设备中。

但是,所有这些环境都有一个共同“点”(thread,也指线程。不论真假与否,这都不算一个很精妙的异步笑话),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环

换句话说,JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。

所以,举例来说,如果你的 JavaScript 程序发出一个 Ajax 请求,从服务器获取一些数据,那你就在一个函数(通常称为回调函数)中设置好响应代码,然后 JavaScript 引擎会通知宿主环境:“嘿,现在我要暂停执行,你一旦完成网络请求,拿到了数据,就请调用这个函数。”

然后浏览器就会设置侦听来自网络的响应,拿到要给你的数据之后,就会把回调函数插入到事件循环,以此实现对这个回调的调度执行。
先通过一段伪代码了解一下这个概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = []
var event

while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift()

// 现在执行下一个事件
try {
event()
} catch (err) {
reportError(err)
}
}
}

这当然是一段极度简化的伪代码,只用来说明概念。不过它应该足以用来帮助大家有更好的理解。

你可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。

一定要清楚,setTineout(..)并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调。

如果这时候事件循环中已经有 20 个项目了会怎样呢?你的回调就会等待。它得排在其他项目后面——通常没有抢占式的方式支持直接将其排到队首。这也解释了为什么setTineout(..)定时器的精度可能不高。大体说来,只能确保你的回调函数不会在指定的时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的状态而定。

所以换句话说就是,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。严格地说,和你的程序不直接相关的其他事件也可能会插入到队列中。

为什么 JavaScript 是单线程?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

同步任务和异步任务:

单线程,就是指一次只能完成一件任务,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务。但如果有一个任务的执行时间很长,比如文件的读取或者数据的请求等等,那么后面的任务就要一直等待,这就会影响用户的使用体验。

为了解决这种情况,Javascript 语言将任务的执行模式分成两种:同步(Synchronous)异步(Asynchronous)

  • 同步模式: 就是前一个任务执行完成后,再执行下一个任务,程序的执行顺序与任务的排列顺序是一致的、同步的;
  • 异步模式: 则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行队列上的后一个任务,而是执行回调函数;后一个任务则是不等前一个任务的回调函数的执行而执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

事件循环

同步任务异步任务分别进入不同的执行”场所”;同步任务进入主线程,异步任务进入事件列表(Event Table)并注册回调函数。
当指定的事情完成时,
Event Table
会将这个函数移入**任务队列(task quene),等待主线程的任务执行完毕;
当栈中的代码执行完毕,
执行栈(call stack)中的任务为空时,就会读取任务队列(task quene)中的任务,去执行对应的回调;如此循环,就形成 js 的事件循环机制(Event Loop)**。
QQ截图20220929093932.png

执行过程:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

  2. 遇到异步任务,进入 Event Table 并注册回调函数;等到指定的事件完成(如 ajax 请求响应返回, setTimeout 延迟到指定时间)时,Event Table 会将这个回调函数移入事件队列(Event Queue)。

  3. 当栈中的代码执行完毕,执行栈(call stack)中的任务为空时,主线程会先检查**micro-task(微任务)队列中是否有任务,如果有,就将micro-task(微任务)队列中的所有任务依次执行,直到micro-task(微任务)队列为空;之后再检查macro-task(宏任务)队列中是否有任务,如果有,则取出第一个macro-task(宏任务)加入到执行栈中,之后再清空执行栈,检查micro-task(微任务)**,以此循环,直到全部的任务都执行完成。

20200821150103500.png20200821150047814.png

任务队列(task queue) 或 事件队列(event queue):

SetTimeout 和 Promise 都是异步任务,那么它们两个先执行谁呢?
其实在 js 中也是有一个机制,就是会分为宏任务微任务。宏任务和微任务分别存放在不同的 event queue,这两个队列分别为 macrotack queue 和 microtack queue。微任务先执行

任务队列分为: macro-task(宏任务)队列, micro-task(微任务)队列;

在最新标准中,它们被分别称为 task 与 jobs。

macro-task(宏任务):

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate
  • I/O (比如 Ajax 操作从网络读取数据)
  • UI render

micro-task(微任务):

  • process.nextTick
  • Promise 本身是同步,Promise.then、.catch 和 .finally 等方法是异步
  • Async/Await(实际就是 promise)
  • MutationObserver(html5 新特性)

事件表格(Event Table):

Event Table 可以理解成一张事件->回调函数 对应表。
用来存储 JS 中的异步事件 (request, setTimeout, IO 等) 及其对应的回调函数的列表。
当指定的事件完成(如 ajax 请求响应返回, setTimeout 延迟到指定时间)时,
Event Table
会将这个回调函数移入 Event Queue, 即**macro-task(宏任务)**队列 或 **micro-task(微任务)**队列。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!