ECMAScript Async Context 提案介绍

简介: ECMAScript Async Context 提案介绍

背景

由阿里巴巴 TC39 代表主导的Async Context 提案[1] 刚在 2023年 2 月初的 TC39 会议中成为了 TC39 Stage 1 提案。提案的目标是定义在 JavaScript 的异步任务中传递数据的方案。

我们先以一个同步调用中访问全局变量为例,来讲讲什么我们为什么需要定义异步上下文。设想一下,我们是一个 npm 库作者。在这个库中,我们提供了一个简单的 log 函数和 run 函数。开发者可以将他们的回调函数和一个 id 传给我们的 run 函数。run 会调用用户的回调函数,并且,开发者可以在这个回调函数中调用我们的 log 函数来生成自动被调用 run 函数时传入的 id 标注了的日志。如我们的库实现如下:

// my-awesome-library let currentId = undefined; export function log() {  if (currentId === undefined) throw new Error('must be inside a run call stack');  console.log(`[${currentId}]`, ...arguments); } export function run<T>(id: string, cb: () => T) {  let prevId = currentId;  try {  currentId = id;  return cb();  } finally {  currentId = prevId;  } }

开发者可以这样调用我们的库:

import { run, log } from 'my-awesome-library'; import { helper } from 'some-random-npm-library'; document.body.addEventListener('click', () => {  const id = nextId();  run(id, () => {  log('starting');  // 假设这个 helper 会调用 doSomething.  helper(doSomething);  log('done');  }); }); function doSomething() {  log("did something"); }

在这个例子中,无论用户点击多少次,对于每一个 id,我们都可以看到如下的完整日志序列:

  • [id1] starting
  • [id1] did something
  • [id1] done

由此,我们实现了一个基于同步调用栈传递的 id 的机制,开发者不需要手动在他们的代码中传递、保存 id。这个模式非常实用,因为不是每一个函数我们都能增加调用参数用来传递额外的信息,比如我们通过 React Context[2] 在 React 中将参数透过数个中间组件传递给内嵌的目标组件中。

image.png

但是,一旦我们开始引入异步操作,这个模式就开始出现问题了:

document.body.addEventListener('click', () => {  const id = new Uuid();  run(id, async () => {  log('starting');  await helper(doSomething);  // 这条日志已经无法打印期望的 id 了  log('done');  }); }); function doSomething() {  // 这条日志能够打印期望的 id 取决于 helper 是否在调用 doSomething 之前 await 过  log("did something"); }

image.png

而我们提案的 AsyncContext 就是为了解决这里的问题。它允许我们将 id 即通过同步调用栈传递,也可以通过异步任务链传递。

// my-awesome-library const context = new AsyncContext(); export function log() {  const currentId = context.get();  if (currentId === undefined) throw new Error('must be inside a run call stack');  console.log(`[${currentId}]`, ...arguments); } export function run<T>(id: string, cb: () => T) {  context.run(id, cb); }

image.png

AsyncContext

AsyncContext 是一个能够将任意 JavaScript 值通过逻辑连接的同步、异步操作,传播到逻辑连接的异步操作的执行上下文的存储。它提供如下操作:

class AsyncContext<T> {  // 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。  // 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。  static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;  // 立刻执行 fn,并在 fn 执行期间将 value 设置为当前  // AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被  // 快照(相当于 wrap)。  run<R>(value: T, fn: () => R): R;  // 获取当前 AsyncContext 实例的值。  get(): T; }

AsyncContext.prototype.run()AsyncContext.prototype.get() 分别向当前执行上下文中写入、读取 AsyncContext 实例值。而 AsyncContext.wrap() 允许我们对所有的 AsyncContext 实例在当前执行上下文保存的值进行快照,并通过返回的函数来将状态快照在后续任意时间恢复为执行上下文的全局 AsyncContext 状态。

这三个操作定义了在异步任务间传播任意 JavaScript 值的最小操作接口。开发者可以通过 AsyncContext.prototype.run()AsyncContext.prototype.get() 来写入、读取保存在异步上下文中的变量,而 JavaScript 运行时、任务队列实现者、框架作者可以通过 AsyncContext.wrap 来传播异步上下文变量。

// 简单实现一个任务队列 const loop = {  queue: [],  addTask: (fn) => {  queue.push(AsyncContext.wrap(fn));  },  run: () => {  while (queue.length > 0) {  const fn = queue.shift();  fn();  }  }, }; const ctx = new AsyncContext(); ctx.run('1', () => {  // loop 通过 AsyncContext.wrap 对当前上下文状态进行了快照。  loop.addTask(() => {  console.log('task:', ctx.get());  });  // AsyncContext 值会通过异步任务自动传播。即使这个 timeout callback  // 在 `ctx.run` 的同步调用栈之外执行,也能获取到传播的值。  // 而且这个 timeout 比下面第二次更迟执行,ctx 的值任然是 1。  setTimeout(() => {  console.log(ctx.get()); // => 1  }, 1000); }); ctx.run('2', () => {  // 设置一个更快执行的 timeout。  setTimeout(() => {  console.log(ctx.get()); // => 2  }, 500); }); console.log(ctx.get()); // => undefined // 清空任务队列。 // AsyncContext.wrap 返回的函数在执行期间恢复了快照的状态。 loop.run(); // => task: 1

使用场景

异步链路追踪

OpenTelemetry[3] 这种应用性能监测工具(APM 工具)为了实现无感知监测(即不需要开发者修改任何业务代码),通常不能修改用户、第三方库、运行时 API。所以对于 APM 工具来说,他们不能让开发者来手动传播链路追踪数据。

而现在,他们可以将链路追踪数据保存在 AsyncContext 中,并在需要判断当前异步调用链路时,从 AsyncContext 中获取当前的链路数据。这就不再需要开发者修改业务代码。如下我们看一个简单的链路追踪例子:

// tracer.js const context = new AsyncContext(); export function run(cb) {  // (a)  const span = {  // 建立异步调用链路  parent: context.get(),  // 设置当前异步调用属性  startTime: Date.now(),  traceId: randomUUID(),  spanId: randomUUID(),  };  context.run(span, cb); } export function end() {  // (b) 标记当前异步调用结束  const span = context.get();  span?.endTime = Date.now(); } // 自动插桩 fetch API,注入链路追踪代码 const originalFetch = globalThis.fetch; globalThis.fetch = (...args) => {  return run(() => {  return originalFetch(...args)  .finally(() => end());  }); };

对于用户代码来说,不管需不需要支持链路追踪,clickHandler 即其内部调用的依赖函数都不需要修改:

// my-app.js import * as tracer from './tracer.js' // 通过框架或者自动插桩,包装用户的 clickHandler button.onclick = e => {  // (1)  tracer.run(async () => {  await clickHandler();  tracer.end();  }); }; // 用户代码 const clickHandler = () => {  return fetch("https://example.com").then(res => {  // (2)  return processBody(res.body).then(data => {  // (3)  const dialog = html`<dialog>Here's some cool data: ${data}  <button>OK, cool</button></dialog>`;  dialog.show();  });  }); }

我们作为 OpenTelemetry 的维护者,这个提案是我们将 OpenTelemetry 的开发者无感知的链路追踪能力带给 Web 应用的必要特性之一。

异步任务属性传递

许多 JavaScript 运行时 API 如 Web API 都会提供任务调度相关的特性。这些任务通常可以设置如优先级等属性,让运行时发挥其启发式的调度逻辑,提供更好用户体验、低延迟的用户交互。

通过 AsyncContext,开发者就不需要再手动传递如任务优先级这些任务属性。JavaScript 运行时可以通过 AsyncContext 获取通过异步调用传播的任务属性,默认配置子任务优先级:

// 假设我们有一个简单的任务调度器 const scheduler = {  context: new AsyncContext(),  postTask(task, options) {  // 实际上,这个 task 需要被延迟到更空闲的时间执行。  // 但是我们这里管不了这么多了,立刻通过 AsyncContext.run 执行。  this.context.run({ priority: options.priority }, task);  },  currentTask() {  return this.context.get() ?? { priority: 'default' };  }, }; // 用户通过调度器 API 设置一个低优先级任务,可以在渲染空闲期执行。 const res = await scheduler.postTask(task, { priority: 'background' }); console.log(res); async function task() {  // 通过 scheduler.currentPriority(),这个 fetch 任务和回复内容解析都可以被  // 自动设置为 'background' 优先级。  const resp = await fetch('/hello');  const text = await resp.text();  // 即使我们上面已经 await 了多个 Promise,当前任务还是我们期望的 'background' 优先级。  scheduler.currentTask(); // => { priority: 'background' }  // doStuffs 运行在 'background' 优先级,不需要我们重新通过调度器  // scheduler.postTask(doStuffs, { priority: 'background' });  // 来设置期望的优先级。  return doStuffs(text); } async function doStuffs(text) {  // 一些异步操作...  return text; }

以上例子是当前 WICG Scheduling APIs[4] 待解决的一个难点[5]。我们目前也正在与 Chrome Web Performance 团队讨论基于 AsyncContext 的 Web API 拓展的设计。

Prior Arts

线程局部变量

线程作为一个程序执行单元,它们有自己的 Program Counter 等等,但是线程之间可以共享整个进程的内存空间访问。但正是因为内存的共享,内存安全是每一个使用线程的程序都需要考虑的问题。而采用线程局部变量 (thread_local)[6]可以以更低的兼容成本来为已有的函数提供可重入的能力。可见线程局部变量设计初衷是为了解决传统 API 的可重入问题的,如 glibc 中许多函数都需要使用到一个变量 errno[7] 用于存储系统调用的错误信息,如果 errno 只是一个普通的全局变量,那么当多个线程同时调用了依赖 errno 的函数时,errno 中的值就可能会在被用户代码使用前被其他系统调用覆盖。而通过将 error 声明为线程局部变量,那么依赖 errno 的 API 无需做任何改动即可获得可重入能力,即线程安全。

举个 C++ 的例子,我们在不同的线程访问同一个 thread_local 变量并修改、赋值,对于这个变量的修改不会影响到其他的线程:

#include <iostream> #include <string> #include <thread> #include <mutex> thread_local unsigned int rage = 1; std::mutex cout_mutex; void increase_rage(const std::string& thread_name) {  ++rage; // modifying outside a lock is okay; this is a thread-local variable  std::lock_guard<std::mutex> lock(cout_mutex);  std::cout << "Rage counter for " << thread_name << ": " << rage << '\n'; } int main() {  std::thread a(increase_rage, "a"), b(increase_rage, "b");  a.join();  b.join();  {  std::lock_guard<std::mutex> lock(cout_mutex);  std::cout << "Rage counter for main: " << rage << '\n';  }  return 0; }

这些例子会输出:

Rage counter for b: 2 Rage counter for a: 2 Rage counter for main: 1

除了在解决函数可重入问题中可以使用线程局部变量之外,另一个常见的使用场景就是在使用线程作为服务端请求处理单元的模型中,我们也可以使用 thread_local 来存储请求的链路信息:因为每一个线程在同一时间只会处理一个请求,那么此时线程局部变量只需要对应当前这个请求链路,并且对这些数据的访问是线程安全的。

使用 thread_local 存储类似的信息有几个好处:

  1. 用户使用起来没有感知,不需要用户主动给框架传递请求链路信息参数;
  2. 多个模块不会互相干扰,如果我们简单地将这些信息寄存在请求对象上,功能当然可以完成,但是如果多个模块都使用了类似的方法进行存储,这样十分容易出现冲突。

AsyncLocalStorage

image.png

与线程局部变量类似,Node.js 的 AsyncLocalStorage 提供了基于单线程的事件循环模型上的"异步局部变量"。AsyncContext 的 API 即是从 AsyncLocalStorage 之上发展而来的:

class AsyncLocalStorage<T> {  constructor();  // 立刻执行 callback,并在 callback 执行期间设置异步局部变量值。  run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R;  // 获取异步局部变量当前值  getStore(): T; } class AsyncResource {  // 快照当前的执行上下文异步局部变量全局状态。  constructor();  // 立刻执行 fn,并在 fn 执行期间将快照恢复为当前执行上下文异步局部变量全局状态。  runInAsyncScope<R>(fn: (...args: any[]) => R, thisArg, ...args: any[]): R; }

这些方法都可以与 AsyncContext 对应。目前 Async Context 提案还在 Stage 1 讨论阶段,后续提案的 API 可能会有所变更,不过可以预期的是整体操作不会有较大的变化。

Noslate & WinterCG

Noslate Aworker[8] 作为 Web 兼容运行时工作组(Web-Interoperable Runtimes CG[9] ) 的实现者之一。WinterCG 包含了如 Cloudflare、Deno 等 JavaScript 运行时产商。当前各个 JavaScript 运行时对于 AsyncContext 的需求是非常迫切的,许多客户都在催促着 Cloudflare、Deno 去实现类似的方案。因此,我们也在 WinterCG 中与 Cloudflare workerd、Deno 等提议了在 AsyncContext 提案进入 Stage 3 之前的实现路径。

为了避免这些运行时在 AsyncContext 提案早期 API 未稳定的阶段就在生产环境中使用 AsyncContext API,我们通过 WinterCG 商议了指导性建议:AsyncLocalStorage 子集[10]。这个子集只包含了保证在未来几年中是能够符合 AsyncContext 提案演进路线、不会限制 AsyncContext 提案发展的 AsyncLocalStorage API 子集。Noslate Aworker 也会实现这个 API 子集:https://noslate-project.github.io/aworker/classes/aworker.AsyncLocalStorage.html

更多 ECMAScript 语言提案

由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:https://github.com/JSCIG/es-discuss/discussions

参考资料

[1]

Async Context 提案: https://github.com/tc39/proposal-async-context

[2]

React Context: https://reactjs.org/docs/context.html

[3]

OpenTelemetry: https://opentelemetry.io/

[4]

Scheduling APIs: https://github.com/WICG/scheduling-apis

[5]

难点: https://github.com/WICG/scheduling-apis/blob/main/misc/userspace-task-models.md#challenges-in-creating-a-unified-task-model

[6]

线程局部变量 (thread_local): https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8

[7]

errno: http://man7.org/linux/man-pages/man3/errno.3.html

[8]

Noslate Aworker: https://noslate.midwayjs.org/docs/noslate_workers/intro

[9]

Web-Interoperable Runtimes CG: https://wintercg.org/

[10]

AsyncLocalStorage 子集: https://github.com/wintercg/proposal-common-minimum-api/blob/main/asynclocalstorage.md


相关实践学习
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
【涂鸦即艺术】基于云应用开发平台CAP部署AI实时生图绘板
相关文章
|
文字识别 Java C++
Tesseract-OCR的简单使用与训练
Tesseract,一款由HP实验室开发由Google维护的开源OCR(Optical Character Recognition , 光学字符识别)引擎,与Microsoft Office Document Imaging(MODI)相比,我们可以不断的训练的库,使图像转换文本的能力不断增强;如果团队深度需要,还可以以它为模板,开发出符合自身需求的OCR引擎。
6417 0
|
存储 人工智能 搜索推荐
详解MySQL字符集和Collation
MySQL支持了很多Charset与Collation,并且允许用户在连接、Server、库、表、列、字面量多个层次上进行精细化配置,这有时会让用户眼花缭乱。本文对相关概念、语法、系统变量、影响范围都进行了详细介绍,并且列举了有可能让字符串发生字符集转换的情况,以及来自不同字符集的字符串进行比较等操作时遵循的规则。对于最常用的基于Unicode的字符集,本文介绍了Unicode标准与MySQL中各个字符集的关系,尤其详细介绍了当前版本(8.0.34)默认字符集utf8mb4。
|
8月前
|
人工智能 Rust API
AI 乱写代码怎么破?使用 Context7 MCP Server 让 AI 写出靠谱代码!
本文通过实际案例演示了如何利用 Context7 MCP Server 解决 AI 编程助手中的代码幻觉问题和使用过时 API 的问题。借助 Context7 获取最新、最准确的代码建议,显著提升了 AI 生成的代码质量,从而有效提高了开发效率。
2226 10
AI 乱写代码怎么破?使用 Context7 MCP Server 让 AI 写出靠谱代码!
|
网络安全 虚拟化 Docker
SSH后判断当前服务器是云主机、物理机、虚拟机、docker环境
结合上述方法,您可以对当前环境进行较为准确的判断。重要的是理解每种环境的特征,并通过系统的响应进行综合分析。如果在Docker容器内,通常会有明显的环境标志和受限的资源视图;而在云主机或虚拟机上,虽然它们也可能是虚拟化的,但通常提供更接近物理机的体验,且可通过硬件标识来识别虚拟化平台。物理机则直接反映硬件真实信息,较少有虚拟化痕迹。通过这些线索,您应该能够定位到您所处的环境类型。
753 2
|
前端开发 JavaScript 安全
第十篇 Axios最佳实战:前端HTTP通信的王者之选
第十篇 Axios最佳实战:前端HTTP通信的王者之选
601 0
|
机器人 测试技术 持续交付
Python进行自动化测试测试框架的选择与应用
【6月更文挑战第9天】本文介绍了Python自动化测试的重要性及选择测试框架的考量因素,如功能丰富性、易用性、灵活性和集成性。文中列举了常用的Python测试框架,包括unittest、pytest、nose2和Robot Framework,并提供了使用pytest进行单元测试的示例代码。此外,还展示了如何使用Robot Framework进行验收测试和Web UI测试。选择合适的测试框架对提升测试效率和软件质量至关重要,团队应根据项目需求、社区支持、集成性和学习曲线等因素进行选择。通过不断学习和实践,可以优化自动化测试流程,确保软件的稳定性和可靠性。
|
Linux 编译器 数据处理
探索Linux命令之nm:解析二进制文件的神器
`nm`命令是Linux下分析二进制文件的工具,显示符号表中的函数、变量等信息及它们的地址和类型。它帮助理解程序结构、调试和优化,支持不同符号类型、输出选项和过滤。常用参数如`-a`显示所有符号,`-t f`列出定义的函数。在实际应用中,可以结合其他工具如`objdump`、`readelf`进行更深入的分析,并注意备份原始文件。
|
XML SQL 自然语言处理
JDK 21中的字符串模板:提升代码可读性与维护性的新利器
本文将介绍JDK 21中引入的字符串模板特性,它是一种创新的文本生成技术,旨在提高代码的可读性和维护性。字符串模板允许开发者使用简洁的语法来构建复杂的字符串,减少了硬编码和字符串拼接的工作量。本文将详细阐述字符串模板的语法、使用场景以及与传统字符串处理方法的比较,并通过示例代码展示其在实际开发中的应用。
|
存储 监控 JavaScript
ECMAScript 双月报告:Async Context 提案成功进入到 Stage 1
ECMAScript 双月报告:Async Context 提案成功进入到 Stage 1
299 0
|
开发工具 git
Git Commit Message规范
Git Commit Message规范
2223 0
Git Commit Message规范
下一篇