skip to content

Describe the strategy of attempting each lock acquisition with a timeout and releasing everything already held on failure, and explain what must be added so the retry loop does not spin forever.

level: seniorimportance: should knowfreq 42%

answer

  1. attacks no-preemption: give up what you hold
  2. release-all, back off with jitter, retry whole op
  3. without randomness: livelock in lockstep
  4. no visible mutation before the full lock set is held
  5. cap retries, emit a metric, fail loudly

basics

~20 s

Acquire the first lock, then attempt the next with a timeout; if it fails, release everything held, wait a randomized interval, and retry the whole operation. It needs randomized backoff, a retry cap, and rollback-safe partial work.

solid answer

~60 s

The pattern attacks the **no-preemption** condition: instead of blocking indefinitely, a thread voluntarily gives up what it holds so someone else can proceed. Loop: acquire lock 1; attempt lock 2 with a bounded timeout; on success do the work and release both; on failure release lock 1, wait, and start over. What it needs to be correct: - **Randomized (usually exponential) backoff.** Without it, two threads can release and retry in lockstep and repeatedly collide - a livelock that burns CPU and never completes. - **No irreversible work between acquisitions.** Everything done after the first lock must be discardable, or you must undo it on the release path. - **A retry cap plus escalation.** Give up, fail the operation with a clear error, and emit a metric; unbounded retries hide the design problem. - **Observability.** A rising retry rate means real contention or a missing lock order. Prefer a global lock ordering when one exists. Use this when the lock set is not knowable in advance, or when you must interoperate with code that will not honour your order.

code

text · 13 lines
text
attempt = 0
loop:
    lock(first)
    if tryLock(second, timeout):
        do_work()                 # first visible mutation happens here
        release(second); release(first)
        return SUCCESS
    release(first)                 # give up everything held
    attempt += 1
    if attempt > MAX_RETRIES:
        record_metric("lock_retry_exhausted")
        return FAILURE             # visible, bounded failure
    sleep(random(0, base * 2^attempt))   # jitter is mandatory

go deeper

for a junior

Recall the shape: try to take the second lock with a timeout, and if you fail, let go of the first one and start over.

for a middle

Explain which Coffman condition it attacks and why randomized backoff is required to avoid livelock.

for a senior

Add the correctness conditions - no visible mutation before the full set is held, bounded retries, timeout sizing - and treat the retry rate as a design signal.

for a principal

Position it against a global ordering: static prevention where the lock set is knowable, bounded attempts where waits cross process boundaries, plus the observability and failure semantics the callers get.

## What the pattern is Ordinary lock acquisition blocks until the lock is free, which is exactly the no-preemption condition: once you hold something you keep it, and once you start waiting you keep waiting. The try-with-timeout pattern removes both halves. A thread acquires the first lock normally, then *attempts* the next one with a bounded timeout. If the attempt fails, it releases everything it holds, waits, and restarts the whole operation from scratch. Because a thread never holds one lock while blocking indefinitely for another, no wait-for edge is permanent. A cycle can still form momentarily, but it dissolves within the timeout when one of its members gives up. Deadlock becomes a transient stall rather than a permanent hang. ## The livelock trap The naive loop trades deadlock for **livelock**. Two symmetric threads can release at the same moment, retry at the same moment, and collide again, indefinitely: state keeps changing, CPU is consumed, nothing completes. This is why the retry interval must be randomized. A common choice is exponential backoff with jitter - wait a random duration in a window that grows with each failed attempt - which decorrelates the retries so one thread wins quickly. Two threads retrying after a fixed delay is the textbook failure; two threads retrying after a random delay in a widening window converges fast. ## Correctness requirements people forget **Partial work must be undoable.** Releasing the first lock means abandoning the operation mid-flight. If you mutated shared state after acquiring it, another thread will observe a half-finished change. The safe discipline is to perform *no* visible mutation until every needed lock is held; compute into local state, and only publish once the full set is acquired. If mutation is unavoidable, the release path must roll it back explicitly, and that rollback must itself be safe under the lock you are about to drop. **Timeout choice is a real parameter.** Too short and you abandon acquisitions that were about to succeed, converting normal contention into wasted work. Too long and a genuine deadlock stalls for the full duration on every occurrence. A useful heuristic is a timeout comfortably above the expected hold time of the critical section, so timing out means something is actually wrong. **Retries must be bounded.** Without a cap, an unlucky thread can retry forever - starvation - while others repeatedly win. With a cap, you convert an unbounded stall into a fast, visible failure that a caller can handle, and you get a metric to alert on. The retry counter is the single most valuable signal this pattern produces: if it climbs, either contention is genuinely high or a lock-ordering violation exists that the pattern is quietly papering over. **Fairness is not guaranteed.** Threads that hold more locks or run longer critical sections retry more often and win less often. If a class of operations is systematically unlucky, that is starvation, and the fix is design change - fewer co-held locks, or a queue that grants access in arrival order. ## When to choose it over an ordering A global lock order is strictly better when it is available: it is static, costs nothing at runtime, wastes no work, and cannot livelock. Reach for try-with-timeout when ordering is impractical: - The set of locks is determined at runtime and cannot be sorted (locks discovered while traversing a graph structure). - You interoperate with code that acquires its own locks in an order you cannot control. - Locks span processes or machines, where a global order cannot be enforced and a hung peer must not hold your resources forever. - You need a hard latency bound: a bounded attempt gives you a deadline you can honour and report, whereas an unbounded wait does not. It is also the natural pattern where waiting is already remote and unreliable - distributed locks and leases are almost always acquired with a timeout for exactly this reason, since a peer that dies while holding an unbounded wait would otherwise hang the caller forever. ## The honest summary This pattern does not make deadlock impossible in the structural sense; it makes deadlock **recoverable** by turning a permanent circular wait into a bounded one. That is a real guarantee, but it buys progress with wasted work and a new hazard - livelock and starvation - which you must design against with randomized backoff, bounded retries and metrics. Presenting it as a free fix, or using it to avoid thinking about lock order, is the mistake interviewers listen for.

  • Your retry metric shows the loop succeeding, but only after 5 to 10 attempts under load. Is that acceptable?
    It is a warning, not a success. High retry counts mean threads are repeatedly doing and discarding work, so effective throughput is far below what the numbers suggest and latency has a long tail. It usually indicates either genuine hot contention on a shared object, in which case the fix is partitioning or a shorter critical section, or an acquisition-order conflict that a global ordering would remove outright. Treat a rising retry rate as a design signal rather than tuning the backoff until the alert stops.
  • Where does this pattern become the default rather than the fallback?
    Whenever the wait crosses a process or machine boundary - distributed locks, leases, remote transactions. There you cannot enforce a global acquisition order across independent deployments, and a peer can die while holding a resource, so an unbounded wait would hang the caller indefinitely. A bounded attempt with a lease expiry gives both liveness and a deadline you can report to the caller.

saying these in an interview costs you the question

  • Retrying with a fixed delay and no randomization, which produces lockstep livelock between symmetric threads.
  • Mutating shared state after the first acquisition and then releasing it on failure, leaving half-applied changes visible.
  • Retrying without a cap, so an unlucky thread starves and the failure never becomes visible.
  • Presenting it as strictly better than lock ordering, when ordering is static, cheaper and cannot livelock.
  • Assuming a timeout on acquisition makes the code deadlock-free across other resources such as pools and queues.

context