Have you ever written a program that works perfectly on one run but produces a completely different result the next time? You change nothing, use the same input, and somehow the output is different.
At first, it might seem like a compiler issue, a hardware problem, or a bug that appears out of nowhere. In many cases, however, the reason is much more fundamental: the program depends on the order in which multiple operations execute, and that order is not guaranteed.
This behavior is closely related to concurrency, thread scheduling, shared state, and race conditions. Understanding these concepts is important because they can explain some of the most frustrating bugs in software development.
1. The Problem: Same Code, Different Output
Consider a program in which two threads increment the same counter.
public class Main {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
}
The expected result is 200000, because each thread increments the counter 100,000 times.
However, the actual result may be smaller than expected.
Why?
Both threads are modifying the same variable. Even though the statement counter++ looks like a single operation, it involves reading the current value, adding one, and writing the result back. These steps can overlap between threads.
The problem is not necessarily that either thread is executing the wrong instructions. The problem is that their operations can interfere with each other.
2. What Is Concurrency?
Concurrency means that multiple tasks make progress during overlapping periods of time. Depending on the system, they may execute through interleaving on one processor or truly run in parallel on multiple processor cores.
Imagine two workers updating the same document. Both read the current version, make a change, and save their copies. If one worker saves an older copy after the other has already saved a newer version, one of the changes can be lost.
Threads can experience the same problem when they access shared memory.
Consider these operations:
- Thread A reads
counter = 10. - Thread B reads
counter = 10. - Thread A calculates
11and writes it. - Thread B calculates
11and writes it.
The final value is 11, even though two increments were attempted. One update has been lost.
This is called a lost update, a common example of a race condition.
3. How Thread Scheduling Changes the Result
An operating system's scheduler decides which runnable thread gets CPU time and when. Threads may be paused and resumed at different points, and their execution order can vary between runs.
For example, two threads might execute in this order:
Run A
Thread A: Read 0
Thread A: Write 1
Thread B: Read 1
Thread B: Write 2
Final value: 2
Run B
Thread A: Read 0
Thread B: Read 0
Thread A: Write 1
Thread B: Write 1
Final value: 1
The source code has not changed. Only the order in which the operations overlap has changed.
This is one reason concurrency bugs can be difficult to reproduce. A program may work correctly during one test and fail under a slightly different workload, on another machine, or when the system is under heavy load.
It is worth remembering that scheduling does not automatically cause a bug. It exposes bugs when the program relies on an execution order that is not guaranteed.
4. What Exactly Is a Race Condition?
A race condition occurs when a program's correctness depends on the timing or ordering of operations that can overlap or interleave.
It is especially dangerous when multiple threads access shared mutable data and at least one of them modifies it without suitable coordination.
Consider a bank account with a balance of ₹1,000. Two transactions attempt to withdraw ₹700 at nearly the same time.
Each transaction checks whether the balance is sufficient. If both transactions read ₹1,000 before either updates the balance, both may conclude that the withdrawal is allowed.
Depending on the implementation, the system might accept both withdrawals or overwrite one update. Either outcome can violate the intended business rules.
This is not merely a theoretical problem. Similar issues can occur in inventory management, payment processing, ticket booking, request counters, and account balances.
A race condition is therefore not just about getting a different number. It can lead to lost updates, duplicated actions, inconsistent data, or incorrect business decisions.
5. Why Doesn't counter++ Work Reliably?
The expression counter++ looks simple, but it is a read-modify-write operation.
Conceptually, it behaves like this:
1. Read the current counter.
2. Add 1 to the value.
3. Store the updated value.
If two threads perform these steps simultaneously, they may both read the same old value and overwrite each other's updates.
The solution is to make the operation atomic or protect the shared state with synchronization.
For example, Java provides the synchronized keyword:
public class Main {
static int counter = 0;
static synchronized void increment() {
counter++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
}
Now only one thread at a time can execute the synchronized increment() method on the same class monitor. The increments are protected, so the final result will be 200000 under this example's assumptions.
Synchronization does more than prevent simultaneous execution of a critical section. It also establishes memory-visibility guarantees between synchronized operations using the same monitor.
6. Locks: Protecting Shared State
A lock is a synchronization mechanism that controls access to a resource. A thread acquires the lock before entering a critical section and releases it after completing the protected operation.
In Java, a ReentrantLock can be used for explicit locking:
import java.util.concurrent.locks.ReentrantLock;
public class Main {
static int counter = 0;
static final ReentrantLock lock = new ReentrantLock();
static void increment() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
}
The finally block ensures that the lock is released even if an exception occurs inside the protected section.
Locks are useful, but they must be applied carefully. If a thread forgets to release a lock, other threads may wait indefinitely. If two threads acquire multiple locks in conflicting orders, they can cause a deadlock.
The goal is not to lock everything. It is to protect the smallest appropriate section of code while preserving correctness.
7. Are Atomic Variables a Better Solution?
Sometimes, a full lock is unnecessary. Java provides atomic classes such as AtomicInteger, which support thread-safe operations on individual values.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}
The atomic increment operation prevents the lost updates seen in the original example.
Atomic variables are particularly useful for counters, flags, and simple shared values. However, they do not automatically make a sequence of several related operations atomic. For more complex invariants, you may still need locks or a different design.
8. Nondeterminism: When Execution Is Not Predictable
Nondeterminism means that a system can produce different outcomes under apparently identical conditions because some part of its behavior is not fully determined by the inputs.
Concurrency is one source of nondeterminism, but it is not the only one.
A program may also depend on:
- Random-number generation.
- Current time or external events.
- Network response timing.
- File-system or database state.
- Distributed systems receiving messages in different orders.
These situations are not necessarily bugs. For example, a program that generates a random password is expected to produce different outputs. A race condition, on the other hand, is a correctness problem when an uncontrolled execution order violates the program's intended behavior.
The distinction matters: different output does not automatically mean a race condition. The key question is whether the observed variation is expected or whether it breaks a required guarantee.
9. Why Are Concurrency Bugs So Difficult to Debug?
Concurrency bugs are often intermittent. Adding a log statement, running a debugger, or changing machine performance can alter thread timing and make the problem disappear temporarily.
This is sometimes described as a Heisenbug: a bug whose behavior changes when you attempt to observe or investigate it.
For example, a logging statement may slow one thread enough to prevent two operations from overlapping in the problematic way. The code then appears to work, even though the underlying race condition remains.
A few techniques can help:
Reproduce the issue under load. Run tests repeatedly and increase the number of concurrent operations. This can make timing-sensitive problems easier to expose.
Inspect shared mutable state. Identify variables, collections, counters, caches, and objects that multiple threads can access.
Use concurrency-aware testing tools. Stress tests and specialized race-detection tools can reveal problems that ordinary unit tests miss. Tool support varies by programming language and runtime.
Prefer deterministic tests where possible. Use controlled executors, synchronization barriers, and explicit coordination to test particular interleavings instead of relying entirely on timing.
Review the synchronization design. Check whether locks, atomic operations, thread-safe collections, or immutability are needed to maintain the intended invariants.
Most importantly, do not assume that a bug is fixed simply because you cannot reproduce it anymore.
10. How Can Developers Prevent Race Conditions?
The most effective strategy is to design concurrent code so that correctness does not depend on an accidental execution order.
A few practices are especially useful:
- Minimize shared mutable state. If threads do not share mutable data, many race conditions disappear.
- Use thread-safe abstractions. Prefer well-tested concurrent collections and atomic classes over manually coordinating every operation.
- Keep critical sections small. Protect related state changes without unnecessarily blocking unrelated work.
- Use immutable data when practical. Objects that cannot change after creation are easier to share safely.
- Avoid assuming a particular thread order. If one operation must happen before another, establish that relationship explicitly.
- Test under realistic concurrency. A program that passes single-threaded tests may still fail under simultaneous requests.
In web applications, for example, a shared in-memory counter may seem harmless during local development but behave incorrectly when many requests arrive simultaneously. Similarly, two API requests updating the same database record can overwrite one another if the database operations do not enforce the required consistency.
Database transactions, atomic SQL updates, optimistic locking, and uniqueness constraints can help enforce business rules at the data layer. A process-level lock alone is not enough when multiple application instances can update the same database.
Conclusion
A program can produce different results every time it runs even when its code and input remain unchanged. One important reason is that concurrent operations may execute in different orders, exposing assumptions that the program makes about shared state.
Race conditions occur when those timing differences affect correctness. Thread scheduling influences when operations execute, while locks, synchronization, atomic variables, and careful state management help ensure that the final result remains correct.
The deeper lesson is that concurrent programming is not simply about making tasks run faster. It is about coordinating operations so that the system behaves correctly even when their execution order changes.
When writing concurrent software, never rely on the order you happened to observe. Establish the guarantees your program actually needs.
Top comments (0)