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.
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.
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val completed = AtomicInteger(0)
println(completed.incrementAndGet())
println(completed.addAndGet(2))
println(completed.get())
}
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.
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)
}
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.