Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

JetBrains IDE插件开发:如何避免后台线程中的不可取消读取操作导致UI冻结

#jetbrains#ide插件开发#性能优化#ui冻结#多线程

在 JetBrains IDE 中,UI 卡顿通常被归咎于“事件调度线程(EDT)上的繁重工作”,但我们最近的调查显示,插件中存在另一个常见的罪魁祸首:在后台线程中运行的长时间、不可取消的读取操作(Read Action)

我们通过自动异常报告系统收到了大量卡顿报告,其中许多报告显示,插件中存在这一单一的错误模式。让我们深入探讨由不可取消代码引发的这一问题,并找出解决方案。

一个真实案例

让我们看看来自 Package Checker 插件卡顿的堆栈跟踪。请注意后台线程 "DefaultDispatcher-worker-27" 是如何执行 ReadAction.compute 的:

"AWT-EventQueue-0" prio=0 tid=0x0 nid=0x0 waiting on condition
     java.lang.Thread.State: TIMED_WAITING
 on com.intellij.openapi.progress.util.EternalEventStealer@1356e599
    at java.base@21.0.8/java.lang.Object.wait0(Native Method)
    at java.base@21.0.8/java.lang.Object.wait(Object.java:366)
    at com.intellij.openapi.progress.util.EternalEventStealer.dispatchAllEventsForTimeout(SuvorovProgress.kt:261)
    at com.intellij.openapi.progress.util.SuvorovProgress.processInvocationEventsWithoutDialog(SuvorovProgress.kt:125)
    at com.intellij.openapi.progress.util.SuvorovProgress.dispatchEventsUntilComputationCompletes(SuvorovProgress.kt:73)
    at com.intellij.openapi.application.impl.ApplicationImpl.lambda$postInit$14(ApplicationImpl.java:1434)
...
    at com.intellij.openapi.application.impl.ApplicationImpl.runWriteAction(ApplicationImpl.java:1106)
    at com.intellij.psi.impl.PsiManagerImpl.dropPsiCaches(PsiManagerImpl.java:108)
...

"DefaultDispatcher-worker-27" prio=0 tid=0x0 nid=0x0 runnable
     java.lang.Thread.State: RUNNABLE
 (in native)
    at java.base@21.0.8/java.io.WinNTFileSystem.getBooleanAttributes0(Native Method)
...
    at com.intellij.packageChecker.javascript.NpmProjectDependenciesModel.declaredDependencies(NpmProjectDependenciesModel.kt:164)
    at com.intellij.openapi.application.ReadAction.compute(ReadAction.java:66)
    at com.intellij.openapi.application.impl.ApplicationImpl.runReadAction(ApplicationImpl.java:1043)
...

"AWT-EventQueue-0" 请求写锁(PsiManagerImpl.dropPsiCaches -> ApplicationImpl.runWriteAction)。 "DefaultDispatcher-worker-27" 持有读锁,且不对写操作尝试做出响应;屏幕上没有进度指示器,因此用户无法取消。

让我们看看导致此卡顿的代码(已简化):

$ kotlin
fun declaredDependencies(project: ProjectSnapshot): List<Package> {
    return project.modules.asSequence()
      .flatMap { module ->
          ReadAction.compute {
            declaredDependencies(module)
          }
        }
      }
      .toList()
  }

尽管此代码不在 事件调度线程 (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 检查取消状态。网络延迟是不可预测的,且这些调用不参与协作式取消机制。

如何从后台处理中以可取消的方式访问模型

请注意,readActionReadAction.nonBlocking 都适用于幂等(Idempotent)计算,这些计算可以安全重试(当 WriteAction 挂起时它们会取消并重启!)。幂等意味着计算多次运行产生的结果相同。这是必需的,因为可取消的读取操作可能会在有写操作挂起时被中断并重启。

1. 在挂起(suspend)上下文中使用 协程 Read Action API

$ kotlin
suspend fun processOnBackground(virtualFile: VirtualFile, project: Project) {
    val methodNames = readAction {
      if (!virtualFile.isValid()) return@readAction null // 先在读取操作中进行有效性检查!

      val psiFile = PsiManager.getInstance(project).findFile(virtualFile)
      if (psiFile == null) return@readAction null

      // 执行耗时计算
      return@readAction PsiTreeUtil.findChildrenOfType(psiFile, PsiMethod::class.java)
        .map { it.name }
    }
    // 在没有锁的情况下继续后台处理
}

2. 使用 Java 和 ReadAction.nonBlocking API

$ java
@RequiresBackgroundThread
public void processOnBackground(@NotNull VirtualFile virtualFile, @NotNull Project project) {
    var methodNames = ReadAction.nonBlocking(() -> {
        if (!virtualFile.isValid()) return null; // 先进行有效性检查!

        PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile);
        if (psiFile == null) return null;

        return ContainerUtil.map(PsiTreeUtil.findChildrenOfType(psiFile, PsiMethod.class), PsiMethod::getName);
      })
      .expireWith(project)
      .executeSynchronously(); // 回调式代码请使用 submit()

    // 在没有锁的情况下继续后台处理
}

关于 API 弃用的说明: 从 IDE 的 2026.1 版本开始,我们将弃用 runReadActionReadAction.compute,转而推荐使用更明确的 runReadActionBlockingReadAction.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 卡顿。我们将共同探讨如何构建可取消、防卡顿的插件代码,并与专家进行实时问答。