Computer scienceProgramming languagesKotlinConcurrency and parallelismConcurrency

Atomic variables with AtomicFU

8 minutes read

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 = 0

The 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 0

This 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: for Int values

  • AtomicLong: for Long values

  • AtomicBoolean: for boolean flags

  • AtomicRef<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) // AtomicLong

You 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: Done

This 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 value

Example: 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() // Safe

or

counter.update { it + 1 } // Also safe

Conclusion

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, and AtomicRef.

  • Perform safe operations like reading, writing, and incrementing values using .value, getAndIncrement(), and update().

  • 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.

2 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo