Event Loop:事件循环

来自Wikioe
Eijux讨论 | 贡献2023年4月18日 (二) 07:26的版本
跳到导航 跳到搜索


关于

在 MDN 上看到 JavaScript 的《并发模型与事件循环》,但由于该页面内容过少,实在不变学习。

而网络上搜到的内容大同小异,互相又有不小出入,难以拿捏。

众所周知,JavaScript 是没有所谓源代码的“源代码”,所以这部分内容只能在“规范文件”中寻找了。
  • 以下内容参考《HTML Standard》[1]、MDN[2]、与网络内容整理。

前置知识

见:浏览器基础:进程与线程

“一个 Tab 页面对应一个渲染进程”,而“渲染进程中只能存在一个 JS 引擎线程”(即,主线程),即:页面中的所有 JS 都由同一单线程运行。

??????由于“渲染进程”负责了页面几乎所有的任务(事件、交互、脚本、渲染、网络),所以如何协调各个任务的执行是关键问题。

浏览器的进程与线程.png

JavaScript的“任务”

JavaScript中,“任务”被分为两种:“同步任务”,“异步任务”。“异步任务”(Task):又分为“宏任务”(MacroTask)与“微任务”(MicroTask)。

Event Loop 是针对“异步任务”而言。


——【P.S. 《HTML Standard》中已经不再有“宏任务”(MacroTask)这一说法 —— 它就是“任务”(task)
  1. 同步任务:在“主线程”上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
  2. 异步任务:不直接进入“主线程”,而进入“任务队列”(task queue)的任务。
    1. 任务(task):事件、解析、回调、使用资源、对 DOM 操作做出反应 ——【根据《HTML Standard》[1]中定义】
      • 包括:script(整体代码)、setTimeout、setInterval、setImmediate(node独有)、requestAnimationFrame(浏览器独有)、I/O、UI rendering
      • 注意:Event Loop 开始时,只有“已经处于 task queues 中”的任务才会被执行
        ——【所以,如果一个 setTimeout 引起的无限循环,会导致性能下降,但是很难导致直接崩溃】
    2. 微任务(microtask):——【暂无明确定义,见:#到底什么是“Microtasks”?
      • 包括:Promises 的回调、queueMicrotask()、process.nextTick(node独有)、Object.observe(废弃)、MutationObserver
      • 注意:Event Loop 开始时,“microtask queue 中的所有”任务会被执行(哪怕是中途添加的“microtask”),直到队列为空
        ——【所以,如果一个 Promises 引起无限循环,将直接导致崩溃。这也是为什么要求代码慎用“queueMicrotask()”


💡一点小小的猜测:

    来源于“渲染进程”中其他线程(非“JS 引擎线程”)引起的异步任务,即“任务(task)”。
    
    来源于“渲染进程”的“JS 引擎线程”引起的异步任务,即“微任务(microtask)”。

JavaScript的“运行时”[3]

从概念上讲,代理(Agent) 是一个独立于体系结构、理想化的“线程”,每个 ECMAScript 程序都必须在其中运行。

代理是一种规范机制,不需要与任何特定的 ECMAScript 实现部件相对应。

Agent 包括了:

  1. 一组执行上下文(a set of execution contexts)[4]:即,一组可执行、待执行的上下文
  2. 执行上下文栈(the execution context stack)[4]:在一些文章中叫做“调用栈(call stack)
    • 栈顶的那个上下文被称为“运行中的执行上下文(running execution context)”
  3. 执行线程(an executing thread):在一些文章中叫做“主线程(a main thread)
  4. 事件循环(event loop)
    • 它包括了:一个“微任务队列(microtask queue)”,一个/多个“任务队列(task queue)”
  5. 代理记录(Agent Record):???
  • 一个宿主(浏览器、Node.js)可以有多个 Agent,Agent 之间相互隔离,可以通过发送消息进行通信。
  • 当两个 Agent 同处一个“Agent Clusters”(代理集群)时,它们可以共享内存。
除了“执行线程”(主线程)、“事件循环”之外,代理的组成部分只属于该代理。 

    “执行线程”(主线程):某些浏览器在多个代理之间共享主线程
    
    “事件循环”:可以在单个线程中协同调度多个窗口事件循环 —— 见:#Event Loop

——【《ECMAScript® 2023 Language Specification》、MDN 只提到:“执行线程”并不一定由“Agent”独占。但从《HTML Standard》对“Event Loop”的表述来看,似乎也并不一定由“Agent”独占】

——【尽管标准提到,不同的“Agent”允许共享同一条“执行线程”,但现代浏览器基本上都是给每个“Agent”启用一条独立的“执行线程”。】

Web 平台上存在以下类型的代理:

  1. Similar-origin window agent:即“同源窗口代理”,包含各种窗口对象,这些对象可以直接或通过使用 document.domain 相互访问。
    • 两个“同源”的窗口对象可以位于不同的“Similar-origin window agent”中,例如,如果它们各自位于自己的浏览上下文组中。
  2. Dedicated worker agent:包含了一个 DedicatedWorkerGlobalScope;
  3. Shared worker agent:包含了一个 SharedWorkerGlobalScope;
  4. Service worker agent:包含了一个 ServiceWorkerGlobalScope;
  5. Worklet agent:包含了一个 WorkletGlobalScope 对象;

Event Loop[1]

要协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理必须使用本节中描述的事件循环。

每个“代理”(Agent)有一个关联的“事件循环”,且该循环对该代理是唯一的。

  1. Similar-origin window agent 的事件循环称为“窗口事件循环”(window event loop)。
  2. Dedicated worker agentShared worker agentService worker agent 的事件循环称为“工作事件循环”(worker event loop)。
  3. Worklet agent 的事件循环称为“工件事件循环”(worklet event loop)。
Event loops do not necessarily correspond to implementation threads. For example, multiple window event loops could be cooperatively scheduled in a single thread.
事件循环不一定对应于“实现线程”(注:implementation threads,即“浏览器线程”)。例如,可以在单个线程中协同调度多个窗口事件循环。

However, for the various worker agents that are allocated with [[CanBlock]] set to true, the JavaScript specification does place requirements on them regarding forward progress, which effectively amount to requiring dedicated per-agent threads in those cases.
然而,对于 [[CanBlock]] 设置为 true 的各种 worker agents,JavaScript 规范确实对它们提出了关于“forward progress?”的要求,在这些情况下,这实际上相当于需要专用的每个代理线程。
  • 一个事件循环具有一个或多个“任务队列”(task queues)
    • 任务队列是“集合”(set),而不是“队列”(queue),因为事件循环处理模型从所选队列中获取第一个可运行的任务,而不是将第一个任务出列。
    • 微任务队列(microtask queue)不是任务队列(task queues)
  • 每个事件循环都有一个当前“正在运行的任务”(currently running task),要么是一个任务,要么是 null。初始为空。
    • 它用于处理可重入性。
  • 每个事件循环都有一个“微任务队列”(microtask queue),该队列是一个初始为空的微任务队列。
  • 每个事件循环都有一个“正在执行微任务的检查点”(performing a microtask checkpoint)的布尔值。该值最初为 false。
    • 它用于防止重入调用。
  • 每个窗口事件循环都有一个 DOMHighResTimeStamp 表示“上次渲染机会时间”(last render opportunity time),最初设置为零。
  • 每个窗口事件循环都有一个 DOMHighResTimeStamp 表示“上一个空闲周期的开始时间”(last idle period start time),最初设置为零。


任务(Tasks)

“任务”(Tasks)包括以下几种:

  1. 事件(Events):在特定 EventTarget 对象上调度 Event 对象,通常由专用的任务完成。
  2. 解析(Parsing):HTML 解析器标记一个或多个字节,然后处理任何生成的标记,通常是一项任务。
  3. 回调(Callbacks):调用回调通常由一个专用的任务完成。
  4. 使用资源(Using a resource):当算法获取资源时,如果获取是以非阻塞的方式发生的,那么一旦部分或全部资源可用,就由任务执行对资源的处理。
  5. 对 DOM 操作做出反应(Reacting to DOM manipulation):一些元素具有响应 DOM 操作而触发的任务,例如:当该元素被插入到文档中时。


从形式上讲,“任务”(Tasks)具有以下结构:

  1. 步骤(steps):指定任务要完成的工作的一系列步骤。
  2. 来源(source):用于对相关任务进行分组和序列化。
  3. 文档(document)与任务关联的文档,对于不在“窗口事件循环”中的任务则为 null。
  4. 脚本评估环境设置对象集(A script evaluation environment settings object set):用于跟踪任务期间的脚本评估。

根据其源字段(source),每个任务都被定义为来自特定的“任务源”(task source)。对于每个事件循环,每个“任务源”都必须与特定的“任务队列”相关联。


通用任务源(task sources)

  1. DOM 操作任务源(The DOM manipulation task source):用于对 DOM 操作做出反应的功能,例如在将元素插入文档时以非阻塞方式发生的事情。
  2. 用户交互任务源(The user interaction task source):用于对用户交互做出反应的功能,例如键盘或鼠标输入。
  3. 网络任务源(The networking task source):用于响应网络活动而触发的功能。
  4. 导航和遍历任务源(The navigation and traversal task source):用于对导航和历史遍历中涉及的任务进行排队。


处理模型

只要事件循环存在,它就必须持续运行以下步骤:

  1. 让 oldestTask 和 taskStartTime 为 null。
  2. 如果事件循环有一个任务队列其中至少有一个可运行的任务,则:
    1. 由“实现”(浏览器)定义的规则,选择一个任务队列作为 taskQueue。(“微任务队列”并不是一个“任务队列”,所以不会被选择)——【“选择哪个任务队列”:由浏览器定义(即文档中的“implementation-defined”,客户端软件本身被称为一种“implementation”)决定,这就允许优先选择对性能敏感的任务】
    2. 将 taskStartTime 设置为“不安全的共享当前时间”。
    3. 将 oldestTask 设置为 taskQueue 中的第一个可运行任务,并将其从 taskQueue 中删除。——【任务队列是“set”,所以并非“出队”】
    4. 将事件循环的“正在运行的任务”设置为 oldestTask。
    5. 执行 oldestTask 的步骤(Steps)。
    6. 将事件循环的“正在运行的任务”设置回 null。
  3. 执行微任务检查点
    1. 如果事件循环的“正在执行微任务的检查点”为 true,则返回。——【检查重入】
    2. 将事件循环的“正在执行微任务的检查点”设置为 true。——【防止重入】
    3. 当事件循环的“微任务队列”不为空时:——【!!!不为空将一直重复执行!!!】
      1. 从事件循环的“微任务队列”中“出队”一个任务作为 oldestMicrotask。
      2. 将事件循环的“正在运行的任务”设置为 oldestMicrotask。
      3. 执行 oldestMicrotask。
        • 这可能涉及调用脚本的回调,并最终调用“clean up after running script”步骤,而该步骤的最后一步又将调用执行微任务检查点。这就是为什么设置“正在执行微任务的检查点”来防止“重入”。
      4. 将事件循环的“正在运行的任务”设置回null。
    4. 对于负责的事件循环为该事件循环的每个“环境设置对象”,通知该“环境设置对象”上 rejected 的 promise。
    5. 清理索引数据库事务。
    6. 执行 ClearKeptObjects()。
    7. 将事件循环的“正在执行微任务的检查点”设置为false。
  4. 将 hasARenderingOpportunity 设置为 false。
  5. 将现在时刻设置为“不安全的共享当前时间”。
  6. 如果 oldestTask 不为 null,则:
    1. ……(省略)
  7. 更新渲染:如果这是一个窗口事件循环,则:
    1. ……(省略)
  8. ……(省略)


总结

💡
当“调用栈”为空时(即,同步代码执行完毕时),“主线程”才会开始处理“(由 Event Loop 循环选取的)来自任务队列中的任务”。

Event Loop 一次循环的顺序为:任务队列的“第一个可运行任务”(宏任务) -> 微任务队列的所有任务(微任务) -> 渲染


对于说法:“微任务 -> 渲染 -> 宏任务”,从代码运行结果来看似乎没问题,其实忽略了一点“script 脚本本身也是一个任务(宏任务)”。

关于:Microtasks

到底什么是“Microtasks”?

无论是《ECMAScript® 2023 Language Specification》、《HTML Standard》、MDN,都没有找到关于“宏任务”、“微任务”的定义性描述。

——《ECMAScript® 2023 Language Specification》:压根就没提到 Microtasks 这个字

——《HTML Standard》:提到了 Microtask queuing:
    A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.
    微任务是一种口语化的方式,指的是通过“排队微任务算法”创建的任务。 ——【但文档中,与“任务”相比似乎只有“任务源”(task source)不同】

—— MDN:只提到了“任务”(Tasks)与“微任务”(Microtasks)的区别

此外,在 Jake Archibald 的文章[5]中提到:
    ECMAScript has the concept of "jobs" which are similar to microtasks, but the relationship isn't explicit aside from vague mailing list discussions. However, the general consensus is that promises should be part of the microtask queue, and for good reason.
    ECMAScript有类似于 microtasks 的“jobs”概念,但除了模糊的邮件列表讨论之外,这种关系并不明确。然而,普遍的共识是,promises 应该是 microtask queue 的一部分,这是有充分理由的。
    ……
    As mentioned, in ECMAScript land, they call microtasks "jobs". In step 8.a of PerformPromiseThen, EnqueueJob is called to queue a microtask.
    如前所述,在 ECMAScript 领域,他们将微任务称为“jobs”。在 PerformPromiseThen 的步骤 8.a 中,调用 EnqueueJob 对 microtask 进行排队。

再此外,javascript.info 中有提到:
    Microtasks come solely from our code. They are usually created by promises……[6]
    微任务仅来自于我们的代码。它们通常是由 promise 创建的……
    
    Asynchronous tasks need proper management. For that, the ECMA standard specifies an internal queue PromiseJobs, more often referred to as the “microtask queue” (V8 term).[7]
    异步任务需要适当的管理。为此,ECMA 标准规定了一个内部队列 PromiseJobs,通常被称为“微任务队列(microtask queue)”(V8 术语)。

所以,Promise 异步任务(注意,仅仅是它的回调,而不是它本身)是 microtask 应该是“潜规则”了,此外,我们还可以使用“queueMicrotask()”方法来添加 microtask。

——【P.S. 对于 Node.js 并不清楚】


为什么要支持“Microtasks”?[2]

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


如何使用“Microtasks”?[8]

WindowWorker 接口的 queueMicrotask() 方法,将微任务加入队列以在控制返回浏览器的事件循环之前的安全时间执行。

语法:

queueMicrotask(() => {/* ... */});

示例:

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

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

log("Main program started");
setTimeout(callback, 0);
queueMicrotask(urgentCallback);
log("Main program exiting");
输出:
Main program started
Main program exiting
*** Oh noes! An urgent callback has run!
Regular timeout callback has run

补充内容

关于 Node.js 中的 Event Loop,暂不了解,有时间再看看其他文章。

参考:一次弄懂Event Loop(彻底解决此类面试问题)
P.S.
JavaScript 是没有一个 WebAPIs 结构的,网上文章图片展示的 WebAPIs 大概是指那些任务来自于 WebAPI,但是这种描述并不准确。

一个有意思的例子

写在后面

通过查询 EventLoop 的资料,前后沟通了许多内容,浅浅地做个梳理,以及一点点未解。


首先,EventLoop 的资料集中在《HTML Standard》,而《ECMAScript® 2023 Language Specification》中并无只言片语,说明:EventLoop 并非 ECMAScript 语言的特性,而是 Web 相关规范。  ——【即,EventLoop 并不由“JS 引擎”提供,而是依靠浏览器实现 】


其次,Event Loop 提到了 ECMAScript 中的 Agent 概念:
1、每个 Agent 都有且仅有一个关联的 Event Loop
2、EventLoop 选取任务是交由 Agent 的“执行线程”(或者说:主线程)来执行


那么,问题来了

1、Agent 与 浏览器的“渲染进程”是否有重叠呢? —— 虽然《HTML Standard》描述 Agent 是一个“idealized "thread"”
    ——【???】

2、Agent 的“执行线程”是不是就是“渲染进程”的“JS 引擎线程”呢? 
    ——【我个人理解就是一个玩意儿(还有《MDN:“主线程”》),虽然概念不同】

3、EventLoop 是由“渲染进程”中的某个线程实现吗? 
    ——【???】


网上并没有找到 Agent 相关的文章……

参考

  1. 1.0 1.1 1.2 参考:《HTML Standard》:“Event loops”
  2. 2.0 2.1 参考:MDN:《深入:微任务与 Javascript 运行时环境》
  3. To run JavaScript code, the runtime engine maintains a set of agents in which to execute JavaScript code. Each agent is made up of a set of execution contexts, the execution context stack, a main thread, a set for any additional threads that may be created to handle workers, a task queue, and a microtask queue. Other than the main thread—which some browsers share across multiple agents—each component of an agent is unique to that agent.

    在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其他组成部分对该代理都是唯一的。

    ——MDN:《深入:微任务与 Javascript 运行时环境》

    An agent comprises a set of ECMAScript execution contexts, an execution context stack, a running execution context, an Agent Record, and an executing thread. Except for the executing thread, the constituents of an agent belong exclusively to that agent.

    “代理”(Agent)包括:一组ECMAScript执行上下文、执行上下文堆栈、运行的执行上下文、代理记录和执行线程。除了执行线程之外,代理的组成部分只属于该代理。

    ——《ECMAScript® 2023 Language Specification》

    Conceptually, the agent concept is an architecture-independent, idealized "thread" in which JavaScript code runs. Such code can involve multiple globals/realms that can synchronously access each other, and thus needs to run in a single execution thread.

    从概念上讲,代理概念是一个独立于体系结构、理想化的“线程”,JavaScript代码在其中运行。这样的代码可能涉及多个全局/领域,这些全局/领域可以同步访问彼此,因此需要在单个执行线程中运行。

    ——《HTML Standard》

    参考:
    1. 《ECMAScript® 2023 Language Specification》:“Agents”
    2. 《HTML Standard》:“Agents and agent clusters”
    3. MDN:《深入:微任务与 Javascript 运行时环境》
    4. 《【人人都能读标准】8.可视化JavaScript的运行环境:agents、执行上下文、Realm》
  4. 4.0 4.1 参考:【JS 执行过程:执行上下文、词法环境
  5. 参考:《Tasks, microtasks, queues and schedules》——Jake Archibald
  6. 参考:javascript.info:《Event loop: microtasks and macrotasks》
  7. 参考:javascript.info:《Microtasks》
  8. 参考:
    1. MDN:《在 JavaScript 中通过 queueMicrotask() 使用微任务》
    2. MDN:《queueMicrotask()》
    3. 《HTML Standard》:“Microtask queuing”