跳转至

协程、读动作与后台任务进阶

IntelliJ Platform 的线程模型正在从传统线程、ProgressIndicatorReadAction.nonBlocking() 逐步迁移到 Kotlin coroutines。官方文档对 2024.1+、2024.2+ 的建议已经很明确:Kotlin 新代码优先使用平台协程 API;Java 或旧代码仍可使用 Progress API 和传统 Read/Write Action,但要按新模型理解取消、调度和锁。

本章是在 线程模型 基础上的进阶补充,重点覆盖:

  • Coroutine scopes 的生命周期。
  • 从 service 和 action 启动协程。
  • Dispatchers.DefaultDispatchers.IODispatchers.EDT 的选择。
  • Coroutine read actions 与 NBRA 的迁移关系。
  • Progress API、execution contexts、取消和进度上报。
  • UI freeze 和泄漏排查。

版本判断

目标平台 推荐写法
2024.2+ Kotlin 新代码 Coroutine Execution Context、service scope、readAction {}withBackgroundProgress()
2024.1 Kotlin 新代码 Suspending Context、coroutine read actions、必要时桥接 blocking context
旧 Kotlin/Java 代码 ReadAction.nonBlocking()Task.BackgroundableProgressManager
Java 插件 继续用传统 API,但按取消、modality、read/write lock 规则写

“Progress Indicator obsolete since 2024.1” 不等于不能用。它仍被大量平台代码使用,也仍适合 Java 插件。对 Kotlin 新代码,优先协程是为了更好的结构化并发、取消传播和 CPU 利用率。

平台协程 Scope

Kotlin 协程遵循 structured concurrency:协程必须运行在某个 CoroutineScope 中,父 scope 取消会取消子协程,父 scope 会等待子协程结束。IntelliJ Platform 为插件提供了与应用、项目和插件生命周期绑定的 scope。

Scope 生命周期
Application IDE 应用生命周期,应用关闭时取消
Project 项目生命周期,项目关闭时取消
Plugin 插件生命周期,插件卸载时取消
Application x Plugin 应用和插件生命周期交集
Project x Plugin 项目和插件生命周期交集
Application Service scope 应用级 service 实例生命周期
Project Service scope 项目级 service 实例生命周期

插件代码通常不直接拿 Application/Project scope,而是让 service 通过构造函数注入自己的 scope。

Service Scope 是默认安全入口

推荐模式:

@Service
class ExampleApplicationService(
    private val cs: CoroutineScope,
) {
    fun scheduleRefresh() {
        cs.launch(CoroutineName("Example refresh")) {
            refreshCaches()
        }
    }

    private suspend fun refreshCaches() {
        // background work
    }
}

项目级服务:

@Service(Service.Level.PROJECT)
class ExampleProjectService(
    private val project: Project,
    private val cs: CoroutineScope,
) {
    fun analyzeLater() {
        cs.launch(CoroutineName("Example project analysis")) {
            val model = readAction {
                collectProjectModel(project)
            }
            withContext(Dispatchers.EDT) {
                showResult(model)
            }
        }
    }
}

优点:

  • scope 随 service 生命周期取消。
  • 项目关闭或插件卸载时不会泄漏协程。
  • 每个 service 实例有独立 scope。
  • scope 默认带 Dispatchers.DefaultCoroutineName(serviceClass)

不要直接用 Application/Project Scope

官方明确不建议使用 Application.getCoroutineScope()Project.getCoroutineScope(),这些 API 已废弃并会移除。问题在于它们的生命周期太大,容易泄漏 project 或 plugin class。

错误示例:

application.coroutineScope.launch {
    project.getService(ExampleProjectService::class.java)
}

项目关闭时 project scope 会取消,但 application scope 仍活着,协程引用了 project service,就可能形成项目泄漏。

另一个错误:

project.coroutineScope.launch {
    project.getService(MyPluginService::class.java)
}

插件卸载时 plugin scope 取消,但 project scope 仍活着,协程可能保留插件类。

原则:插件协程挂在插件提供的 service scope 上,而不是挂在全局 application/project scope 上。

Action 中启动协程

Action 触发的一次性行为可以使用 currentThreadCoroutineScope()

internal class ExampleAction : AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        val project = e.project ?: return
        val file = e.getData(LangDataKeys.PSI_FILE) ?: return

        currentThreadCoroutineScope().launch {
            val summary = readAction {
                summarize(file)
            }

            withContext(Dispatchers.EDT) {
                Notifications.Bus.notify(
                    Notification(
                        "Example",
                        "Analysis complete",
                        summary,
                        NotificationType.INFORMATION,
                    ),
                    project,
                )
            }
        }
    }
}

适用场景:

  • 用户触发 Action 后执行短生命周期任务。
  • 任务应该由 Action System 控制取消。
  • 不希望把一次性任务挂到比 action 更长的 service scope。

如果 action 只是调度一个长期后台状态机,仍然应调用 service 方法,由 service scope 管理长期任务。

避免 runBlockingCancellable

runBlockingCancellable() 是从阻塞代码桥接到 suspending code 的工具,但官方不推荐作为常规入口。优先顺序:

  1. 重构为 suspending API。
  2. 从 service scope 或 action scope 启动协程。
  3. 只有在必须从旧同步 API 调用 suspending API 时,才局部使用 runBlockingCancellable()

不要在 EDT 上用它包耗时任务;这会把协程优势变回阻塞等待。

Dispatcher 选择

平台协程主要使用三个 dispatcher:

Dispatcher 用途
Dispatchers.Default CPU-bound 计算,默认限制并行度,避免过度抢 CPU
Dispatchers.IO 文件、网络、外部进程等 IO 操作
Dispatchers.EDT Swing EDT 上执行 UI 操作,带平台 modality 语义

实践:

suspend fun loadAndRender(path: Path) {
    val parsed = withContext(Dispatchers.IO) {
        Files.readString(path)
    }.let { text ->
        parse(text)
    }

    withContext(Dispatchers.EDT) {
        updateUi(parsed)
    }
}

Dispatchers.IO 应尽量贴近真正 IO 调用,不要把解析、索引读取、UI 更新都放进去。

Dispatchers.MainDispatchers.EDT

IntelliJ Platform 安装了 EDT dispatcher 作为 Dispatchers.Main,但插件代码仍应优先用 Dispatchers.EDT

  • Dispatchers.EDT 是平台上下文里的明确 UI dispatcher。
  • Dispatchers.Main 更适合平台无关库代码。
  • Dispatchers.Main 不应发起 read/write action,避免无意中冻结 UI。

写插件时不确定就选 Dispatchers.EDT

Coroutine Read Actions

2024.1+ Kotlin 插件读取 PSI/VFS/项目模型时优先使用 suspending readAction {}

val names = readAction {
    psiFile.children.mapNotNull { it.name }
}

官方将 coroutine read actions 分为两类:

类型 API 写入到来时
Write Allowing Read Action, WARA readActionsmartReadActionconstrainedReadAction 取消当前尝试,稍后重试或向上传播取消
Write Blocking Read Action, WBRA readActionBlockingsmartReadActionBlockingconstrainedReadActionBlocking 阻塞写入直到 lambda 完成

默认不带 Blocking 的函数优先保证写入能及时执行。迁移旧代码时要特别注意:传统 Application.runReadAction() 是会阻塞写入的;协程里的 readAction {} 默认是 write-allowing。

从 NBRA 迁移到 WARA

旧写法:

ReadAction.nonBlocking<List<Result>> {
    collectResults(project)
}
    .inSmartMode(project)
    .expireWith(disposable)
    .finishOnUiThread(ModalityState.defaultModalityState()) { results ->
        render(results)
    }
    .submit(AppExecutorUtil.getAppExecutorService())

协程写法:

cs.launch {
    val results = smartReadAction {
        collectResults(project)
    }

    withContext(Dispatchers.EDT) {
        render(results)
    }
}

对应关系:

NBRA 概念 协程替代
submit(executor) dispatcher 决定执行位置
expireWith(disposable) 父 coroutine scope 取消
expireWhen(...) Flow.collectLatest()、显式取消或条件判断
finishOnUiThread() withContext(Dispatchers.EDT)
coalesceBy(...) Flow.distinctUntilChanged() + collectLatest()
wrapProgress(indicator) 协程取消和 progress context

如果需要“读完后立刻写,中间不能插入写动作”,使用 readAndWriteAction

readAndWriteAction {
    val data = computeDataInReadAction()
    writeAction {
        applyData(data)
    }
}

它类似 NBRA 的 finishOnUiThread 保证,但不强行绑定 EDT。

Read Action 中不能随意 suspend

readAction {} 的 lambda 应保持短小,不要在里面做长 IO 或主动 suspend:

readAction {
    withContext(Dispatchers.IO) {
        loadHugeFile()
    }
}

这种写法会在写动作到来时反复取消和重试,既慢又难以推理。正确拆分:

val data = withContext(Dispatchers.IO) {
    loadHugeFile()
}

val result = readAction {
    resolveAgainstPsi(data)
}

同理,不要把 PSI、VirtualFile、Module 等对象跨多个 read action 长期保存。跨边界保存时使用 pointer、URL、file path、FQN、SmartPsiElementPointer,并在新 read action 内重新解析和检查有效性。

取消检查

协程 read action 可能因为写动作到来而取消当前尝试。block 内部要定期检查取消:

val result = readAction {
    files.mapNotNull { file ->
        ProgressManager.checkCanceled()
        analyze(file)
    }
}

不要手动 throw ProcessCanceledException()。调用 ProgressManager.checkCanceled() 或平台提供的取消 API,让框架决定如何取消和重试。

在纯协程循环中:

coroutineContext.ensureActive()

在 2024.2+ Coroutine Execution Context 中,ProgressManager.checkCanceled() 也能按预期工作。旧 Suspending Context 下如果必须调用依赖 blocking context 的 API,需要理解 blockingContext() 的版本语义;2024.2+ 它基本是 no-op。

Background Processes

传统后台任务仍可用:

object : Task.Backgroundable(project, "Synchronizing Data", true) {
    override fun run(indicator: ProgressIndicator) {
        indicator.text = "Collecting files"
        files.forEachIndexed { index, file ->
            indicator.checkCanceled()
            indicator.fraction = index.toDouble() / files.size
            process(file)
        }
    }
}
    .setCancelText("Stop loading")
    .queue()

Progress API 提供:

  • modal、non-modal、invisible progress。
  • 用户取消。
  • 文本、详细文本、fraction 进度。
  • ProgressManager.checkCanceled() 深层取消。

不要隐藏用户可感知的长任务。官方提醒,用 invisible progress 隐藏耗时过程可能破坏 UX。

协程进度上报

2024.2+ 新 Kotlin 后台任务优先用 coroutine execution context。进度上报必须在 withBackgroundProgress()withModalProgress()runWithModalProgressBlocking() 这类上下文内进行:

cs.launch {
    withBackgroundProgress(project, "Synchronizing Data", cancellable = true) {
        reportRawProgress { reporter ->
            files.forEachIndexed { index, file ->
                ProgressManager.checkCanceled()
                reporter.text("Processing ${file.name}")
                reporter.fraction(index.toDouble() / files.size)
                process(file)
            }
        }
    }
}

没有 progress reporter 的上下文里调用 report*Progress() 通常没有效果。先建立 progress context,再上报。

选择 Progress API 还是协程

需求 推荐
Kotlin 新代码,目标 2024.2+ 协程 + withBackgroundProgress()
Kotlin 读取 PSI 并允许写入优先 readAction / smartReadAction
Java 插件 Task.Backgroundable / ProgressManager
旧 NBRA 逻辑迁移 Service scope + WARA + Flow
只需跑一个无进度短任务 协程 service scope 或 pooled thread,仍要可取消
需要用户可见进度和取消 progress context 或 Task.Backgroundable

如果一个任务超过几百毫秒并且用户能感知,就应该有进度、取消和清晰状态。

Modality 与 EDT

写入模型目前仍要在 EDT/write-safe context 中执行。传统代码应使用 Application.invokeLater(),因为它支持 ModalityState。不要用普通 SwingUtilities.invokeLater() 去修改 PSI/VFS/项目模型。

常见 modality:

ModalityState 用途
defaultModalityState() 大多数情况的默认选择
current() 当前 modal 栈不增长时执行
stateForComponent() 与某个 UI 组件所属 dialog 绑定
nonModal() 所有 modal dialog 关闭后执行
any() 仅限纯 UI 操作,不要修改模型

如果 EDT 操作要访问索引,使用 DumbService.smartInvokeLater(),让它等索引完成后再执行。

避免 UI Freeze

不要在 EDT 做:

  • 遍历大型 VFS。
  • 解析大量 PSI。
  • resolve references。
  • 查询 file-based index。
  • 等待网络、文件、外部进程。
  • 执行长 read action。

长 read action 要么拆小,要么使用 WARA/NBRA,让写动作优先。写动作范围也要尽量小:先在后台准备数据,再用短 write action 应用变更。

监听器也不能做重活。VFS 大批量事件可以用 AsyncFileListener 预处理;普通事件可用队列合并,再在后台/协程中处理。

Coroutine Dump

Help | Diagnostic Tools | Dump Threads 生成的 dump 会包含线程和协程。协程条目会显示:

  • coroutine name。
  • coroutine class 和 job state。
  • CREATEDSUSPENDEDRUNNING 等状态。
  • context,例如 Application/Project、dispatcher、modality state。
  • 父子协程缩进关系。

建议给长期协程加 CoroutineName

cs.launch(CoroutineName("Example indexing watcher")) {
    watchIndexing()
}

没有名字的协程很难在 dump 中定位,尤其是多个插件同时运行时。

迁移清单

  • Kotlin 新代码不再默认写 executeOnPooledThread()
  • Service 构造函数注入 CoroutineScope,从 service 方法启动长期任务。
  • Action 一次性任务用 currentThreadCoroutineScope()
  • 读取 PSI/VFS/项目模型用 readActionsmartReadAction
  • 旧 NBRA 的 finishOnUiThread() 改成 withContext(Dispatchers.EDT)
  • 读后写且不能插入写动作时用 readAndWriteAction
  • 不把 PSI/VFS 对象跨 suspend/read action 长期保存。
  • 循环里调用取消检查。
  • 不吞 ProcessCanceledException
  • 有用户可感知耗时就上报进度。
  • 诊断冻结时同时看 thread dump 和 coroutine dump。

与基础线程章节的分工

章节 主要回答
线程模型 EDT/BGT、读写动作、后台任务和 Action update thread 的基础规则
本章 2024+ 协程 scope、dispatcher、read action、progress context 和迁移策略

如果项目还支持较老 IDE,可以在基础线程模型上继续用传统 API;如果目标平台已经进入 2024.1+,新 Kotlin 代码应优先采用本章的协程模型。

参考来源