JavaScript 资源管理

本指南讨论如何在 JavaScript 中进行资源管理。资源管理与内存管理不完全相同,后者是一个更高级的主题,通常由 JavaScript 自动处理。资源管理涉及管理由 JavaScript 自动清理的资源。有时,内存中存在一些未使用的对象是可以接受的,因为它们不会干扰应用程序逻辑,但资源泄漏通常会导致功能失常或大量内存占用。因此,这并非关于优化的可选功能,而是编写正确程序的核心功能!

注意:虽然内存管理和资源管理是两个独立的主题,但有时你可以作为最后的手段,利用内存管理系统进行资源管理。例如,如果你的 JavaScript 对象代表外部资源的句柄,你可以创建一个FinalizationRegistry,在句柄被垃圾回收时清理资源,因为此后肯定无法访问该资源。然而,无法保证终结器一定会运行,因此不建议依赖它来处理关键资源。

问题

我们首先看几个需要管理的资源示例

  • 文件句柄:文件句柄用于读取和写入文件中的字节。使用完毕后,必须调用fileHandle.close(),否则文件将保持打开状态,即使 JS 对象不再可访问。正如链接的 Node.js 文档所述:

    如果 <FileHandle> 未使用 fileHandle.close() 方法关闭,它将尝试自动关闭文件描述符并发出进程警告,有助于防止内存泄漏。请不要依赖此行为,因为它可能不可靠且文件可能未关闭。相反,请始终显式关闭 <FileHandle>。Node.js 将来可能会更改此行为。

  • 网络连接:某些连接,例如 WebSocketRTCPeerConnection,如果未传输消息,则需要关闭。否则,连接将保持打开状态,并且连接池的大小通常非常有限。

  • 流读取器:如果你不调用 ReadableStreamDefaultReader.releaseLock(),流将被锁定,不允许其他读取器使用它。

这是一个具体示例,使用可读流

js
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("a");
    controller.enqueue("b");
    controller.enqueue("c");
    controller.close();
  },
});

async function readUntil(stream, text) {
  const reader = stream.getReader();
  let chunk = await reader.read();

  while (!chunk.done && chunk.value !== text) {
    console.log(chunk);
    chunk = await reader.read();
  }
  // We forgot to release the lock here
}

readUntil(stream, "b").then(() => {
  const anotherReader = stream.getReader();
  // TypeError: ReadableStreamDefaultReader constructor can only
  // accept readable streams that are not yet locked to a reader
});

这里,我们有一个发出三个数据块的流。我们从流中读取,直到找到字母“b”。当 readUntil 返回时,流只被部分消耗,因此我们应该能够使用另一个读取器继续从其中读取。然而,我们忘记释放锁,所以尽管 reader 不再可用,但流仍被锁定,我们无法创建另一个读取器。

在这种情况下,解决方案很简单:在 readUntil 的末尾调用 reader.releaseLock()。但是,仍然存在一些问题

  • 不一致性:不同的资源有不同的释放方式。例如,我们有 close()releaseLock()disconnect() 等。这种模式不具备通用性。

  • 错误处理:如果 reader.read() 调用失败会发生什么?那么 readUntil 将终止,并且永远不会到达 reader.releaseLock() 调用。我们可以使用 try...finally 修复此问题

    js
    async function readUntil(stream, text) {
      const reader = stream.getReader();
      try {
        let chunk = await reader.read();
    
        while (!chunk.done && chunk.value !== text) {
          console.log(chunk);
          chunk = await reader.read();
        }
      } finally {
        reader.releaseLock();
      }
    }
    

    但每次你有重要的资源要释放时,你都必须记住这样做。

  • 作用域:在上面的例子中,当我们退出 try...finally 语句时,reader 已经关闭,但它在其作用域中仍然可用。这意味着你可能会在它关闭后意外地使用它。

  • 多重资源:如果我们对不同的流有两个读取器,我们必须记住释放它们两者。这是一个值得称赞的尝试

    js
    const reader1 = stream1.getReader();
    const reader2 = stream2.getReader();
    try {
      // do something with reader1 and reader2
    } finally {
      reader1.releaseLock();
      reader2.releaseLock();
    }
    

    然而,这引入了更多的错误处理麻烦。如果 stream2.getReader() 抛出,那么 reader1 就不会被释放;如果 reader1.releaseLock() 抛出错误,那么 reader2 就不会被释放。这意味着我们实际上必须将每个资源获取-释放对都包装在自己的 try...finally

    js
    const reader1 = stream1.getReader();
    try {
      const reader2 = stream2.getReader();
      try {
        // do something with reader1 and reader2
      } finally {
        reader2.releaseLock();
      }
    } finally {
      reader1.releaseLock();
    }
    

您可以看到,调用 releaseLock 这样看似无害的任务,很快就会导致嵌套的样板代码。这就是 JavaScript 为资源管理提供集成语言支持的原因。

usingawait using 声明

我们的解决方案是两种特殊的变量声明:usingawait using。它们类似于 const,但只要资源是可处置的,它们就会在变量超出作用域时自动释放资源。以上面的相同示例,我们可以将其重写为:

js
{
  using reader1 = stream1.getReader();
  using reader2 = stream2.getReader();

  // do something with reader1 and reader2

  // Before we exit the block, reader1 and reader2 are automatically released
}

注意:在撰写本文时,ReadableStreamDefaultReader 未实现可处置协议。这是一个假设的示例。

首先,请注意代码周围额外的花括号。这为 using 声明创建了一个新的块作用域。用 using 声明的资源在超出 using 的作用域时会自动释放,在这种情况下,无论是因为所有语句都已执行,还是因为某个地方遇到了错误或 return/break/continue,都会在退出块时释放。

这意味着 using 只能在具有明确生命周期的作用域中使用——也就是说,它不能在脚本的顶层使用,因为脚本顶层的变量在页面所有未来的脚本中都处于作用域内,这实际上意味着如果页面永不卸载,资源就永远无法释放。但是,你可以在模块的顶层使用它,因为模块作用域在模块执行完毕时结束。

现在我们知道 using 何时进行清理。但它是如何完成的呢?using 要求资源实现可处置协议。如果对象具有 [Symbol.dispose]() 方法,则它是可处置的。此方法在没有参数的情况下被调用以执行清理。例如,在读取器的情况下,[Symbol.dispose] 属性可以是 releaseLock 的简单别名或包装器

js
// For demonstration
class MyReader {
  // A wrapper
  [Symbol.dispose]() {
    this.releaseLock();
  }
  releaseLock() {
    // Logic to release resources
  }
}

// OR, an alias
MyReader.prototype[Symbol.dispose] = MyReader.prototype.releaseLock;

通过可处置协议,using 可以以一致的方式处置所有资源,而无需了解资源的类型。

每个作用域都带有一个与其关联的资源列表,按声明顺序排列。当作用域退出时,资源将按反向顺序处置,通过调用它们的 [Symbol.dispose]() 方法。例如,在上面的示例中,reader1reader2 之前声明,因此 reader2 首先被处置,然后是 reader1。在尝试处置某个资源时抛出的错误不会阻止其他资源的处置。这与 try...finally 模式一致,并尊重资源之间可能存在的依赖关系。

await usingusing 非常相似。语法告诉你某个地方会发生 await——不是在资源声明时,而是在它被处置时。await using 要求资源是异步可处置的,这意味着它有一个 [Symbol.asyncDisposable]() 方法。此方法在没有参数的情况下被调用,并返回一个 Promise,该 Promise 在清理完成时解决。这在清理是异步的情况下很有用,例如 fileHandle.close(),在这种情况下,处置的结果只能异步得知。

js
{
  await using fileHandle = open("file.txt", "w");
  await fileHandle.write("Hello");

  // fileHandle.close() is called and awaited
}

因为 await using 需要执行 await,所以它只允许在允许 await 的上下文中使用,这包括 async 函数内部和模块中的顶层 await

资源是按顺序清理的,而不是并发的:一个资源的 [Symbol.asyncDispose]() 方法的返回值将在下一个资源的 [Symbol.asyncDispose]() 方法被调用之前被 await

需要注意的一些事项

  • usingawait using选择性加入的。如果你使用 letconstvar 声明你的资源,则不会发生自动处置,就像任何其他不可处置的值一样。
  • usingawait using 要求资源是可处置的(或异步可处置的)。如果资源分别没有 [Symbol.dispose]()[Symbol.asyncDispose]() 方法,你将在声明行收到 TypeError。但是,资源可以是 nullundefined,允许你条件性地获取资源。
  • const 类似,usingawait using 变量不能被重新赋值,尽管它们所持有的对象的属性可以被改变。然而,[Symbol.dispose]()/[Symbol.asyncDispose]() 方法在声明时就已经保存,因此在声明后改变该方法不会影响清理。
  • 当将作用域与资源生命周期混淆时,会出现一些陷阱。有关几个示例,请参见using

DisposableStackAsyncDisposableStack 对象

usingawait using 是特殊的语法。语法很方便,隐藏了大量的复杂性,但有时你需要手动操作。

一个常见的例子是:如果你不想在当前作用域结束时处置资源,而是在稍后的作用域中处置呢?考虑这种情况

js
let reader;
if (someCondition) {
  reader = stream.getReader();
} else {
  reader = stream.getReader({ mode: "byob" });
}

如我们所说,using 类似于 const:它必须被初始化且不能被重新赋值,所以你可能会尝试这样做

js
if (someCondition) {
  using reader = stream.getReader();
} else {
  using reader = stream.getReader({ mode: "byob" });
}

然而,这意味着所有逻辑都必须写在 ifelse 内部,导致大量重复。我们想要做的是在一个作用域中获取和注册资源,但在另一个作用域中处置它。为此,我们可以使用一个 DisposableStack,它是一个包含可处置资源集合且自身可处置的对象

js
{
  using disposer = new DisposableStack();
  let reader;
  if (someCondition) {
    reader = disposer.use(stream.getReader());
  } else {
    reader = disposer.use(stream.getReader({ mode: "byob" }));
  }
  // Do something with reader
  // Before scope exit, disposer is disposed, which disposes reader
}

你可能有一个尚未实现可处置协议的资源,因此它将被 using 拒绝。在这种情况下,你可以使用 adopt()

js
{
  using disposer = new DisposableStack();
  // Suppose reader does not have the [Symbol.dispose]() method,
  // then it cannot be used with using.
  // However, we can manually pass a disposer function to disposer.adopt
  const reader = disposer.adopt(stream.getReader(), (reader) =>
    reader.releaseLock(),
  );
  // Do something with reader
  // Before scope exit, disposer is disposed, which disposes reader
}

你可能有一个处置操作要执行,但它不“绑定”到任何特定资源。也许你只是想在同时打开多个连接时记录一条消息,例如“所有数据库连接已关闭”。在这种情况下,你可以使用 defer()

js
{
  using disposer = new DisposableStack();
  disposer.defer(() => console.log("All database connections closed"));
  const connection1 = disposer.use(openConnection());
  const connection2 = disposer.use(openConnection());
  // Do something with connection1 and connection2
  // Before scope exit, disposer is disposed, which first disposes connection1
  // and connection2 and then logs the message
}

你可能想进行有条件的处置——例如,只在发生错误时处置已声明的资源。在这种情况下,你可以使用 move() 来保留否则将被处置的资源。

js
class MyResource {
  #resource1;
  #resource2;
  #disposables;
  constructor() {
    using disposer = new DisposableStack();
    this.#resource1 = disposer.use(getResource1());
    this.#resource2 = disposer.use(getResource2());
    // If we made it here, then there were no errors during construction and
    // we can safely move the disposables out of `disposer` and into `#disposables`.
    this.#disposables = disposer.move();
    // If construction failed, then `disposer` would be disposed before reaching
    // the line above, disposing `#resource1` and `#resource2`.
  }
  [Symbol.dispose]() {
    this.#disposables.dispose(); // Dispose `#resource2` and `#resource1`.
  }
}

AsyncDisposableStack 类似于 DisposableStack,但用于异步可处置资源。它的 use() 方法期望一个异步可处置对象,它的 adopt() 方法期望一个异步清理函数,而它的 dispose() 方法期望一个异步回调。它提供了一个 [Symbol.asyncDispose]() 方法。如果你有同步和异步资源的混合,你仍然可以向它传递同步资源。

DisposableStack 的参考资料包含更多示例和详细信息。

错误处理

资源管理功能的一个主要用例是确保资源始终被处置,即使发生错误也是如此。让我们探讨一些复杂的错误处理场景。

我们从以下代码开始,通过使用 using,它可以健壮地应对错误

js
async function readUntil(stream, text) {
  // Use `using` instead of `await using` because `releaseLock` is synchronous
  using reader = stream.getReader();
  let chunk = await reader.read();

  while (!chunk.done && chunk.value !== text) {
    console.log(chunk.toUpperCase());
    chunk = await reader.read();
  }
}

假设 chunk 结果为 null。那么 toUpperCase() 将抛出 TypeError,导致函数终止。在函数退出之前,stream[Symbol.dispose]() 被调用,它释放了流上的锁。

js
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("a");
    controller.enqueue(null);
    controller.enqueue("b");
    controller.enqueue("c");
    controller.close();
  },
});

readUntil(stream, "b")
  .catch((e) => console.error(e)) // TypeError: chunk.toUpperCase is not a function
  .then(() => {
    const anotherReader = stream.getReader();
    // Successfully creates another reader
  });

因此,using 不会吞噬任何错误:所有发生的错误仍然会被抛出,但资源会在抛出之前关闭。现在,如果资源清理本身也抛出错误会发生什么?让我们使用一个更刻意的例子

js
class MyReader {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock");
  }
}

function doSomething() {
  using reader = new MyReader();
  throw new Error("Failed to read");
}

try {
  doSomething();
} catch (e) {
  console.error(e); // SuppressedError: An error was suppressed during disposal
}

doSomething() 调用中生成了两个错误:一个是在 doSomething 期间抛出的错误,另一个是因为第一个错误导致在处置 reader 期间抛出的错误。这两个错误一起抛出,因此你捕获到的是一个 SuppressedError。这是一个特殊的错误,它包装了两个错误:error 属性包含后面的错误,suppressed 属性包含较早的错误,该错误被后面的错误“抑制”。

如果我们将多个资源,并且它们在处置期间抛出错误(这种情况应该极其罕见——处置失败本身就很少见!),那么每个较早的错误都会被较晚的错误抑制,形成一个抑制错误的链。

js
class MyReader {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock on reader");
  }
}

class MyWriter {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock on writer");
  }
}

function doSomething() {
  using reader = new MyReader();
  using writer = new MyWriter();
  throw new Error("Failed to read");
}

try {
  doSomething();
} catch (e) {
  console.error(e); // SuppressedError: An error was suppressed during disposal
  console.error(e.suppressed); // SuppressedError: An error was suppressed during disposal
  console.error(e.error); // Error: Failed to release lock on reader
  console.error(e.suppressed.suppressed); // Error: Failed to read
  console.error(e.suppressed.error); // Error: Failed to release lock on writer
}
  • reader 最后释放,所以它的错误是最新的,因此抑制了其他所有错误:它显示为 e.error
  • writer 首先释放,所以它的错误比原始的退出错误晚,但比 reader 错误早:它显示为 e.suppressed.error
  • 关于“Failed to read”的原始错误是最早的错误,所以它显示为 e.suppressed.suppressed

示例

自动释放对象 URL

在以下示例中,我们创建一个指向 Blob 的对象 URL(在实际应用程序中,此 Blob 将从某个地方获取,例如文件或 fetch 响应),以便我们可以将 Blob 作为文件下载。为了防止资源泄漏,我们必须在不再需要对象 URL 时(即下载成功开始时)使用 URL.revokeObjectURL() 释放它。由于 URL 本身只是一个字符串,因此不实现可处置协议,我们不能直接使用 using 声明 url;因此,我们创建一个 DisposableStack 作为 url 的处置器。一旦 disposer 超出作用域,即 link.click() 完成或某个地方发生错误时,对象 URL 就会被撤销。

js
const downloadButton = document.getElementById("download-button");
const exampleBlob = new Blob(["example data"]);

downloadButton.addEventListener("click", () => {
  using disposer = new DisposableStack();
  const link = document.createElement("a");
  const url = disposer.adopt(
    URL.createObjectURL(exampleBlob),
    URL.revokeObjectURL,
  );

  link.href = url;
  link.download = "example.txt";
  link.click();
});

自动取消进行中的请求

在下面的例子中,我们使用 Promise.all() 并发地获取一个资源列表。Promise.all() 在一个请求失败后立即失败并拒绝结果 Promise;然而,其他挂起的请求会继续运行,尽管它们的结果对程序来说是不可访问的。为了避免这些剩余的请求不必要地消耗资源,我们需要在 Promise.all() 解决时自动取消进行中的请求。我们使用 AbortController 实现取消,并将其 signal 传递给每个 fetch() 调用。如果 Promise.all() 成功,那么函数正常返回并且控制器中止,这是无害的,因为没有挂起的请求需要取消;如果 Promise.all() 拒绝并且函数抛出,那么控制器中止并取消所有挂起的请求。

js
async function getAllData(urls) {
  using disposer = new DisposableStack();
  const { signal } = disposer.adopt(new AbortController(), (controller) =>
    controller.abort(),
  );

  // Fetch all URLs in parallel
  // Automatically cancel any incomplete requests if any request fails
  const pages = await Promise.all(
    urls.map((url) =>
      fetch(url, { signal }).then((response) => {
        if (!response.ok)
          throw new Error(
            `Response error: ${response.status} - ${response.statusText}`,
          );
        return response.text();
      }),
    ),
  );
  return pages;
}

陷阱

资源处置语法提供了许多强大的错误处理保证,确保无论发生什么,资源都始终被清理,但你仍然可能会遇到一些陷阱

  • 忘记使用 usingawait using。资源管理语法只在你需要时提供帮助,但如果你忘记使用它,它不会发出任何警告!不幸的是,事发前没有好的方法可以防止这种情况,因为没有语法线索表明某个东西是可处置的资源,即使是可处置的资源,你可能也想在不自动处置的情况下声明它们。你可能需要一个类型检查器结合一个 linter 来捕获这些问题,例如 typescript-eslint该功能仍在规划中)。
  • 使用后释放。通常,using 语法确保资源在超出作用域时被释放,但有许多方法可以在绑定变量之外持久化值。JavaScript 没有像 Rust 那样的所有权机制,所以你可以声明一个不使用 using 的别名,或者将资源保存在闭包中等等。using 参考中包含许多此类陷阱的示例。同样,在复杂的控制流中没有好的方法可以正确检测到这一点,所以你需要小心。

资源管理功能并非灵丹妙药。它确实比手动调用处置方法有所改进,但它还不够智能,无法防止所有资源管理错误。你仍然需要小心并理解你正在使用的资源的语义。

总结

以下是资源管理系统的关键组件

通过正确使用这些 API,你可以创建与外部资源交互的系统,这些系统在所有错误条件下都保持强大和健壮,而无需大量样板代码。