JetBrains IDE插件开发:如何避免后台线程中的不可取消读取操作导致UI冻结
在 JetBrains IDE 中,UI 卡顿通常被归咎于“事件调度线程(EDT)上的繁重工作”,但我们最近的调查显示,插件中存在另一个常见的罪魁祸首:在后台线程中运行的长时间、不可取消的读取操作(Read Action)。
我们通过自动异常报告系统收到了大量卡顿报告,其中许多报告显示,插件中存在这一单一的错误模式。让我们深入探讨由不可取消代码引发的这一问题,并找出解决方案。
一个真实案例
让我们看看来自 Package Checker 插件卡顿的堆栈跟踪。请注意后台线程 "DefaultDispatcher-worker-27" 是如何执行 ReadAction.compute 的:
"AWT-EventQueue-0" 请求写锁(PsiManagerImpl.dropPsiCaches -> ApplicationImpl.runWriteAction)。
"DefaultDispatcher-worker-27" 持有读锁,且不对写操作尝试做出响应;屏幕上没有进度指示器,因此用户无法取消。
让我们看看导致此卡顿的代码(已简化):
尽管此代码不在 事件调度线程 (EDT) 上运行,但不可取消的读取操作会阻塞写操作,从而导致 UI 卡顿,直到操作完成。这里的“不可取消”意味着读取操作块必须完整执行,无法被写操作或用户中断。
核心问题
请注意,以下 API 默认是不可取消的:
ReadAction.compute { ... }Application.runReadAction { ... }runReadAction { ... }
当此类读取操作在后台线程中长时间运行时,它会阻塞写操作(PSI 变更、工作区模型更新、编辑器更改)。由于许多写操作是由 UI 线程发起的,因此即使工作本身是在“后台”进行的,结果也会导致 UI 卡顿。
后台线程持有读锁时间过长会阻碍平台运行。
为什么这很危险?
- 后台并不意味着安全:读锁会影响整个平台。
- 长时间的读取会使写操作处于“饥饿”状态。
- UI 响应性需要写操作。
- 平台无法取消这些读取操作。
其底层工作原理是:IntelliJ 平台使用读写锁,允许多个读取操作并发运行,但写操作需要独占访问权。当请求写操作时,它必须等待所有活动的读取操作完成才能继续。不可取消的读取操作会一直持有锁直到结束,平台无法中断它。如果该读取操作耗时数秒,则在此期间,所有挂起的写操作和 UI 线程都将被阻塞。
简而言之,长时间的读取操作可能会导致一切卡死。
你应该怎么做?
在后台线程执行长时间工作时,避免使用 ReadAction.compute(及类似 API)。
仅在以下情况使用:
- 读取操作非常简短,或者
- 它在模态、可取消的进度条下运行。
推荐方案:
- 使用可取消的读取操作:对于协程 API,使用
readAction/smartReadAction{}、ReadAction.nonBlocking{ ... }.submit();对于非协程的 Java 代码,使用ReadAction.nonBlocking{ ... }.executeSynchronously()。 - 对于阻塞式的非协程代码,将工作拆分为小且可预测的块。在
ProgressManager.run(Task.Backgroundable){ ... }下运行它们,并配合异步进度和简短的读取操作。 - 定期使用
ProgressManager.checkCanceled()检查取消请求。 - 在高级用例中(如内嵌提示或高亮显示),使用
ReadAction.computeCancellable { ... },它仅尝试一次,不会在被写操作中断后重启。
如果你的代码涉及 PSI、项目模型或索引且运行时间较长,它必须是可取消的,否则最终会导致 UI 卡顿。遵循此规则是插件开发者保持 JetBrains IDE 快速且响应灵敏的最有效方法之一。
最后,代码不应在读写操作期间进行网络调用,原因相同:此类调用无法轻易取消,因为它们不会通过 ProgressManager 检查取消状态。网络延迟是不可预测的,且这些调用不参与协作式取消机制。
如何从后台处理中以可取消的方式访问模型
请注意,readAction 和 ReadAction.nonBlocking 都适用于幂等(Idempotent)计算,这些计算可以安全重试(当 WriteAction 挂起时它们会取消并重启!)。幂等意味着计算多次运行产生的结果相同。这是必需的,因为可取消的读取操作可能会在有写操作挂起时被中断并重启。
1. 在挂起(suspend)上下文中使用 协程 Read Action API
2. 使用 Java 和 ReadAction.nonBlocking API
关于 API 弃用的说明: 从 IDE 的 2026.1 版本开始,我们将弃用 runReadAction 和 ReadAction.compute,转而推荐使用更明确的 runReadActionBlocking 和 ReadAction.computeBlocking。与其直接替换用法,不如考虑将后台处理改为使用 ReadAction.nonBlocking(非 suspend 上下文)或 readAction(suspend 上下文)。
如何分析卡顿
你可以轻松利用 IDE 内置的 Analyze Stacktrace or Thread Dump 操作(通过 Search Everywhere,快捷键 Shift-Shift)来分析线程转储。你只需要完整的转储文本。
例如,此处的原因被清晰地检测为:
Long read action in com.intellij.packageChecker.javascript.NpmProjectDependenciesModel.declaredDependencies$lambda$15$lambda$14
我们强烈建议重构插件中的此类代码路径以修复 UI 卡顿。考虑到报告的数量和对客户的影响,这并非理论问题,而是切实影响用户的问题。这些改进将显著提升 JetBrains IDE 的感知性能。
欢迎参加我们于 3 月 19 日 UTC 下午 3:00 举办的直播活动,我将与 Patrick Scheibe 共同深入探讨如何消除 JetBrains IDE 插件中的 UI 卡顿。我们将共同探讨如何构建可取消、防卡顿的插件代码,并与专家进行实时问答。