Distributed Locking
Lock Requirements: Mutual Exclusion, Deadlock Freedom, and Liveness
Read aloud is unavailable in this browser.
The Properties That Define Correctness
In 1981, Leslie Lamport published a seminal paper on the mutual exclusion problem, establishing formal properties that any lock—local or distributed—must satisfy to be considered correct. These properties aren't academic curiosities; they're the contract that lock implementations must fulfill, and violations of any property lead to system failures.
Understanding these properties is essential because:
- They help you evaluate different lock implementations
- They explain why certain edge cases cause failures
- They reveal the trade-offs that every implementation must make
- They provide a vocabulary for reasoning about correctness
This page formalizes the core requirements: mutual exclusion (safety), deadlock freedom (progress), and fairness/starvation freedom (liveness). We'll see why achieving all three simultaneously in a distributed environment is challenging, and how real implementations compromise.
By the end of this page, you will be able to precisely define the formal properties of distributed locks, understand why each property matters for real systems, recognize how implementations trade off between properties, and identify which properties specific algorithms guarantee or violate.
Mutual Exclusion: The Fundamental Safety Property
Mutual exclusion is the defining property of a lock. Without it, the lock is not a lock at all—it's a coordination service that happens to have a similar API.
Formal Definition:
At any point in time, at most one process may hold the lock. If process P holds the lock, no other process Q can simultaneously hold it.
This seems obvious, but the devil is in the distributed details. What does "simultaneously" mean when there's no shared clock? What does "hold the lock" mean when network partitions can make a holder unreachable?
123456789101112131415
Safety Property: Mutual Exclusion━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Let H(t) = {P₁, P₂, ..., Pₙ} be the set of processes holding the lock at time t Invariant: ∀t: |H(t)| ≤ 1 In words: For all points in time t, the cardinality of the holder set is at most 1. Violation: If at any time t, we have |H(t)| > 1, mutual exclusion is violated. Example violation: t₁: H(t₁) = {A} ✓ Valid - A holds lock t₂: H(t₂) = {} ✓ Valid - No one holds lock t₃: H(t₃) = {A, B} ✗ VIOLATION - Two holdersWhy Mutual Exclusion Is a Safety Property:
In distributed systems theory, we distinguish between safety and liveness properties:
- Safety: "Bad things never happen" — can be violated at a specific moment
- Liveness: "Good things eventually happen" — requires infinite execution to violate
Mutual exclusion is a safety property because a violation is observable at a single point in time: "At time T, both A and B held the lock." This violation can never be undone; the damage is done.
Once mutual exclusion is violated, data corruption is typically irreversible. If two processes simultaneously modify shared state, the resulting state may be neither what A intended nor what B intended, but some impossible hybrid. Unlike availability issues (which are temporary), safety violations can require manual data recovery or complete state reconstruction.
The Distributed Challenge:
In a single-machine system, mutual exclusion is enforced by a single authority: the OS kernel. In distributed systems, there is no single authority, so we must establish consensus about who holds the lock. This is fundamentally a consensus problem, subject to the same impossibility results.
Consider the following scenario:
12345678910111213
Scenario: Three lock servers (L1, L2, L3), process A and process B Time A's view L1, L2 (majority) L3 (partitioned) B's view---- -------- ----------------- ---------------- --------t1 Request lock Receive A's request (partitioned) (idle)t2 (waiting) Grant to A (partitioned) Request lockt3 "I have lock" A is holder Receive B's req (waiting)t4 (in critical A is holder Grant to B "I have lock" section) (thinks A dead) t5 BOTH IN CRITICAL SECTION — MUTUAL EXCLUSION VIOLATED The partition between L3 and {L1, L2} caused L3 to believe A was dead,leading to granting the lock to B while A still held it.This is why serious lock implementations require majority quorums: a lock is only valid if acknowledged by a majority of servers. A holder isolated in a minority partition loses its lock, even if it doesn't know it yet (which is where fencing tokens become essential).
Deadlock Freedom: Ensuring Progress Is Possible
Deadlock freedom ensures that the system can make progress—that lock acquisition requests will eventually be resolved, either by granting the lock or by definitively rejecting the request.
Formal Definition:
If one or more processes are waiting to acquire the lock, and the current holder (if any) eventually releases the lock or is deemed failed, then some waiting process will eventually acquire the lock.
This property ensures the system doesn't permanently freeze. Without deadlock freedom, a lock service could enter a state where no process can ever acquire the lock, even though no process currently holds it.
12345678910111213141516171819202122232425262728293031
Classic Deadlock (Multiple Locks):━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Process A Process B--------- ---------Acquire(Lock1) ✓ Acquire(Lock2) ✓Acquire(Lock2) ⏳ Acquire(Lock1) ⏳ waiting for B waiting for A ↓ ↓ DEADLOCK: Neither can proceed, neither will release Single Lock Deadlock:━━━━━━━━━━━━━━━━━━━━ Process A acquires lockProcess A crashes without releasingNo timeout configuredLock is held foreverAll other processes wait forever ↓ DEADLOCK: System frozen Livelock (Variant):━━━━━━━━━━━━━━━━━━━ Process A tries to acquire, fails, retries immediatelyProcess B tries to acquire, fails, retries immediatelyBoth constantly retry at the exact same momentNeither ever succeeds ↓ LIVELOCK: Progress is attempted but never madeDeadlock Prevention Mechanisms:
Distributed lock implementations employ several strategies to ensure deadlock freedom:
- •Lock Timeouts (TTL) — Every lock has a maximum lifetime. If the holder doesn't refresh or release the lock within this time, it automatically expires. This prevents crashed holders from permanently blocking others.
- •Heartbeats/Lease Renewal — Holders must periodically renew their lease. Failure to renew (due to crash, partition, or slowness) causes lock release. This is more responsive than fixed timeouts.
- •Session-Based Locks — Locks are tied to client sessions. If the session dies (detected via heartbeats), all locks held by that session are released immediately.
- •Try-Lock with Timeout — Instead of blocking indefinitely, lock requests have a maximum wait time. After timeout, the request fails, allowing the caller to retry or take alternative action.
- •Lock Ordering — For systems acquiring multiple locks, requiring locks to be acquired in a consistent global order prevents circular wait conditions.
Lock timeouts prevent deadlocks but introduce safety risks. If the timeout is too short, a slow holder loses its lock while still in the critical section. If too long, a crashed holder blocks others unnecessarily. There is no universally correct timeout value—it depends on expected operation duration, network latency, and failure detection speed.
The Timeout Calibration Dilemma:
| Timeout Length | Advantage | Disadvantage |
|---|---|---|
| Very Short (1-5s) | Fast recovery from holder crashes | False expiration during GC pauses, network hiccups |
| Medium (15-30s) | Balance of recovery speed and stability | Still vulnerable to long GC pauses, partitions |
| Long (60s+) | Stable even under transient issues | Crashed holder blocks others for extended period |
| No Timeout | No false expirations ever | Single crash can deadlock system forever |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
import asynciofrom typing import Optional class DistributedLock: def __init__(self, lock_service, resource_id: str, ttl_seconds: int = 30): self.lock_service = lock_service self.resource_id = resource_id self.ttl = ttl_seconds self.renewal_interval = ttl_seconds // 3 # Renew at 1/3 of TTL self.lock_token: Optional[str] = None self.renewal_task: Optional[asyncio.Task] = None async def acquire(self, wait_timeout: float = 10.0) -> bool: """Attempt to acquire lock with timeout.""" deadline = time.time() + wait_timeout while time.time() < deadline: result = await self.lock_service.try_lock( self.resource_id, ttl=self.ttl ) if result.acquired: self.lock_token = result.token self._start_renewal_loop() return True # Exponential backoff with jitter await asyncio.sleep(random.uniform(0.1, 0.5)) return False # Timeout without acquiring def _start_renewal_loop(self): """Background task to keep lock alive.""" async def renew_loop(): while True: await asyncio.sleep(self.renewal_interval) try: renewed = await self.lock_service.renew( self.resource_id, self.lock_token, ttl=self.ttl ) if not renewed: # Lost the lock (token mismatch or expired) raise LockLostException("Lock renewal failed") except Exception as e: # Notify application that lock is no longer held self._handle_lock_loss(e) return self.renewal_task = asyncio.create_task(renew_loop()) async def release(self): """Explicitly release the lock.""" if self.renewal_task: self.renewal_task.cancel() if self.lock_token: await self.lock_service.unlock(self.resource_id, self.lock_token) self.lock_token = NoneStarvation Freedom: Guaranteeing Every Request Gets Its Turn
Starvation freedom (also called lockout freedom) is a stronger form of liveness that ensures fairness: every request that waits long enough will eventually acquire the lock.
Formal Definition:
If process P requests the lock, P will eventually acquire the lock (assuming P remains interested and doesn't fail).
This is stronger than deadlock freedom. Deadlock freedom only guarantees that some process makes progress; starvation freedom guarantees that every process makes progress eventually.
12345678910111213141516171819202122
Deadlock Freedom (weaker):━━━━━━━━━━━━━━━━━━━━━━━━━━ "If there are waiters, someone will get the lock." Allows: A request that waits forever while others continuously acquire and release the lock. Example: Process A requests lock. Processes B, C, D also request. B gets lock, releases, C gets lock, releases, B gets lock again... A never gets the lock, but the system isn't deadlocked. Starvation Freedom (stronger):━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ "If you request the lock, you WILL get it eventually." Forbids: Any scenario where a request waits forever. Guarantee: There is a bounded number of times a request can be "skipped" before it must be granted.Why Starvation Happens:
Without explicit fairness mechanisms, starvation can occur for several reasons:
- •Retry Timing Collisions — If all waiters retry at similar intervals, some may consistently lose the race. A process with slightly longer retry intervals may always arrive just after another process acquires the lock.
- •Network Latency Bias — Processes closer to the lock server (lower latency) may consistently acquire the lock before processes that are farther away, effectively starving distant workers.
- •Priority Inversion — If locks support priorities and high-priority requests keep arriving, a low-priority request may wait indefinitely.
- •Thundering Herd on Release — When a lock is released and many waiters compete simultaneously, the winner may be whoever happens to have the best network conditions at that moment—creating systematic bias.
Fair Queueing: The Solution to Starvation
To guarantee starvation freedom, lock services implement fair queueing (also called FIFO ordering): requests are granted in the order they were received.
123456789101112131415161718192021222324252627
Fair Lock with Queue:━━━━━━━━━━━━━━━━━━━━━ Lock State: - holder: Current lock holder (or null) - queue: [P3, P5, P7, P2] # Waiting processes in FIFO order Operation: acquire(Pi) if holder is null: holder = Pi return SUCCESS else: queue.append(Pi) wait_until_notified(Pi) return SUCCESS Operation: release(Pcurrent) assert holder == Pcurrent if queue is not empty: next = queue.pop(0) # First in queue holder = next notify(next) else: holder = null Guarantee: If P requests at time T, P will be granted the lock after at most |queue_at_T| other grants. No starvation possible.Zookeeper provides fair locks through its sequential ephemeral nodes. Each requester creates a node like /locks/resource/lock-0000000001. The process with the lowest sequence number holds the lock. Others watch the node immediately preceding their own. This creates a queue without central coordination of queue order.
Trade-offs of Fair Queueing:
| Property | Fair (Queued) | Unfair (Racing) |
|---|---|---|
| Starvation | Impossible | Possible under contention |
| Latency (low contention) | Similar | Similar |
| Latency (high contention) | Predictable | Highly variable |
| Server Complexity | Must maintain queue | Stateless possible |
| Client Complexity | Must watch queue position | Simple retry loop |
| Priority Support | Requires priority queue | Natural via retry aggressiveness |
Bounded Wait: Quantifying Liveness Guarantees
An even stronger property than starvation freedom is bounded wait: not only will every request eventually succeed, but there's a known upper bound on how long it must wait.
Formal Definition:
There exists a bound B such that if process P requests the lock, P will acquire the lock within B lock acquisitions by other processes.
Bounded wait transforms a qualitative guarantee ("you will get the lock") into a quantitative one ("you will get the lock within N others").
123456789101112131415161718192021
Bounded Wait with B = 3:━━━━━━━━━━━━━━━━━━━━━━━━ If process P requests at position 5 in queue: - Maximum others served first: 4 (positions 1-4) - P's position when it acquires: ≤ 5 - Bound satisfied: 4 ≤ B would require B ≥ 4 For FIFO queue: B = N-1 where N is queue length when P joined This is optimal—P must wait for everyone ahead of it. For priority queue: B depends on arrival rate of higher priorities In worst case (infinite high-priority arrivals): B = ∞ (no bound) Example of bounded wait violation: P joins queue at position 3 Before P reaches front, 100 new high-priority requests arrive 100 more high-priority requests arrive ... P may never be served (B = ∞) Solution: Aging—requests increase priority over timePractical Implications of Bounded Wait:
Bounded wait matters for system predictability. If you can bound the maximum wait time, you can:
- •Set Meaningful Timeouts — Clients can set request timeouts that are guaranteed to be reached only if something is broken, not due to normal queueing.
- •Design SLAs — Service Level Agreements for lock acquisition latency can be specified with confidence.
- •Capacity Planning — With known bounds, you can calculate the maximum lock service throughput needed for a given workload.
- •Detect Anomalies — If wait time exceeds the bound, you know something abnormal has occurred, enabling faster problem detection.
In practice, distributed lock services rarely provide formal bounded wait guarantees in terms of lock acquisitions. Instead, they provide timeouts and best-effort ordering. True bounded wait requires a queuing discipline that may not be practical in all scenarios (e.g., when lock holders take variable time to complete their work).
Safety vs. Liveness: The Fundamental Trade-off
In distributed systems, we cannot have both perfect safety and perfect liveness. This is a consequence of the FLP impossibility theorem and CAP theorem applied to the locking domain.
The Fundamental Tension:
- •Safety (Mutual Exclusion): At most one holder at a time. Violated if two processes simultaneously believe they hold the lock.
- •Liveness (Progress): Requests are eventually resolved. Violated if requests wait forever without resolution.
These properties conflict during network partitions:
12345678910111213141516171819202122
Scenario: Network partition separates lock service from some clients Option A: Prioritize Safety━━━━━━━━━━━━━━━━━━━━━━━━━━━- Lock service refuses to grant new locks if it can't contact enough nodes- Existing locks may be held indefinitely by unreachable holders- Clients in minority partition cannot acquire locks- Result: System stops making progress, but never violates mutual exclusion Option B: Prioritize Liveness━━━━━━━━━━━━━━━━━━━━━━━━━━━━━- Lock service continues operating in each partition independently- New locks granted on both sides of partition- If same resource is locked on both sides: MUTUAL EXCLUSION VIOLATED- Result: System continues operating, but safety may be compromised No Third Option:━━━━━━━━━━━━━━━━Given: Lock holder in Partition A, new request in Partition BWe cannot determine if A has released without communicating with AIf we grant to B: Safety violation possibleIf we wait for A: Liveness violation (may wait forever)A distributed lock is a form of distributed state. CAP applies: during a partition, you must choose between Consistency (safety—only one holder) and Availability (liveness—requests are processed). You cannot have both. Most production systems choose safety over liveness, accepting that partitions cause lock service unavailability.
How Real Systems Handle This Trade-off:
| System | Choice | Mechanism | Consequence |
|---|---|---|---|
| Zookeeper | Safety first | Majority quorum required | Minority partition loses lock service |
| etcd | Safety first | Raft consensus | No leader election in minority partition |
| Redis (single) | Liveness first | Single server, no consensus | Partition can cause split-brain |
| Redlock (Redis) | Attempts both | Majority of independent servers | Controversial—safety debated |
| DynamoDB Locks | Tunable | Consistent reads optional | Trade-off per operation |
Design Principle: Safety Is Not Negotiable
For correctness locks (not efficiency locks), the industry consensus is clear: never sacrifice safety for liveness. A system that is temporarily unavailable is annoying but recoverable. A system that has violated mutual exclusion may have corrupted data that is permanently lost or irreconcilable.
This is why coordination systems like Zookeeper and etcd prioritize consistency over availability. They would rather refuse to operate than operate incorrectly.
Complete Formal Properties Hierarchy
Let's consolidate all formal properties into a complete hierarchy, from weakest to strongest guarantees:
12345678910111213141516171819202122232425262728293031323334353637383940414243
SAFETY PROPERTIES (Bad things never happen):━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1. Mutual Exclusion ───────────────── Guarantee: At most one holder at any time Violation: Two processes in critical section simultaneously Severity: CRITICAL—data corruption possible Required for: ALL legitimate lock implementations LIVENESS PROPERTIES (Good things eventually happen):━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2. Deadlock Freedom (weakest liveness) ──────────────────────────────────── Guarantee: If waiters exist and holder releases, someone gets lock Violation: System frozen permanently with no holder and no progress Severity: HIGH—system completely unavailable Achieved by: Lock timeouts, crash detection ↓ (stronger) 3. Starvation Freedom (lockout freedom) ───────────────────────────────────── Guarantee: Every requester eventually gets the lock Violation: Some requests wait forever while others succeed Severity: MEDIUM—certain clients/requests starved Achieved by: Fair queuing (FIFO order) ↓ (strongest) 4. Bounded Wait (bounded bypass) ────────────────────────────── Guarantee: At most B others can "cut in line" before any request Violation: Unbounded number of others served before some request Severity: LOW—system works but wait times unpredictable Achieved by: Strict queue ordering, priority aging RELATIONSHIP: Bounded Wait ⟹ Starvation Freedom ⟹ Deadlock Freedom (Each property implies the weaker ones above it)What Real Implementations Guarantee:
| System | Mutual Exclusion | Deadlock Free | Starvation Free | Bounded Wait |
|---|---|---|---|---|
| Zookeeper Locks | ✓ (with caveats) | ✓ (via sessions) | ✓ (via sequence) | ✓ (FIFO) |
| etcd Locks | ✓ (with caveats) | ✓ (via leases) | ✓ (via revisions) | ✓ (FIFO) |
| Redis SETNX | ✗ (no consensus) | ✓ (via TTL) | ✗ | ✗ |
| Redlock | ? (debated) | ✓ (via TTL) | ✗ | ✗ |
| Database SELECT FOR UPDATE | ✓ (single DB) | ✓ (via timeout) | Depends | Depends |
All distributed mutual exclusion has caveats. Zookeeper and etcd guarantee mutual exclusion assuming clocks don't drift unboundedly and sessions are properly managed. The 'with caveats' notation reflects that absolute guarantees are impossible in asynchronous systems—there are always edge cases involving extreme clock skew, prolonged partitions, or Byzantine failures.
Additional Lock Properties for Production Systems
Beyond the core safety and liveness properties, production lock systems often need to satisfy additional requirements:
- •Reentrancy — Can the same holder acquire the lock multiple times? Reentrant locks count acquisitions and require matching releases. Useful for recursive call patterns.
- •Lock Upgrade/Downgrade — Can a read lock be upgraded to a write lock atomically? Can a write lock be downgraded? Useful for read-heavy workloads with occasional writes.
- •Conditional Acquisition — Can a lock be acquired only if certain conditions are met? Useful for leader election with version checks.
- •Lock Chaining — Can multiple locks be acquired atomically to prevent deadlock? Useful for multi-resource transactions.
- •Observability — Can you see who holds the lock, how long they've held it, and who is waiting? Essential for debugging production issues.
- •Graceful Degradation — How does the lock service behave under overload? Can it shed load without corrupting state?
123456789101112131415161718192021222324252627282930313233
interface ProductionDistributedLock { // Core Operations acquire(timeout?: Duration): Promise<LockToken>; tryAcquire(): Promise<LockToken | null>; release(token: LockToken): Promise<void>; // Reentrancy Support acquireReentrant(timeout?: Duration): Promise<LockToken>; releaseReentrant(token: LockToken): Promise<boolean>; // true if fully released // Lock Extension extend(token: LockToken, duration: Duration): Promise<LockToken>; // Observability getHolder(): Promise<LockHolderInfo | null>; getQueueLength(): Promise<number>; getQueuePosition(requestId: string): Promise<number | null>; // Conditional Operations acquireIfVersion(expectedVersion: number, timeout?: Duration): Promise<LockToken>; // Events onLockLost(callback: (reason: LockLostReason) => void): void; onAcquired(callback: (token: LockToken) => void): void;} interface LockHolderInfo { holderId: string; acquiredAt: Date; expiresAt: Date; renewalCount: number; metadata: Record<string, string>;}The onLockLost callback is perhaps the most important feature for production use. When a lock is lost (due to expiration, session death, or explicit revocation), the holder must stop critical section work immediately. Without this callback, a holder may continue modifying shared state after losing the lock, causing corruption.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.