Concepts / Concurrency in one process / Optimistic concurrency and versions

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:

StepAshaBenStored version
1load → v3load → v33
2save(based_on=3) → ok, v4typing4
3save(based_on=3) → ConflictError4
4load → v4, merge, save(based_on=4) → v55

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

02Used in these problems