Node.Js 中异步上下文如何共享与通讯?


在Node.js中,管理异步流之间的上下文是一个挑战,因为它是单线程的。传统的多线程环境中可以使用线程本地存储(TLS)来实现上下文的管理,但在Node.js中不适用。Node.js提供了AsyncLocalStorage API来解决这个问题,它类似于其他语言中的线程本地存储,可以在异步流中传播上下文。

本文介绍了在异步流程中管理上下文的最佳方法。传统的多线程编程可以使用线程本地存储(TLS)来存储与请求相关的上下文信息。然而,Node.js是单线程环境,无法直接使用TLS。文章介绍了一种解决方案,即使用Node.js的内置API AsyncLocalStorage来实现类似TLS的功能。通过使用AsyncLocalStorage,可以在异步操作的调用链中传播当前异步操作的上下文,而无需在每个函数调用中显式传递。文章还对AsyncLocalStorage的性能进行了测试,并提供了使用AsyncLocalStorage的性能优化建议。

  • 探讨了使用全局对象作为上下文的局限性,以及在Node.Js中实现多线程方法的挑战
  • 提出了使用AsyncLocalStorage API在调用链中传播上下文的解决方案
  • 分享AsyncLocalStorage的性能分析及其对应用程序性能的影响
  • 解释AsyncLocalStorage的潜在上下文丢失和性能影响

重点:

  1. 在整个异步流程中维护上下文
  2. 确保仅在同一异步流中可访问上下文

例如,考虑一个处理几个 I/O 操作的标准 Web 服务器:

// server.ts
router.get(
  path.join('/', 'user-details'), async (req, res) => {
      const userDetailsRequest = extractRequestParams(req);
      const xRequestId = generateXRequestId();
      const userDetailsResponse = await getUserDetails(xRequestId, userDetailsRequest);
      res.send(userDetailsResponse);
  },
);

// getUserDetails.ts
import { logger } from
"logger";
export async function getUserDetails(xRequestId: string, userDetailsRequest: UserDetailsRequest) {
  const isPermitted = await checkUesrReqPermissions(xRequestId, userDetailsRequest);
    if (!isPermitted) {
        throw new NotPermittedError('Not permitted');
    }

    const userDetalis = await queryUserDetails(xRequestId, userDetailsRequest);
    await logger.report(xRequestId, `getUserDetails response ${userDetalis} for request ${userDetailsRequest}`);

    return userDetails;
}

// logger.ts
export const logger = {
  report(xRequestId: string, msg: string) {
    return remoteLogger.log(`${xRequestId ?? '-'}: ${msg}`);
  }
}

对于每次调用“用户详细信息”端点,我们有

  • 1. 后端API调用(权限检查)
  • 2. DB查询(查询用户详细信息)
  • 3. 后端API调用(登录远程服务器)

从请求到达后端到响应发送回客户端,贯穿所有回调和 Promise 链的这一操作链,从现在开始我将称为异步流程asynchronous flow.

该端点在整个异步流程中使用两个参数 -

  1. userDetailsRequest- 保存有关所请求用户的信息
  2. xRequestId- 在同一流程中的所有异步操作之间传输的独特标识符,可以更轻松地搜索相应的日志条目,而无需依赖时间戳和 IP 地址。

这些参数是与此请求关联的上下文数据,它们在端点内触发的所有函数范围内可用。尽管显式发送 userDetailsRequest 参数是合乎逻辑的,但 xRequestId 与端点内函数的业务逻辑无关。

首选的解决方案是访问 xRequestId 参数,而无需在每次函数调用中明确提供该参数。

在传统的多线程编程中,I/O 操作是在不同的线程中处理的。这意味着每个请求都将在不同的线程中处理,因此我们可以使用线程本地存储(TLS)解决方案来存储与请求相关的上下文。

而 Node.js 则不同,它是一个单线程环境,所有请求都在同一个线程(又称主线程)上运行。

使用全局变量对象

// store.ts
const store = new Map<string, string>();

// server.ts
import { store } from
"store"
router.get(
  path.join('/', 'user-details'), async (req, res) => {
      const userDetailsRequest = extractRequestParams(req);
      store.set('xRequestId', generateXRequestId());
      const userDetailsResponse = await getUserDetails(userDetailsRequest);
      res.send(userDetailsResponse);
  },
);

// getUserDetails.ts
import { logger } from
"logger";
export async function getUserDetails(userDetailsRequest: UserDetailsRequest) {
  const isPermitted = await checkUesrReqPermissions(userDetailsRequest);
    if (!isPermitted) {
        throw new NotPermittedError('Not permitted');
    }

    const userDetalis = await queryUserDetails(userDetailsRequest);
    await logger.report(`getUserDetails response ${userDetalis} for request ${userDetailsRequest}`);

    return userDetails;
}

// logger.ts
import { store } from
"./store";
export const logger = {
  report(msg: string) {
    const xRequestId = store.get(
"xRequestId");
    return remoteLogger.log(`${xRequestId ?? '-'}: ${msg}`);
  }
}

当请求 #2 到达后台时,这段代码就会出现问题。它会用一个新值覆盖 xRequestId 值,当请求 #1 完成最后一次后台 API 调用时,就会用请求 #2 的 xRequestId 记录下来。

因此,将全局对象作为上下文是不可行的,我们需要一种在异步流程中关联上下文的方法,类似于 TLS,但这次是在同一线程中关联。

多线程
"如果你不能解决一个问题,那么你可以解决一个更容易的问题:找到它"。- 乔治-波利亚

我们已经有了多线程环境的解决方案,那就是 TLS,所以也许我们应该解决另一个问题--让 Node 成为一个多线程环境,然后我们也可以使用 TLS

尽管 Node 是单线程环境,但它提供了一种创建线程(如工作线程、子进程和集群)的方法。如果我们在 Node 中使用多线程方法,效果会如何呢?举例来说,每个请求和每个异步操作都将由不同的工作线程处理。由于 Node 线程默认不共享内存,因此在这种情况下,我们可以使用全局对象作为 TLS 解决方案的实现。

这意味着子进程和群集无论如何都不会共享内存,除非明确使用 ArrayBuffers 或 SharedArrayBuffers,否则工作线程也不会共享内存。

虽然理论上我们似乎找到了解决方案,但实际上并不可行。与传统的多线程环境不同,Node 使用单线程进行 I/O 操作,以减少上下文切换,这也是它在 I/O 方面表现更好的原因。工作线程的设计目的是处理 CPU 密集型任务,而不是 I/O 任务,所以如果你需要多个线程来处理 I/O,你可能不应该使用 Node。

工作进程(线程)对于执行 CPU 密集型 JavaScript 操作非常有用。它们对 I/O 密集型工作帮助不大。Node.js 内置的异步 I/O 操作比 Worker 更有效。

解决方案--AsyncLocalStorage
AsyncLocalStorage 是一个内置的 Node.js API,它提供了一种通过调用链传播当前异步操作上下文的方法,而无需显式地将其作为函数参数传递。它类似于其他语言中的线程本地存储。

异步本地存储的主要理念是,我们可以用 AsyncLocalStoragerun 调用来封装一些函数调用。在封装调用中调用的所有代码都能访问同一个存储空间,而每个调用链的存储空间都是唯一的。

// store.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export const asyncLocalStorage = new AsyncLocalStorage<Map<string, string>>();

// server.ts
import { asyncLocalStorage } from
"./store";
router.get(
  path.join('/', 'user-details'), async (req, res) => {
    const store = new Map<string, string>();
    store.set(
"xRequestId", generateXRequestId());

    asyncLocalStorage.run(store, async () => {
      const userDetailsRequest = extractRequestParams(req);
      const userDetailsResponse = await getUserDetails(userDetailsRequest);
      res.send(userDetailsResponse);
    });
  }
);

// getUserDetails.ts
import { logger } from
"logger";
export async function getUserDetails(userDetailsRequest: UserDetailsRequest) {
  const isPermitted = await checkUesrReqPermissions(userDetailsRequest);
    if (!isPermitted) {
        throw new NotPermittedError('Not permitted');
    }

    const userDetalis = await queryUserDetails(userDetailsRequest);
    await logger.report(`getUserDetails response ${userDetalis} for request ${userDetailsRequest}`);

    return userDetails;
}

// logger.ts
import { asyncLocalStorage } from
"./store";
export const logger = {
  report(msg: string) {
    const store = asyncLocalStorage.getStore();
    const xRequestId = store.get(
"xRequestId");
    return remoteLogger.log(`${xRequestId ?? '-'}: ${msg}`);
  }
}


现在,日志记录器可以访问 xRequestId 值,而 AsyncLocalStorage 则为我处理隔离问题。

性能测试:使用内置 AsyncLocalStorage 比自定义 AsyncLocalStorage 更好

上下文丢失
在大多数情况下,AsyncLocalStorage可以正常工作。在极少数情况下,当前存储会在异步操作之一中丢失。
如果您的代码是基于承诺的,那么应该没有问题,否则如果它是基于回调的,则足以对其进行承诺,util.promisify()因此它开始使用本机承诺。

结论
AsyncLocalStorage 是类 TLS 解决方案在异步流之间存储本地上下文的最佳选择。
在使用它之前,您应该了解它的陷阱

  1. 如果您有基于回调的 API,则可能会丢失上下文
  2. 性能影响