Optimistic concurrency and versions
Check-then-write with a version number instead of a lock.
01The intuition
The problem it solves
Two people edit the same wiki page. Both open it, both change a sentence, both press save. The first version saves whatever arrives last, and the earlier edit vanishes without a trace.
class Wiki:
def __init__(self) -> None:
self.pages: dict[str, str] = {}
def load(self, title: str) -> str:
return self.pages.get(title, "")
def save(self, title: str, text: str) -> None:
self.pages[title] = text # last writer wins, silently
A lock does not help here, because the "critical section" is a human reading and typing for ten minutes; nobody can hold a lock across that. The same shape appears whenever the check and the write are separated by time: a seat looked at and then booked, a calendar slot viewed and then taken, a record read by a service and written back later.
Build it one piece at a time
Step one: give every record a version number that goes up on each write, and return it with the data. The reader now knows which version it based its edit on.
from dataclasses import dataclass
@dataclass(frozen=True)
class Page:
text: str
version: int
class Wiki:
def __init__(self) -> None:
self.pages: dict[str, Page] = {}
def load(self, title: str) -> Page:
return self.pages.get(title, Page("", 0))
Step two: save takes the version the writer started from. If the stored version still matches, the write goes through and the version increases. If not, someone else saved in between, and the writer is told so instead of overwriting them. This compare-then-write must itself be atomic, which is a short lock, microseconds, not minutes.
import threading
class ConflictError(Exception):
pass
class Wiki(Wiki): # continued
_lock = threading.Lock()
def save(self, title: str, text: str, based_on: int) -> int:
with self._lock:
current = self.pages.get(title, Page("", 0))
if current.version != based_on:
raise ConflictError(f"page changed: you had v{based_on}, now v{current.version}")
new = Page(text, current.version + 1)
self.pages[title] = new
return new.version
Step three: decide what the caller does on conflict. Reload and merge, show both versions to the person, or retry automatically when the change is mechanical (a counter increment). The retry loop is the shape of every optimistic update.
def append_line(wiki: Wiki, title: str, line: str, attempts: int = 5) -> None:
for _ in range(attempts):
page = wiki.load(title)
try:
wiki.save(title, page.text + line + "\n", based_on=page.version)
return
except ConflictError:
continue # somebody else saved; read again and retry
raise RuntimeError("too much contention")
Two editors, Asha and Ben, starting from version 3:
| Step | Asha | Ben | Stored version |
|---|---|---|---|
| 1 | load → v3 | load → v3 | 3 |
| 2 | save(based_on=3) → ok, v4 | typing | 4 |
| 3 | save(based_on=3) → ConflictError | 4 | |
| 4 | load → v4, merge, save(based_on=4) → v5 | 5 |
Why it works
The invariant is that a write succeeds only if nothing changed since the writer's read, and that every successful write bumps the version. Because the compare and the write happen under one short lock, two writers based on the same version cannot both succeed: the first bumps the version and the second sees the mismatch. No lock is held while the human or the slow service thinks, which is the whole point.
Where it breaks: high contention. If twenty threads increment the same counter this way, nineteen conflict on every round and the retries cost more than a lock would. Optimism assumes conflicts are rare. The second trap is the ABA problem: a record changed and changed back looks unchanged if you compare values; comparing a version number that only ever increases avoids it.
expected attempts per write ≈ 1 / (1 − p), with p the chance another write lands during yours. p = 0.01 gives 1.01 attempts; p = 0.9 gives 10
The trade-off you must say out loud
Pessimistic locking blocks others and guarantees progress for the holder; optimistic versioning never blocks and makes the loser redo work. Choose optimistic when reads are frequent, writes are rare, and a human or a slow step sits between read and write. Say what the conflict response is, because that is where the product meets the code. The follow-up that usually comes next is "what if the record lives in a database", where the version becomes a column and the compare-and-write becomes an UPDATE with a WHERE clause on the version.
Where it shows up in the 100 Google problems
- Calendar event conflict handling: two edits of the same event, with the version checked on save and a merge offered on conflict.
- Movie ticket booking: the seat map is viewed, then booked; a seat version detects that someone else took it.
- In-memory database with transactions: a transaction records the versions it read and validates them at commit.
- Multi-threaded parking lot: claim a spot by compare-and-set on its state rather than locking the floor.