协程、读动作与后台任务进阶¶
IntelliJ Platform 的线程模型正在从传统线程、ProgressIndicator 和 ReadAction.nonBlocking() 逐步迁移到 Kotlin coroutines。官方文档对 2024.1+、2024.2+ 的建议已经很明确:Kotlin 新代码优先使用平台协程 API;Java 或旧代码仍可使用 Progress API 和传统 Read/Write Action,但要按新模型理解取消、调度和锁。
本章是在 线程模型 基础上的进阶补充,重点覆盖:
- Coroutine scopes 的生命周期。
- 从 service 和 action 启动协程。
Dispatchers.Default、Dispatchers.IO、Dispatchers.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.Backgroundable、ProgressManager |
| 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.Default和CoroutineName(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 的工具,但官方不推荐作为常规入口。优先顺序:
- 重构为 suspending API。
- 从 service scope 或 action scope 启动协程。
- 只有在必须从旧同步 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.Main 与 Dispatchers.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 | readAction、smartReadAction、constrainedReadAction |
取消当前尝试,稍后重试或向上传播取消 |
| Write Blocking Read Action, WBRA | readActionBlocking、smartReadActionBlocking、constrainedReadActionBlocking |
阻塞写入直到 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。
CREATED、SUSPENDED、RUNNING等状态。- context,例如 Application/Project、dispatcher、modality state。
- 父子协程缩进关系。
建议给长期协程加 CoroutineName:
cs.launch(CoroutineName("Example indexing watcher")) {
watchIndexing()
}
没有名字的协程很难在 dump 中定位,尤其是多个插件同时运行时。
迁移清单¶
- Kotlin 新代码不再默认写
executeOnPooledThread()。 - Service 构造函数注入
CoroutineScope,从 service 方法启动长期任务。 - Action 一次性任务用
currentThreadCoroutineScope()。 - 读取 PSI/VFS/项目模型用
readAction或smartReadAction。 - 旧 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 代码应优先采用本章的协程模型。