Oh My Learning

Clocks, Logical Time, and Mutual Exclusion

distributed-systems/06 senior drafted · 18 min read

Distributed Systems, 3rd ed. (van Steen & Tanenbaum), Chapter 6

Prerequisites distributed-systems/01 ddia/12

Clocks, Logical Time, and Mutual Exclusion#

TL;DR#

Because nodes have no shared clock (Lesson 01), physical time synchronization (Cristian's algorithm, NTP) can only ever approximate agreement, never guarantee it. Logical clocks sidestep the problem by tracking causality instead of wall-clock time: Lamport timestamps give a total order consistent with causality, and vector clocks let you detect true concurrency (events with no causal relationship). These same tools underpin distributed mutual exclusion - coordinating exclusive access to a resource across machines that cannot simply share a lock variable.

The idea#

On a single machine, "which of these two events happened first?" has an unambiguous answer: the CPU executed one instruction before the other, and a shared system clock timestamps both consistently. Across machines, this question becomes genuinely hard: each machine's physical clock drifts independently, and network delays mean a message's receipt time tells you very little about its true send time relative to your own clock.

This lesson tackles the problem in two parts: 1. Physical clock synchronization - can we make independent clocks agree closely enough to be useful, and how closely? 2. Logical time - if physical clocks can never be perfectly synchronized, can we define an ordering of events that doesn't need wall-clock agreement at all, just an agreement on cause and effect?

The payoff is direct: without some notion of ordering across nodes, you cannot correctly implement mutual exclusion (who gets the lock first?), you cannot resolve concurrent writes in a replicated system (Lesson 08), and you cannot reason about causality in a distributed log or event system. This lesson is the formal-theory counterpart to ddia/12's practical treatment of clocks and process pauses - that lesson focuses on why relying on physical clocks for correctness is dangerous in practice (clock skew, leap seconds, NTP jumps); this lesson builds the theoretical alternative (logical clocks) that avoids the problem entirely.

How it works#

1. Physical clock synchronization: Cristian's algorithm and NTP#

Every machine has a physical clock (a crystal oscillator) that drifts relative to true time and relative to every other machine's clock - drift rates of tens of milliseconds per day are typical for cheap hardware clocks. Clock synchronization tries to keep these independent clocks close to each other (and ideally close to true UTC time), but it can never make them exactly equal, because any synchronization message itself takes a variable, uncertain amount of time to arrive.

Cristian's algorithm is the simplest approach: a client asks a time server for the current time, the server responds with its timestamp T, and the client sets its own clock to T + (round-trip time / 2) - estimating that the response took roughly half the round trip to arrive. This assumes the request and response legs took roughly equal time, which is often false in practice (asymmetric network paths), and it gives no bound on error beyond "half the round-trip time, plus or minus whatever asymmetry existed."

Worked example. A client measures a round-trip time of 40ms to a time server, which responds with timestamp T = 12:00:00.000. The client estimates the one-way delay as 20ms and sets its own clock to 12:00:00.020. If the actual network path was asymmetric (request took 30ms, response took 10ms), the server's timestamp was actually captured 30ms after the client sent the request, not 20ms - so the client's corrected clock is now off by 10ms in the wrong direction. This residual, unavoidable uncertainty is exactly why physical clock synchronization can only ever produce approximate agreement, never an exact one, and why any correctness argument built on "if my clock says X, no other node's clock has passed X yet" is fragile.

NTP (Network Time Protocol) is the real-world, production-grade version of this idea: a hierarchy of time servers (Stratum 0 = precise reference clocks like atomic clocks/GPS receivers; Stratum 1 = servers directly synced to Stratum 0; Stratum 2 = servers synced to Stratum 1, and so on) where each machine synchronizes against a small number of more-authoritative servers, using multiple round-trip samples and statistical filtering to estimate and correct clock offset and drift far more robustly than Cristian's single-request algorithm. Well-run NTP typically keeps machines within a few milliseconds of each other on a LAN, and tens of milliseconds across the wider internet - good enough for logging and monitoring, but still not good enough to safely use as the sole basis for ordering events in a correctness-critical distributed algorithm (this is exactly ddia/12's warning about using wall-clock time for things like lock leases or "last write wins" conflict resolution).

2. Why physical time synchronization is not enough#

Even with excellent NTP synchronization (say, clocks within 1ms of each other), two events that are genuinely only 1ms apart in wall-clock time cannot be reliably ordered using timestamps alone - you cannot tell if the timestamps reflect true order or just clock skew. And more fundamentally: even a perfectly synchronized clock cannot capture causality. If event A on node 1 causes a message that triggers event B on node 2, you need B to be recognized as happening "after" A - but if node 2's clock happens to be running slightly behind node 1's, a naive physical timestamp comparison could show B's timestamp as earlier than A's, violating the causal relationship that actually exists. This is precisely the gap logical clocks are designed to close: they track causality directly, using the fact that causal relationships are established by message passing (a "happens-before" relationship), not by clock readings.

3. Lamport timestamps: a total order consistent with causality#

Leslie Lamport defined the happens-before relation (written ->) capturing causality directly from the structure of events and messages, without reference to physical time: - If A and B occur in the same process (on the same node) and A occurs before B in that process's execution order, then A -> B. - If A is the sending of a message and B is the receipt of that same message (possibly on a different node), then A -> B. - The relation is transitive: if A -> B and B -> C, then A -> C. - If neither A -> B nor B -> A holds, A and B are said to be concurrent (written A || B) - there is no causal relationship between them, even though one might have a "later" real-world timestamp.

A Lamport clock is a simple counter maintained by each process, updated by two rules: 1. Before executing an event, a process increments its own counter: C = C + 1. 2. When sending a message, the process attaches its current counter value. When receiving a message with attached timestamp T_msg, the receiving process sets its own counter to max(C, T_msg) + 1.

This guarantees the clock condition: if A -> B, then C(A) < C(B). (Note the guarantee is one-directional - the converse doesn't hold: C(A) < C(B) does not imply A -> B, since concurrent events get arbitrary relative counter values too.)

Worked example. Three processes, P1, P2, P3, each start with counter 0.

P1:  e1(C=1) ----send m1(C=2)---->
                                  \
P2:  e2(C=1) --recv m1(C=3)-- e3(C=4) --send m2(C=5)-->
                                                        \
P3:  e4(C=1) ------------------------------- e5(C=1) --recv m2(C=6)--

Walking through it: - P1 executes local event e1: counter becomes 1. - P1 sends message m1 carrying timestamp 2 (it increments before sending: counter becomes 2). - P2 executes local event e2: counter becomes 1. - P2 receives m1 (carrying timestamp 2): P2 sets its counter to max(1, 2) + 1 = 3. - P2 executes local event e3: counter becomes 4. - P2 sends message m2 carrying timestamp 5 (increments before sending: counter becomes 5). - P3 independently executes e4 (counter 1) and e5 (counter stays at 1, or increments to whatever P3's local sequence dictates - shown here as continuing its own count, unrelated to P1/P2's events since nothing yet ties them). - P3 receives m2 (carrying timestamp 5): P3 sets its counter to max(P3's prior count, 5) + 1 = 6.

Now: e1 -> m1's receipt -> e3 -> m2's send -> m2's receipt, and indeed the Lamport counters respect this: 1 < 3 < 4 < 5 < 6. But e4 and e5 on P3 are concurrent with e1, e2, e3 on P1/P2 (no message links them) - yet they still get counter values that could numerically compare in either direction with P1/P2's events. This is the key limitation: Lamport timestamps give you a valid total order (useful for things like deciding a consistent order for a distributed log), but they cannot tell you whether two events were actually causally related or merely happened to get sequential numbers. To detect true concurrency, you need vector clocks.

Totally ordering with ties. Since two different processes can end up with the same counter value for unrelated events, Lamport's scheme is usually extended by breaking ties using process ID (e.g., order by (counter, process_id)), giving a full total order over all events in the system - useful for algorithms (like the mutual exclusion algorithm below) that need every process to agree on one global ordering of requests.

4. Vector clocks: detecting true concurrency#

A vector clock gives each process an array of counters, one slot per process in the system (V = [c1, c2, ..., cn]), updated by similar rules: 1. Before executing an event, a process increments its own slot: V[i] = V[i] + 1. 2. When sending a message, the process attaches its entire current vector. 3. When receiving a message with vector V_msg, the receiving process takes the element-wise maximum of its own vector and V_msg, then increments its own slot: V[j] = max(V[j], V_msg[j]) for every j, then V[i] = V[i] + 1.

The critical property: A -> B if and only if V(A) < V(B) (every component of V(A) is less than or equal to the corresponding component of V(B), and at least one is strictly less). This is a two-directional guarantee, unlike Lamport clocks - vector clocks let you detect concurrency, not just impose an order despite it.

Worked example. Two processes, P1 and P2, each maintain a 2-element vector [P1_count, P2_count].

  • P1 executes event e1: V1 = [1, 0].
  • P1 sends message m to P2 carrying [1, 0].
  • Meanwhile, independently, P2 executes event e2: V2 = [0, 1] (before receiving m).
  • P2 receives m: V2 = [max(0,1), max(1,0)] = [1, 1], then increments its own slot: V2 = [1, 2].

Now compare e1's vector [1, 0] and e2's vector [0, 1]: neither is less-than-or-equal to the other in every component (e1 has a bigger first component, e2 has a bigger second component) - so the vectors correctly report that e1 and e2 are concurrent, which matches reality: e2 happened independently of e1, before P2 even received P1's message. A Lamport clock, by contrast, would simply have assigned e1 and e2 some sequential-looking numbers and given no way to tell they were actually unrelated.

This concurrency detection is exactly what systems like Amazon's original Dynamo (and Riak, which inherited the design) use vector clocks for: when two replicas each accept a concurrent write to the same key (no causal relationship between the writes), the vector clocks reveal that fact so the system can flag a genuine conflict (requiring application-level or last-writer-wins resolution) rather than silently and arbitrarily picking a "winner" as if one write had causally superseded the other. This connects directly to Lesson 08's data-centric consistency models: knowing when two writes are truly concurrent (versus one causally following the other) is the mechanism that lets a system implement causal consistency correctly.

5. Distributed mutual exclusion#

A related, classic problem: multiple processes on different machines need exclusive access to a shared resource (e.g., only one process may hold a lock at a time), but there is no shared memory to hold a lock variable. Three canonical algorithm families:

Centralized algorithm. One process is designated the coordinator; every process sends a REQUEST message to it and waits for a GRANT before entering the critical section, then sends a RELEASE when done. The coordinator serializes all requests (often via a simple FIFO queue). Simple and correct, but the coordinator is a single point of failure and a scalability bottleneck (echoing the "hidden centralization" pitfall from Lesson 01) - if it crashes, no process can acquire the lock until a new coordinator is elected (Lesson 07's election algorithms exist partly to solve exactly this).

Distributed algorithm (Ricart-Agrawala style, using logical time). Every process wishing to enter the critical section broadcasts a timestamped REQUEST to every other process and enters only after receiving a REPLY from all of them. A process receiving a request replies immediately unless it is itself in the critical section or has a pending request with an earlier Lamport timestamp - in which case it defers its reply until it's done. Ties (equal Lamport timestamps, which can't actually happen if timestamps are broken by process ID) are broken by process ID. This has no single point of failure, but requires 2(N-1) messages per critical-section entry (a request and a reply to/from every other process), which scales poorly as N grows.

Token-ring algorithm. Processes are logically arranged in a ring; a single token circulates around the ring, and only the process currently holding the token may enter the critical section (passing the token onward when done, or immediately if it doesn't need the section). This is efficient in the steady state (at most N messages per full cycle, and if you need the critical section rarely, mostly just token-passing overhead) and avoids the "ask everyone" cost of the distributed algorithm, but a lost token (e.g., the holder crashes) requires a separate, non-trivial regeneration protocol, and latency to acquire the lock is unpredictable (you might have to wait for the token to traverse most of the ring).

Algorithm Messages per entry Single point of failure? Fault-tolerance cost
Centralized 3 (request, grant, release) Yes (the coordinator) Needs coordinator re-election
Distributed (Ricart-Agrawala) 2(N-1) No Needs failure detection per peer
Token ring 1 to N (amortized) The token itself Needs token regeneration protocol

Worked example. A cluster of 5 worker nodes needs exclusive access to rotate a shared credential file. Using the centralized approach, one node acts as lock manager: any worker sends REQUEST, waits for GRANT, rotates the file, sends RELEASE - 3 messages total, simple, but if the lock-manager node crashes while a worker holds the lock, no other worker can ever acquire it until an operator intervenes or an election protocol (Lesson 07) picks a new manager. Using Ricart-Agrawala instead, any worker wanting the lock messages all 4 others and waits for all 4 replies - 8 messages total, no single point of failure, but if one peer is merely slow (not dead - remember Lesson 01's core pitfall: you can't tell the difference), the requester waits indefinitely for that one reply. This is exactly why most production systems reach for a battle-tested consensus-backed lock service (like ZooKeeper or etcd, covered conceptually in Lesson 10) rather than hand-rolling either of these two extremes - a consensus-backed lock service gives you the robustness of "no single point of failure that isn't itself replicated" without requiring every single client to coordinate with every other client directly.

Pros#

  • Physical clock sync (Cristian's/NTP): gives useful, human-meaningful timestamps for logging, monitoring, and coarse-grained scheduling, with well-understood, bounded (if not zero) error.
  • Lamport clocks: cheap (a single integer per process), gives a valid total order consistent with causality - sufficient for many ordering needs (e.g., a global sequence number for a log).
  • Vector clocks: detect true concurrency, not just impose an arbitrary order - essential for correctly identifying conflicting concurrent writes in a replicated system.
  • Mutual exclusion algorithms: let processes coordinate exclusive access without shared memory, with different points on the simplicity/fault-tolerance/message-cost trade-off curve.

Cons#

  • Physical clock sync: can never guarantee exact agreement (only bounded approximate agreement); dangerous to rely on for correctness-critical ordering decisions.
  • Lamport clocks: cannot distinguish "causally related" from "merely got sequential numbers" - false confidence in an ordering that may not reflect true causality.
  • Vector clocks: space cost grows with the number of processes (O(N) per timestamp), which is expensive at very large N and requires knowing/bounding the participant set.
  • Mutual exclusion algorithms generally: every family has a real weakness under partial failure - centralized has a SPOF, fully distributed has O(N) message cost and no protection against a merely-slow peer, and token-ring needs a token-loss recovery protocol.

Alternatives#

  • Hybrid Logical Clocks (HLC) - combine physical time and logical causality tracking, giving timestamps that are both close to wall-clock time (useful for humans and for pruning old data) and causally consistent (like Lamport clocks) - used in systems like CockroachDB and MongoDB.
  • TrueTime / bounded-uncertainty clocks (Google Spanner) - instead of pretending clocks are exact, TrueTime exposes clock uncertainty explicitly as an interval [earliest, latest] and the system waits out the uncertainty window before proceeding when needed, converting "clocks are imprecise" from a hidden danger into an engineered, bounded cost.
  • Consensus-backed distributed locks (Lesson 10) - rather than hand-rolling mutual exclusion with logical clocks, use a lock service built on a consensus protocol (ZooKeeper, etcd/Raft) which already handles leader failure, network partitions, and fencing tokens correctly - the practical default for production systems needing distributed locking.

When to use it#

  • Use NTP/physical clock sync for logging, monitoring, coarse scheduling, and human-facing timestamps - anywhere "close enough" wall-clock time suffices and correctness doesn't hinge on exact ordering.
  • Use Lamport timestamps when you need a cheap, valid total order over distributed events (e.g., assigning a globally consistent sequence number to log entries) and don't need to distinguish true concurrency from coincidence.
  • Use vector clocks when you must detect genuine write conflicts in a replicated, potentially concurrently-updated dataset (Dynamo-style systems, collaborative editing conflict detection).
  • Use a centralized mutual exclusion algorithm for small-scale, low-criticality locking where a brief unavailability during coordinator failover is acceptable; use a consensus-backed lock service for anything production-critical.

When NOT to use it#

  • Don't use physical clock comparisons (if timestamp_a < timestamp_b) as the sole basis for correctness-critical ordering decisions across nodes - clock skew makes this unreliable, however tight your NTP sync appears (this is the direct link to ddia/12's warnings about relying on synchronized clocks).
  • Don't use vector clocks at very large scale (thousands of processes) without bounding or pruning the vector - the O(N) per-timestamp cost becomes a real storage and bandwidth burden.
  • Don't hand-roll a fully distributed (Ricart-Agrawala style) mutual exclusion algorithm for a production system when a mature consensus-backed lock service is available - the edge cases around partial failure and message loss are easy to get subtly wrong, and existing systems have already hardened against them.

Key takeaways / mental model#

Physical clocks can be synchronized only approximately, never exactly, because the very act of synchronizing them takes an uncertain amount of time - so never build correctness on "my clock says X, so nothing else has happened yet." Logical clocks solve a different, answerable question: not "when did this happen in real time" but "what could this have possibly been caused by." Lamport clocks give you a consistent total order (a single number line everyone can sort by) but cannot tell concurrency from coincidence; vector clocks can tell the difference, at the cost of one counter per participant. Mutual exclusion is the direct practical payoff of ordering: once you can order (or detect the lack of ordering among) distributed requests, you can decide who gets a shared resource first - though production systems almost always outsource this to a consensus-backed lock service rather than reimplementing the coordination logic by hand.

Self-check questions#

  1. Explain why no clock synchronization algorithm (Cristian's, NTP, or otherwise) can achieve exact agreement between two independent physical clocks, even in principle.
  2. Given two events with Lamport timestamps 5 and 7 on different processes, can you conclude the first happened-before the second? Why or why not? What would you need to check with a vector clock to answer definitively?
  3. Walk through a concrete scenario (like the two-process example in this lesson) where vector clocks correctly identify two writes as concurrent, and explain what a system should do once it detects that concurrency.
  4. Compare the message cost and failure characteristics of the centralized, distributed (Ricart-Agrawala), and token-ring mutual exclusion algorithms. For a 100-node cluster needing rare, occasional exclusive access to a shared resource, which would you pick and why?
  5. Why do most production systems prefer a consensus-backed lock service (ZooKeeper/etcd) over hand-rolled distributed mutual exclusion algorithms, despite the latter being simpler to implement initially?

References#

  • Distributed Systems, 3rd ed. (van Steen & Tanenbaum), Chapter 6: Coordination (clocks and mutual exclusion sections)
  • Leslie Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System" (1978)
  • ddia/12 (The Trouble with Distributed Systems) - practical treatment of clock unreliability and process pauses
  • distributed-systems.net (free companion site for the source book)