Course outline

Kotlin / Concurrency, interoperability, and integrated design / Lesson 17

Protect shared state with the right atomic boundary

Distinguish visibility, atomic updates, compound invariants, coroutine mutexes, and immutable ownership.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain why a volatile reference or val declaration does not make increment atomic.
  • Choose synchronization around the business invariant rather than individual fields alone.

A compound update contains more than one step

Incrementing a shared counter conceptually reads a value, computes a replacement, and writes it. Two workers can read the same old value and lose one update. A volatile field can provide relevant visibility guarantees but does not turn that compound operation into an atomic transaction. A val reference prevents reassignment of the reference, not mutation of its object. Coroutines on multithreaded dispatchers face ordinary shared-memory races too. Explain the exact invariant and every access path before deciding whether an atomic variable, confinement, a mutex, or a different ownership model is appropriate.

Use an atomic operation for the invariant it actually covers

The JVM AtomicInteger type provides atomic operations for one integer state. incrementAndGet can safely express a single counter increment under its API contract, but two independent atomic counters do not make a combined business rule atomic. If available seats and active reservations must change together, protecting each separately can still reveal an invalid intermediate combination or lose coordination. The example below is intentionally sequential and demonstrates the operationโ€™s result only; it is not a stress test, a performance benchmark, or evidence about a larger reservation implementation.

kotlin A JVM atomic-value API example
import java.util.concurrent.atomic.AtomicInteger

fun main() {
    val completed = AtomicInteger(0)
    println(completed.incrementAndGet())
    println(completed.addAndGet(2))
    println(completed.get())
}
Expected output
1
3
3

Expected output is 1, 3, 3. The code is sequential, requires JVM APIs, and was not compiled. Atomic API guarantees come from its documented contract, not from this unexecuted example.

Protect every related access through one agreed protocol

A coroutine Mutex provides suspending mutual exclusion. withLock releases the lock as its block exits, including exceptional paths, according to the library contract. Every competing access to protected state must obey the same protocol; a single unlocked writer can invalidate the reasoning. Keep the critical section focused and avoid external waiting or unrelated work while holding the lock. A mutex coordinates memory inside its intended process context; it does not become a distributed database lock or protect another application instance that has a separate mutex object.

Wait for children before reading the protected final state

The proposed program launches two small child coroutines and performs two increments each while holding one mutex. The enclosing scope waits for both children before the final read, so the declared model expects four. It is deliberately tiny and contains no I/O or load generation. A real design may use confinement to one owner instead, provided every access follows that ownership and lifecycle. Avoid moving every small operation between contexts merely to claim confinement; correctness and transition cost must be evaluated together. No concurrent runtime experiment was executed here.

kotlin A bounded mutex-protected coroutine model
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

fun main() = runBlocking {
    val mutex = Mutex()
    var count = 0
    coroutineScope {
        repeat(2) {
            launch(Dispatchers.Default) {
                repeat(2) { mutex.withLock { count += 1 } }
            }
        }
    }
    println(count)
}
Expected output
4

Expected output is 4 if built and executed with compatible kotlinx.coroutines. The final read follows scope completion. No compiler, dispatcher, race, or timing test was performed in this authoring environment.

Prefer less shared state when the model allows it

Immutable snapshots and message-based ownership can reduce the surface needing synchronization, but they still require correct publication, lifecycle, and event semantics. A data class containing mutable lists is not deeply immutable, and a read-only view can expose changing contents through another alias. Test the real invariant under an isolated concurrency strategy, including cancellation and failure while work owns resources. Report allowed outcomes and exact environment rather than relying on one lucky run. This course supplies reasoned source examples only and makes no concurrency, throughput, or production safety claim.

Common errors and fixes

A volatile counter loses increments.

Why it happens
Visibility did not make read-modify-write atomic.
How to fix it
Use an appropriate atomic operation or protect the complete update.
Prevent it next time
Model compound operations rather than only field modifiers.

Two atomic fields violate one combined rule.

Why it happens
The invariant spans more state than either operation protects.
How to fix it
Protect the combined transition or redesign it under one owner.
Prevent it next time
Name the business invariant before choosing synchronization.

A second application instance oversells despite a local mutex.

Why it happens
Process-local coordination was treated as cross-instance protection.
How to fix it
Enforce allocation at the authoritative shared storage boundary.
Prevent it next time
Document the exact scope of every lock and owner.

Check your understanding

1. Does a data class with val reservations: MutableList<String> become deeply immutable?

Show worked solution

No. The property cannot be reassigned through that declaration, but the list contents can change and aliases can observe or cause those changes. Use stable element and container ownership or an immutable representation when snapshots must be safely shared. Copying the data class is also shallow unless a deeper policy is implemented.

2. Why is one successful run of an unsafe counter insufficient to prove correctness?

Show worked solution

A race depends on scheduling and may not appear in that run. Correctness requires a synchronization argument covering every access plus appropriate isolated tests, not merely the expected final number once. Testing can reveal defects but cannot transform an unprotected compound operation into an atomic one.

Key takeaways

  • Synchronization must cover the whole invariant and every access path.
  • Language convenience does not remove shared-memory or distributed-state boundaries.

Official references