In concurrent programming, safely managing access to shared data is crucial. If two threads or coroutines try to update the same variable simultaneously, the result can be unpredictable - a situation known as race condition.
For example, imagine two coroutines trying to increment the same counter variable. If they both read and update the value at the same time, one of the updates might be lost: this may lead to subtle bugs that are difficult to detect and reproduce.
To solve this problem, we use atomic variables. These are special variables that support safe, low-level updates that other operations cannot interrupt or overlap. Their operations remains safe even when multiple threads or coroutines use them simultaneously.
In Kotlin, the AtomicFU library provides atomic variables. This lightweight and cross-platform solutions that works seamlessly on JVM and Kotlin/Native. In this topic, you’ll learn how to use AtomicFU to safely manage shared state in your programs.
Race condition
Let's look at a simple example where a race condition might occur.
var count = 0
fun incrementTwice() {
count++
count++
}Now imagine that two coroutines call incrementTwice at the same time. What should the final value of the count variable be?
If each call increments count by 2, you might expect a total increase of 4. However, due to how operations are executed concurrently, some increments may overlap. As a result, the final value might be 3, 4, 5 - depending on timing.
This is a race condition: the result of the program depends on the unpredictable order of operations.
What about volatile?
You might think that marking the variable count as @Volatile will fix the issue:
@Volatile
var count = 0The problem is that volatile only guarantees that reads and writes are visible between threads, it doesn't make compound operations like count++ atomic.
To safely increment a counter, we need atomic operations: this is where atomic variables come in.
Atomic variables
To avoid race conditions, we need a safe way to perform read-modify-write operations, even when multiple threads or coroutines access them.
Kotlin offers the AtomicFU library to support this kind of safe concurrency. It's lightweight and multi-platform solution for managing shared state with atomic variables.
How to create an atomic variable
To create an atomic variable, you use the atomic() function from kotlinx.atomicfu:
import kotlinx.atomicfu.atomic
val counter = atomic(0) // creates an AtomicInt with initial value 0This line creates an AtomicInt which is a thread-safe wrapper around Int.
Available types
AtomicFU provides several atomic types depending on what kind of data you want to store:
AtomicInt: forIntvaluesAtomicLong: forLongvaluesAtomicBoolean: for boolean flagsAtomicRef<T>: for reference types, like strings or custom objects
Let's see more examples:
val flag = atomic(true) // AtomicBoolean
val message = atomic("Hello") // AtomicRef<String>
val sessionCount = atomic(42L) // AtomicLongYou can share these variables across coroutines or threads and update them safely without locks. The best part is that you can use them just like regular variables, but they're much safer!
Operations on atomic variables
After creating an atomic variable, you can read and update its value using the .value property. This works like a regular variable, but it is thread-safe, which means we can safely access and modify it across threads or coroutines.
You can also assign a new value directly using the .value property:
val message = atomic("Loading...")
message.value = "Done"
println(message.value) // prints: DoneThis is a valid and safe operation because the assignment is atomic. However, avoid using .value when performing compound operations like read–modify–write in multiple steps: that’s where getAndUpdate(), compareAndSet() or update() are preferred.
Example: atomic operations
val counter = atomic(0)
val current = counter.value // reads the current value
val updated = counter.getAndIncrement() // safely increments the counter
val newValue = counter.value // reads the new valueExample: safe increment with coroutines
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.*
import kotlin.system.*
val counter = atomic(0)
fun main() = runBlocking {
val n = 1000
val k = 1000
val time = measureTimeMillis {
coroutineScope {
repeat(n) {
launch {
repeat(k) {
counter.getAndIncrement()
}
}
}
}
}
println("Final counter value: ${counter.value}")
println("Completed in $time ms")
}This program launches 1000 coroutines, each incrementing the counter 1000 times. The atomic operation getAndIncrement() ensures the final result will be 1_000_000, without requiring locks or synchronization primitives.
Fixing race conditions with atomic variables
Earlier, we saw how race conditions can occur when multiple threads or coroutines modify shared data without coordination. Let’s look at another example: a simple analytics service that tracks how many times a user opens the app.
var launchCount = 0
fun onAppLaunch() {
launchCount++
}This code appears simple and it works well in single-threaded contexts. However, in a concurrent environment (such as analytics collected in parallel coroutines), multiple increments can interfere with each other. If five coroutines call onAppLaunch() simultaneously, the final value of launchCount could be anywhere between 1 and 5.
To prevent this, we can rewrite the logic using an atomic counter:
import kotlinx.atomicfu.atomic
val launchCount = atomic(0)
fun onAppLaunch() {
launchCount.getAndIncrement()
}Now, even when multiple coroutines trigger this function simultaneously, each increment operation happens atomically. No values are lost, and the final count remains accurate.
In Kotlin, AtomicFU offers methods like getAndIncrement(), update(), and compareAndSet() to ensure safe updates to shared state without requiring complex synchronization primitives like locks or mutexes.
Atomic vs Mutex
Using Mutex from kotlinx.coroutines provides another way to ensure thread safety. For example:
val mutex = Mutex()
var count = 0
suspend fun increment() {
mutex.withLock {
count++
}
}While this approach works correctly, but it introduces blocking behavior and requires explicit locking and unlocking. Atomic variables, on the other hand, are non-blocking and typically more efficient for simple state updates like counters or flags.
In general:
Atomic variables work best for lightweight, lock-free state management.
Mutexes are more suitable for protecting complex critical sections that involve multiple variables or operations.
Best practices when using atomic variables
Atomic variables are powerful tools for managing shared state in concurrent applications. Like any tool, you should use them thoughtfully. Here are some best practices to keep in mind:
Use only when necessary
Atomic variables are designed for simple, low-overhead synchronization. They work well for counters, flags, or status indicators. However, for more complex state updates involving multiple variables, use higher-level synchronization tools like Mutex.
Minimize shared state
Keep shared mutable state small and well-contained. The more data you expose to concurrency, the harder it becomes to ensure correctness.
Prefer immutability + AtomicRef
When sharing structured data across threads or coroutines, use immutable data classes in combination with AtomicRef. This approach makes your updates safe and predictable:
data class UserState(val online: Boolean, val lastSeen: Long)
val userState = atomic(UserState(false, 0L))
fun goOnline() {
userState.update { it.copy(online = true, lastSeen = System.currentTimeMillis()) }
}Avoid read–modify–write with .value
One common mistake is reading the .value, modifying it, and writing it back which creates a race condition:
val counter = atomic(0)
val current = counter.value
counter.value = current + 1 // Not safe!Between the read and the write, another thread might have updated the value, causing your change to overwrite it. Instead, use:
counter.getAndIncrement() // Safeor
counter.update { it + 1 } // Also safeConclusion
In this topic, we explored how to work with atomic variables in Kotlin using the AtomicFU library.
We learned how to:
Create atomic variables such as
AtomicInt,AtomicBoolean, andAtomicRef.Perform safe operations like reading, writing, and incrementing values using
.value,getAndIncrement(), andupdate().Avoid race conditions by replacing unsafe read–modify–write patterns with atomic operations.
Apply best practices for working with shared state in concurrent applications.
Atomic variables provide a lightweight alternative to traditional synchronization tools like Mutex. They work best with simple shared state, such as flags, counters, and small immutable objects.
By applying these concepts, you can write concurrent code that is more predictable, efficient, and bug-free.
In the next topic, we'll explore advanced operations such as compareAndSet() and loop(), which allow for more complex atomic logic.