About async-rust
A Rust skill for developers working with asynchronous code. It focuses on concurrency correctness, executor blocking, lock usage across awaits, and cancellation safety, with guidance on runtime and shared state choices.
Key features
- 01Concurrency and cancellation correctness
- 02Avoiding executor-thread blocking
- 03Lock handling across await points
- 04Runtime and shared-state guidance
- 05Async lint and test verification
Use cases
- 01Reviewing Rust async code for deadlocks
- 02Handling cancellation in multi-step operations
- 03Choosing channels for task communication
- 04Checking async changes with Clippy and tests
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ rewrite-rs/ skills --skill async-rustAfter installing, type/async-rustin Claude Code to confirm it loaded.
Bundled filesagents/openai.yamlCANCELLATION.md
Details
From the project READMEAsync Rust
Async Rust fails in three places: an executor thread that gets blocked, a guard held across an await point, and a future dropped mid-operation. This skill governs correctness under concurrency and cancellation — it does not decide whether the code should be async in the first place.
Async is not free concurrency
An async fn yields a future that does nothing until polled. Concurrency comes
from the runtime and from combining futures — join!, select!, spawn — not
from the async keyword:
// Serial: the second fetch starts only after the first completes.
let a = fetch_user(id).await;
let b = fetch_orders(id).await;
// Concurrent: both futures are polled at the same time.
let (a, b) = tokio::join!(fetch_user(id), fetch_orders(id));
Every .await in a function adds to the size of the generated future, and a
large future is copied on every move — into Box::pin, into a JoinSet.
Many awaits and large locals mean kilobytes; box the inner future or split
the function once a profile says the moves matter.
Runtime choice, once, at the top
Pick a runtime, in the overwhelming majority of cases tokio, deliberately.
The library rules: stay runtime-agnostic if you can, feature-gate the
integration if you cannot, and never start a runtime inside a library
function (#[tokio::main] belongs in a binary or test; a Runtime::block_on
there hands the consumer a second, conflicting executor).
Blocking work in an async context
A CPU-bound loop, a synchronous file or network call, or a compute stretch
without an .await inside an async fn stalls the executor thread and starves
every other task on it. The fix: tokio::task::spawn_blocking for blocking I/O
and short CPU work, a dedicated thread pool (rayon) for sustained CPU work,
and never a std::thread::sleep inside a task. For a compute loop, yielding
periodically is the weaker fix — it only helps if the chunks are genuinely
small.
Send, Sync, and the spawn bound
tokio::spawn requires Send + 'static: everything held across an .await
must be Send — where Rc and RefCell fail to compile, and where a
std::sync::MutexGuard held across an .await becomes a deadlock:
// Deadlock waiting to happen: the std guard is held across the await.
let mut guard = state.lock().unwrap();
guard.value = fetch().await;
// Either drop the guard before awaiting…
let value = fetch().await;
state.lock().unwrap().value = value;
// …or use the async-aware mutex when the lock genuinely must span the await.
let mut guard = state.lock().await; // tokio::sync::Mutex
guard.value = fetch().await;
Prefer std::sync::Mutex unless the lock must span an .await, then
tokio::sync::Mutex — clippy::await_holding_lock catches the common case.
Cancellation safety
Any future can be dropped at any .await point — that is what select! and
timeouts do. A future that has consumed input but not yet committed it loses
data when dropped mid-flight. The rule: do the irreversible step in one
non-cancellable piece, or make the operation resumable. The worked cases
live in CANCELLATION.md.
Shared state across tasks
Arc<Mutex<T>> for shared mutation, channels for handing work between tasks,
and message passing when the state has one logical owner — a task that owns
its state and is told what to do beats tasks that reach into shared memory.
| Channel | Use for |
|---|---|
mpsc | Many producers to one consumer |
oneshot | A single reply |
watch | The latest value, where missing intermediate values is correct |
broadcast | Every receiver seeing every message, with a bounded backlog |
The choice is semantic, not performance — picking broadcast where watch
was meant produces a lagging receiver error nobody expected.
Dynamic task groups and their abort semantics are in CANCELLATION.md.
Async in a public API
Prefer async fn over -> impl Future unless the extra control is needed —
the desugared form additionally promises Send-ness (or its absence) in ways
the caller then depends on. In traits, async fn still lacks Send bound
sugar, and #[async_trait] remains the pragmatic choice for object-safe
async traits.
Deferrals
Error types for timeouts and cancellation are /rust-errors; shared-ownership,
/ownership-not-clone; public-surface commitments, /rust-api-design; the
Pin invariants of a manual Future impl, /unsafe-rust.
Verification
cargo clippy --all-targets # add -- -D warnings only if the repo has no lint config
cargo test
The async-relevant lints (await_holding_lock, await_holding_refcell_ref,
async_yields_async) come out of its output, not switched on. Async bugs
are timing-dependent — a passing cargo test is weak evidence: where
the repo uses #[tokio::test], cover the changed path with
#[tokio::test(flavor = "multi_thread", worker_threads = 2)].