JavaScript 系列 - 事件循环

113 阅读8分钟

执行上下文

当一段 JavaScript 代码在运行的时候,它实际上是运行在执行上下文中。

  • 每一个上下文在本质上都是一种作用域层级。每个代码段开始执行的时候都会创建一个新的上下文来运行它,并且在代码退出的时候销毁掉。
  • 每个上下文创建的时候会被推入执行上下文栈。当退出的时候,它会从上下文栈中移除。
  • 每次递归调用自身都会创建一个新的上下文。

分类

  • 全局上下文是为运行代码主体而创建的执行上下文,也就是说,它是为那些存在于 JavaScript 函数之外的任何代码而创建的。
  • 每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的“本地上下文”。
  • 使用 eval() 函数也会创建一个新的执行上下文。

JavaScript 运行时

代理

在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理

  • 一组执行上下文的集合
  • 执行上下文栈
  • 主线程
  • 一组可能创建用于执行 worker 的额外的线程集合
  • 一个任务队列
  • 一个微任务队列

除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其他组成部分对该代理都是唯一的。

事件循环

每个代理都是由事件循环(Event loop)驱动的,事件循环负责收集事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务,然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。

网页或者 app 的代码和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。该线程就是主线程,它除了运行网页本身的代码之外,还负责收集和派发用户和其他事件,以及渲染和绘制网页内容等。

  • Window 事件循环

    window 事件循环驱动所有共享同源的窗口(尽管这有进一步的限制,如下所述)。

  • Worker 事件循环

    worker 事件循环驱动 worker 的事件循环。这包括所有形式的 worker,包括基本的 web workershared worker 和 service worker。Worker 被保存在一个或多个与“主”代码分开的代理中;浏览器可以对所有特定类型的工作者使用一个事件循环,也可以使用多个事件循环来处理它们。

  • Worklet 事件循环

    worklet 事件循环驱动运行 worklet 的代理。这包含了 WorkletAudioWorklet 以及 PaintWorklet

在特定情况下,同源窗口之间共享事件循环,例如:

  • 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
  • 如果窗口是包含在 <iframe> 中的容器,则它可能会和包含它的窗口共享一个事件循环。
  • 在多进程浏览器中多个窗口碰巧共享了同一个进程。

任务 vs 微任务

一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。除了使用事件,你还可以使用 setTimeout() 或者 setInterval() 来添加任务。

任务队列和微任务队列的区别很简单,但却很重要:

  • 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行
  • 每次当一个任务退出且执行上下文栈为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,这些新的微任务将在下一个任务开始运行之前,在当前事件循环迭代结束之前执行。

问题

由于你的代码和浏览器的用户界面运行在同一个线程中,共享同一个事件循环,假如你的代码阻塞了或者进入了无限循环,则浏览器将会卡死。无论是由于 bug 引起还是代码中进行复杂的运算导致的性能降低,都会降低用户的体验。

当来自多个程序的多个代码对象尝试同时运行的时候,一切都可能变得很慢甚至被阻塞,更不要说浏览器还需要时间来渲染和绘制网站和 UI、处理用户事件等。

解决方案

使用 web worker 可以让主线程另起新的线程来运行脚本,这能够缓解上面的情况。一个设计良好的网站或应用会把一些复杂的或者耗时的操作交给 worker 去做,这样可以让主线程除了更新、布局和渲染网页之外,尽可能少的去做其他事情。

通过使用像 promise 这样的异步 JavaScript 技术可以使得主线程在等待请求返回结果的同时继续往下执行,这能够更进一步减轻上面提到的情况。

微任务是另一种解决该问题的方案,通过将代码安排在下一次事件循环开始之前运行而不是必须要等到下一次开始之后才执行,这样可以提供一个更好的访问级别。

let callback = () => log("Regular timeout callback has run");

let urgentCallback = () => log("*** Oh noes! An urgent callback has run!");

let doWork = () => {
  let result = 1;

  queueMicrotask(urgentCallback);

  for (let i = 2; i <= 10; i++) {
    result *= i;
  }
  return result;
};

log("Main program started");
setTimeout(callback, 0);
log(`10! equals ${doWork()}`);
log("Main program exiting");

单线程

单线程,是指在 JS 引擎中负责解释和执行 JavaScript 代码的线程只有一个, 同时只能执行一个任务,其他任务都必须在后面排队等待。

JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

Web Worker

  • 允许 JavaScript 脚本创建多个线程
  • 子线程完全受主线程控制,没有执行 I/O 操作的权限

JS 单线程如何是实现异步操作

调用栈(execution context stack)

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

事件循环(Event Loop)

事件循环.png

虽然 JS 是单线程的, 但是浏览器的内核是多线程的,在浏览器的内核中不同的异步操作由不同的浏览器内核模块调度执行,异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同

  • 主线程执行同步任务
  • 循环检查任务队列满足条件放入主线程执行回调函数
    • 异步任务没有回调函数,就不会进入任务队列
  • 定时器
    • setTimeout()setInterval()
    • 最短间隔不得低于 4 毫秒
      • DOM 的变动每 16 毫秒执行一次
      • requestAnimationFrame()

任务队列(task queue)

JavaScript 运行时,除了一个正在运行的主线程,引擎还提供多个任务队列处理异步任务

微任务 micro task

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

宏任务 macro task

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
    • 数据库操作
    • Ajax 的 onload,click 事件
  • UI rendering
  • script

微任务在宏任务之前执行

Node.js

可以用很少的资源,应付大流量访问的原因

event loop nodejs.png

  • V8 引擎解析 JavaScript 脚本
  • 解析后的代码,调用 Node API
  • libuv 库负责 Node API 的执行
    • 它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎
  • V8引擎再将结果返回给用户
  • process.nextTick
    • 当前"执行栈"的尾部触发回调
  • setImmediate
    • 当前"任务队列"的尾部添加事件
  • 阶段

libuv引擎中的事件循环的模型.jpg

  • poll
    • 查看 poll queue 中是否有事件并执行
    • 检查是否有 setImmediate()callback
      • 进入 check 阶段执行这些 callback
      • 是否有到期的 timer
        • 到期的 timercallback 按照调用顺序放到 timer queue
        • 进入 timer 阶段执行 queue 中的 callback
  • check
    • 专门用来执行 setImmediate() 方法的回调
  • close callbacks
    • 当一个 socket 连接或者一个 handle 关闭
  • timer
    • 执行所有到期的 timer 加入 timer 队列里的 callback
  • I/O callback
    • 执行大部分 I/O 事件的回调
process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B() {
    console.log(2);
  });
});

// 不能确定顺序
setTimeout(function timeout() {
  console.log("TIMEOUT FIRED");
}, 0);

setImmediate(function A() {
  console.log(1);
  setImmediate(function B() {
    console.log(2);
  });
});

setTimeout(function timeout() {
  console.log("TIMEOUT FIRED");
}, 0);

// setImmediate递归调用才能确定顺序
// 1--TIMEOUT FIRED--2
setImmediate(function () {
  setImmediate(function A() {
    console.log(1);
    setImmediate(function B() {
      console.log(2);
    });
  });

  setTimeout(function timeout() {
    console.log("TIMEOUT FIRED");
  }, 0);
});

// 死循环
// process.nextTick(function foo() {
//   process.nextTick(foo);
// });