崩溃、卡死与超时
上一章的插件想偷东西。这一章的插件什么坏心思都没有 —— 它只是写了个死循环,或者不小心在主线程上解析了一个 50MB 的 JSON。结果比偷东西更常见,也更难防。这一章有一个会真的卡住这个页面的按钮,请务必点一下:那 0.7 秒里,你就是那个用户。
三种「插件把事情搞砸」的方式
它们的可防性差别巨大,先分清:
| 方式 | 表现 | 宿主能做什么 | 难度 |
|---|---|---|---|
| 抛异常 | 调用插件时炸了 | 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 Worker | worker.terminate() | 干净 —— 立即销毁整个上下文 | 全异步、数据要序列化 |
| 独立进程 | kill / child.kill() | 干净 —— 操作系统保证 | IPC、启动开销、内存占用 |
| WASM | 燃料计数 / epoch 中断 | 干净,而且能精确到指令数 | 要用支持这个的运行时 |
| 独立 JS 引擎实例 | QuickJS 的中断回调 | 干净 | 要自己嵌运行时 |
共同点:它们都在你的执行流之外,所以你的「掐死」指令有机会被执行。
WASM 那一行值得注意:一些 WASM 运行时(比如 Wasmtime)支持「燃料」机制 —— 每执行一定数量的指令就消耗燃料,燃料耗尽就中断。这是唯一一种能精确限制「插件能算多久」的机制,而且不需要另起线程。如果你的插件系统需要跑真正不可信的计算,这是个很强的理由去考虑 WASM。
各家的真实处境
| 系统 | 插件死循环时 | 宿主能做什么 |
|---|---|---|
| Obsidian | 整个应用冻结 | 什么都做不了。用户只能强杀进程 |
| Mihon | 如果在主线程 → ANR;在 IO 线程 → 那个源卡住 | 靠约定让扩展在协程/IO 线程里干活,宿主 UI 通常还活着 |
| IntelliJ | IDE 卡住(如果在 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、以及真正的运行时边界。同一段坏代码,在三种环境里分别落到什么下场。