AlgoInsight

Free lesson · Concurrency Interview Mastery

Exercise: Try-Lock and Timed Locking

Medium6 min readconcurrencymultithreadinginterview-prep

This exercise puts try-lock to work for its three uses - responsiveness, deadlock avoidance, and the livelock trap that deadlock avoidance creates if you're careless. Part 1 uses try-lock for responsiveness; Part 2 solves the transfer deadlock with try-lock backoff; Part 3 hits the livelock and fixes it. Reason each through before the solution - the goal is to internalize why non-blocking acquisition helps and the new failure mode it introduces.

Important: The mental shift here: stop thinking of locking as "wait until I get it" and start thinking "try, and have a plan B." Every part asks what the plan B is - do other work, release and retry, or back off randomly. And keep the sibling failure modes straight: deadlock is stuck-and-idle, livelock is stuck-and-busy, and try-lock can trade one for the other if you skip the backoff.


Part 1 - Responsiveness

A worker periodically tries to flush a shared buffer to disk, but if another thread is already flushing (holds the lock), the worker should skip this round and keep doing its own work rather than block waiting.

function workLoop():
    while running:
        doRegularWork()
        // TODO: flush the buffer IF we can get the lock, else skip and continue
        flushLock.lock()
        flushBuffer()
        flushLock.unlock()

Your task: Change the flush so the worker never blocks on the flush lock - it flushes if it can acquire immediately, otherwise skips this round. Why is try-lock the right tool here rather than a plain lock?

<details> <summary>Hint</summary>

Use try_lock: if it succeeds, flush and unlock; if it fails, skip the flush this round and continue the loop. A plain lock would stall the worker behind whoever's flushing, wasting time it could spend on regular work - and since another thread is already flushing, skipping is harmless (the buffer still gets flushed, just not by this worker right now). </details>


Part 2 - Deadlock avoidance

The transfer-between-accounts problem deadlocks when transfer(A, B) and transfer(B, A) run concurrently (each locks one account, waits for the other). Suppose you cannot use lock ordering - for this exercise, assume the accounts have no orderable identity you can rank.

function transfer(from, to, amount):
    from.lock.lock()
    to.lock.lock()                  // deadlock potential here
        from.balance -= amount
        to.balance   += amount
    to.lock.unlock()
    from.lock.unlock()

Your task: Make this deadlock-free using try-lock backoff. Explain which deadlock condition your fix breaks.

<details> <summary>Hint</summary>

Lock from normally, then try_lock to. If the try succeeds, you have both - proceed. If it fails, release from and retry the whole sequence after a short wait. Because you never hold from while blocked waiting for to, you break the hold-and-wait condition, so deadlock can't form. </details>


Part 3 - The livelock trap

A developer implements Part 2's try-lock backoff but with immediate retry (no delay):

function transfer(from, to, amount):
    while true:
        from.lock.lock()
        if to.lock.try_lock():
            break
        from.lock.unlock()
        // immediately retry - no wait
    from.balance -= amount; to.balance += amount
    to.lock.unlock(); from.lock.unlock()

Under heavy contention, two opposing transfers sometimes spin forever, both CPUs pinned at 100%, no transfer completing.

Your task: Explain why it spins forever, and fix it. Name the failure mode.

<details> <summary>Hint</summary>

Two threads in perfect lockstep: both lock their first account, both fail the try-lock on the second, both release, both immediately retry - and collide the same way again, forever. They're busy, not blocked, but make no progress. This is livelock. The fix: a random backoff delay before each retry, so the threads fall out of lockstep and one wins. </details>


Solution

Part 1 - Try-lock for responsiveness

function workLoop():
    while running:
        doRegularWork()
        if flushLock.try_lock():       // attempt without blocking
            try:
                flushBuffer()
            finally:
                flushLock.unlock()
        // else: someone else is flushing - skip this round, keep working

try_lock returns immediately: if the worker gets the lock, it flushes; if not, it skips and continues its loop without stalling. This is the right tool because blocking would be wasteful and pointless here - pointless because another thread is already flushing (so the buffer is being handled), and wasteful because the worker has its own regular work it could be doing instead of waiting. Try-lock turns "wait for a flush that's already happening" into "let them flush, I'll keep working" - exactly the responsiveness use. The general pattern: when a thread has useful alternative work and the locked action is optional-this-instant, try-lock lets it stay productive under contention.

Part 2 - Try-lock backoff for deadlock avoidance

function transfer(from, to, amount):
    while true:
        from.lock.lock()
        if to.lock.try_lock():
            break                          // acquired both
        from.lock.unlock()                 // RELEASE from - break hold-and-wait
        sleep(random_small_delay())        // backoff (see Part 3)
    from.balance -= amount
    to.balance   += amount
    to.lock.unlock()
    from.lock.unlock()

Lock from normally, then try to. If the try succeeds, break out with both locks held. If it fails, release from and retry after a short wait. The condition this breaks is hold-and-wait: a deadlock needs a thread to hold one lock while blocking for another, and this code never does - whenever it can't get to, it lets go of from rather than waiting. With hold-and-wait eliminated, the circular wait that deadlock requires can't form. This is the right tool because the problem statement removed lock ordering (no orderable account identity); when ordering is available, you'd prefer it (simpler, no retries), but try-lock backoff is the deadlock-prevention strategy that works without a global order.

Part 3 - Livelock and the randomized-backoff fix

Why it spins forever: two opposing transfers can run in perfect lockstep. Thread 1 locks account A and tries B; thread 2 locks account B and tries A. Both try-locks fail (each second account is held by the other). Both release their first lock. Both immediately retry - and because they're perfectly synchronized, they grab their first locks and fail their second try-locks again, identically, forever. No thread is blocked (they're all running flat-out), but no thread makes progress. This is livelock - the busy sibling of deadlock.

The fix - randomized backoff:

        from.lock.unlock()
        sleep(random(0, maxBackoff))       // RANDOM delay breaks the lockstep

By waiting a random amount before retrying, the two threads fall out of sync: one retries slightly before the other, acquires both locks, and completes its transfer while the other waits. The randomness is essential - a fixed delay would just preserve the lockstep (both wait the same amount and collide again). Exponential backoff with jitter (waiting progressively longer after repeated failures, plus randomness) is the production-grade version, the same approach used for network request retries. The lesson: try-lock backoff requires randomized backoff, or it trades deadlock for livelock - a worse outcome in some ways, since livelock burns CPU while making no progress and can be harder to diagnose than a clean hang.


Common mistakes

Warning: Watch for: (1) Forgetting to release the first lock on try-lock failure - if you try_lock the second lock but don't release the first when it fails, you've recreated hold-and-wait and the deadlock returns. (2) Immediate retry with no backoff - the livelock trap; always add randomized delay. (3) Fixed (non-random) backoff - preserves lockstep; the delay must be random to break symmetry. (4) Forgetting to release on success paths and exceptions - try-lock acquisitions still need scope-bound release. (5) Using try-lock to mask a tangled design - if you're reaching for it to silence a deadlock you don't understand rather than for a principled reason, fix the lock structure instead (the Go-docs caution).

Why this exercise matters for interviews

Interview: Try-lock shows up in deadlock questions as the alternative to lock ordering, and Part 2 is the canonical demonstration - "solve this deadlock without lock ordering" → try-lock backoff breaking hold-and-wait. Part 3 is the follow-up that separates strong candidates: "what new problem does that introduce?" → livelock, fixed by randomized backoff. Being able to name which deadlock condition try-lock breaks (hold-and-wait), and that backoff must be randomized to avoid livelock, demonstrates you understand the deadlock conditions as a system rather than memorizing a pattern. Part 1's responsiveness use is the simpler but still-valuable point that non-blocking acquisition lets a thread stay productive. The deadlock-vs-livelock distinction (idle vs busy, both no-progress) is worth being able to state crisply - it's a favorite clarifying question.

Back to Concurrency Interview Mastery