、
在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的潜在上下文丢失和性能影响
重点:
- 在整个异步流程中维护上下文
- 确保仅在同一异步流中可访问上下文
例如,考虑一个处理几个 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.
该端点在整个异步流程中使用两个参数 -
- userDetailsRequest- 保存有关所请求用户的信息
- 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 解决方案在异步流之间存储本地上下文的最佳选择。
在使用它之前,您应该了解它的陷阱
- 如果您有基于回调的 API,则可能会丢失上下文
- 性能影响