Multithreading Concurrency

Locks & Semaphores

The explicit locking toolkit beyond synchronized. ReentrantLock with unlock in finally, and the leak when it isn't; tryLock with a timeout so a thread can give up instead of waiting forever; why an idle user must never hold a lock, and the seat hold with an expiry time that replaces it; ReadWriteLock for read-heavy data; and Semaphore for capping how many threads use something at once, from device limits to connection pools. Ends with how monitors, locks, mutexes and semaphores differ. Every example runs in Java and Python, with verified output.

Suggest an edit

Locks & Semaphores — Explicit Control Over Who Runs When

synchronized is enough for most code that must run one thread at a time, but it has limits. A thread waiting for a monitor waits as long as it takes; it cannot give up after a timeout. And a monitor always admits one thread, even when several threads only want to read. A booking system needs timeouts and shared reading, and it also needs to limit how many devices or database connections are in use at the same time.

The java.util.concurrent.locks package and the Semaphore class provide those controls. This lesson applies them to a ticket-booking system. Along the way it replaces a tempting but broken idea, a lock that "expires" when a user goes idle, with the design that real booking systems use.

💡 The core idea.

  • ReentrantLock is a lock you take and release with explicit method calls, so unlock() must go in a finally block. In return you get waiting with a timeout (tryLock), waiting that can be interrupted, an optional first-come-first-served mode, and condition variables.
  • A lock should protect a few lines of code, not a user's whole session. A reservation that lasts minutes should be stored as data with an expiry time.
  • A ReadWriteLock lets many readers in at the same time. A Semaphore lets up to N threads in at the same time, and no thread owns it.

This builds on Thread Safety & Synchronization. The Java API details of these classes are in the Java guide's Concurrency: Coordination; this lesson uses them to solve design problems. Every output below was produced by running the code on Java 21 and Python 3.11.

You'll be able to: guard a critical section with ReentrantLock and explain why unlock() belongs in finally; use tryLock(timeout) to bound a wait; design a seat hold that expires instead of a lock held across user think time; tell when a ReadWriteLock pays off; cap concurrent access with a Semaphore, failing fast or waiting; choose between a monitor, a ReentrantLock and a Semaphore.

📘 How to read the Intuition boxes. Each one is built in three moves:

  1. The mechanism — what the lock or semaphore does.
  2. A concrete bite — a specific, runnable failure, shown so the trap is visible.
  3. The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.

Table of contents

  1. ReentrantLock: lock, and unlock in finally
  2. tryLock: waiting with a limit
  3. Never hold a lock while a user thinks
  4. ReadWriteLock: many readers, one writer
  5. Semaphore: at most N at a time
  6. Monitor, lock, mutex, semaphore: choosing
  7. Mental-model summary
  8. Gotcha checklist
  9. Check yourself
  10. Sources

1. ReentrantLock: lock, and unlock in finally

ReentrantLock does the same job as a synchronized block, but as an object with methods you call [1]. Like a monitor, it is reentrant (the thread holding it can lock it again) and owned (only the thread that locked it may unlock it). Here, three users try to book one seat:

Output (illustrative — which user wins varies per run; exactly one always books):

user-1 booked
user-2: sold out
user-3: sold out

Analysis. One user booked and two found the show sold out. The pattern is always the same: call lock(), open a try, do the protected work, and call unlock() in finally. Python's Lock follows the same shape with acquire() and release(), and with self._lock: writes that pattern for you.

Intuition. Mechanism. A synchronized block releases its monitor automatically whenever execution leaves the block, however it leaves. An explicit lock is released only when unlock() actually runs. If an exception skips that call, the lock stays held, even after the thread that owns it has ended.

Concrete bite. If unlock() sits at the end of the method instead of in finally, an exception in between (a declined payment, say) skips it. The lock then stays held after the thread that took it has ended. Every later caller waits forever, and nothing in the program can release it. The Java guide runs that failure.

💡 Earned rule. Write lock.lock(); try { … } finally { lock.unlock(); } every time, with nothing that can throw between lock() and try. When you don't need the extra features listed below, prefer synchronized (or Python's with), because it can never be left locked by mistake.

The cost of an explicit lock is that you must follow this pattern every time. The benefit is the features synchronized lacks: waiting with a timeout or waiting that can be interrupted (tryLock, lockInterruptibly), a fair mode that gives the lock to threads in the order they asked for it (new ReentrantLock(true)), and several condition variables per lock (newCondition(), used in Producer-Consumer).


2. tryLock: waiting with a limit

A thread waiting for a monitor cannot stop waiting. tryLock() returns false immediately if another thread holds the lock, and tryLock(timeout, unit) waits for at most the given time [2]. Here, Alice takes one second to pay, and Bob is willing to wait half a second:

Output:

alice has the lock
bob gave up after 500 ms: try again later
alice released the lock

Analysis. Bob arrived while Alice held the lock. He waited 500 ms, then gave up and printed a message instead of hanging. Alice finished afterwards and released the lock. In Python, acquire(timeout=0.5) does the same.

Intuition. Mechanism. tryLock(timeout) returns true if it got the lock, or false if it didn't. Only call unlock() when it returned true: unlocking a lock you don't hold throws IllegalMonitorStateException.

Concrete bite. With a timeout, the caller can no longer simply wait forever; it has to decide what to do instead: retry, show "this seat is busy, please try again", or fail the request. It is also one of the ways out of deadlock.

💡 Earned rule. In any code that serves a user, put a time limit on every lock wait with tryLock(timeout), and decide what happens when the wait fails.

The cost is an extra failure path to write and test. The benefit is a system that answers "busy, please try again" under pressure, instead of freezing.


3. Never hold a lock while a user thinks

A ticket site lets a user pick a seat, then fill in payment details. Suppose the booking thread locks the seat when it is picked and unlocks it after payment. A user who walks away from the screen then keeps the lock indefinitely, and everyone else waits.

The tempting fix is a lock that "expires": a timer that releases it after a few minutes. That can't work with ReentrantLock, because only the thread that owns the lock may unlock it; a timer thread that calls unlock() gets an IllegalMonitorStateException. A lock with no owner, such as a Semaphore or Python's plain Lock, can be released by a timer, but that is worse. The original thread may still be inside its protected code, and the next user's thread would now be running that code at the same time.

The real problem is the design. A lock should protect a few lines of code for a few microseconds. A reservation that lasts minutes is data: record who holds the seat and until when, and use a lock only while changing that record.

Output:

alice: holding A1
bob: A1 is held by alice
bob: holding A1
alice: hold on A1 expired, cannot confirm
bob: confirmed A1

Analysis. Alice held seat A1 and went idle. Bob was refused while her hold was still valid. After it expired, Bob took the seat. When Alice came back, her confirmation was rejected, and Bob's went through. No thread ever waited for a lock longer than the few microseconds each method takes; synchronized (or with self._lock:) only protects the updates to the maps.

Intuition. Mechanism. The expiry time is checked only when someone looks at the hold. No timer needs to fire: an expired hold simply stops counting. A cleanup job can delete old entries later, but the booking logic is correct without it.

Concrete bite. Large booking systems work this way. There, the hold is often stored in a database row or in a cache entry with an expiry time, so that it survives restarts and works across many servers.

💡 Earned rule. Hold a lock only while changing shared state. Never hold one while waiting for slow I/O you don't control, or while a user decides what to do. Store long reservations as data with an owner and an expiry time.

The cost is more to design: holds, expiry, and confirmation. The benefit is that no user can freeze the system by walking away.


4. ReadWriteLock: many readers, one writer

Much shared data is read far more than it is written: prices, configuration, a product catalogue. With a plain lock, readers queue up behind each other even though reading changes nothing. A ReadWriteLock has two locks: a read lock, which many threads can hold at the same time as long as nobody is writing, and a write lock, which only one thread can hold, with no readers [3]. Here are three slow readers, first with a plain lock and then with a read lock:

Output:

3 readers, one ReentrantLock:  ~900 ms
3 readers, shared read lock:   ~300 ms

Analysis. With one ReentrantLock, the three 300 ms reads ran one after another: about 900 ms. With the shared read lock, they ran at the same time: about 300 ms. A writer calling writeLock().lock() would wait for all readers to finish, then keep everyone else out while it writes.

Python's standard library has no read-write lock; threading only provides locks that admit one thread at a time. You can build one from a Condition, but the standard library has no ready-made version, so this section has no Python example.

💡 Earned rule. Use a ReadWriteLock when reads are frequent and slow, and writes are rare. For short reads, a plain lock, or an immutable copy of the data stored in a volatile field, is simpler and often faster.

The cost is extra bookkeeping: a read-write lock does more work each time it is taken, and while readers keep arriving, a writer may wait a long time.


5. Semaphore: at most N at a time

A semaphore holds a number of permits. acquire() takes a permit, waiting if none are left. release() gives one back. tryAcquire() takes a permit only if one is available right now [4]. Here, a premium account allows two devices:

Output:

phone logged in
laptop logged in
tv refused: device limit reached
phone logged out
tv logged in

Analysis. There were two permits, so two devices got in. The TV was refused at once by tryAcquire(), without waiting. When the phone logged out and released its permit, the TV got in. In Python, acquire(blocking=False) does the same as tryAcquire().

The waiting form, acquire(), limits how many threads use something at once. Here, eight threads run queries against a database that allows two connections:

Output:

8 queries on 8 threads; most connections in use at once: 2

Analysis. Eight threads were ready to run, but no more than two ever held a connection at the same time. The other six waited in acquire() until a permit was returned. This protects a limited resource however many threads there are, for example with virtual threads.

Intuition. Mechanism. A semaphore is a counter that threads can wait on. It has no owner: any thread may call release(), and nothing checks that it acquired a permit first. That makes it useful for passing permits between threads, and dangerous when a release() is missed or called twice.

Concrete bite. A skipped release() loses a permit forever; after enough of them, nobody gets in at all. A release() without a matching acquire adds a permit, quietly raising the limit. Put release() in a finally block, and only on the code path where the acquire succeeded. In Python, threading.BoundedSemaphore turns an extra release into a ValueError instead of a silent extra permit [5].

💡 Earned rule. Use a Semaphore to limit how many threads use something at the same time: database connections, device sessions, calls to an API with a rate limit. Use tryAcquire to fail immediately, and acquire (or tryAcquire(timeout)) to wait.

The cost is that a semaphore can't tell a correct release() from a buggy one. Keep the acquire and the release in the same method, in a try/finally.


6. Monitor, lock, mutex, semaphore: choosing

Mutex (short for mutual exclusion lock) is the general name for a lock that one thread holds at a time and that only the same thread may release. Java has two: the monitor behind synchronized, and ReentrantLock. A semaphore with one permit also lets in one thread at a time, but it is not a mutex, because nobody owns it.

Monitor (synchronized) ReentrantLock ReadWriteLock Semaphore
Threads inside at once 1 1 many readers, or 1 writer up to N
Owner the locking thread the locking thread the locking thread none
Reentrant yes yes yes no
Released automatically yes, on leaving the block no: unlock() in finally no: unlock() in finally no: release() in finally
Timed or non-blocking attempt no tryLock(…) tryLock(…) tryAcquire(…)
Fair mode no optional optional optional
Waiting for a condition wait/notify Condition objects Condition (write lock) —
Typical use most critical sections timeouts, fairness, several conditions read-heavy shared data capping concurrent use

💡 Earned rule. Start with synchronized. Switch to ReentrantLock when you need a timeout, interruptible waiting, fairness or several conditions; to a ReadWriteLock when slow reads make up most of the work; and to a Semaphore when the limit is N threads rather than 1.

Each step adds more ways to get the release wrong. Take it only when you need the feature it brings.


7. Mental-model summary

Principle Consequence
ReentrantLock is released only by unlock() unlock() in finally, or a thrown exception leaks the lock forever
tryLock(timeout) bounds a wait The caller decides what to do when the lock is busy
Only the owner may unlock a ReentrantLock A timer cannot release it; expiring locks are the wrong design
Long reservations are data with an expiry time Locks guard microseconds of state change, never user think time
A read lock is shared; a write lock is exclusive Slow, frequent reads overlap; writes still exclude everyone
A semaphore counts permits and has no owner Caps concurrency at N; a missed or extra release() changes the limit
A mutex has an owner; a semaphore does not A one-permit semaphore excludes, but anyone can release it

8. Gotcha checklist

Symptom Likely cause Fix
Every thread waits on a lock nobody seems to hold an exception skipped unlock(); the dead owner still holds it unlock() in finally
IllegalMonitorStateException from unlock() the thread doesn't hold the lock (the tryLock failed, or another thread locked it) unlock only on the path where you acquired
Users wait minutes for a seat someone abandoned a lock held across user think time a hold with an expiry time, guarded by a short lock
A request hangs on a lock an unbounded lock() tryLock(timeout) with a failure path
The ReadWriteLock made things slower reads are short, or writes are frequent a plain lock, or an immutable snapshot
More users get in than the limit allows an extra release() added permits pair each release() with a successful acquire
Fewer and fewer users get in over time a missed release() leaked permits release() in finally
Python: the same thread hangs on a lock it holds threading.Lock is not reentrant threading.RLock

✅ Check yourself

One check per objective. Answer before you open anything.

The 🧪 box below: Bob's timeout raised to 1.5 s; a semaphore that is released twice; and a hold duration of 50 ms.
  1. Alice holds the lock for 1 s, and Bob arrives at 100 ms, so a 1.5 s wait is long enough. Bob gets the lock at about 1 s, as soon as Alice releases it.
  2. The second release() adds a permit nobody acquired, so the TV and the tablet both get in and only the watch is refused. Three devices (laptop, TV, tablet) are now logged in, although the limit is two. The semaphore never checks which thread acquired a permit. Python's plain Semaphore does the same; BoundedSemaphore raises ValueError on the extra release.
  3. With 50 ms holds, Alice's hold has already expired when Bob asks at 100 ms, so Bob's first request succeeds: bob: holding A1.

📚 Sources

  1. java.util.concurrent.locks.ReentrantLock, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html
  2. java.util.concurrent.locks.Lock, Java SE 21 API (tryLock, lockInterruptibly, newCondition) — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/Lock.html
  3. java.util.concurrent.locks.ReentrantReadWriteLock, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantReadWriteLock.html
  4. java.util.concurrent.Semaphore, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Semaphore.html
  5. Python 3 documentation, threading — Lock, RLock and Semaphore objects — https://docs.python.org/3/library/threading.html

🧪 Predict, then check.

  1. In section 2, raise Bob's timeout to 1,500 ms. Predict what Bob prints, and roughly when.
  2. In section 5, call account.logout("phone") twice in a row, then log in "tv", "tablet" and "watch". Predict which are refused.
  3. In section 3, make holds last 50 ms instead of 300 ms. Predict Bob's first answer.

Your Turn

Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.

Mark as read