Engineering

The lock held across a network call, and the deadlock that followed

A mutex held for microseconds is fine. Held across an unbounded wait, it serialises your service.

MobileByteSensei 8 min read
practicesconcurrencyreliability

TL;DR

A lock's cost is not the lock. It is how long you hold it, and that is determined entirely by what you put inside the critical section. A mutex around a map update is free. The same mutex around a network call makes every concurrent request wait for that call.

Ours wrapped a token refresh: acquire the lock, call the auth server, store the token, release. Under normal conditions the call took 40ms and nobody noticed. When the auth server degraded to 20 seconds, throughput went to one request per 20 seconds — across the whole service, because they all shared the lock.

Hold locks over memory operations only. I/O inside a critical section is the bug, and it looks like correct synchronisation.

The case this post is about

A token cache, synchronised to avoid refreshing twice:

class TokenProvider(private val auth: AuthClient) {
    private val mutex = Mutex()
    private var token: Token? = null

    suspend fun current(): Token = mutex.withLock {        // ← held across everything below
        val existing = token
        if (existing != null && !existing.isExpired()) return@withLock existing

        val fresh = auth.refresh()                          // ← NETWORK CALL, inside the lock
        token = fresh
        fresh
    }
}

The intent is right: without the lock, 50 concurrent requests on an expired token cause 50 refreshes. The mutex solves that, and introduces a worse problem.

The symptom

Total throughput collapse during an auth-provider incident:

auth.refresh() p99 latency   40ms → 19.8s   (provider degraded)

our service
  requests/sec     1,240 → 0.05
  p99 latency      85ms  → 112s
  every endpoint affected, including those not using the token

The thread dump, which made it obvious:

"http-worker-1"  RUNNABLE   at AuthClient.refresh  (waiting on socket read)
"http-worker-2"  WAITING    at Mutex.lock           ← and 47 more identical
"http-worker-3"  WAITING    at Mutex.lock
…
47 threads waiting on the same mutex, held by one thread in a socket read

And then an actual deadlock, which is the part that took longest to understand. The auth client had a response interceptor that logged request metadata — and the metadata included the current token:

worker-1  holds tokenMutex, awaiting auth.refresh()
          → interceptor fires on the response
          → interceptor calls TokenProvider.current() for logging
          → tries to acquire tokenMutex
          → deadlock: waiting for a lock it already holds, re-entrantly

Mutex in kotlinx.coroutines is not reentrant, so the same coroutine re-acquiring it blocks forever. The service required a restart.

Why the obvious fix failed

"Make the mutex reentrant." It would have converted the deadlock back into the throughput collapse, which is an improvement and not a fix. Reentrancy also hides the real problem: a lock whose critical section can call arbitrary code.

"Add a timeout to the lock acquisition." withTimeout(5.seconds) { mutex.withLock { … } } makes waiters give up, so now 47 requests fail fast instead of hanging. Better behaviour, same serialisation, and the failures are not actually necessary.

"Add a timeout to the auth call." Necessary and insufficient: a 2-second timeout bounds the hold to 2 seconds, so throughput is 0.5 req/s instead of 0.05. The lock still serialises everything.

"Use a read-write lock." The refresh is a write and all callers go through the same path. There is no read-mostly pattern to exploit while the token is expired.

The actual mechanism

Two things to separate.

A lock protects an invariant, and the invariant here is "do not refresh concurrently". That is a legitimate thing to want.

A lock's hold duration is whatever is inside it. Hold time determines how much concurrency you destroy, and the relationship is brutal: if a lock is held for duration d, maximum throughput through that section is 1/d, regardless of how many cores or threads you have.

That relationship is the part worth internalising, because it means a lock's cost is set by its slowest occupant rather than its typical one. A section holding the lock for 2ms on the happy path and 2 seconds when the token endpoint is slow does not average out to something acceptable — during those 2 seconds the ceiling is 0.5 requests/second, and every arrival queues. The tail latency of whatever is inside the lock becomes the throughput of everything that needs it.

d = 40ms   →   25 req/s through the section
d = 20s    →   0.05 req/s

So putting I/O inside a critical section makes your concurrency limit a function of someone else's latency. The auth provider's p99 became our throughput ceiling.

And the deadlock has a specific cause worth naming: the critical section called out to code it did not control. Once an interceptor, callback, listener or plugin can run inside your lock, the set of things that might try to acquire it is unbounded.

A critical section containing a network call, so forty-seven workers queue on a mutex held by one worker blocked in a socket read, with throughput equal to the reciprocal of hold time and therefore governed by the auth provider's latency — and a response interceptor inside the same section re-entering the non-reentrant mutex and deadlocking

A lock's cost is its hold time, and I/O inside a critical section hands your concurrency limit to another system. Hold locks over memory only, and never call out to code you do not control while holding one.

The fix

Keep the I/O outside the lock, and use the lock only to publish the result — with single-flight semantics so the original "do not refresh twice" invariant survives.

class TokenProvider(private val auth: AuthClient, private val scope: CoroutineScope) {

    private val mutex = Mutex()                    // guards ONLY the two fields below
    private var token: Token? = null
    private var inFlight: Deferred<Token>? = null

    suspend fun current(): Token {
        // 1. fast path: a short, memory-only critical section
        mutex.withLock {
            token?.let { if (!it.isExpired()) return it }
            // 2. single-flight: if a refresh is running, join it — do NOT start another
            inFlight?.let { return@withLock }
                ?: run { inFlight = scope.async { auth.refresh() } }      // started, not awaited
        }

        // 3. the network wait happens with NO lock held
        val existing = mutex.withLock { inFlight }!!
        val fresh = try {
            existing.await()
        } finally {
            mutex.withLock { if (inFlight === existing) inFlight = null }
        }

        mutex.withLock { token = fresh }
        return fresh
    }
}

Three properties:

Every critical section is memory-only — a field read, a field write, an assignment. Hold time is measured in microseconds, so the lock stops being a throughput limit.

Single-flight preserves the original invariant. Fifty concurrent callers on an expired token start one refresh and all await the same Deferred. That is what the lock was for, achieved without holding it across the call.

And the interceptor deadlock is structurally impossible, because no external code runs inside a critical section.

Two more things that belong with this fix:

Refresh proactively, so the expired-token path is rare:

// refresh at 80% of the lifetime, in the background — callers never wait at all
private fun scheduleRefresh(t: Token) = scope.launch {
    delay(t.lifetime * 0.8)
    runCatching { current() }
}

And a bounded wait, so a degraded provider fails rather than queues:

val fresh = withTimeout(5.seconds) { existing.await() }

Which composes with a request deadline rather than inventing its own number, if you have one.

How we proved it

Throughput under a slow dependency, which is the claim:

auth.refresh() forced to 20s, 50 concurrent requests
                              throughput   p99      refresh calls
lock held across I/O             0.05/s     112s          1
+ lock timeout 5s                0.05/s     5s (fail)     1
+ auth timeout 2s                 0.5/s     2s            1
single-flight, no lock over I/O    980/s     20s          1

That last row needs explaining, because it looks paradoxical: throughput is high because requests not needing a token proceed freely, and the 50 that do all wait on one shared refresh. One refresh call, no serialisation of unrelated work.

Normal-conditions throughput, to confirm no regression:

auth.refresh() at 40ms
  before:  1,240 req/s
  after:   1,290 req/s

That single-flight holds — the invariant the lock originally existed for:

@Test
fun fiftyConcurrentCallersCauseOneRefresh() = runTest {
    val auth = CountingAuthClient(latency = 500.milliseconds)
    val provider = TokenProvider(auth, backgroundScope)
    (1..50).map { async { provider.current() } }.awaitAll()
    assertEquals(1, auth.refreshCount)
}
no lock at all:        FAILED — 50 refreshes
lock across I/O:       PASSED — 1
single-flight:         PASSED — 1

And a deterministic reproduction of the deadlock, before and after:

@Test
fun anInterceptorCallingBackDoesNotDeadlock() = runTest {
    val auth = AuthClient(interceptor = { provider.current() })   // calls back in
    withTimeout(2.seconds) { provider.current() }                 // fails by timing out if deadlocked
}
lock across I/O:   FAILED — TimeoutCancellationException (deadlock)
single-flight:     PASSED

Plus an audit for the shape, since this is a pattern rather than an instance:

# find critical sections containing a suspend call that is not a pure computation
$ grep -rn -A12 'withLock\s*{\|synchronized\s*(' --include='*.kt' . \
  | grep -E '\.(refresh|fetch|get|post|query|execute|await)\('
critical sections containing I/O:   5
  TokenProvider           (this one)
  ConfigCache             (HTTP fetch inside a lock)
  MetricsFlusher          (network flush inside a lock)
  SchemaRegistry          (DB query inside a lock)
  AvatarCache             (file read inside a lock — bounded, left alone)

Four more, three of which were fixed the same way.

One limit: the single-flight implementation above has a subtle sequence — the first critical section starts the async without awaiting it, which means a failure in auth.refresh() propagates to every joined caller. That is correct behaviour and worth being explicit about, since it means one provider failure fails all 50 waiters rather than letting them retry independently. We accepted it; a retry belongs around current(), not inside it.

What this generalizes to

A lock's cost is its hold time, and hold time is a design choice. Maximum throughput through a critical section is 1/hold_time. Shortening the section is almost always more effective than switching lock implementations.

Never do I/O while holding a lock. Network calls, disk reads, database queries, and anything with an unbounded wait. The hold becomes a function of another system's latency, which means your concurrency limit is set by someone else's bad day.

Never call out to unknown code inside a critical section. Interceptors, callbacks, listeners, plugins, user-supplied comparators. The moment arbitrary code runs under your lock, re-entry and deadlock are reachable.

Separate "do the work" from "publish the result". Single-flight is the general pattern: one actor performs the operation outside any lock, and a short critical section coordinates who waits and who starts. It preserves the invariant without buying the serialisation.

Keep reading

This is part of 100 practices of senior engineers — the habits that separate five years of experience from one year five times. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS