[threading] Add kotlin notebook explaining cancellation model

GitOrigin-RevId: 66f36f5796861458c227fedc515f15e00e9a35e3
This commit is contained in:
Konstantin Nisht
2025-07-14 10:50:50 +02:00
committed by intellij-monorepo-bot
parent e55d467f2e
commit 263d7190cf

View File

@@ -0,0 +1,465 @@
{
"cells": [
{
"metadata": {},
"cell_type": "markdown",
"source": "# Cancellation Model"
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-08-05T11:12:30.531120Z",
"start_time": "2025-08-05T11:12:30.075189Z"
}
},
"cell_type": "code",
"source": [
"%use intellij-platform\n",
"import com.intellij.platform.ide.progress.ModalTaskOwner\n",
"import com.intellij.platform.ide.progress.runWithModalProgressBlocking\n",
"import com.intellij.util.application\n",
"import kotlinx.coroutines.delay\n",
"import com.intellij.openapi.application.EDT\n",
"import com.intellij.openapi.application.runReadAction\n",
"import com.intellij.openapi.application.runWriteAction\n",
"import kotlinx.coroutines.Dispatchers\n",
"import kotlinx.coroutines.launch\n",
"import kotlinx.coroutines.runBlocking\n",
"import kotlin.time.measureTime\n",
"import kotlinx.coroutines.Job\n",
"import kotlinx.coroutines.awaitCancellation\n",
"import kotlinx.coroutines.cancelAndJoin\n",
"import kotlinx.coroutines.job\n",
"import com.intellij.openapi.progress.ProcessCanceledException\n",
"import com.intellij.openapi.progress.ProgressManager\n",
"import com.intellij.openapi.vfs.VfsUtilCore\n",
"import com.intellij.openapi.vfs.VirtualFile\n",
"import com.intellij.openapi.vfs.VirtualFileManager\n",
"import com.intellij.openapi.vfs.VirtualFileVisitor\n",
"import com.intellij.openapi.progress.runBlockingCancellable\n",
"import kotlinx.coroutines.TimeoutCancellationException\n",
"import kotlinx.coroutines.withTimeout\n",
"import com.intellij.openapi.progress.EmptyProgressIndicator\n",
"import kotlinx.coroutines.GlobalScope\n",
"\n",
"\n",
"\n"
],
"outputs": [
{
"data": {
"text/plain": [
"IntelliJ Platform integration is loaded"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 1
},
{
"metadata": {},
"cell_type": "markdown",
"source": "In this notebook, we will descibe the Cancellation Model of IntelliJ Platform alonside some examples of how to use it."
},
{
"metadata": {},
"cell_type": "markdown",
"source": "### Motivation"
},
{
"metadata": {},
"cell_type": "markdown",
"source": "First of all, let's figure out why cancellation is important. An intuitive reason can be that some processes are long, and they need to be canceled if the user gets impatient:"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"application.invokeAndWait {\n",
" runWithModalProgressBlocking(ModalTaskOwner.guess(), \"A long process which is not cancellable for some time\") {\n",
" Thread.sleep(1000) // this is not cancellable\n",
" delay(5.seconds) // but this can be canceled\n",
" }\n",
"}"
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"There is also another reason, which is more important. The threading model in IntelliJ Platform is built around a read/write lock. Over time, the locking API moved towards transactional semantics: write operations are meant to invalidate the results of read operations, so read operations try to terminate quickly if someone wants to initiate a write. To promptly react to this termination request, the code needs to check cancellation frequently enough.\n",
"\n",
"**The takeaway here is that almost every area in the platform needs to be prepared to get canceled**"
]
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"runBlocking(Dispatchers.Default) {\n",
" launch {\n",
" runReadAction {\n",
" DISPLAY(\"Read action starting\")\n",
" Thread.sleep(1000) // some long non-cancellable operation\n",
" DISPLAY(\"Read action ending\")\n",
" }\n",
" }\n",
" delay(100)\n",
" launch(Dispatchers.EDT) {\n",
" runWriteAction { // you may experience a UI freeze because this write action cannot start\n",
" DISPLAY(\"Write action is executing\")\n",
" }\n",
" }\n",
"}"
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": "### Cancellation in Coroutines"
},
{
"metadata": {},
"cell_type": "markdown",
"source": "The Platform's cancellation model is inspired by Structured Concurrency of Kotlin Coroutines. There, they have a class `Job` which represents a descriptor of a computation. `Job`s can be canceled and organized in tree-like structures:"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"runBlocking(Dispatchers.Default) {\n",
" lateinit var innerJob1: Job\n",
" lateinit var innerJob2: Job\n",
" val enclosingJob = launch {\n",
" val parentJob = coroutineContext.job\n",
" launch {\n",
" innerJob1 = coroutineContext.job\n",
" DISPLAY(\"Current job has a parent: ${innerJob1.parent}, which is ${parentJob}\")\n",
" awaitCancellation()\n",
" }\n",
" launch {\n",
" innerJob2 = coroutineContext.job\n",
" DISPLAY(\"Current job also has a parent: ${innerJob2.parent} which is ${parentJob}\")\n",
" awaitCancellation()\n",
" }\n",
" }\n",
" delay(500)\n",
" DISPLAY(\"Now we can cancel the enclosing job: ${enclosingJob}, and it will cancel all its children\")\n",
" enclosingJob.cancelAndJoin()\n",
" DISPLAY(\"Parent job is canceled: ${enclosingJob}\")\n",
" DISPLAY(\"Inner job 1 is canceled: ${innerJob1}\")\n",
" DISPLAY(\"Inner job 2 is canceled: ${innerJob2}\")\n",
"}"
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": "## Cancellation in IntelliJ Platform"
},
{
"metadata": {},
"cell_type": "markdown",
"source": "There are two cancellation models in IntelliJ Platform: `Job`-based and `ProgressIndicator`-based. The latter is considered legacy, as it suffers from architectural flaws and offers poor integration with Kotlin Coroutines. In the following text, we will focus on the `Job`-based cancellation."
},
{
"metadata": {},
"cell_type": "markdown",
"source": "Cancellation in the Platform is checked with the globally available function `ProgressManager.checkCanceled()`. If the Platform decides that the currently executing code should be aborted, then `ProgressManager.checkCanceled()` throws an instance of `ProcessCanceledException`. `ProcessCanceledException` is an inheritor of `CancellationException`, so it would also cancel the enclosing coroutines."
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": [
"runBlocking(Dispatchers.Default) {\n",
" val job = launch {\n",
" try {\n",
" while (true) {\n",
" ProgressManager.checkCanceled()\n",
" }\n",
" }\n",
" catch (e: ProcessCanceledException) {\n",
" DISPLAY(\"Caught a ProcessCanceledException: ${e}\")\n",
" throw e\n",
" }\n",
" }\n",
" delay(100)\n",
" job.cancel()\n",
"}"
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": "The majority of the Platform functions already contain `checkCanceled()` in necessary places. Every time you pass control back to the Platform, it checks in hot places cancellation."
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-07-10T13:20:28.568670Z",
"start_time": "2025-07-10T13:20:27.682946Z"
}
},
"cell_type": "code",
"source": [
"runBlocking(Dispatchers.Default) {\n",
" val job = launch {\n",
" while (true) {\n",
" VfsUtilCore.visitChildrenRecursively(VirtualFileManager.getInstance().findFileByNioPath(notebook.workingDir)!!,\n",
" object : VirtualFileVisitor<Any>() {\n",
" override fun visitFile(file: VirtualFile): Boolean {\n",
" return true\n",
" }\n",
" })\n",
" }\n",
" }\n",
" delay(100)\n",
" job.cancel()\n",
"}"
],
"outputs": [],
"execution_count": 11
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"As you could notice in the examples above, `Job`-based cancellation is seamlessly integrated with coroutines. This is because it is built on top of Context Propagation (this particular application is called Cancellation Propagation), which is designed to be coroutine-friendly. The `job` from Coroutines is automatically installed to thread local, which is later inspected by the Platform.\n",
"The intended way of transitioning back to coroutines in the Platform is the function `runBlockingCancellable`. This function is different from `runBlocking` in a way that it also looks into the thread local with `Job` (which is located in `currentThreadContext()`), and runs with it."
]
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-07-10T13:59:48.633985Z",
"start_time": "2025-07-10T13:59:43.692218Z"
}
},
"cell_type": "code",
"source": [
"// here we see that `runBlocking` is not terminated on external cancellation\n",
"runBlocking(Dispatchers.Default) {\n",
" val regularBlockingJob = launch {\n",
" runBlocking {\n",
" try {\n",
" withTimeout(1.seconds) {\n",
" while (true) {\n",
" ProgressManager.checkCanceled()\n",
" }\n",
" }\n",
" }\n",
" catch (e: ProcessCanceledException) {\n",
" DISPLAY(\"Caught a ProcessCanceledException: ${e}\")\n",
" }\n",
" catch (e: TimeoutCancellationException) {\n",
" DISPLAY(\"Caught a TimeoutCancellationException: ${e}\")\n",
" }\n",
" }\n",
" }\n",
" delay(100)\n",
" regularBlockingJob.cancel()\n",
"}"
],
"outputs": [
{
"ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplInterruptedException",
"evalue": "The execution was interrupted",
"output_type": "error",
"traceback": [
"The execution was interrupted"
]
}
],
"execution_count": 4
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-07-10T14:02:23.449300Z",
"start_time": "2025-07-10T14:02:23.031036Z"
}
},
"cell_type": "code",
"source": [
"import kotlin.coroutines.cancellation.CancellationException\n",
"\n",
"// but `runBlockingCancellable` actually terminates\n",
"runBlocking(Dispatchers.Default) {\n",
" val regularBlockingJob = launch {\n",
" runBlockingCancellable {\n",
" try {\n",
" withTimeout(1.seconds) {\n",
" while (true) {\n",
" ProgressManager.checkCanceled()\n",
" }\n",
" }\n",
" }\n",
" catch (e: CancellationException) {\n",
" DISPLAY(\"Caught a Cancellation: ${e}\")\n",
" }\n",
" catch (e: TimeoutCancellationException) {\n",
" DISPLAY(\"Caught a TimeoutCancellationException: ${e}\")\n",
" }\n",
" }\n",
" }\n",
" delay(100)\n",
" regularBlockingJob.cancel()\n",
"}"
],
"outputs": [
{
"data": {
"text/plain": [
"Caught a ProcessCanceledException: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@1db808a0"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 6
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"Basically, there are three cancellation contexts in IntelliJ Platform: coroutines, job-based cancellation and progress-indicator-based cancellation. Some of these contexts require explicit transition. A missing transition can usually result in an accidentally non-cancellable region. Historically, job-based cancellation was called \"blocking context\", this explains some naming choices.\n",
"\n",
"Here is a table that explains how to perform the transition from one cancellation context to another.\n",
"The vertical left column means the source context, and the horizontal top row means the destination context.\n",
"For example, to transition from coroutines to indicators, one should use `coroutineToIndicator`.\n",
"\n",
"| Transition table | Coroutines | Jobs | Progress indicators |\n",
"|:---------------------:|:------------------------:|:-----------:|:----------------------------:|\n",
"| Coroutines | - | automatic | `coroutineToIndicator` |\n",
"| Jobs | `runBlockingCancellable` | - | `blockingContextToIndicator` |\n",
"| Progress indicators | `runBlockingCancellable` | unsupported | - |\n",
"\n",
"`runBlockingCancellable` was discussed above. `coroutineToIndicator` is a helper function that transforms the `Job` taken from `coroutineContext` to a `ProgressIndicator` and runs a computation with this indicator. `blockingContextToIndicator` does a similar thing, but for the `Job` taken from `currentThreadContext()`."
]
},
{
"metadata": {},
"cell_type": "markdown",
"source": "## Progress Indicators"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"Progress indicators are a legacy way of managing cancellation in the Platform. The core idea is that we have a descriptor of computation (an instance of `ProgressIndicator`) which can be installed as a thread-local or passed manually to the necessary computations.\n",
"\n",
"The classical way to execute something under progress indicators is with `ProgressManager.executeProcessUnderProgress`. This function takes a `ProgressIndicator` and a computation, and executes it under the indicator."
]
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-07-29T08:43:17.001164Z",
"start_time": "2025-07-29T08:43:15.887843Z"
}
},
"cell_type": "code",
"source": [
"val indicator = EmptyProgressIndicator()\n",
"GlobalScope.launch {\n",
" delay(1.seconds)\n",
" indicator.cancel()\n",
"}\n",
"ProgressManager.getInstance().runProcess(\n",
" {\n",
" val currentTime = measureTime {\n",
" try {\n",
" while (true) {\n",
" ProgressManager.checkCanceled()\n",
" }\n",
" }\n",
" catch (e: ProcessCanceledException) {\n",
" DISPLAY(\"Canceled!\")\n",
" }\n",
" }\n",
" DISPLAY(\"Process took $currentTime until cancellation\")\n",
" }, indicator)"
],
"outputs": [
{
"data": {
"text/plain": [
"Canceled!"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
},
{
"data": {
"text/plain": [
"Process took 1.001906291s until cancellation"
]
},
"metadata": {},
"output_type": "display_data",
"jetTransient": {
"display_id": null
}
}
],
"execution_count": 5
},
{
"metadata": {},
"cell_type": "markdown",
"source": "There are multiple methods in `ProgressManager` that allow running computations with indicators, with possibility to configure synchronous or asynchronous execution."
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"There are several problems with `ProgressIndicator`:\n",
"1. This class is not responsible _just_ for cancellation, but also for progress reporting. It also contains a lot of unrelated methods which are not needed in such a supposedly lightweight entity.\n",
"2. It plays badly with coroutines and structured concurrency.\n",
"One of the principles that we strive to achieve in IntelliJ Platform is to have a hierarchy of computations so that unnecessary computations can promptly free the resources they occupy. Coroutines do great job with it -- all computations are organized in tree-like structures, and they can be canceled or tracked. Progress indicators were designed for `invokeLater`-based computations, and an instance of progress indicator may easily outlive the computation where it was created."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Kotlin",
"language": "kotlin",
"name": "kotlin"
},
"language_info": {
"name": "kotlin",
"version": "2.2.20-dev-7701",
"mimetype": "text/x-kotlin",
"file_extension": ".kt",
"pygments_lexer": "kotlin",
"codemirror_mode": "text/x-kotlin",
"nbconvert_exporter": ""
},
"ktnbPluginMetadata": {
"sessionRunMode": "IDE_PROCESS"
}
},
"nbformat": 4,
"nbformat_minor": 0
}