JavaScript JavaScript资源管理

原创
admin 4个月前 (08-19) 阅读数 19 #JavaScript

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();
  }
  // 我们忘记在这里释放锁
}

readUntil(stream, "b").then(() => {
  const anotherReader = stream.getReader();
  // TypeError: ReadableStreamDefaultReader构造函数只能
  // 接受尚未锁定到读取器的可读流
});

这里,我们有一个发出三个数据块的流。我们从流中读取,直到找到字母"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 {
      // 使用reader1和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 {
        // 使用reader1和reader2做一些事情
      } finally {
        reader2.releaseLock();
      }
    } finally {
      reader1.releaseLock();
    }
    

你可以看到,一个看似简单的调用releaseLock的任务如何迅速导致嵌套的样板代码。这就是为什么JavaScript为资源管理提供了集成的语言支持。

using和await using声明

我们拥有的解决方案是两种特殊的变量声明:usingawait using。它们类似于const,但只要资源是可释放的(disposable),它们会在变量离开作用域时自动释放资源。使用与上面相同的示例,我们可以将其重写为:

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

  // 使用reader1和reader2做一些事情

  // 在我们退出块之前,reader1和reader2会自动释放
}

注意: 在撰写本文时,ReadableStreamDefaultReader没有实现可释放协议。这是一个假设性的示例。

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

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

现在我们知道using何时进行清理。但是它是如何做到的?using要求资源实现可释放协议。如果一个对象有[Symbol.dispose]()方法,那么它就是可释放的。此方法不带参数调用以执行清理。例如,在读取器的情况下,[Symbol.dispose]属性可以是releaseLock的简单别名或包装器:

js
// 为了演示
class MyReader {
  // 一个包装器
  [Symbol.dispose]() {
    this.releaseLock();
  }
  releaseLock() {
    // 释放资源的逻辑
  }
}

// 或者,一个别名
MyReader.prototype[Symbol.dispose] = MyReader.prototype.releaseLock;

通过可释放协议,using可以一致地释放所有资源,而无需了解它是什么类型的资源。

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

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

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

  // fileHandle.close()被调用并等待
}

因为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获取一些示例。

DisposableStack和AsyncDisposableStack对象

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" }));
  }
  // 使用reader做一些事情
  // 在作用域退出前,disposer被释放,从而释放reader
}

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

js
{
  using disposer = new DisposableStack();
  // 假设reader没有[Symbol.dispose]()方法,
  // 那么它不能与using一起使用。
  // 但是,我们可以手动将释放函数传递给disposer.adopt
  const reader = disposer.adopt(stream.getReader(), (reader) =>
    reader.releaseLock(),
  );
  // 使用reader做一些事情
  // 在作用域退出前,disposer被释放,从而释放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());
  // 使用connection1和connection2做一些事情
  // 在作用域退出前,disposer被释放,首先释放connection1
  // 和connection2,然后记录消息
}

您可能想要进行条件性释放—例如,仅在发生错误时释放已声明的资源。在这种情况下,您可以使用move()来保留否则会被释放的资源。

js
class MyResource {
  #resource1;
  #resource2;
  #disposables;
  constructor() {
    using disposer = new DisposableStack();
    this.#resource1 = disposer.use(getResource1());
    this.#resource2 = disposer.use(getResource2());
    // 如果我们到达这里,那么构造过程中没有错误,并且
    // 我们可以安全地将可释放对象从`disposer`移到`#disposables`中。
    this.#disposables = disposer.move();
    // 如果构造失败,那么`disposer`将在到达
    // 上面一行之前被释放,释放`#resource1`和`#resource2`。
  }
  [Symbol.dispose]() {
    this.#disposables.dispose(); // 释放`#resource2`和`#resource1`。
  }
}

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

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

错误处理

资源管理功能的一个主要用例是确保资源总是被释放,即使发生错误。让我们研究一些复杂的错误处理场景。

我们从以下代码开始,通过使用using,它可以抵抗错误:

js
async function readUntil(stream, text) {
  // 使用`using`而不是`await using`,因为`releaseLock`是同步的
  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();
    // 成功创建另一个读取器
  });

所以,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(),
  );

  // 并行获取所有URL
  // 如果任何请求失败,自动取消任何未完成的请求
  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,您可以创建与外部资源交互的系统,这些系统在所有错误条件下都保持强大和稳健,而无需大量样板代码。

版权声明

本文仅代表作者观点,不代表本站立场。
本文系作者授权本站发表,未经许可,不得转载。

作者文章
热门
最新文章
标签列表