Mehdi Akiki
Published on

Release the mutex before calling a remote API

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Through the layers

One request refreshes a cached record from a remote API. Every other request waits behind it, including requests that only need to read the current value.

The cache is protected by a mutex. The refresh function acquired it before making the HTTP call and kept it until the response arrived.

Changing to an async mutex may stop a thread from blocking while it waits for the lock. It does not make the lock available to anyone else during the remote call. The serialization is still there.

Copy the input under a short lock

Take the information needed for the request, then release the guard. The Rust reproduction uses an async mutex and a controlled response channel. The channel stands in for a remote client that has not answered yet:

#[derive(Default)]
struct Cache {
    version: usize,
    value: usize,
}

async fn refresh(
    shared: Arc<Mutex<Cache>>,
    started: oneshot::Sender<()>,
    response: oneshot::Receiver<usize>,
) -> bool {
    let version = { shared.lock().await.version };
    started.send(()).unwrap();
    let response = response.await.unwrap();
    let mut state = shared.lock().await;
    if state.version == version {
        state.value = response;
        state.version = state.version.checked_add(1).expect("version exhausted");
        true
    } else {
        false
    }
}

The copied version leaves the first scope. The lock guard does not. Other tasks can use the state while the network operation is waiting.

The second version check is important. The complete fixture is practical_runtime.rs. Another request may change the record during the fetch. Applying the old response unconditionally would turn a latency fix into a stale-write bug.

Decide what should happen on a version conflict. Discarding the refresh can be correct for a cache. A user-initiated mutation may need an explicit conflict response or a new attempt. Keep that policy visible instead of silently retrying forever.

Tokio's shared-state tutorial explains the choice between mutex-protected data and a task that owns asynchronous work. The Send-across-await article explains why a guard can also affect whether a future is spawnable.

Reproduce the wait without guessing its duration

Use a mock client whose response waits for a release signal. Start the refresh and wait until it reaches that client. Before releasing it, ask another task to read the cache.

With the original code, the reader cannot get the lock. With the smaller scope, it can. This tests the relationship directly; a sleep followed by “it seemed faster” does not.

Add a second scenario where the record version changes while the fetch is paused. When the old response arrives, it must not overwrite the newer state. Check the stored fields, not only a conflict log line.

Sometimes one owner is clearer

If several operations must use one stateful socket or a strict protocol sequence, passing commands to a dedicated owner task can be easier than repeatedly releasing and reacquiring a mutex. That task still needs bounded admission, deadlines, and an error path.

Some resources really must process one operation at a time. Holding an async mutex across that work may be correct. Check whether the resource requires it and how long other callers can wait.

For an ordinary cache refresh, I would keep the slow external wait outside the lock and make stale results explicit. Then measure lock wait and refresh failures separately. The service needs both acceptable concurrency and correct state.

A paused refresh and a newer record

The complete fixture runs two refreshes. It starts the first and waits for an explicit signal after the initial lock scope ends. Before allowing the response to arrive, the observer calls try_lock. That call must succeed immediately. It then changes the cached value to 99 and advances its version.

The old response carries value twelve. The refresh returns false, and the stored value must remain 99. This checks the correctness consequence of releasing the lock, not only its availability. A second refresh runs without an intervening version change. Its value thirteen must be applied, and the successful refresh advances the version too. That prevents another refresh started at the old version from overwriting it. Together, the cases show that the version check both rejects stale work and permits a valid refresh.

The adapter does not open an HTTP connection. It isolates the ownership rule from DNS, TLS, retries, and response parsing. A production client can spend its wait at any of those layers while retaining an application lock. Trace the actual client operation separately when investigating its latency.

The test uses one process and one cache. The version is local state, not a database transaction identifier or a cross-replica synchronization mechanism. A distributed cache refresh needs an identity and conflict rule that remain meaningful across its writers. Do not assume that moving an await outside a mutex settles that wider protocol.

Keep the lock scope short enough to inspect. Copy the request inputs and version together, perform the slow operation, then apply a result only under the chosen conflict policy. That sequence makes the trade visible during the next code review.