PAYATHON 2026

如何彻底销毁 AudioContext 并清理相关线程?

排障赵师傅

结论

Web Audio API 没有“强制销毁 AudioContext”或“立即终止 AudioContext 线程”的接口。应用可以完成以下清理工作:

  1. 停止所有输入 MediaStreamTrack。
  2. 断开音频节点。
  3. 关闭 AudioWorkletNode.port,并清除事件处理器。
  4. 只要上下文尚未关闭,就调用并等待 AudioContext.close(),无论它当前处于 running 还是 suspended 状态。
  5. 移除业务代码对 AudioContext、节点、流、回调和缓冲区的引用,让垃圾回收器能够回收 JavaScript 对象。

清理完成后,资源何时从开发者工具中消失,以及底层工作线程何时退出或被浏览器复用,仍由浏览器决定。Performance 面板暂时保留线程记录,不能单独作为内存泄漏的证据。

当前代码中的主要问题

下面的判断没有处理处于 suspended 状态的上下文:

if (context.state == "running") {
  await context.close();
}

close() 也可以关闭 suspended 状态的上下文。只要状态不是 closed,就应尝试关闭:

if (context.state !== "closed") {
  await context.close();
}

另外,disconnect() 只负责断开音频图中的连接。它不会销毁节点,也不会移除 MessagePort、onmessage、onprocessorerror 等引用。如果某个长生命周期对象仍持有这些回调,对应闭包可能继续引用整个录音会话。

建议的停止顺序

清理时可以采用以下顺序:

  1. 向 AudioWorkletProcessor 发送 Finish。
  2. 等待处理器发送最后一批音频数据和 Finished。
  3. 停止处理来自 Worklet 的后续消息。
  4. 关闭 recorder.port。
  5. 清除节点和轨道上的事件处理器。
  6. 断开 source 与 recorder。
  7. 停止流中的所有轨道。
  8. 调用并等待 context.close()。
  9. 清空业务对象仍保存的引用。

如果只使用一条音轨,停止 audioTrack 即可。如果同一个 MediaStream 可能包含多条轨道,使用 stream.getTracks() 更合适:

for (const track of stream.getTracks()) {
  track.stop();
}

如果 prewarmedMediaStream 会缓存或复用这个流,还要检查它是否长期保存了已经停止的 MediaStream 或相关回调。关闭 AudioContext 不会自动移除外部对象持有的流引用。

可重复调用的清理实现

停止操作适合设计成幂等流程,避免用户主动停止、轨道结束和异常处理同时触发重复清理。

let stopPromise: Promise<void> | null = null;
let finishing = false;
let disposed = false;
let recordingEndedError: AsyncError | null = null;

async function disposeRecording(): Promise<void> {
  if (disposed) return;
  disposed = true;

  // 先清除回调,避免清理过程中再次进入停止流程。
  audioTrack.onended = null;
  recorder.onprocessorerror = null;
  recorder.port.onmessage = null;

  // MessagePort 不再需要时应显式关闭。
  recorder.port.close();

  try {
    source.disconnect();
  } catch {
    // 节点可能已经断开。
  }

  try {
    recorder.disconnect();
  } catch {
    // 节点可能已经断开。
  }

  for (const track of stream.getTracks()) {
    if (track.readyState !== "ended") {
      track.stop();
    }
  }

  if (context.state !== "closed") {
    await context.close();
  }
}

recorder.port.onmessage = async (event: MessageEvent<IncomingMessage>) => {
  if (disposed) return;

  const msg = event.data;

  if (msg.type === "Audio") {
    listener.onDataAvailable?.(msg.data);
    return;
  }

  if (msg.type !== "Finished") return;

  try {
    await disposeRecording();

    if (recordingEndedError) {
      listener.onRecordingTerminated?.(recordingEndedError);
    } else {
      try {
        listener.onRecordingCompleted?.();
      } catch {
        // 用户回调异常不应阻止停止 Promise 完成。
      }
    }
  } finally {
    recordingStopped.resolve();
  }
};

function stopRecording(error?: AsyncError | null): Promise<void> {
  if (stopPromise) return stopPromise;

  stopPromise = recordingStopped.promise;
  recordingEndedError = error ?? null;

  if (!finishing && !disposed) {
    finishing = true;
    recorder.port.postMessage({ type: "Finish" });
  }

  return stopPromise;
}

audioTrack.onended = () => {
  void stopRecording(new AudioEndedError());
};

如果这些变量保存在录音器实例中,停止完成后还应清空相应字段:

this.context = null;
this.stream = null;
this.source = null;
this.recorder = null;
this.listener = null;

局部变量会在闭包不可达后自然回收,无须逐个赋值为 null。更需要检查的是全局变量、单例、缓存、事件监听器和未完成的 Promise。

改进 AudioWorkletProcessor 的结束逻辑

处理器发送 Finished 后,可以清空缓冲区和消息回调,同时保证结束消息只发送一次:

class PcmRecorder extends AudioWorkletProcessor {
  constructor() {
    super();

    this.buffers = [];
    this.finishRequested = false;
    this.finished = false;

    this.port.onmessage = (event) => {
      if (event.data?.type === "Finish") {
        this.finishRequested = true;
      }
    };
  }

  process(inputs) {
    if (this.finished) return false;

    const floats = inputs[0]?.[0];

    if (floats) {
      const samples = Int16Array.from(floats, this.convertSample);
      this.buffers.push(samples);
    }

    const total = this.buffers.reduce(
      (length, buffer) => length + buffer.length,
      0
    );

    if (total > 1600 || this.finishRequested) {
      if (total > 0) {
        const mergedBuffer = new Int16Array(total);
        let writeLocation = 0;

        for (const source of this.buffers) {
          mergedBuffer.set(source, writeLocation);
          writeLocation += source.length;
        }

        this.port.postMessage(
          {
            type: "Audio",
            data: new Uint8Array(mergedBuffer.buffer),
          },
          [mergedBuffer.buffer]
        );
      }

      this.buffers = [];
    }

    if (this.finishRequested) {
      this.finished = true;
      this.port.onmessage = null;
      this.port.postMessage({ type: "Finished" });
      return false;
    }

    return true;
  }

  convertSample(floatSample) {
    return Math.max(
      -32768,
      Math.min(Math.round(floatSample * 32768), 32767)
    );
  }
}

这段代码使用 transferable object 传递 mergedBuffer.buffer,可以少保留一份缓冲区副本。传输完成后,Worklet 一侧的 ArrayBuffer 会被分离,后续不能再读取。

使用 inputs[0]?.[0] 也比直接访问 inputs[0][0] 更安全,因为输入暂时不可用时,inputs 或对应通道可能为空。

为什么 close() 后仍能看到线程

AudioContext.close() 会关闭上下文,并释放它占用的系统音频资源,但不保证:

  • JavaScript 对象立即被垃圾回收。
  • 开发者工具中的历史记录立即消失。
  • 底层线程立即退出。
  • 浏览器不再保留或复用音频工作线程。
  • Performance 面板立即移除所有异步分析数据。

await context.close() 表示关闭流程已经完成到 API 所规定的程度,不等于浏览器必须在这一刻销毁线程对象。线程可能处于空闲状态,也可能由多个音频上下文共享,或者正在等待浏览器内部回收。

要判断是否真的发生泄漏,可以连续执行多轮录音,观察以下现象:

  • 活跃的麦克风轨道是否回到零。
  • 已关闭上下文的数量是否无限增长。
  • 可达的 AudioContext、AudioWorkletNode 和 MessagePort 对象是否持续增加。
  • 触发垃圾回收后,内存是否仍持续增长。
  • 旧回调是否还会收到新消息。

Performance 面板中出现多个名为 AudioContext 的线程,无法说明它们是真实泄漏、浏览器复用的线程,还是残留的分析记录。

需要注意的边界情况

不要无限等待 Finished。如果 Worklet 抛出异常、音频图停止渲染或消息丢失,当前流程可能始终无法调用 close()。生产代码可以设置超时,并在超时后直接执行 disposeRecording()。这样做可能丢失最后一小段缓冲区,需要在数据完整性和确定释放资源之间作出取舍。

以下失败路径也要分别处理:

  • audioWorklet.addModule() 失败。
  • new AudioWorkletNode() 失败。
  • context.resume() 失败。
  • onprocessorerror 被触发。
  • 获取到的 MediaStream 没有音频轨道。
  • 初始化尚未完成,调用方已经要求停止。

遇到这些情况,也要停止已经取得的轨道,并关闭已经创建的 AudioContext。正常停止流程即使没有问题,初始化异常仍可能留下资源。

如果应用需要频繁开始和停止录音,可以考虑复用一个 AudioContext,减少反复创建底层音频资源的开销。前提是应用能够正确管理节点和录音会话。如果每次录音都需要完全隔离,继续使用独立上下文,并在结束时调用 close() 也可以。

应用应关闭上下文、断开连接并移除引用,让资源进入可回收状态。浏览器没有提供更强的同步销毁机制,业务清理也不应依赖手动触发垃圾回收。

备注:内容仅供参考。