Skip to content
BytePatterns

Mutex vs Lock: Reentrant Locks, Ownership and Granularity

8 min readBytePatterns

Mutex vs lock explained: why they are usually the same thing, reentrant vs plain locks, who may release a lock, and how lock granularity decides throughput.

"What is the difference between a mutex and a lock?" is a question with a short answer and a long one. The short answer: usually none. A mutex, short for mutual exclusion, is a lock that one thread holds at a time, and most libraries simply call it a lock. The long answer is about the properties that differ between locks in practice: whether the holder can enter again, whether only the holder may release, and how much data one lock guards.

The problem it solves

Threads that share memory need a sequence of steps to happen without interference: read a balance, check it, write it back. Race conditions shows how interleaving those steps loses an update. A lock turns the sequence into a critical section: take the lock before the first step, release it after the last, and any other thread that asks in between waits.

The rule is strict: every access to the shared state goes through the same lock. A lock protects data only by convention; nothing stops unguarded code from touching it.

The intuition

Three properties distinguish the locks you will meet:

  • Reentrancy. A reentrant (recursive) lock lets the thread that holds it acquire it again, counting how many times, and frees it only when every acquire is matched by a release. A plain lock does not: a thread that asks twice waits for itself forever. Reentrancy is convenient when locked methods call other locked methods, but it hides how long a lock is really held.
  • Ownership. Some locks remember which thread holds them and refuse a release from anyone else. Others are just a flag that any thread may clear. Ownership catches bugs; the absence of it allows a lock to be handed off between threads.
  • Granularity. One lock around a whole data structure is simple and correct, but every thread queues behind it. Striping splits the data into slices, each with its own lock, so threads touching unrelated keys never wait for each other. Finer locks buy throughput and cost reasoning: an operation that touches two slices must take two locks, in a fixed order, or risk deadlock.

The names vary by language. As of October 2026: Python's threading.Lock is neither reentrant nor owned, while threading.RLock is both. Java's synchronized blocks and ReentrantLock are reentrant. Go's sync.Mutex is not reentrant and is not tied to a goroutine. C++ separates std::mutex from std::recursive_mutex. In C#, the lock statement is an in-process monitor, while the Mutex class is an operating-system object that can be shared between processes, the one place where "mutex" names something heavier than "lock". Check your own runtime's documentation before relying on any of these.

Two relatives: a spinlock busy-waits instead of sleeping, worth it only for very short critical sections, and a read-write lock admits many readers or one writer. For more than one holder, use a semaphore.

Watch it run

The animation shows the mutex as one token: whoever holds it may touch the counter, and everyone else waits. Thread A acquires the lock before it touches anything shared. B asks for the same token, finds it taken, and simply stops. Inside the guard, A does the whole read-modify-write, and 0 becomes 1. A releases, and with lock: hands the token back even when the body raises. B wakes, takes the token, and only now looks at the counter: it reads 1, never the stale 0, adds one and stores 2. B releases, and four threads making 50,000 bumps each still print 200000. The last two frames are the rules: the guard must span the whole read-modify-write, because a gap between read and store is the race again, and every access must go through the same lock, because one unguarded read is all it takes.

Locks and Mutexes

Step 1 of 10

A mutex is one token. Whoever holds it may touch the counter; everyone else waits.

The same interactive animation as the lesson — step through it with the controls.

The code

Reentrancy and ownership, shown with Python's two locks. A thread that already holds a lock asks for it again, with a timeout so the plain lock's self-deadlock ends instead of hanging; then a second thread tries to release a lock it never took:

import threading

def enter_twice(lock):
    """The same thread asks for a lock it already holds."""
    with lock:
        again = lock.acquire(timeout=0.2)    # without a timeout, a Lock waits forever
        if again:
            lock.release()
        return again

print(enter_twice(threading.Lock()), enter_twice(threading.RLock()))   # False True

plain = threading.Lock()
plain.acquire()
helper = threading.Thread(target=plain.release)     # a different thread releases it
helper.start(); helper.join()
print(plain.locked())                                # False

owned, errors = threading.RLock(), []
owned.acquire()
def intruder():
    try:
        owned.release()
    except RuntimeError as e:
        errors.append(type(e).__name__)
helper = threading.Thread(target=intruder)
helper.start(); helper.join()
owned.release()
print(errors)                                        # ['RuntimeError']

The plain Lock refused its own holder and let a stranger release it; the RLock did the opposite on both counts. Now granularity: a counter map split into stripes, each guarded by its own lock. Each stripe records how many threads were ever inside it at once. On 20 seeded random workloads with up to six real threads, the result must equal a brute-force serial count with no threads and no locks, and no stripe may ever have had two threads inside:

import random
from collections import Counter

class StripedCounter:
    """Many locks, each guarding a slice of the keys, so unrelated keys do not queue."""
    def __init__(self, stripes=8):
        self.locks = [threading.Lock() for _ in range(stripes)]
        self.shards = [Counter() for _ in range(stripes)]
        self.inside = [0] * stripes                  # threads inside each stripe right now
        self.peak = [0] * stripes                    # the most ever inside at once

    def add(self, key):
        i = hash(key) % len(self.locks)
        with self.locks[i]:                          # read, add and store under one lock
            self.inside[i] += 1
            self.peak[i] = max(self.peak[i], self.inside[i])
            self.shards[i][key] += 1
            self.inside[i] -= 1

    def snapshot(self):
        total = Counter()
        for lock, shard in zip(self.locks, self.shards):
            with lock:
                total.update(shard)
        return total

rng = random.Random(34)
ok = True
for _ in range(20):
    plans = [[rng.randrange(50) for _ in range(rng.randint(100, 3000))] for _ in range(rng.randint(2, 6))]
    counter = StripedCounter(rng.choice([1, 4, 8, 16]))
    workers = [threading.Thread(target=lambda p=p: [counter.add(k) for k in p]) for p in plans]
    for w in workers: w.start()
    for w in workers: w.join()
    serial = Counter(k for p in plans for k in p)   # brute force: one thread, no locks
    ok &= counter.snapshot() == serial and max(counter.peak) == 1
print(ok)                                            # True

A snapshot that takes the stripes one at a time is consistent per stripe, not across all of them; a total that must be exact at one instant needs every lock, in index order.

The complexity

  • Uncontended acquire and release: a few atomic instructions, cheap enough to ignore in most code.
  • Contended: the waiting thread does no work, so the critical section runs serially. Throughput is bounded by 1 / (time held) per lock.
  • Striping with s locks: up to s threads make progress at once on unrelated keys, at the cost of s locks and ordered acquisition for multi-key operations.

Where it goes wrong

  • Re-entering a plain lock. A locked method calling another locked method on the same object deadlocks the thread with itself.
  • Releasing from the wrong thread. A plain Python Lock allows it, silently losing exclusion.
  • Slow work while holding a lock. Network or disk I/O inside a critical section serialises everyone; copy what you need, release, then do the slow part.
  • Two locks, two orders. Fine-grained locking is where deadlocks come from; take locks in one global order.

When it shows up in interviews

As "mutex vs lock", "mutex vs semaphore", "what is a reentrant lock?" and "how would you make this class thread-safe?", in backend and systems rounds. Follow-ups ask how to reduce contention, which leads to striping, read-write locks, compare-and-swap and designs that avoid shared state, covered in thread safety.

How to say it in an interview

"A mutex is a mutual-exclusion lock, so in most libraries the two words mean the same thing: one holder at a time, everyone else waits. The real differences are properties. A reentrant lock lets its holder acquire it again; a plain one deadlocks. An owned lock refuses a release from another thread. And granularity: one lock per structure is simple, while striping by key lets unrelated work run in parallel but needs a fixed lock order for multi-key operations. I keep critical sections short, cover the whole read-modify-write, and release with a scoped block."