Beginner14 min readConcurrency & Thread Safetylive prototype

Locks, Mutex, Semaphore

When two threads touch the same data at the same time, the data breaks. A mutex fixes it like the key to a single-occupancy toilet: one key, one person inside, everyone else waits in the corridor. A semaphore is the barrier at a car park with 3 spaces: it doesn't care who you are, it just counts — three cars in, the fourth waits for someone to drive out. These two tools guard almost every piece of shared state you will ever write.

The idea

What it is

Two threads. One shared variable. Both run balance = balance + 1. You would expect the number to go up by two — and sometimes it does. But that one line is really three steps: read the value, add one, write it back. If both threads read 100 before either writes, both write 101, and one increment vanishes forever. Nothing crashed, no exception was thrown — the number is just quietly wrong. That is a data race, and it is the bug this whole lesson exists to prevent.

The stretch of code that touches shared data — those three steps — is called the critical section. The rule is simple: only one thread at a time may be in it. The tool that enforces the rule is a lock, also called a mutex (short for mutual exclusion). Picture the single-occupancy toilet at a café with exactly one key hanging behind the counter. You take the key, go in, lock the door, do your business, come out and hang the key back. There is no rule about who goes next and no schedule — the key itself makes it impossible for two people to be inside at once.

A semaphore answers a different question. It is not a key, it is a counter of permits. Think of the barrier at a small car park with 3 spaces: the barrier lifts while a space is free, and once all three are taken it stays down until someone drives out. It never asks who you are. A semaphore with 1 permit behaves like a mutex, and the shorthand is worth memorising: a mutex asks "is anyone inside?", a semaphore asks "how many are inside?"

The one sentence to remember

Mutex = one key, one holder at a time (mutual exclusion). Semaphore = N permits, so up to N threads may be inside at once. Both follow the same rhythm — acquire → work → release — and the release must happen no matter what, which is why it lives in a finally block (Java/Python), a destructor (C++ RAII), or a defer (Go).

You are already surrounded by these

A database connection pool that allows 10 connections is a semaphore with 10 permits. A rate limiter that permits 5 concurrent uploads is a semaphore. Java's synchronized keyword, Python's with lock:, C++'s std::lock_guard, and Go's mu.Lock() are all mutexes. Even the SELECT ... FOR UPDATE row lock in your database is the same idea, one layer down.

Mechanics

How it works

The critical section

A critical section is any piece of code that reads and writes state shared with other threads — a counter, a list, a cache entry, a file handle, a bank balance. Its danger is not the code itself but the fact that a thread can be paused by the operating system between any two machine instructions. Your innocent balance++ can be interrupted right after the read and before the write, and another thread can slip in during that gap. You never get to choose when that happens, so you must make the gap impossible to exploit.

  • Read — thread A loads balance (100) into a register.
  • Modify — thread A computes 101 in its own registers. Meanwhile thread B also loads balance and still sees 100.
  • Write — A stores 101. Then B stores 101 as well. Two increments happened; the counter moved by one. That is a lost update.

Mutex: acquire → work → release

A mutex has exactly two operations. acquire() (also called lock()) says "give me the key; if someone else has it, put me to sleep until it is free." release() (unlock()) hands the key back and wakes one waiting thread. Between those two calls, you are guaranteed to be alone. The critical rule is that the release must be unconditional — if your code throws an exception between acquire and release and you skip the unlock, the key is gone forever and every other thread blocks until the process is killed.

java
private final Lock lock = new ReentrantLock();
private int balance = 100;

void deposit() {
    lock.lock();              // acquire — blocks if someone else holds it
    try {
        balance = balance + 1;   // the critical section: read → +1 → write
    } finally {
        lock.unlock();        // release — ALWAYS, even if the body throws
    }
}

Every language spells that safety net differently, but it is always the same idea: tie the release to leaving the block, not to reaching a line of code. Java uses try/finally (or synchronized, which unlocks automatically). C++ uses std::lock_guard, whose destructor unlocks when the scope ends — that is RAII, resource acquisition is initialisation. Python uses with lock:. Go uses defer mu.Unlock() on the line right after mu.Lock(), so the two are impossible to separate by accident.

synchronized and reentrancy, in two lines

In Java, every object carries a built-in lock called its monitor. Writing synchronized (obj) { ... } acquires that object's monitor for the block, and marking a method synchronized locks this for the whole method — no explicit unlock needed, the JVM releases it when the block exits, even on an exception. Java's locks are also reentrant: the thread that already holds a lock can acquire it again without blocking itself (a counter goes up, and the lock is only truly free when it hits zero). That is what lets one synchronized method safely call another on the same object. Not every lock is reentrant, though — a plain POSIX mutex or a Go sync.Mutex will deadlock against itself if you lock it twice in the same goroutine.

Semaphore: a counter, not an owner

A semaphore holds an integer: the number of permits available. acquire() takes one permit and blocks while the count is zero; release() gives one back and wakes a waiter. Set it to 3 and three threads run inside the guarded region simultaneously. Two consequences of "it is only a counter" catch beginners out:

  • There is no owner. A mutex remembers who locked it, and in most libraries only that thread may unlock it. A semaphore does not — any thread may call release(), even one that never acquired. That is a feature: it lets one thread signal another (a producer releasing a permit that a consumer is waiting on).
  • A binary semaphore (1 permit) ≈ a mutex, but without ownership, and usually without reentrancy. It gives you mutual exclusion; it does not give you the safety checks or the "same thread must unlock" discipline of a real mutex. When you want mutual exclusion, use a mutex; use a semaphore when you want to cap how many.
  • N > 1 permits is not mutual exclusion. A semaphore of 3 happily lets three threads corrupt the same counter together — exactly what the prototype shows. It limits concurrency; it does not make a critical section safe.

Blocking, tryLock and timeouts

A plain lock() blocks — the thread sleeps until the lock is free, however long that takes. Sometimes you would rather not wait forever: tryLock() returns false immediately if the lock is taken, and tryLock(200, MILLISECONDS) waits only as long as you allow, then gives up and returns false. Use them when you have something useful to do instead of waiting (serve a cached value, return "busy, try again", fail a request cleanly rather than piling up threads).

Keep critical sections small — and never take two locks in different orders

A lock is a queue: while you hold it, every other thread that wants it is frozen. Do slow work — network calls, disk I/O, logging, waiting on a user — outside the lock, and hold it only for the few lines that touch shared memory. And a second, sharper trap: if thread A locks x then wants y, while thread B locks y then wants x, both wait forever. That is a deadlock, and the standard cure is to always acquire multiple locks in one globally agreed order.

Locks are not free

An uncontended lock is cheap (often just an atomic instruction), but a contended one is not: the loser is put to sleep by the OS and woken later, costing microseconds and a context switch. If a single counter is all you are protecting, an atomic type (AtomicInteger, std::atomic<int>, sync/atomic) does the job lock-free. Locks earn their keep when you must keep several pieces of state consistent together.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Four threads (T1–T4) on the left, a door in the middle, and a room on the right holding one shared number: balance: 100. Each thread does the same tiny job — read the balance, add 1, write it back — so four threads should leave 104 behind. On the 🔒 Mutex tab flip the chip to 🔓 No lock and press ▶ Run all: everybody strolls in together, two threads read 100 at the same moment, and the counter ends on a red 102. Flip back to 🔒 With lock and run again — now only one card turns green inside, the door shows holder: T1, the other three sit amber in the waiting queue, and each ✅ release lets exactly one more in until the balance reads a green 104 ✓. The 🎟 Semaphore tab turns the same door into a counting barrier: + / − sets permits: 3 / 3, and ▶ Acquire all lets three threads in at once while one waits. Drop the permits to 1 and it behaves exactly like the mutex. Watch inside: 2/3 and waiting: 1 in the caption — that pair of numbers is the difference between the two tools.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Break it first — 🔓 No lock

On the 🔒 Mutex tab, click the chip so it reads 🔓 No lock, then hit ▶ Run all. All four threads walk straight into the room: T1 and T2 both show read 100 and both write 101, then T3 and T4 both read 101. The card flashes red and lands on balance: 102 when four +1s should have produced 104. Two updates were lost — and nothing errored. That is exactly what an unguarded critical section looks like.

try 02

Fix it — 🔒 With lock

Flip the chip back to 🔒 With lock (the board resets to 100) and press ▶ Run all. Only one card goes green inside the room; the door shows holder: T1 with its single 🔑, and the caption reads inside: 1/1 · waiting: 3 while the other three sit amber. Now click ✅ release four times: each release writes the value and admits exactly one waiter, and the balance climbs 101 → 102 → 103 → 104 ✓ in green. Same four threads, same work — one key made it correct.

try 03

Count instead of own — 🎟 Semaphore

Switch to the 🎟 Semaphore tab (permits: 3 / 3) and hit ▶ Acquire all. Three threads get in together — inside: 3/3 · waiting: 1 — because a semaphore limits how many, not who. Click ✅ release repeatedly and watch the balance end on a red 102 again: three threads that all read 100 still overwrite each other. Now press − twice to reach permits: 1 / 1 and re-run: one in at a time, and the balance finishes on 104 ✓. A binary semaphore behaves just like a mutex — minus the owner.

In practice

When to use it — and what trips people up

Reach for a mutex when...

  • Two or more threads read and write the same state — a counter, a map, a cached object, a file. If even one thread writes, the access needs guarding.
  • Several fields must stay consistent together — moving money between two accounts, updating an index and its list. Atomics fix one variable; a lock lets you keep an invariant.
  • The guarded work is short — a handful of lines with no I/O, no waiting, no calls into code you do not control.

Reach for a semaphore when...

  • You want to cap concurrency, not exclude it — at most 10 open database connections, 5 parallel uploads, 3 workers hitting a fragile downstream API.
  • One thread needs to signal another — a producer release()s a permit that a waiting consumer acquire()s. A mutex cannot do this, because only the owner may unlock it.

Skip both when...

  • Nothing is shared — thread-local or immutable data needs no lock at all. The cheapest synchronisation is not sharing.
  • It is a single counter or flag — an atomic type is faster and cannot be forgotten to unlock.
  • A higher-level tool already fits — a ConcurrentHashMap, a blocking queue, an actor, or a Go channel hides the locking and is much harder to get wrong.

What it gives you

  • Correctness — mutual exclusion makes lost updates and torn state impossible, turning read-modify-write into an all-or-nothing step.
  • Simple mental model — acquire, work, release; one key or N permits, and the same three calls in every language.
  • Semaphores give you a throttle — cap concurrent connections, uploads or workers with a single counter instead of custom queueing.
  • Composable with invariants — a lock can protect several fields at once, which atomic variables alone cannot do.

Common mistakes

  • Deadlock — take two locks in different orders and both threads wait forever; the program simply stops with no error.
  • Throughput loss — while one thread holds the lock, every other contender is asleep, so a fat critical section serialises your whole system.
  • Easy to forget the release — an early return or a thrown exception without finally/RAII/defer leaves the lock held permanently.
  • Semaphores have no owner and no reentrancy — a stray release() inflates the permit count silently, and self-locking a non-reentrant mutex deadlocks against itself.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private final Lock lock = new ReentrantLock();  // reentrant: same thread may re-enter
    private int balance = 100;

    // --- MUTEX: one key, one holder --------------------------------
    void depositWithLock() {
        lock.lock();                       // acquire (blocks while taken)
        try {
            balance = balance + 1;         // critical section: read → +1 → write
        } finally {
            lock.unlock();                 // release — ALWAYS, even on exception
        }
    }

    // The keyword version: every object has a built-in monitor lock.
    synchronized void depositSynchronized() {
        balance = balance + 1;             // JVM locks 'this' and unlocks on exit
    }

    // Don't want to wait forever? Ask, don't block.
    boolean depositIfFree() {
        if (!lock.tryLock()) return false; // taken → do something else instead
        try { balance = balance + 1; return true; }
        finally { lock.unlock(); }
    }

    int get() { synchronized (this) { return balance; } }
}

// --- SEMAPHORE: N permits, no owner --------------------------------
class ConnectionPool {
    private final Semaphore permits = new Semaphore(3);   // 3 cars, 3 spaces

    void query(String sql) throws InterruptedException {
        permits.acquire();                 // blocks when all 3 are in use
        try {
            System.out.println("running " + sql +
                " · free permits: " + permits.availablePermits());
            Thread.sleep(50);              // pretend this is real I/O
        } finally {
            permits.release();             // any thread may release a permit
        }
    }
}

public class Demo {
    public static void main(String[] args) throws Exception {
        Counter c = new Counter();
        Thread[] ts = new Thread[4];
        for (int i = 0; i < ts.length; i++) {
            ts[i] = new Thread(c::depositWithLock);
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        System.out.println("balance = " + c.get());   // always 104
    }
}

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

Four threads each run balance = balance + 1 on a shared balance of 100, with no lock. Why can the result be 102 instead of 104?

question 02 / 06

What is the difference between a mutex and a semaphore?

question 03 / 06

Why must unlock() live in a finally block (or a C++ destructor, or a Go defer)?

question 04 / 06

Which statement about a semaphore's release() is true?

question 05 / 06

You protect a shared counter with a semaphore initialised to 3 permits. Is the counter safe?

question 06 / 06

Thread A locks accountX then tries to lock accountY, while thread B locks accountY then tries to lock accountX. What happens, and what is the standard fix?

0/6 answered