Kotlin Concurrency Primitives: A Practical Guide to Mutex, Semaphore, Atomics, Channels, Actors &…
Concurrency allows multiple tasks to run simultaneously, but coordinating them safely is the real challenge.
Kotlin Concurrency Primitives: A Practical Guide to Mutex, Semaphore, Atomics, Channels, Actors & More
Photo by Sebastian Svenson on Unsplash
Concurrency allows multiple tasks to run simultaneously, but coordinating them safely is the real challenge.
Concurrency primitives provide the building blocks for protecting shared state, limiting concurrency, and enabling safe communication between tasks.
The Problem All Concurrency Primitives Solve
Consider a scenario where 100 concurrent coroutines attempt to increment a single shared counter variable:
import kotlinx.coroutines.*
var counter = 0
fun main() = runBlocking {
withContext(Dispatchers.Default) {
val jobs = List(100) {
launch {
repeat(1000) { counter++ }
}
}
jobs.joinAll()
}
println("Final Counter: $counter")
}
Mathematically, the expected result is 100 x 1000 = 100,000 . Yet, running this code will yield unpredictable, lower values like 87342 or 92115.
This discrepancy occurs because the expression counter++ is not a single, atomic CPU operation. It breaks down into three distinct operations under the hood:
1. Read counter value from RAM
2. Add 1 to value in CPU Register
3. Write value back to RAM
When multiple coroutines execute this sequence simultaneously across separate CPU threads, their instruction paths interleave.
Because Coroutine B read the value before Coroutine A could write back the updated state, one entire increment operation is permanently lost.
This structural flaw is known as a race condition. Every concurrency primitive exists to eliminate this class of bugs.
The Concurrency Decision Framework
Choosing a primitive purely by memory or API familiarity leads to over-engineering or hidden performance bottlenecks. Instead, use this structural evaluation matrix before writing code:

1. Atomics
Use Case
Use atomics when you need to perform isolated, single-operation updates on a single variable without thread-blocking overhead.
Example
Imagine an application tracking a global metrics dashboard monitoring incoming network requests and initialisation states:
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class MetricsCollector {
// Thread-safe primitive counter
val requestsProcessed = AtomicInteger(0)
// Thread-safe flag for one-time initialization
private val isInitialized = AtomicBoolean(false)
fun registerRequest() {
requestsProcessed.incrementAndGet() // Atomically adds 1 and retrieves value
}
fun initializeSystem(config: String) {
// Atomic Compare-And-Swap (CAS): Checks if current state is false, flips to true
if (isInitialized.compareAndSet(false, true)) {
performHeavySetup(config)
}
}
private fun performHeavySetup(config: String) { /* ... */ }
}
Why They Are Fast
Atomics do not block operating system threads or suspend coroutines. They compile down directly to specialized hardware CPU instructions, primarily CAS (Compare-And-Swap). The CPU attempts to write a new value only if the memory location matches the expected current value. If it fails due to a concurrent write, the CPU loops and retries the operation directly in hardware registers.
Critical Limitation
Atomics only protect single operations. Combining multiple atomic calls sequentially breaks atomicity:
// WARNING: This code contains a race condition!
if (counter.get() < 100) {
// Another thread could increment counter past 100 right here!
counter.incrementAndGet()
}
This pattern fails because the sequence contains distinct read, evaluate, and write windows. If your logic spans multiple variables or dependent evaluations, look to a Mutex.
2. Mutex
Use Case
Use a Mutex (Mutual Exclusion) when your business logic requires multiple variables to change together reliably, ensuring that only one coroutine can execute a critical code block at any given moment.
Example
A financial ledger processing account withdrawals where the balance, transaction count, and audit timestamp form a unified transactional boundary:
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class BankAccount(private var balance: Double) {
private var transactionCount = 0
private var lastUpdated: Instant = Clock.System.now()
private val mutex = Mutex()
suspend fun withdraw(amount: Double): Boolean {
// Only one coroutine can enter this block at a time
mutex.withLock {
if (balance >= amount) {
balance -= amount
transactionCount++
lastUpdated = Clock.System.now()
return true
}
return false
}
}
}
Mutex vs. Java Synchronized Locks
While a standard Java synchronized block or ReentrantLock physically halts and parks the underlying OS thread, a Kotlin Mutex is built explicitly for coroutines. When a coroutine hits a locked Mutex, it suspends gracefully, allowing the underlying thread to process other unrelated coroutines. Once the lock is released, the suspended coroutine resumes execution on an available thread.
3. Concurrent Data Structures
Use Case
Use concurrent data structures when multiple asynchronous tasks must concurrently read, insert, remove, or update elements inside a collection (Map, List, Queue, Set).
Example
An in-memory user session cache serving concurrent web requests:
import java.util.concurrent.ConcurrentHashMap
class UserSessionCache {
private val activeSessions = ConcurrentHashMap<String, UserSession>()
fun updateSession(token: String, session: UserSession) {
activeSessions[token] = session
}
// FULLY ATOMIC check-and-compute
fun getOrCreateSessionAtomic(token: String): UserSession {
return activeSessions.computeIfAbsent(token) { key ->
createNewSession(key)
}
}
private fun createNewSession(token: String): UserSession = UserSession(token)
}
data class UserSession(val token: String)
Underlying Mechanics
Standard Collections (HashMap, ArrayList) throw ConcurrentModificationException or corrupt internal pointers under concurrent mutation. ConcurrentHashMap avoids this by leveraging lock-stripping; it divides the underlying hash table buckets into segments, locking only the specific segment being mutated while leaving other segments open for concurrent reads and writes.
4. Semaphore
Use Case
Use a Semaphore when you are not trying to protect shared data, but are instead enforcing a concurrency threshold to prevent external systems from collapsing under high load.
Example
An online streaming service dashboard that aggregates UI rows by calling multiple upstream microservices. Fetching every row simultaneously would exhaust network pools or trigger rate limits:
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class DashboardAggregator(private val api: MovieApiService) {
// Allow maximum 3 concurrent API network calls simultaneously
private val concurrencyLimiter = Semaphore(permits = 3)
suspend fun fetchDashboardRows(sections: List<DashboardSection>): List<MovieRow> = coroutineScope {
sections.map { section ->
async {
concurrencyLimiter.withPermit {
api.fetchSectionData(section)
}
}
}.awaitAll()
}
}
Mental Model
Think of a Semaphore(3) as a digital parking garage with exactly 3 available slots. When a task finishes, it leaves the garage, releasing a permit and immediately wake-suspending the next task waiting in line.
5. Advanced Synchronization Primitives
5.1 ReadWriteLock
Use Case: You have a shared resource that is read constantly by hundreds of concurrent tasks, but updated very rarely.
Example: Imagine an in-memory configuration cache. Standard locks (Mutex or synchronized) are inefficient here because they force readers to wait in line, even though reading data simultaneously is completely safe.
import java.util.concurrent.locks.ReentrantReadWriteLock;
class ConfigurationCache {
private var configData = mapOf<String, String>()
private val rwl = ReentrantReadWriteLock()
fun getConfig(key: String): String? {
rwl.readLock().lock() // Allows infinite simultaneous readers
try {
return configData[key]
} finally {
rwl.readLock().unlock()
}
}
fun updateConfig(newConfig: Map<String, String>) {
rwl.writeLock().lock() // Blocks ALL readers and other writers completely
try {
configData = newConfig
} finally {
rwl.writeLock().unlock()
}
}
}
Mental Model: An airport departure screen. Hundreds of passengers can look at the screen simultaneously (Read Lock). However, the moment a staff member updates a gate number (Write Lock), the screen temporarily freezes for everyone until the update is finished.
5.2 CountDownLatch
Use Case: One or more threads/coroutines must wait until a specific set of independent operations completes before they are allowed to proceed.
Example: Imagine an e-commerce order checkout system. Before allowing a user to finalize their purchase, the system must concurrently validate the shipping address, check warehouse stock, and verify payment eligibility.
import java.util.concurrent.CountDownLatch
import kotlinx.coroutines.*
class CheckoutProcessor {
suspend fun processOrder() = coroutineScope {
val latch = CountDownLatch(3) // Initialize latch with a count of 3 services
launch(Dispatchers.IO) {
validateShipping()
latch.countDown() // Decrements count from 3 to 2
}
launch(Dispatchers.IO) {
checkInventory()
latch.countDown() // Decrements count from 2 to 1
}
launch(Dispatchers.IO) {
verifyPayment()
latch.countDown() // Decrements count from 1 to 0
}
// The current coroutine suspends/blocks until the count hits exactly 0
withContext(Dispatchers.IO) {
latch.await()
}
finalizeOrder()
}
private fun validateShipping() {}; private fun checkInventory() {}; private fun verifyPayment() {}; private fun finalizeOrder() {}
}
Mental Model: A race track starting gate. The race cannot begin until a fixed number of horses enter their stalls. As each horse clicks into place (countDown()), the gate gets closer to opening. When the last horse is in place (count hits 0), the gates snap open (await() unblocks) and execution charges forward.
5.3 CyclicBarrier
Use Case: A fixed number of parallel threads or tasks must wait for each other at a common barrier point before any of them can continue.
Unlike a CountDownLatch (which is a one-time countdown), a CyclicBarrier is reusable. It is perfect for multi-stage concurrent algorithms, like rendering graphics frames or batch-processing large datasets in chunks.
Example: Imagine a distributed game engine where 4 player state engines run parallel math loops. No player can start “Turn 2” until all 4 players have completely finished processing “Turn 1”.
import java.util.concurrent.CyclicBarrier
import kotlinx.coroutines.*
class GameEngine {
// 4 player threads must meet here. When they do, the barrier action runs.
private val barrier = CyclicBarrier(4) {
println("All players synchronized! Syncing world state to server...")
}
fun startPlayerLoop(playerId: Int, scope: CoroutineScope) = scope.launch(Dispatchers.Default) {
while (true) {
processPlayerInput(playerId)
calculatePhysics(playerId)
// Block/Suspend here until all 3 other players reach this exact same line
withContext(Dispatchers.IO) {
barrier.await()
}
// The loop moves to the next turn together seamlessly
}
}
private fun processPlayerInput(id: Int) {}
private fun calculatePhysics(id: Int) {}
}
Mental Model: A tour bus group. The bus driver announces: “We stop here for 30 minutes. The bus will not leave for the next monument until all 4 members of our group are back on board.” If you finish early, you sit on the bus and wait for the rest. Once the last person steps on, the bus immediately departs, resetting the barrier for the next tour stop.
6. Channels
Use Case
Use Channels when your architecture shifts away from shared mutable state entirely, moving toward a model where coroutines communicate by passing data streams directly to each other.
Example
An analytics pipeline where client user events are captured quickly on a lightweight ingest thread, buffered, and passed down to a background worker coroutine for batch writing to a local database:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
class AnalyticsPipeline(private val database: DatabaseDriver) {
// A buffered channel that holds up to 100 events before suspending producers
private val eventChannel = Channel<AnalyticsEvent>(capacity = 100)
suspend fun trackEvent(event: AnalyticsEvent) {
eventChannel.send(event) // Non-blocking send; suspends if buffer is full
}
fun startProcessingProcessor(scope: CoroutineScope) = scope.launch(Dispatchers.IO) {
// Loops continuously, suspending when the channel is empty
for (event in eventChannel) {
database.write(event)
}
}
}
Core Architecture Shift
Channels embody the core Go/Kotlin concurrency philosophy: “Do not communicate by sharing memory; instead, share memory by communicating.” By pushing your state into a channel payload, you ensure that only one coroutine owns or modifies that data packet at any point along the pipeline.
7. Actors
Use Case
Use an Actor when you have complex internal state requiring frequent updates, and you want to isolate that state within a single dedicated coroutine owner, processing all mutations via an internal message mailbox.
Example
A stateful high-performance counter handling real-time application traffic:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
sealed interface CounterMessage
data object Increment : CounterMessage
data class GetCount(val response: CompletableDeferred<Int>) : CounterMessage
class CounterActor(scope: CoroutineScope) {
private val mailbox = Channel<CounterMessage>(Channel.UNLIMITED)
init {
scope.launch {
var counter = 0 // Encapsulated Mutable State
for (message in mailbox) { // Process incoming messages sequentially
when (message) {
is Increment -> counter++
is GetCount -> message.response.complete(counter)
}
}
}
}
suspend fun increment() = mailbox.send(Increment)
suspend fun getCurrentCount(): Int {
val response = CompletableDeferred<Int>()
mailbox.send(GetCount(response))
return response.await()
}
}
Mental Model
Instead of multiple coroutines contending for an operational lock directly on variables, they queue their requests cleanly into the actor mailbox. Because the internal state variable var counter = 0 is only ever accessed by the single coroutine processing the mailbox loop, race conditions are mathematically impossible. No locking or atomic overhead is needed.
Summary
When designing your next concurrent system, avoid asking which primitive is the most advanced. Instead, isolate your specific problem:
- Protect a variable → Atomics
- Protect a container → Concurrent Collections
- Lock complex sequential operations → Mutex
- Rate-limit throughput → Semaphore
- Read-heavy, write-sparse data sharing → ReadWriteLock
- Wait for multiple independent initializations → CountDownLatch
- Cycle through phased multi-task checkpoints → CyclicBarrier
- Stream data across tasks → Channel
- Isolate complex business logic → Actor
👏🏻👏🏻 A few claps would really support the post and help more people discover it.
Thanks for making it to the end — hope you found something useful here. Stay curious. 💡
메타데이터
- post_id
- a211d7270cd3
- slug
- kotlin-concurrency-primitives-a-practical-guide-to-mutex-semaphore-atomics-channels-actors-a211d7270cd3
- url
- https://medium.com/@hsinha610/kotlin-concurrency-primitives-a-practical-guide-to-mutex-semaphore-atomics-channels-actors-a211d7270cd3
- canonical_url
- https://medium.com/@hsinha610/kotlin-concurrency-primitives-a-practical-guide-to-mutex-semaphore-atomics-channels-actors-a211d7270cd3
- author_url
- https://medium.com/@hsinha610
- status
- ok
- fetched_at
- 2026-06-26 12:24:55