AlgoInsight

System Design

Locks vs. Synchronization vs. Atomic Operations: 3 Ways to Stop Race Conditions

You know race conditions silently corrupt data. Now here's how to stop them. Three tools - locks, synchronization, and atomic operations - each with a different scope, a different cost, and a right time to use it.

By Admin2026-07-1114 min readconcurrencylockssynchronizationatomic-operationsrace-conditionsystem-design

In the previous post, we watched a race condition silently drain a bank account twice - two ₹600 transactions both reading a ₹1,000 balance, both succeeding, ₹1,200 leaving an account that held ₹1,000, with no error and no crash. The lesson was frightening: when multiple threads read and modify shared data at the same time, the data corrupts silently. Today we fix it. There are three main tools for preventing race conditions - locks, synchronization, and atomic operations - and while they share one goal, they differ in scope, in cost, and in when you should reach for each. This post covers all three, and the decision of which to use when.

(This builds directly on the race conditions post - if you haven't seen how the corruption happens, start there.)

Recap: why we need these solutions

Quick recap of the problem. A race condition happens because a sequence like read the balance, check it, update it isn't a single instant action - it's several steps - and another thread can slip in between those steps, working from stale data. Both threads read ₹1,000 before either writes, so both think there's enough money, and both succeed. The result depends on unpredictable thread timing, and it's silently wrong.

Every solution below attacks this the same way: by ensuring that the dangerous sequence - the critical section where shared data is touched - is executed by only one thread at a time, so it completes as an uninterruptible unit. No other thread can slip in mid-sequence and read stale data. Where the three approaches differ is how much they protect and how heavy the protection is.

Approach 1: Locks

A lock ensures that only one thread can access a shared resource at any given point in time. Before a thread modifies the shared data, it acquires the lock - claiming exclusive access. It does its work, then releases the lock. While one thread holds the lock, every other thread that wants that resource must wait until the lock is released.

The resource being locked can be many things: a database row, a file, or an in-memory object. The lock is a gate around that resource, and only the thread holding the key can pass.

Back to the bank: when one thread wants to update the balance, it locks the account record first. Now, while it reads ₹1,000, deducts ₹600, and writes ₹400, no other thread can touch that record. The second transaction, trying to access the same account, is blocked - it waits. Only when the first thread finishes and releases the lock does the second proceed - and now it correctly reads the updated ₹400, sees that's not enough for another ₹600, and fails as it should. The lock forced the two transactions to happen one after another instead of overlapping, and the race condition is gone.

Locks are the most direct and widely-used tool. Their essence: exclusive access to a resource, one thread at a time.

Approach 2: Synchronization

Synchronization extends the idea of locking from protecting a single resource to protecting an entire block of code. Instead of just guarding one piece of data, you wrap a whole sequence of operations - reading, validating, and updating - in a single synchronized block, and guarantee that only one thread can enter and execute that block at a time.

Why does this matter beyond a simple lock? Because often the danger isn't a single operation but a sequence that must happen together, uninterrupted. Consider the bank transaction as three steps: read the balance, validate there's enough, update it. These three must happen as one atomic unit - if another thread slips in between the "validate" and the "update," you're right back to the double-deduction. Synchronization protects the whole sequence: one thread enters the block, does all three steps, and only then can another thread enter. This is crucial whenever multiple steps must happen together without interruption to keep data consistent.

You can think of synchronization as a lock around a region of code rather than around a single resource. The distinction is one of scope: a lock guards access to a resource; synchronization guards execution of a block of operations. Both enforce "one thread at a time," but synchronization does it for a coordinated sequence of steps.

Approach 3: Atomic Operations

Atomic operations take a completely different, lower-level approach. An atomic operation is one so small and indivisible that the CPU itself guarantees it executes as a single, unbreakable step. It either completes entirely or doesn't happen at all - there's no "halfway," and critically, no other thread can interrupt it partway through.

The beauty of atomic operations is that they require no explicit locks and no thread coordination. The hardware provides the guarantee. Because the operation can't be interrupted, there's simply no gap for another thread to slip into - the race condition can't occur, without any locking machinery at all. This makes atomic operations the lightest and fastest option.

The catch: they only work for simple, single-step tasks. Atomic operations are commonly used for things like incrementing a counter, toggling a flag, or updating a metric - small, critical updates where the whole operation is a single indivisible step (like "add one to this number") and full-blown synchronization would be overkill. When your critical section genuinely is one tiny operation, an atomic operation handles it with zero locking overhead. When it's a multi-step sequence, you need a lock or synchronization instead.

# Atomic operation - one indivisible step, no lock needed (great for counters)
atomic_counter.increment()   # the CPU guarantees this completes as a single step

# Lock - exclusive access to a resource for a multi-step update
with account_lock:
    balance = read_balance()
    if balance >= 600:
        write_balance(balance - 600)

# Synchronization - protect a whole block of operations as one unit
synchronized(account):
    balance = read(account)      # read
    if balance >= 600:           # validate
        write(account, balance - 600)  # update - all three run without interruption
graph TD
    A["Atomic Operation<br/>scope: one instruction<br/>cost: lowest"] --> B["Lock<br/>scope: one resource<br/>cost: medium"]
    B --> C["Synchronization<br/>scope: a block of code<br/>cost: higher"]
    A -.->|"counters, flags, metrics"| U1[Single-step updates]
    B -.->|"a DB row, file, object"| U2[Protect one resource]
    C -.->|"read + validate + update"| U3[Multi-step sequences]
This diagram shows the three approaches ordered by scope and cost. An atomic operation has the smallest scope - a single instruction - and the lowest cost, suited to single-step updates like counters, flags, and metrics. A lock has a medium scope - one resource such as a database row, file, or object - and medium cost, used to protect one resource. Synchronization has the largest scope - a whole block of code - and higher cost, used when a multi-step sequence such as read, validate, and update must run as one uninterrupted unit.

The common mindset: pessimistic locking

Step back, and all three approaches share a fundamental assumption: that conflicts will occur. They all proactively prevent concurrent access - a thread must acquire a lock, or enter a synchronized block, or use an atomic operation - before touching the data, on the assumption that another thread might collide with it. This "expect the worst" mindset is called pessimistic locking.

Pessimistic locking prioritizes data safety above all. It proactively blocks concurrent access to guarantee correctness - even if the feared conflict may never actually happen in practice. Every thread pays the cost of coordinating, whether or not a collision would have occurred. It's the cautious approach: assume conflict, prevent it up front, never risk corruption.

(There's a contrasting mindset - optimistic locking - that assumes conflicts are rare, proceeds without locking, and only checks for a conflict at the end, retrying if one occurred. It trades occasional retries for less waiting. But the three approaches in this post are all pessimistic: prevent first, ask questions never.)

The trade-off: data safety vs. performance

Pessimistic locking protects data integrity beautifully - but it comes at a cost, and understanding that cost is what makes you a thoughtful concurrent programmer.

The cost is waiting. When a thread holds a lock, every other thread that needs that resource is blocked, sitting idle. If many threads contend for the same locks, they spend time waiting instead of working, and throughput drops. Data safety is bought with reduced concurrency - the more you lock, the safer and the slower.

And there's a far more dangerous failure lurking in that waiting. When multiple threads lock resources and one thread ends up waiting on another - which is in turn waiting on the first - the system can freeze completely. Thread A holds Lock 1 and wants Lock 2; Thread B holds Lock 2 and wants Lock 1. Each waits for the other forever, neither can proceed, and the program hangs. This is the classic deadlock - threads stuck in a circular dependency, no progress possible.

That's the irony we're building toward: the very locks you add to prevent silent corruption can, if used carelessly, freeze your program solid. Preventing race conditions and avoiding deadlocks are two sides of the same coin, and navigating between them is the real craft of concurrency. Deadlocks - how they happen, and how to prevent them - are exactly what we'll tackle next.

Lock granularity: how much to lock

A subtle but important decision when using locks and synchronization is granularity - how much you lock at once. This is where a lot of real-world concurrency skill lives.

Coarse-grained locking means locking a large scope - for instance, one big lock around your entire account system, so only one thread can do anything with any account at a time. It's simple and unquestionably safe (no two threads ever collide), but it's slow: threads working on completely unrelated accounts still have to wait for each other, because they share the one big lock. You've serialized work that could have run in parallel.

Fine-grained locking means locking a small scope - for instance, a separate lock per account, so a thread updating account A doesn't block a thread updating account B at all; they only wait if they touch the same account. This allows much more concurrency (unrelated work proceeds in parallel) but is more complex to get right, and - importantly - using many small locks is exactly where deadlocks tend to arise, because a thread might need to hold several locks at once, and lock-ordering mistakes creep in.

So granularity is a trade-off: coarse locks are simple and safe but limit concurrency; fine locks maximize concurrency but add complexity and deadlock risk. The skilled move is locking at the right granularity - small enough that unrelated work runs in parallel, but not so fragmented that the code becomes an unmanageable web of locks. This is one of the judgment calls that separates someone who's used locks from someone who understands them.

The same problem at every scale

It's worth noticing that these three tools appear at every level of computing, not just between threads in one program. The bank example is telling: when the "shared data" is a database row touched by multiple servers, the database provides its own versions of these exact tools - row locks (a lock), transactions (synchronization of a multi-step sequence into one atomic unit), and atomic database operations. The concepts you're learning here on threads scale directly up to distributed systems.

This is why the vocabulary carries over: a database transaction is essentially synchronization applied to a sequence of database operations (all succeed together or none do); a database row lock is a lock on that shared resource; and databases offer atomic operations for simple updates. The "one thread at a time in the critical section" principle becomes "one transaction at a time on this data" at the database level, and "one operation at a time on this record" across servers. So mastering locks, synchronization, and atomic operations at the thread level gives you the mental model for concurrency control all the way up to systems spanning many machines - the same three ideas, the same pessimistic mindset, the same trade-offs, just at a larger scale. (This connects directly to the ACID transactions and database locking material.)

When to use what

Here's the practical decision - the part that turns "I know three tools" into "I know which tool to reach for":

The guiding principle: match the tool to the scope of what needs protecting. A single tiny operation → atomic (cheapest). A single resource → lock. A multi-step sequence → synchronization. And in all cases, protect only what needs it - over-locking costs performance and invites deadlocks, so guard the critical sections and nothing more.

Interview: Interviewers love asking how you'd prevent a race condition, and the strong answer names all three tools and distinguishes them: locks give exclusive access to a resource; synchronization protects a whole block of operations as one unit; atomic operations are indivisible single steps guaranteed by the hardware, needing no locks. The senior-level depth is when to use which - atomics for simple single-step updates (counters, flags), locks for guarding a resource, synchronization for multi-step sequences that must run together. Add that all three embody a pessimistic mindset (assume conflict, prevent it up front), that the alternative is optimistic locking, and that over-using locks risks deadlocks - and you've shown you understand concurrency as a set of trade-offs, not a single hammer.

The bottom line

There are three main tools to prevent race conditions, differing in scope and cost. Locks give a thread exclusive access to a shared resource (a database row, file, or object) - one thread at a time, others wait. Synchronization extends that to a whole block of code, so a multi-step sequence (read, validate, update) executes as one uninterrupted unit. Atomic operations are indivisible single steps the CPU guarantees, needing no locks - the lightest option, ideal for simple updates like counters and flags. Choose by scope: single step → atomic, single resource → lock, multi-step sequence → synchronization.

All three share a pessimistic mindset - assume conflicts will happen and prevent them proactively - which guarantees data safety at the cost of threads waiting. And that waiting hides a danger: when threads wait on each other in a circle, the program deadlocks and freezes. Preventing race conditions can cause deadlocks - the next problem in the series. We cover locks, synchronization, and concurrency control across System Design Fundamentals.


Go deeper

More articles