skip to content

What do a non-blocking lock attempt (tryLock) and a timed lock acquisition give you that a plain blocking acquire does not, and what goes wrong when they are used carelessly?

level: seniorimportance: should knowfreq 52%

answer

  1. bounded wait, two outcomes
  2. breaks hold-and-wait
  3. release everything, back off, retry
  4. randomised jitter or livelock
  5. empty else branch = silent no-op

basics

~20 s

They bound how long you wait: the attempt returns success or failure instead of blocking indefinitely, so you can take a fallback path, stay responsive, or release locks you already hold and retry. The risks are livelock from lockstep retries and code that ignores the failure branch.

solid answer

~50 s

A plain acquire is an unbounded commitment: you wait as long as it takes, and while waiting you keep every lock you already hold. `tryLock` returns immediately with a boolean; timed acquisition waits at most a deadline. Both convert 'block forever' into a branch you must handle. That branch buys three things. **Deadlock avoidance** where a strict global lock ordering is impossible: acquire the second lock with a try, and on failure release the first, back off, and retry — no cycle can persist. **Responsiveness**: a request handler can fail fast, shed load, or answer from a cache rather than pile up on a contended lock. **Observability**: counting failed attempts is a direct contention metric. The hazards: retry loops that resynchronise and livelock, so back off with randomised jitter and a bounded retry count; failure paths that are unwritten or untested; treating tryLock in a tight loop as a spinlock; and, on a fair lock, a barging tryLock that jumps the queue.

code

text · 10 lines
text
retries = 0
loop:
  acquire(A)
  if tryAcquire(B, 10ms):
        try { transfer() } finally { release(B); release(A) }
        return OK
  release(A)                       // never hold-and-wait
  retries += 1
  if retries > MAX: return BUSY    // bounded, not forever
  sleep(random(0, base * 2^retries))

go deeper

for a junior

Know that try returns a boolean and never blocks, that timed acquire waits at most a deadline, and that you must handle the failure case.

for a middle

Explain the release-and-retry pattern for two locks and why the first lock must be released before retrying.

for a senior

Discuss livelock and randomised backoff, bounded retries, treating timed acquisition as load shedding, and using failure counts as a contention metric.

for a principal

Treat it as a latency-versus-success-rate policy: decide where the system prefers a bounded error rate over an unbounded tail, and prefer a global lock ordering over try-and-retry wherever a stable order exists.

## The semantics Three acquisition modes matter: | Mode | Blocks? | Returns | |---|---|---| | `acquire()` | until granted | nothing — you hold it | | `tryAcquire()` | never | true (held) / false (not held) | | `tryAcquire(timeout)` | up to the deadline | true / false | The crucial change is control flow: a blocking acquire has one outcome, so callers write no failure logic. The `try` variants have two, and the second one is a design decision — retry, degrade, fail the request, or escalate. ## Use 1 — breaking potential deadlock cycles Deadlock needs four conditions to hold at once; one of them is **hold-and-wait**. A blocking acquire while already holding a lock is hold-and-wait made concrete. A timed or non-blocking attempt breaks it: ``` acquire(A) if (not tryAcquire(B)): release(A) // give up everything sleep(randomBackoff()) // desynchronise retry ``` This is the standard technique when a consistent global lock order cannot be established — for example transferring between two accounts identified only at runtime, where the natural order differs per call. Ordering locks by a stable key is still the better fix when available; try-and-back-off is what you use when it is not. ## Use 2 — bounded latency and load shedding In a request-serving system, an unbounded acquire converts lock contention into unbounded queueing: threads pile up invisibly, the latency tail explodes, and thread pools saturate. A timed acquire converts it into an explicit, measurable failure: return a cached value, return 'busy', or route elsewhere. That is a deliberate choice to trade a small error rate for a bounded p99. ## Use 3 — cancellation and health A timed acquire gives cancellation points: a shutdown routine that must not hang, a watchdog that reports 'lock held longer than X', or a diagnostic that samples how often acquisition fails. Failed-attempt counts and wait-time histograms are among the cheapest contention signals you can collect. ## The failure modes **Livelock.** Threads that fail, immediately retry, fail again, and repeat make progress-free noise. Because they retry in lockstep after each collision, they can collide indefinitely. The fix is randomised exponential backoff, exactly as in network collision avoidance: sleep a random interval that grows with the retry count. Without randomisation, symmetric threads resynchronise; without a bound, a starving thread never gives up. **Unwritten failure paths.** `if (tryLock()) { ...work... }` with no `else` silently skips the work. This is a real and common bug: the code compiles, tests pass under no contention, and under load the operation quietly does not happen. Every try must have a considered alternative — including 'fall back to a blocking acquire with a deadline'. **Try-as-spin.** A tight `while (!tryLock()) {}` loop burns a core and, on a single-core or oversubscribed system, can prevent the holder from being scheduled to release it. Spinning is a legitimate strategy but with a bounded spin count then a park, which is what a proper adaptive lock already does internally. **Fairness interaction.** On a fair lock, plain acquire queues, but an untimed `tryAcquire` typically succeeds only if the lock is *free*, and in some designs it barges ahead of queued waiters. Mixing barging tries into a fair lock quietly reintroduces the starvation that fairness was chosen to prevent. **Correct release discipline.** The acquire result must gate the release: releasing a lock you failed to acquire is an error, and forgetting to release on the success path is a leak. The safe shape is: attempt, and only on success enter a try/finally that releases.

  • Your try-and-retry loop is spinning at 100% CPU and no transfer completes. What is happening and how do you fix it?
    That is livelock: threads keep failing, retrying in lockstep, and colliding again, so they are busy but make no progress. Add randomised exponential backoff so competing threads desynchronise, cap the retry count so a caller eventually returns an error rather than looping, and consider imposing a stable global lock order so the try path is rarely needed at all.

saying these in an interview costs you the question

  • Writes tryLock with no else branch, silently skipping the work
  • Retries immediately in a tight loop with no backoff or jitter
  • Keeps the first lock held while blocking for the second
  • Releases the lock even when the attempt returned false
  • Claims tryLock 'prevents deadlock' by itself, without releasing held locks

context