卷 IV · 墙CH 15深度 15/24

崩溃、卡死与超时

上一章的插件想偷东西。这一章的插件什么坏心思都没有 —— 它只是写了个死循环,或者不小心在主线程上解析了一个 50MB 的 JSON。结果比偷东西更常见,也更难防。这一章有一个会真的卡住这个页面的按钮,请务必点一下:那 0.7 秒里,你就是那个用户。

死循环超时的幻觉Worker崩溃隔离

三种「插件把事情搞砸」的方式

它们的可防性差别巨大,先分清:

方式表现宿主能做什么难度
抛异常调用插件时炸了try/catch 接住,标记这个插件出错容易(第 6 章已解决)
卡死调用插件后不返回同进程:什么都做不了只能靠架构
耗尽资源内存爆了、句柄漏光同进程:跟着一起死只能靠架构

第一行是免费的。第二、三行要么你付出整个架构,要么你完全无能为力 —— 中间没有便宜的选项。这一章解释为什么。

先亲身体验一下

下面这条心跳线,由宿主的主线程每 50ms 画一格。绿色 = 按时画上了,红色 = 迟到了。

点第一个按钮,让插件在主线程上干一件「重活」(一个真的 700ms 忙等循环):

你刚刚经历的:页面僵住、心跳停摆、按钮点不动、滚动也不动。而这才 0.7 秒 —— 真实世界里插件的死循环没有上限。

然后点第二个按钮,同样的重活,扔进 Worker:心跳一格没漏。

为什么「给插件加个超时」是做不到的

这是本章最重要的一节,因为它是几乎每个人的第一反应,而且几乎每个人都会先写出下面这段错的代码:

// ✗ 这段代码在 JS 里是不可能工作的
function callWithTimeout(fn, ms) {
  const timer = setTimeout(() => {
    throw new Error('插件超时');    // ← 这行永远不会执行
  }, ms);
  fn();                             // ← 如果 fn 是死循环,代码停在这里
  clearTimeout(timer);
}

为什么不行?因为 JavaScript 是单线程的,而 setTimeout 的回调要等当前调用栈清空才有机会执行。

而当前调用栈正被插件的死循环占着。

所以那个定时器永远排不上队。你的「超时检查」和被检查的对象在抢同一个线程,而对方不打算放手。

◆ 一句话

你不能用一个被占着的线程,去检查那个占着它的东西。

这不是 JS 的缺陷 —— 在任何单线程的执行模型里都一样。要打断一段正在运行的代码,你需要一个不在它控制之下的执行单元:另一个线程、另一个进程,或者一个能强制中断它的运行时。

那 Java 呢?Thread.stop 不是能杀线程吗

JVM 是多线程的,所以理论上你可以让插件跑在单独的线程里,超时了就杀掉它。

Thread.stop() 从 Java 1.2 起就被废弃了,而且在后来的版本里被彻底移除(调用会直接抛 UnsupportedOperationException)。

原因很实在:Thread.stop() 会在任意一条指令处抛出异常。想象一下这个时刻:

synchronized (lock) {
    map.put(key, value)      // ← 恰好在这里被强杀
    // 数据结构处于半更新状态,而锁被释放了
}

结果是一个内部状态已经损坏、但看起来完全正常的对象,它会在几分钟后以一个完全无法诊断的方式炸掉。这比让线程一直跑着更糟。

所以 JVM 上的正解也是「合作式取消」:设一个 volatile 标志位,让被中断的代码自己检查。但插件的死循环不会检查你的标志位 —— 我们又回到了原点。

(Kotlin 协程的取消也是合作式的:它靠挂起点检查 isActive。一段不含挂起点的 CPU 密集循环,cancel() 对它无效 —— 除非插件自己写 ensureActive()。同样指望不上。)

唯一的解法:把它放到你能掐死的地方

「能掐死」的前提是:那个执行单元的生命周期由你控制,而不是由它自己的代码控制

手段怎么掐掐得干净吗代价
Web Workerworker.terminate()干净 —— 立即销毁整个上下文全异步、数据要序列化
独立进程kill / child.kill()干净 —— 操作系统保证IPC、启动开销、内存占用
WASM燃料计数 / epoch 中断干净,而且能精确到指令数要用支持这个的运行时
独立 JS 引擎实例QuickJS 的中断回调干净要自己嵌运行时

共同点:它们都在你的执行流之外,所以你的「掐死」指令有机会被执行。

WASM 那一行值得注意:一些 WASM 运行时(比如 Wasmtime)支持「燃料」机制 —— 每执行一定数量的指令就消耗燃料,燃料耗尽就中断。这是唯一一种能精确限制「插件能算多久」的机制,而且不需要另起线程。如果你的插件系统需要跑真正不可信的计算,这是个很强的理由去考虑 WASM。

各家的真实处境

系统插件死循环时宿主能做什么
Obsidian整个应用冻结什么都做不了。用户只能强杀进程
Mihon如果在主线程 → ANR;在 IO 线程 → 那个源卡住靠约定让扩展在协程/IO 线程里干活,宿主 UI 通常还活着
IntelliJIDE 卡住(如果在 EDT 上)事后归因:抓冻结时的调用栈,报「插件 X 可能导致了卡顿」
VS Code只有 extension host 卡住UI 完全正常,可以提示「扩展宿主无响应,是否重启」
Chrome只有那个扩展的上下文卡住UI 正常,任务管理器里能看到并单独结束它
◆ 这一行的差别,是产品体验的分水岭

注意 IntelliJ 那一行:它能做的只是事后告诉你是谁干的。这是法医,不是保安。

而 VS Code 那一行是完全不同的用户体验:你的编辑器从不会因为某个扩展而卡住。你甚至可能不知道某个扩展正在死循环 —— 直到 VS Code 提示你。

这不是一个「优化」,这是一条产品上的分界线。VS Code 能对用户说「随便装扩展,装坏了也不影响你写代码」,Obsidian 说不出这句话。而这句话的价值,大到足以支付掉整套 RPC 基础设施的成本。

如果你暂时上不了隔离,能做什么

现实是大多数人一开始都在同进程。这里有五条能缓解(不是解决)的措施,按性价比排序:

① 把插件调用赶出 UI 线程

这是最重要的一条。插件的代码永远不要在 UI 线程上执行。

// Android / Kotlin
suspend fun runPlugin(source: Source, page: Int): MangasPage =
    withContext(Dispatchers.IO) {        // ← 绝不在 Main 上
        source.fetchPopularManga(page)
    }

这挡不住死循环(那个 IO 线程还是会被占死),但它把「整个应用冻结」降级成了「这个功能没响应」—— 用户至少还能操作界面、还能去禁用那个插件。这个降级的价值极大,而成本几乎为零。

② 给插件调用设「观察式」超时

虽然你打断不了它,但你可以不等它

val result = withTimeoutOrNull(5_000) {
    withContext(Dispatchers.IO) { source.fetchPopularManga(page) }
}
if (result == null) {
    // 那个线程还在跑(你杀不掉),但至少:
    // - UI 不再等待,可以显示「这个源没响应」
    // - 可以记录:这个插件超时了 N 次
    // - 可以自动禁用它,防止它下次又占一个线程
}

要清楚它的局限:超时之后那个线程还在跑,还在占 CPU。如果用户重试几次,你就有几个线程在死循环 —— 所以「超时后自动禁用」这一条必须配套,否则会越来越糟。

③ 记录并归因

学 IntelliJ:检测到 UI 冻结时抓调用栈,看看栈里有没有插件的类/文件名。做不到阻止,至少做到知情 —— 而且这个数据对你决定「要不要投资隔离」极有价值。

④ 给「慢」定一个明确的契约

在插件 API 文档里明确写:哪些回调必须在多少毫秒内返回。比如「transform 会在每次渲染时被调用,必须在 16ms 内返回,需要长时间计算请用异步 API」。

写清楚之后,你就有资格在开发模式下打印警告:[插件 X] transform 耗时 340ms,超出建议值 16ms大部分性能问题是无心的,一条警告就能让作者自己修好。

⑤ 让 API 本身不鼓励长时间同步操作

如果你的 API 全是异步的(第 12 章那条建议),插件作者写出长时间同步阻塞的机会就少很多 —— 因为他们本来就在 await 里。API 的形状会塑造插件的行为。

还有一种更隐蔽的:慢,但不卡死

比死循环常见得多的,是「每个插件都慢一点点」。

假设你的渲染流程会依次调用所有 transform 插件。装了 20 个插件,每个 8ms,那就是 160ms —— 每次渲染都掉帧,但没有任何一个插件「有问题」

这类问题没有单一元凶,所以也没人负责。你能做的:

  • 测量每个插件的耗时,并展示给用户(VS Code 的「扩展运行状况」就是干这个的 —— 它会告诉你每个扩展的激活耗时);
  • 设计不需要串行调用所有插件的扩展点(比如让插件声明它关心什么,只调相关的那几个);
  • 把慢的东西移出关键路径(渲染时不调插件,改成插件主动推送更新)。

第三条是最有效的:重新设计扩展点的形状,让它天然不在热路径上。

装到自己身上

◆ 一个可以今天就加的东西:插件耗时表
class TimedRegistry(private val inner: Registry) {
    private val stats = ConcurrentHashMap<String, LongAdder>()
    private val counts = ConcurrentHashMap<String, LongAdder>()

    fun <T> callPlugin(pluginId: String, hook: String, block: () -> T): T {
        val t0 = System.nanoTime()
        try {
            return block()
        } finally {
            val ms = (System.nanoTime() - t0) / 1_000_000
            val key = "$pluginId#$hook"
            stats.computeIfAbsent(key) { LongAdder() }.add(ms)
            counts.computeIfAbsent(key) { LongAdder() }.increment()
            if (ms > SLOW_THRESHOLD_MS) {
                log.w("[慢] $key 耗时 ${ms}ms(建议 < ${SLOW_THRESHOLD_MS}ms)")
            }
        }
    }

    fun report(): List<String> = stats.entries
        .sortedByDescending { it.value.sum() }
        .map { (k, v) -> "$k  累计 ${v.sum()}ms / ${counts[k]?.sum()} 次" }
}

这三十行会告诉你三件你现在不知道的事:哪个插件在拖慢你的应用、慢在哪个钩子上、以及它是「偶尔很慢」还是「一直有点慢」。

而且这份数据是你未来说服自己(或团队)「值得投资隔离」时最有力的证据 —— 比任何架构论证都有说服力。

◆ 一个决策问句

问自己一句:「如果我最受欢迎的那个插件的作者,某天不小心提交了一个死循环,会发生什么?」

如果答案是「几万个用户的应用全部卡死,他们会去应用商店给打一星,而我除了紧急下架那个插件之外什么也做不了」—— 那你已经有了投资隔离的商业理由,不需要再论证架构上的优雅。

如果答案是「我们只有五个插件,全是自己人写的,出事了改一下就好」—— 那就别做隔离,把力气花在第 ①②④ 条上,性价比高得多。

这一章的一句话

你不能用一个被占着的线程去检查那个占着它的东西 —— 所以「给插件加超时」在同进程里是幻觉;能掐死插件的前提是它跑在你控制的执行单元里,而这是必须在第一天做的架构决定。

下一章把三种隔离手段并排跑一遍:Proxy 受限对象、Worker、以及真正的运行时边界。同一段坏代码,在三种环境里分别落到什么下场。