docs/notebooks/3-ReadWriteLock.ipynb
%use intellij-platform
import com.intellij.openapi.application.ApplicationManager
import com.intellij.util.application
import kotlinx.coroutines.runBlocking
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.application.runWriteAction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.application.writeAction
import com.intellij.openapi.progress.ProgressManager
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asExecutor
import java.util.concurrent.Callable
import com.intellij.openapi.application.readAction
import com.intellij.openapi.application.edtWriteAction
import com.intellij.openapi.application.backgroundWriteAction
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import kotlinx.coroutines.withContext
import com.intellij.openapi.application.readAndEdtWriteAction
import java.util.concurrent.atomic.AtomicInteger
import com.intellij.openapi.progress.ProcessCanceledException
IntelliJ Platform is a multithreaded application. It also contains a globally accessible set of mutable structures -- such as PSI or a snapshot of the Virtual File System. These structures are not inherently thread-safe, so the Platform must have a way of restricting concurrent access to them. Usually it is done with a mutual exclusion or persistent data structures. In IntelliJ Platform, the approach with a global lock was chosen.
The platform works on top of the Read-Write lock. This kind of lock is a specialization of a regular mutex which allow taking "read" or "write" permits of access to data. Multiple "read" states can coexist, but there can be only one "write" state at a time. This specialization ensures that the Platform can perform multiple read operations in parallel.
| Can coexist | READ | WRITE |
|---|---|---|
| READ | ✅ | ❌ |
| WRITE | ❌ | ❌ |
The simplest way to run something under read or write lock is to use the functions from Application:
application.runReadAction {
DISPLAY("Read access in read action: ${application.isReadAccessAllowed}")
DISPLAY("Write access in read action: ${application.isWriteAccessAllowed}")
}
// an alias to application.runReadAction
runReadAction { }
DISPLAY("-------------")
application.invokeLater {
application.runWriteAction {
DISPLAY("Read access in write action: ${application.isReadAccessAllowed}")
DISPLAY("Write access in write action: ${application.isWriteAccessAllowed}")
}
// an alias to application.runWriteAction
runWriteAction {}
}
These functions execute the actions synchronously, and they block the underlying thread until the acquisition of the lock is possible. Later we will see how to work with locks in an asynchronus way.
One interesting thing you can notice above is that runWriteAction is preceded with invokeLater. This is because historically the platform allowed write actions to run only on EDT (the Event Dispatch Thread).
try {
application.runWriteAction {}
error("Should not terminate successfully")
}
catch (e: IllegalStateException) {
println("${e.message}")
}
This is a crucial part of the IntelliJ Platform Threading Model: by default, write actions are allowed only on UI thread. This behavior has several consequences:
The second problem is considered significant. There is an ongoing effort on transferring write actions to a background thread.
As part of its stable API, the Platform provides only read and write actions, giving an impression that it works on top of Read/Write lock. In fact, the platform uses Read/Write/Write-Intent lock.
The actions executed under write-intent state are often called write-intent read actions, as they essentially provide read access.
Write-Intent is an additional state of the lock, which does not prevent parallel read access, while allowing atomic upgrade to write:
| Can coexist | READ | WRITE | WRITE-INTENT-READ |
|---|---|---|---|
| READ | ✅ | ❌ | ✅ |
| WRITE | ❌ | ❌ | ❌ |
| WRITE-INTENT-READ | ✅ | ❌ | ❌ |
application.runWriteIntentReadAction<Unit, Throwable> {
DISPLAY("Read access allowed: ${application.isReadAccessAllowed}")
DISPLAY("Write-Intent access allowed: ${application.isWriteIntentLockAcquired}")
DISPLAY("Write access allowed: ${application.isWriteAccessAllowed}")
}
Write-Intent state emerged during the Platform's initial attempts to run write actions on background.
Imagine that there is an execution inside a single EDT event. This event may execute some read operations, and then modify something in a short write action. When write actions are confined to a single thread, this is not a problem at all -- EDT is the only one who control writes. But if there can be some other write operations, then, to ensure semantical backward compatibility, the model inside this EDT event must be consistent -- the EDT event can execute several read and write actions sequentially, and it does not expect that the model can change between these reads and writes. The easiest way to achieve consistency is to take the write lock for each EDT event -- but that would harm scalability, as background read actions would not get a chance to proceed.
So in this case the platform uses write-intent lock -- each EDT event acquired the write-intent lock, and then they can run several read and write operations without worrying about accidental model changes. Write-Intent state prevents simultaneous write actions but allows background read actions.
DISPLAY("Write-Intent access allowed by default: ${application.isWriteIntentLockAcquired}")
application.invokeLater {
DISPLAY("Write-Intent access allowed in invokeLater: ${application.isWriteIntentLockAcquired}")
}
application.invokeAndWait {
DISPLAY("Write-Intent access allowed in invokeAndWait: ${application.isWriteIntentLockAcquired}")
}
Write-Intent action is not intended as a public API and eventually will be discouraged to use. They are allowed only on EDT.
However, sometimes there can be a pattern where you can gather some data in read action and then apply it in write action, with guarantees that the transition between read and write is atomic. For this case, the platform provides readAndWriteAction and NonBlockingReadAction.finishOnUiThread. More on this below.
Write-Intent Read action is internal part of the Platform API that helps to ensure consistency of model in UI events.
A significant part of locking API in the platform is driven by the intention to make the UI thread free and responsive. This results in several concepts that are important to understand for perceived performance optimization: pending write actions and non-blocking read actions.
A very frequent source of UI freezes is in the acquisition of lock:
runBlocking {
launch(Dispatchers.EDT) {
delay(100)
runWriteAction { } // a short write action
}
launch(Dispatchers.Default) {
runReadAction {
Thread.sleep(1000)
}
}
}
You can notice a UI freeze in your IDE, despite that the write action is almost instantaneous. That's because the EDT is frozen on the acquisition of the write lock. The write lock cannot be acquired because there it waits for read actions to terminate gracefully.
The Platform's policy is that Write Actions have a higher priority than read actions. One consequence of this is the state of pending write actions.
A write action is pending if it signalled that it wants to acquire a write lock, but not actually started executing an action. When a write action is pending, new read actions cannot start. Existing read actions continue to run. If a read action is non-blocking, then it gets canceled. When all read actions finish, then write action can start.
It is often not enough to postpone new read actions when write action is pending. We also need to quickly terminate existing read actions to ensure that a model change gets processed quickly and UI starts handling painting events again. For this purpose, the Platform has a concept of non-blocking read actions. The naming choice is a bit unfortunate here, as it is not related to blocking of a thread. Treat this name as a separate concept.
Non-blocking read actions have two important properties:
As a consequence, it is important that a non-blocking read action is idempotent: a runnable inside it can be executed multiple times until it manages to finish without interruptions.
The default way of running non-blocking read actions in Java and blocking Kotlin code is with the builder ReadAction.nonBlocking:
import com.intellij.util.io.await
import java.util.concurrent.atomic.AtomicInteger
runBlocking {
val counter = AtomicInteger()
val promise = ReadAction.nonBlocking(Callable {
DISPLAY("Non-blocking read action starts")
Thread.sleep(500)
DISPLAY("Non-blocking read action is still working")
if (counter.incrementAndGet() < 3) {
ProgressManager.checkCanceled()
}
DISPLAY("Non-blocking read action passed cancellation check")
})
.submit(Dispatchers.Default.asExecutor())
delay(100)
writeAction { }
promise.await()
}
Due to the scale of IntelliJ Platform, the Read/Write lock is reentrant:
runReadAction {
runReadAction {
DISPLAY("Ok!")
}
}
Reentrancy works the following way: if a thread already holds read lock, then the attempt to acquire read lock will proceed directly. In particular, the cancellability of a stack of read actions depends only on the topmost read action.
// here we wrap a blocking RA into a non-blocking RA
ReadAction.nonBlocking(Callable { // non-blocking RA
runReadAction { // blocking RA
GlobalScope.launch(Dispatchers.EDT) {
runWriteAction {
}
}
Thread.sleep(10)
try {
ProgressManager.checkCanceled()
}
catch (e: ProcessCanceledException) {
DISPLAY("Throwing ProcessCanceledException!")
throw e
}
DISPLAY("Ok!")
}
}).submit(Dispatchers.Default.asExecutor()).get()
runReadAction { // blocking read action
ReadAction.nonBlocking(Callable { // non-blocking read action
GlobalScope.launch(Dispatchers.EDT) {
runWriteAction {
}
}
Thread.sleep(10)
try {
ProgressManager.checkCanceled()
}
catch (e: ProcessCanceledException) {
DISPLAY("Throwing ProcessCanceledException!") // notice, this line is never printed
throw e
}
DISPLAY("Ok!")
}).executeSynchronously()
}
The biggest source of UI freeze is a blocking read action that prevents a write action on EDT from starting. To avoid this, prefer using non-blocking read actions.
In some cases, non-blocking read actions can waste a lot of CPU time on new attempts. In this case, it is advisable to revise your usage pattern and split non-blocking read actions into several smaller ones.
The platform provides coroutine-friendly API for performing actions under read or write lock.
The actions protected by lock are usually not suspending, i.e., the Platform utilities often have this signature:
suspend fun <T> lockingAction(action: () -> T): T
There are several reasons behind this choice:
suspend modifier inside a write action.readActionThe default choice for initiating read actions in coroutine context is readAction. This is a non-blocking read action which suspends on lock acquisition.
import com.intellij.openapi.progress.ProcessCanceledException
runBlocking(Dispatchers.Default) {
repeat(5) { counter ->
launch {
edtWriteAction {
DISPLAY("Write action $counter is executed")
}
}
}
readAction {
DISPLAY("Read action starts")
try {
ProgressManager.checkCanceled()
}
catch (e: ProcessCanceledException) {
DISPLAY("Read action cancelled")
throw e
}
DISPLAY("Read action passed cancellation check")
}
}
edtWriteActionTo run write action in suspending code, consider using edtWriteAction. This function will switch to EDT and run a write action there. Its name contrasts to backgroundWriteAction, which is currently in experimental stage.
GlobalScope.launch(Dispatchers.Default) {
edtWriteAction {
DISPLAY("Write access: ${application.isWriteAccessAllowed}")
DISPLAY("EDT: ${application.isDispatchThread}")
}
}
readAndWriteActionSometimes there is a need to perform a possibly long operation of collecting some data in read action, and then apply the collected data in write action. One cannot simply use consecutive readAction and edtWriteAction -- the collected data may become inconsistent if some other edtWriteAction appears in the gap between these two functions. So to perform this operation, we need an atomic transition between read and write parts.
It is also not desirable to use writeIntentReadAction -- while it does provide atomicity of transition, it will prevent write actions from proceeding.
The Platform provides a function readAndEdtWriteAction to support this pattern. Effectively, this utility runs read action part in non-blocking style, and then it checks that the data is still valid at the entrance of the write part.
runBlocking(Dispatchers.Default) {
val data = AtomicInteger(0)
repeat(5) {
launch {
edtWriteAction {
data.set(2)
}
}
}
val restartCounter = AtomicInteger()
readAndEdtWriteAction {
restartCounter.incrementAndGet()
data.set(1)
writeAction {
DISPLAY("Finished in write action with: ${data}; was restarted $restartCounter times")
}
}
}
Sometimes there can be a need to perform a suspending operation while holding a read lock. The Platform discourages such situations -- it is better to refactor the code so that it does not perform expensive operations under read lock. Remember, that suspending read actions are non-blocking, so they will rerun the lambda on each cancellation.
Still, there is a pattern that may be helpful. One can use runBlockingCancellable while holding read lock, and the suspending computation inside runBlockingCancellable shall inherit read access.
runBlocking {
readAction {
runBlockingCancellable {
repeat(5) {
launch(Dispatchers.Default) {
DISPLAY("Thread: ${Thread.currentThread()}; Read access: ${application.isReadAccessAllowed()}")
}
}
}
}
}
Due to the fact that locks are historically tightly coupled to EDT, they are affected by modality states.
When a modal computation emerges, its starts running a nested event loop. Modal computations are always initiated under write-intent lock. The code inside a modal computation is executed on background, but it can occasionally go to EDT and execute write actions.
The lock is not held in any way inside modal computations. One has to acquire it explicitly.
runBlocking(Dispatchers.EDT) {
DISPLAY("Write-Intent lock outside modal progress: ${application.isWriteIntentLockAcquired}")
runWithModalProgressBlocking(ModalTaskOwner.guess(), "Sample") {
DISPLAY("Current thread in modal progress: ${Thread.currentThread()}")
DISPLAY("Write-Intent lock in modal progress: ${application.isWriteIntentLockAcquired}")
DISPLAY("Read access in modal progress: ${application.isReadAccessAllowed}")
runReadAction {
DISPLAY("Read access inside explicit read action: ${application.isReadAccessAllowed}")
}
withContext(Dispatchers.EDT) {
DISPLAY("Write-Intent lock on EDT: ${application.isWriteIntentLockAcquired}")
}
}
}
One of the goals of modality states is to prevent unrelated UI events from execution. The clients of IntelliJ Platform can bypass this restriction by executing their code with ModalityState.any().
This has an interesting effect on write locks -- the use-case of ModalityState.any() is to run pure UI code, so the Platform needs to forbid the execution of write actions with any. Otherwise, anyone could sneak their model change inside any modal dialog that expects the consistent state of the world.
The Platform extends its handling of any and write locks to the concept of write-safety, meaning that write actions are allowed only in write-safe contexts. The user's input is considered write-safe, and all modal computations initiated from write-safe contexts are also write-safe. All other contexts are write-unsafe.
import com.intellij.openapi.application.TransactionGuard
import javax.swing.SwingUtilities
runBlocking {
withContext(Dispatchers.EDT) {
DISPLAY("Write-safety #1: ${TransactionGuard.getInstance().isWritingAllowed}")
}
SwingUtilities.invokeAndWait {
DISPLAY("Write-safety #2: ${TransactionGuard.getInstance().isWritingAllowed}")
}
}
There is an ongoing effort on moving some write actions to background threads. This functionality is unstable at the moment, so we give no promises of deadlock-freedom if you are using it in your code.
Nevertheless, the intended way to use background write actions is via the corresponding suspending function:
runBlocking {
backgroundWriteAction {
DISPLAY("Write access: ${application.isWriteAccessAllowed}")
DISPLAY("EDT: ${application.isDispatchThread}")
}
}