前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JavaScript 面试要点: Event Loop (事件循环)

JavaScript 面试要点: Event Loop (事件循环)

作者头像
Cellinlab
发布2023-05-17 16:02:46
6430
发布2023-05-17 16:02:46
举报
文章被收录于专栏:Cellinlab's BlogCellinlab's Blog

# 单线程

用于和浏览器交互,JavaScript 诞生时起就是单线程非阻塞的脚本语言。

单线程意味着,JavaScript 在执行代码的任何时候,都只有一个主线程来处理所有的任务。非阻塞则是当代码需要进行一项异步任务时,主线程会挂起这个任务,然后在异步任务返回结果时再根据一定规则去执行相应回调。

单线程是必要的,缘于其最初的宿主环境——浏览器中,要进行各种 DOM 操作。如果多线程,可能会导致 DOM 操作困难和结果不一致。JavaScript 选择只用一个主线程来执行代码,保证了程序执行的一致性。

但是,单线程在保证了执行顺序的同时限制了 JavaScript 的效率,因此开发出了 Web Worker 技术。不过,Web Worker 的使用有很多限制,如:新线程受主线程完全控制,不能独立执行,即这些“线程”实际上是主线程的子线程;子线程没有 I/O 操作权限,只能为主线程分担一些诸如计算等任务。所以,严格讲这些线程并没有完整的功能,故无法改变 JavaScript 语言单线程的本质。

那 JavaScript 引擎是怎么实现“非阻塞”呢?事件循环!

# 浏览器环境下的事件循环机制

# 执行栈和事件队列

JavaScript 代码执行时会将不同的变量存在内存中不同位置:

  • 堆(heap):存放对象
  • 栈(stack):存放基础类型变量和对象的指针

在调用方法时,JavaScript 引擎会生成一个对应的执行环境(context,执行上下文),其中包含:

  • 该方法的私有作用域
  • 上层作用域的指向
  • 方法的参数
  • 当前作用域中定义的变量
  • 当前作用域的 this 对象

当一系列的方法被调用的时候,因为 JavaScript 是单线程的,同一时刻只能执行一个方法,所以这些方法被排队在一个单独的地方——调用栈

当一段代码第一次执行,JavaScript 引擎会解析代码,并将其中的同步代码按照执行顺序加入执行栈,然后从头开始执行。如果当前执行的是个方法,那 JavaScript 引擎会像执行栈添加这个方法的执行上下文,然后进入该执行上下文继续执行其中的代码。当该执行上下文中的代码执行完毕返回结果后,JavaScript 会退出这个执行环境并将该执行环境销毁,再回到上一个方法的执行环境,知道栈中所有代码执行结束。

调用栈中的执行环境可以不断添加,知道发生栈溢出,即超过所能利用的最大内存。

以上都是同步代码,当异步代码执行时,会使用非阻塞特点的实现机制——事件队列

JavaScript 引擎遇到异步事件后并不会一直等待其返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,JavaScript 会将这个事件加入与当前执行栈不同的一个队列——事件队列。被放入事件队列后不会立即执行器回调,而是等待当前执行栈中所有任务都执行完毕,主线程属于闲置状态时,主线程回去查找事件队列中是否有任务。如果有,就会取出排在第一位的事件,并将对应的回调放入执行栈,然后执行同步代码,如此反复,形成一个无限的循环——事件循环(Event Loop)。

# macrotasks 和 microtasks

  • 宏任务 (macrotask)
    • SetInterval()
    • SetTimeout()
  • 微任务 (microtask)
    • new Promise()
    • new MutationObserver()

在事件循环中,异步事件返回结果会被放到一个任务队列中,根据异步事件的类型,事件会被放到对应的宏任务队列或微任务队列中。在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在,如果不存在,再去宏任务队列取出一个事件把对应回调加入到当前执行栈;如果存在,这会一次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列...如此反复。

当当前执行栈执行完毕会立刻去处理所有微任务队列中的事件,然后再去宏观任务队列中处理一个事件。

setTimeout(function() {
  console.log(1);
})

new Promise(function(resolve, reject) {
  console.log(2);
  resolve(3);
}).then(function(value) {
  console.log(value);
});

// 2
// 3
// 1

# Node.js 环境下的事件循环机制

# 与浏览器环境的不同

在 Node.js 中,事件循环表现出的状态与浏览器中大致相同,不过 Node.js 有一套自己的模型。

Node.js 中事件循环依靠 libuv 引擎实现。Node.js 选择 Chrome V8 作为 JavaScript 解释器,V8 引擎将 JavaScript 代码分析后去调用对应的 Node.js API,而这些 API 最后由 libuv 引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程的执行。实际上 Node.js 中的事件循环存在于 libuv 引擎中。

# 事件循环模型

libuv 引擎中的事件循环模型:

从模型中可以大致看出,Node.js 中的事件循环顺序:

外部输入数据 -> 轮询阶段(poll) -> 检查阶段(check) -> 关闭事件回调阶段(close callback) -> 定时器检测阶段(timer) -> I/O 事件回调阶段 (I/O callbacks) -> 闲置阶段(idle,prepare) -> 轮询阶段...。

  • timers:执行定时器队列中的回调如 setTimeout()setInterval()
  • I/O callbacks:执行机会所有的回调,但是不包括 close 事件,定时器和 setImmediate() 的回调
    • 执行大部分 I/O 事件回调,包括一些为操作系统执行的回调,如 TCP 连接发生错误,系统需要执行回调获得错误报告
  • idle,prepare:该阶段仅在内部使用
  • poll:等待新的 I/O 事件,Node.js 在一些特殊情况下会阻塞在这里
    • 当 V8 引擎将 JavaScript 代码解析后传入 libuv 引擎后,循环首先进入 poll 阶段
    • 执行逻辑
      • 先检查 poll queue 中是否有事件,有就按先进先出的顺序依次解决
      • 如果 queue 为空,检查是否有 setImmediate() 的回调,若有就进入 check 阶段执行执行回调
      • 同时也检查是否有到期的 timer,若有就将到期的 timer 的回调按顺序放入 timer queue,之后循环会进入 timer 阶段执行 queue 中的回调
      • 如果两者的 queue 都为空,那循环会在 poll 阶段停留,直到有一个 I/O 事件返回,循环会进入 I/O callback 阶段并立即执行回调。
    • poll 阶段在执行 poll queue 中的回调时实际上不会无限地执行下去,一些情况会终止执行 poll queue 中的回调:
      • 所有回调执行完毕
      • 执行数超过了 Node.js 的限制
  • check:setImmediate() 回调会在这里执行
    • 当 poll 阶段进入空闲状态,且 setImmediate queue 不为空时,会进入 check 阶段
  • close callback:如 socket.on('close', function() { ... }) 这种 close 事件的回调
    • 当一个 socket 连接或者一个 handle 被突然关闭,close 事件会被发送到这个阶段执行回调,否则事件会用 process.nextTick() 方法发送出去

# process.nextTick, setImmediate, setTimeout 的区别和使用场景

在 Node.js 中有三个常用来推迟任务执行的方法:process.nextTick()setImmediate()setTimeout()(setInterval()与之相同)。

  • process.nextTick()
    • Node.js 中存在着一个特殊的队列—— nextTick queue。该队列中的回调执行虽然没有被表示为一个阶段,但是这些事件却会在每个阶段执行完准备进入下一个阶段时优先执行。
    • 当事件循环准备进入下一个阶段之前,会先检查 nextTick queue 中是否有任务,如果有,会先清空该队列,和 poll queue 不一样,这个操作在队列清空前是不会停止的。因此错误地使用 process.nextTick() 会导致 Node.js 进入死循环,直至内存泄露。
  • setTimeout()setImmediate()
    • setTimeout() 定义一个回调,希望回调在指定时间间隔后第一时间去执行。注意“第一时间”受到操作系统和当前执行任务的诸多影响,回调并不会在预期时间执行。
    • setImmediate() 从命名理解是立即执行,但实际上是在一个固定的阶段才会执行,即 poll 阶段之后。
    • setTimeout() 不设置时间间隔时和setImmediate() 表现极其相似
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2021/2/22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • # 单线程
  • # 浏览器环境下的事件循环机制
    • # 执行栈和事件队列
      • # macrotasks 和 microtasks
      • # Node.js 环境下的事件循环机制
        • # 与浏览器环境的不同
          • # 事件循环模型
            • # process.nextTick, setImmediate, setTimeout 的区别和使用场景
            相关产品与服务
            腾讯云代码分析
            腾讯云代码分析(内部代号CodeDog)是集众多代码分析工具的云原生、分布式、高性能的代码综合分析跟踪管理平台,其主要功能是持续跟踪分析代码,观测项目代码质量,支撑团队传承代码文化。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档