Race Condition is a situation in multithreaded programming where the final result depends on the order in which threads execute. According to the Oracle Java Tutorials (2024), a race condition occurs when multiple threads access a shared resource without synchronization. Without proper mechanisms, Race Condition leads to data corruption and non-reproducible bugs in mobile applications.
Key Takeaways
Race Condition is an error in a multithreaded program where the correctness of operation depends on the unpredictable order of thread execution. When two or more threads simultaneously access a shared resource without synchronization, the final state of the resource becomes undefined.
In mobile development, Race Condition is especially dangerous because threads can execute on different CPU cores at different speeds. The developer cannot control which thread completes the operation first — this is decided by the operating system scheduler. According to an IBM study (Concurrency Bugs in Android, 2022), about 23% of critical bugs in Android applications are related to race conditions.
A key feature of Race Condition is its non-determinism. The same code can work without errors thousands of times, and then suddenly crash. This makes diagnosis particularly difficult: the bug only manifests under a specific combination of circumstances — CPU load, number of active threads, and scheduling phase.
Race Condition occurs when a thread performs a non-atomic operation — a sequence of several steps that can be interrupted by another thread. For example, the increment operation counter++ actually consists of three steps: reading the value from memory, incrementing by one, and writing back. If two threads interleave these steps, the result will be incorrect.
The main cause of race condition is lack of synchronization when accessing shared data. When one thread modifies an object while another simultaneously reads it, the read result is unpredictable. In Android, this problem is compounded by the fact that application components (Activity, Service, BroadcastReceiver) can execute in different threads.
In modern Kotlin Android development, Race Condition often occurs due to incorrect use of coroutines. If two coroutines work with shared state in different Dispatchers without synchronization, the result will be unpredictable. This is especially common when combining Dispatchers.IO and Dispatchers.Main with shared mutable objects.
Let us consider a classic example of data race — counter increment from multiple threads. Without synchronization, the final value will be less than expected because operations overlap each other.
class RaceCounter {
private var counter = 0
fun increment() {
// Non-atomic operation — three steps
counter++ // reads, increments, writes
}
fun getCount(): Int = counter
}
fun main() = runBlocking {
val rc = RaceCounter()
val jobs = List(1000) {
launch(Dispatchers.Default) {
rc.increment()
}
}
jobs.forEach { it.join() }
println(rc.getCount()) // Expected 1000, got ~997
}
In this example, 1000 coroutines simultaneously call increment(). Due to the non-atomic nature of the counter++ operation, the final value is almost never equal to 1000. Each run produces a different result — a classic symptom of Race Condition. The more threads participate in the race, the greater the deviation from the expected value.
The fix is to use an atomic type or a lock. In Kotlin, AtomicInteger from the java.util.concurrent.atomic package is suitable for this task. It guarantees that read-modify-write operations execute as a single indivisible action at the CPU level.
import java.util.concurrent.atomic.AtomicInteger
class SafeCounter {
private val counter = AtomicInteger(0)
fun increment() {
counter.incrementAndGet() // atomic operation
}
fun getCount(): Int = counter.get()
}
Data Race is the most common type of Race Condition. It occurs when one thread writes data to a variable while another simultaneously reads or writes the same variable without synchronization. In the Java Memory Model, this behavior is considered undefined — a thread may see a stale value due to CPU-level caching.
The Check-Then-Act pattern is a situation where a thread checks a condition and then performs an action based on that check. Between the check and the action, another thread can change the state. A typical example: checking if an element exists in a collection and then removing it. In Android, this is common when working with SharedPreferences or databases.
Read-Modify-Write is a situation where a thread reads a value, modifies it in local memory, and writes it back. If another thread changed the original value between the read and write, the modification result is lost. The classic example is the counter++ operation, discussed above in the Kotlin code.
Software Transactional Memory (STM) is an approach where operations on shared data are performed in transactions, similar to databases. If two transactions conflict, one is rolled back and retried. In Kotlin for JVM, the Multiverse STM library is available, which automatically handles access conflicts without explicit locks. STM is especially useful in Android when working with multiple interconnected objects.
A special category of Race Condition is thin races related to the Activity lifecycle. A typical scenario: a background thread finishes loading data, but the Activity is already destroyed (screen rotation). The coroutine tries to update a non-existent View and crashes with IllegalStateException. The solution is to use viewModelScope and Lifecycle-aware components, which automatically cancel coroutines when the Lifecycle Owner is destroyed.
Detecting Race Condition is one of the most difficult tasks in debugging multithreaded applications. Standard testing rarely reveals race conditions because they only manifest under specific timing coincidences. According to Google (Android Testing Guide, 2023), about 70% of Race Conditions are not detected by unit tests due to the deterministic execution order in the test environment.
The main detection methods include specialized tools. ThreadSanitizer (TSan) is a dynamic analyzer built into the Android NDK that tracks all memory accesses and detects unsynchronized access. For Java/Kotlin code, Google recommends Android Studio Layout Inspector together with StrictMode, which intercepts illegal UI-thread accesses from background threads.
Another effective approach is Stress Testing with repeated test runs under load. The Lincheck framework by JetBrains is specifically designed for testing concurrent data structures on JVM. It automatically generates scenarios with different operation permutations and verifies result correctness in each case.
| Tool | Platform | Analysis Type |
|---|---|---|
| ThreadSanitizer | Android NDK | Dynamic memory analysis |
| Intel Inspector | Windows | Static + dynamic |
| Lincheck | JVM / Kotlin | Stress testing |
| StrictMode | Android | Runtime interception |
Atomic variables (AtomicInteger, AtomicLong, AtomicReference) are the easiest way to eliminate data races for single operations. They use low-level CPU CAS instructions (Compare-And-Swap) that execute atomically without locks. This gives maximum performance in low-contention scenarios.
Mutex and locks are a classic synchronization mechanism suitable for complex operations and critical sections. In Kotlin for coroutines, suspending Mutex from the kotlinx.coroutines library is used, which supports suspension instead of thread blocking. This avoids busy waiting typical of traditional locks.
State isolation is an architectural approach where each thread works with its own copy of data. In mobile development, this is achieved through the Actor model, where each actor owns its state and communicates with other actors via messages. Kotlin Coroutines provides Actor implementation through Channel and SendChannel, which completely eliminates Race Condition at the architecture level.
An additional layer of protection is Immutability: if shared data is fundamentally immutable, Race Condition becomes impossible even without synchronization. In Kotlin, data classes with val fields and collections from kotlinx.collections.immutable are used for this purpose, guaranteeing structural immutability when publishing between threads.
Frequently Asked Questions
Data Race is a specific type of Race Condition where two threads simultaneously access the same memory, and at least one of them performs a write. Race Condition is a broader concept that includes any errors depending on the order of thread execution, including logical race states.
It cannot be completely eliminated, but it can be minimized. Use immutable objects, atomic types, and coroutines with a single-thread dispatcher. Static analysis tools such as Android Lint with the ThreadSafety rule help identify potential races at compile time.
In UI applications, Race Condition often manifests as screen flickering, incorrect data display, or crashes when updating a list. A typical scenario: a background thread loads data and updates the adapter, while the user scrolls the list — simultaneous access to the Adapter DataSet occurs.
volatile guarantees visibility of changes between threads — a write to a volatile variable is immediately visible to all threads. However, volatile does not solve the Read-Modify-Write and Check-Then-Act problems because it does not provide atomicity for compound operations. Such scenarios require locks or atomic classes.
In Kotlin Coroutines, Race Condition occurs at the level of the coroutine scheduler, not the OS thread scheduler. Coroutines can switch at suspension points (suspend), which creates additional opportunities for races. The kotlinx.coroutines.debug tool and the IntelliJ IDEA debugger help track coroutine state.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also