Worker 的生命周期和 WorkerRefs

Worker 作为一种线程编程模型,被引入 Web 世界以有效利用 Web 世界中的计算能力。就像常规的线程编程一样,Worker 可以根据需要随时创建和删除。

由于 Worker 可以随时被删除,因此在 Workers 上开发 API 时,在处理关闭行为时应格外小心。否则,内存问题、UAF、内存泄漏或关闭挂起将不足为奇。此外,调试 Workers 上的这些问题有时并不容易。崩溃堆栈可能无法提供非常有用的信息。由于可能是线程交错问题,因此错误有时需要特殊的序列才能重现。为了避免遇到这些麻烦,牢记 Worker 的生命周期以及如何使用 WorkerRefs 将非常有帮助。

Worker 生命周期

Worker 的生命周期由 WorkerPrivate 类中的状态机维护。Worker 可能处于以下状态

  • Pending(挂起)

  • Running(运行)

  • Closing(关闭)

  • Canceling(取消)

  • Killing(终止)

  • Dead(死亡)

下面我们简要描述每个状态的作用。

Pending(挂起)

这是 Worker 的初始状态。

Worker 的初始化在此状态下在父线程(主线程或父 Worker)和 Worker 线程中完成。

Worker 的初始化从其父线程开始,包括:

  1. 从父窗口/Worker 获取 WorkerLoadInfo

  2. 为 Worker 创建一个 WorkerPrivate

  3. 在 RuntimeService 对象中注册 Worker

  4. 为 Worker 初始化一个线程(Worker 线程),并在 Worker 线程上分派 WorkerThreadPrimaryRunnable

  5. 连接调试器

  6. 将 CompileScriptRunnable 分派到 Worker 线程

在 Worker 线程开始运行 Runnable 之前,Worker 可能已经暴露给其父窗口/Worker。因此,父窗口/Worker 可以通过 postMessage() 方法向 Worker 发送消息。如果 Worker 尚未处于“Running”状态,则这些 Runnable 将保留在 WorkerPrivate::mPreStartRunnables 中。

当 WorkerThreadPrimaryRunnable 在 Worker 线程上开始执行时,它会在 Worker 线程上继续初始化,包括:

  1. 建立 WorkerPrivate 和 Worker 线程之间的连接。然后将 WorkerPrivate::mPreStartRunnables 移动到 Worker 线程的事件队列。

  2. 为 Worker 初始化 PerformanceStorage。

  3. 启动 Worker 的 Cycle-Collector。

  4. 为 Worker 初始化 JS 上下文。

  5. 调用 WorkerPrivate::DoRunLoop() 以使用 Worker 线程事件队列中的 Runnable。

Running(运行)

这是 Worker 开始在 Worker 线程上执行 Runnable 的状态。

一旦 Worker 进入“Running”状态,

  1. 启用内存报告器

  2. 启动 GC 定时器。

“Running”是我们使用 Worker 的状态。此时,我们可以:

  1. 创建 WorkerRefs 以获取 Worker 关闭通知,并通过注册的回调运行关闭清理作业。

  2. 创建同步事件循环,使 Worker 线程等待另一个线程的执行。

  3. 将事件分派到 WorkerGlobalScope 以触发脚本中定义的事件回调。

我们将在后面详细讨论 WorkerRef 和同步事件循环。

Closing(关闭)

当调用 DedicateWorkerGlobalScope.close()/SharedWorkerGlobalScope.close() 时,这是 DedicatedWorker 和 SharedWorker 的特殊状态。

当 Worker 进入“Closing”状态时,

  1. 取消 Worker 的所有超时/时间间隔。

  2. 不允许在 WorkerGlobalScope 上使用 BroadcastChannel.postMessage()。

Worker 将保持在“Closing”状态,直到 Worker 的所有同步事件循环都关闭。

Canceling(取消)

当 Worker 进入“Canceling”状态时,它开始 Worker 关闭步骤。

  1. 将 WorkerGlobalScope(nsIGlobalObject)设置为正在死亡。

这意味着事件不会分派到 WorkerGlobalScope,并且挂起的已分派事件的回调不会执行。

  1. 取消 Worker 的所有超时/时间间隔。

  2. 通知 WorkerRef 持有者和子 Worker。

因此,WorkerRef 持有者和子 Worker 将开始关闭作业

  1. 立即中止脚本。

一旦所有同步事件循环都关闭,

  1. 断开 WorkerGlobalScope 的 EventTarget/WebTaskScheduler 连接

Killing(终止)

这是开始销毁 Worker 的状态

  1. 关闭 GC 定时器

  2. 禁用内存报告器

  3. 将状态切换到“Dead”

  4. 取消并释放剩余的 WorkerControlRunnables

  5. 退出 WorkerPrivate::DoRunLoop()

Dead(死亡)

Worker 退出主事件循环,它继续关闭过程

  1. 释放剩余的 WorkerDebuggerRunnables

  2. 取消根 WorkerGlobalScope 和 WorkerDebugGlobalScope

    1. 触发 GC 以释放 GlobalScopes

  3. 关闭 Worker 的 Cycle-Collector

  4. 将 TopLevelWorkerFinishRunnable/WorkerFinishRunnable 分派到父线程

    1. 禁用/断开 WorkerDebugger 连接

    2. 在 RuntimeService 对象中注销 Worker

    3. 释放 WorkerPrivate::mSelf 和 WorkerPrivate::mParentEventTargetRef

WorkerPrivate 应该在其自身引用被取消后释放。

  1. 将 FinishedRunnable 分派到主线程以释放 Worker 线程。

如何关闭 Worker

通常,有四种情况会导致 Worker 进入关闭状态。

  1. Worker 被 GC/CC。

    1. 导航到另一个页面。

    2. Worker 空闲一段时间。(请注意,空闲不是 Worker 的状态,它是“Running”状态下的条件)

  2. 在 Worker 的脚本中调用 self.close()。

  3. 在父脚本中调用 Worker.terminate()。

  4. Firefox 关闭。

Worker 状态流程图

Worker Status Flowchart

此流程图显示了 Worker 状态如何变化。

当 WorkerThreadPrimaryRunnable 在 Worker 线程上调用 WorkerPrivate::DoRunLoop 时,状态从“Pending”更改为“Running”。如果 Firefox 关闭发生在进入“Running”之前,则状态会直接从“Pending”更改为“Dead”。

当 Worker 处于“Running”状态时,状态更改必须由请求 Worker 关闭导致。对于 Worker 的脚本调用 self.close() 的特殊情况,状态切换到“Closing”。否则,状态切换到“Canceling”。当所有同步事件循环完成后,“Closing”状态的 Worker 将切换到“Canceling”。

当满足以下要求时,“Canceling”状态的 Worker 会将其状态切换到“Killing”。

  1. 没有 WorkerRefs、没有子 Worker、没有超时和没有同步事件循环

  2. Worker 线程主事件队列、控制 Runnable 和调试器 Runnable 没有挂起的 Runnable

状态会自动从“Killing”切换到“Dead”。

WorkerRefs

由于 Worker 的关闭可能随时发生,因此了解关闭何时开始对于开发至关重要,尤其是在释放资源并在 Worker 关闭阶段完成操作时。因此,引入了 WorkerRefs 来获取 Worker 关闭的通知。当 Worker 进入“Canceling”状态时,它会通知相应的 WorkerRefs 在 Worker 线程上执行注册的回调。WorkerRef 持有者在注册的回调中同步或异步地完成其关闭步骤,然后释放 WorkerRef。

根据以下要求,引入了四种类型的 WorkerRefs。

  • WorkerRef 是否应阻塞 Worker 的关闭

  • WorkerRef 是否应阻塞 Worker 上的循环收集

  • WorkerRef 是否需要在其他线程上持有。

WeakWorkerRef

WeakWorkerRef 顾名思义是一个“弱”引用,因为 WeakWorkerRef 在 WeakWorkerRef 的注册回调执行完成后会立即释放对 Worker 的内部引用。因此,WeakWorkerRef 不会阻塞 Worker 的关闭。此外,持有 WeakWorkerRef 不会阻塞 GC/CC Worker。这意味着即使有指向 Worker 的 WeakWorkerRefs,Worker 也会被视为已循环收集。

WeakWorkerRef 是引用计数的,但不是线程安全的。

WeakWorkerRef 旨在仅获取 Worker 的关闭通知并同步完成关闭步骤。

StrongWorkerRef

与 WeakWorkerRef 不同,StrongWorkerRef 在回调执行后不会释放其对 Worker 的内部引用。StrongWorkerRef 的内部引用在 StrongWorkerRef 被销毁时释放。这意味着 StrongWorkerRef 允许其持有者通过置空 StrongWorkerRef 来确定何时释放 Worker。这也使得 StrongWorkerRef 的持有者阻塞 Worker 的关闭。

使用 StrongWorkerRef 时,资源清理可能涉及多个线程和异步行为。StrongWorkerRef 的释放时机至关重要,以避免出现内存问题,例如 UAF 或泄漏。必须释放 StrongWorkerRef。否则,关闭挂起将不足为奇。

StrongWorkerRef 还会阻塞 GC/CC Worker。一旦有指向 Worker 的 StrongWorkerRef,GC/CC 将不会收集 Worker。

StrongWorkerRef 是引用计数的,但不是线程安全的。

ThreadSafeWorkerRef

ThreadSafeWorkerRef 是 StrongWorkerRef 的扩展。不同之处在于 ThreadSafeWorkerRef 持有者可以位于另一个线程上。由于它是 StrongWorkerRef 的扩展,因此它具有与 StrongWorkerRef 相同的特性。这意味着它的持有者会阻塞 Worker 的关闭,并且它还会阻塞 GC/CC Worker。

使用 ThreadSafeWorkerRef 时,就像 StrongWorkerRef 一样,ThreadSafeWorkerRef 的释放时机对于内存问题至关重要。除了释放时机之外,还应注意回调是在 Worker 线程上执行,而不是在持有者的拥有线程上执行。

ThreadSafeWorkerRef 是引用计数的且线程安全的。

IPCWorkerRef

IPCWorkerRef 是一种特殊的 WorkerRef,用于将 IPC actor 的生命周期与其 Worker 关闭通知绑定。(在我们当前的代码库中,Cache API 和 Client API 使用 IPCWorkerRef)

因为某些 IPC 关闭需要在 Worker 关闭期间按照特定的顺序进行。但是,为了进行这些 IPC 关闭,需要确保 Worker 保持活动状态,因此 IPCWorkerRef 会阻塞 Worker 的关闭。但 IPC 关闭不需要阻塞 GC/CC Worker。

IPCWorkerRef 是引用计数的,但不是线程安全的。

以下是 WorkerRefs 之间比较的表格

WeakWorkerRef StrongWorkerRef ThreadSafeWorkerRef IPCWorkerRef
持有者线程 Worker 线程 Worker 线程 任何线程 Worker 线程
回调执行线程 Worker 线程 Worker 线程 Worker 线程 Worker 线程
阻塞 Worker 的关闭
阻塞 GC Worker

WorkerRef 回调

创建 WorkerRef 时可以注册 WorkerRef 回调。回调负责释放与 WorkerRef 持有者相关的资源。例如,解决/拒绝由 WorkerRef 持有者创建的 Promise。清理行为可能是同步的或异步的,具体取决于所涉及的功能的复杂程度。例如,Cache API 可能需要等到操作在 IO 线程上完成,并在主线程上释放仅限主线程的对象。

为了避免内存问题,对于 WorkerRef 回调,需要牢记一些事项

  • 在完成清理步骤之前不要释放 WorkerRef。(UAF)

  • 不要忘记释放相关的资源。(内存泄漏)

  • 不要忘记释放 WorkerRef(StrongWorkerRef/ThreadWorkerRef/IPCWorkerRef)(关闭挂起)