Representative interview topic

Rust 2024 interview: What problem do async closures solve?

CodingHard
Offer.cc Editorial TeamPublished Updated

Question

How do Rust 2024 async closures differ from `|| async {}`? How would you design an async callback API that borrows inputs, supports cancellation, and preserves lifetimes?

Prompt and scope

You maintain a Rust 1.85 service that passes callbacks to an asynchronous retryer. A callback must borrow a caller-owned buffer, perform asynchronous I/O, and work with different borrow lifetimes. Compare async || {} with || async {}, then cover AsyncFn, lifetimes, captures, cancellation, and migration.

What the interviewer is testing

  • Whether you understand that an async-closure future can borrow captured data.
  • Whether you use AsyncFn, AsyncFnMut, and AsyncFnOnce for higher-order async callbacks.
  • Whether you distinguish ownership and lifetimes when a future is created, polled, or cancelled.
  • Whether you provide a version migration, boundary tests, and resource-cleanup plan.

Clarifying questions to ask

  1. Is the callback called repeatedly, mutates captured state, or is consumed once? That selects the trait.
  2. Is input owned or a short borrow? Must the future complete during the call?
  3. Does cancellation happen during I/O or when the retryer exits? How are external resources cleaned?
  4. Has the MSRV moved to Rust 1.85, and do dependencies support Rust 2024?

30-second answer framework

|| async {} is a regular closure returning an async block; its inner future cannot express the same borrowing relationship as an async closure. Rust 1.85 makes async || {} a first-class async call and adds AsyncFn traits. Choose the trait by call pattern, state that an input borrow lasts until the future completes, and let future drop perform cancellation cleanup. Migrate the toolchain and edition first, use cargo fix conservatively, then verify borrowing, retries, and cancellation with compiler and runtime tests.

Step-by-step deep dive

1. Compare the two forms

A regular closure returns a future:

rust
let old = |buf: &mut Vec<u8>| async move { buf.push(1); };

An async closure makes the asynchronous call itself part of the closure contract:

rust
let new = async |buf: &mut Vec<u8>| { buf.push(1); };

The key question is whether each call can produce a future tied to that call's input. The first form often makes higher-order lifetime constraints hard to express; the second is represented by the AsyncFn traits.

2. Choose an AsyncFn trait

Use AsyncFn for a read-only callback that can be called repeatedly, AsyncFnMut when repeated calls mutate captured state, and AsyncFnOnce when the callback consumes its captures. Do not force every future into BoxFuture merely because it is asynchronous; that would reject valid short borrows.

3. Lifetimes and captures

An input borrow must live until the future completes or is dropped. A callback must not place that borrow in a 'static task, and a retryer must not queue a future beyond the buffer's lifetime. For true background work, copy or move owned data first so the task owns its lifetime.

4. Cancellation and cleanup

Rust has no mandatory async-cancellation protocol; future drop commonly represents cancellation. I/O wrappers should close sockets, release locks, and remove temporary files on drop or through an explicit cancellation token. A retryer must not poll one future concurrently or use state after cancellation.

5. Retries and side effects

Only idempotent operations should retry automatically. Non-idempotent I/O needs a request ID, transaction, or compensation. Create a new future for each attempt and record the attempt, error, and cancellation reason. If an external side effect may have committed, query it or use an idempotency key before retrying.

6. Migration and verification

Move CI, development environments, and the MSRV to Rust 1.85, then follow the Rust 2024 migration guide. cargo fix --edition is conservative and cannot replace a semantic review. Test short borrows, mutable captures, repeated calls, future drop, timeout, retries, and every supported target.

Model high-quality answer

I would choose AsyncFn, AsyncFnMut, or AsyncFnOnce according to whether the callback repeats and consumes or mutates captures. async || directly expresses an async closure, so each call's future can be tied to its input borrow; || async {} is harder to constrain this way in higher-order generics. I would not store a short-borrow future as 'static; a background task receives owned data instead. Future drop or a cancellation token cleans up I/O and locks. Retries are limited to idempotent operations and record an attempt and request ID. After moving to Rust 1.85, compiler, Miri, and runtime tests cover lifetimes, retries, and cancellation.

Common mistakes

  • Claim the forms are identical → ignore captured-borrow and higher-order trait semantics → test a short-borrow callback.
  • Require 'static for every callback → reject valid foreground borrows → separate borrowed calls from owned background data.
  • Use only a boolean cancellation flag → I/O still holds locks or sockets → provide drop and token cleanup paths.
  • Retry non-idempotent work forever → duplicate side effects → use request IDs, queries, or compensation.
  • Treat cargo fix as the whole migration → leave semantic and MSRV risks → add cross-target and behavior tests.

Follow-up questions and responses

Why not box every callback future as 'static?

That loses the lifetime tied to the input borrow and rejects valid short-borrow callbacks. Only data entering a long-lived background task should be owned first and then become 'static.

What happens if an AsyncFnMut callback is called concurrently?

It expresses mutable capture but does not provide concurrency safety. Serialize calls, add synchronization, or give each call independent state; never create overlapping mutable borrows.

How do you prove cleanup when a future is dropped during I/O?

Wrap the resource with Drop or an explicit cancellation path, test timeouts and task cancellation, and inspect the final connection, lock, temporary-file, and external request-ID state.

Public sources

Related questions

Related interview tool

Use Screenshot for a coding prompt

Capture the problem, then work through the constraints, solution, code, edge cases, and complexity in order.

View the tool