Skip to main content

Vibe Check Your Rust Code: A Practical Checklist for Memory Safety & Concurrency

Rust's selling point is simple: memory safety without a garbage collector, and data-race-free concurrency at compile time. But anyone who has worked on a nontrivial Rust project knows that the compiler doesn't catch everything. Unsafe blocks, poor design choices, and subtle misuse of concurrency primitives can still introduce bugs that are hard to reproduce. This guide is for teams that want a repeatable checklist—something you can run through during code review or before a release—to catch the most common safety and concurrency issues. We're not here to tell you that Rust is perfect; we're here to help you make your code actually safe. 1. The Borrow Checker Is Your Friend, Not Your Fridge Most Rust newcomers learn to respect the borrow checker, but experienced developers sometimes start working around it in ways that undermine safety.

Rust's selling point is simple: memory safety without a garbage collector, and data-race-free concurrency at compile time. But anyone who has worked on a nontrivial Rust project knows that the compiler doesn't catch everything. Unsafe blocks, poor design choices, and subtle misuse of concurrency primitives can still introduce bugs that are hard to reproduce. This guide is for teams that want a repeatable checklist—something you can run through during code review or before a release—to catch the most common safety and concurrency issues. We're not here to tell you that Rust is perfect; we're here to help you make your code actually safe.

1. The Borrow Checker Is Your Friend, Not Your Fridge

Most Rust newcomers learn to respect the borrow checker, but experienced developers sometimes start working around it in ways that undermine safety. The borrow checker enforces that references either have exclusive mutable access or shared immutable access—no aliasing with mutation. That rule prevents whole classes of bugs, but only if you let it.

The first thing to check in any code review is whether you're fighting the borrow checker unnecessarily. Common patterns that indicate trouble: excessive use of RefCell or Mutex to get interior mutability when a simple restructure would avoid it; cloning large data structures to dodge lifetime issues; or sprinkling unsafe just to make the compiler happy. Each of these is a red flag that should trigger a deeper discussion.

Checklist Item: Audit Your unsafe Blocks

Every unsafe block is a promise to the compiler that you have manually verified the invariants the compiler couldn't check. That promise is easy to break. For each unsafe block in your codebase, ask: can this be rewritten with safe abstractions? If not, is there a comment explaining why the invariants hold? A common failure is assuming that because the code compiles, it's safe—for example, dereferencing a raw pointer that might be dangling after a reallocation. We recommend a policy: every unsafe block must have a safety comment that references the specific invariants from the Rustonomicon or standard library documentation.

Checklist Item: Inspect Lifetime Annotations

Lifetime elision works most of the time, but explicit annotations can hide mistakes. When a function signature has multiple lifetime parameters, check that they are actually distinct and necessary. A common trap is using 'static when a shorter lifetime would suffice—this can lead to memory leaks or prevent deallocation. Also look for functions that return references tied to the wrong input; the compiler catches some cases, but not all when generics are involved.

One concrete scenario: a function that takes &'a str and &'b str and returns &'a str might accidentally return a reference to the second string if the logic is wrong. The compiler won't flag this because the lifetimes are consistent with the signature. Code review should verify that the returned reference actually points to the correct input.

2. Ownership Patterns: When to Clone, When to Borrow, When to Arc

Rust's ownership model forces you to be explicit about sharing, but that doesn't prevent design mistakes. The most common issue we see is overusing Arc<Mutex<T>> as a default way to share state, when a simpler pattern would work. This adds overhead and makes the code harder to reason about.

Three Approaches to Sharing

We can categorize sharing patterns into three broad approaches, each with trade-offs. First, exclusive ownership with borrowing: pass references down a call chain. This is the simplest and most performant, but it only works when the data flow is acyclic and single-threaded. Second, reference counting with Rc or Arc: use Rc for single-threaded shared ownership, Arc for multithreaded. This works well for immutable data or when combined with interior mutability. Third, message passing via channels: send data between threads using mpsc or crossbeam channels. This avoids shared state entirely and is often the best choice for concurrent systems.

Checklist Item: Identify Unnecessary Arc Usage

Every Arc incurs atomic reference counting overhead. If the data is only used in one thread, use Rc instead—or better, restructure to avoid reference counting altogether. Similarly, Arc<Mutex<T>> is often used when a single-threaded RefCell would suffice, or when the data could be moved into a channel. A good heuristic: if you have more than a few Arcs in a single module, step back and ask whether the design could be simplified.

Checklist Item: Check for Memory Leaks from Reference Cycles

Using Rc or Arc with RefCell or Mutex can create reference cycles that prevent deallocation. This is especially common in graph-like structures or event systems where nodes hold references to each other. To detect cycles, consider using weak references (Weak<T>) for back-edges. A practical check: if your code uses Rc or Arc and you never call Weak::upgrade, you might be leaking memory. Tools like valgrind or heaptrack can help identify unexpected memory growth during testing.

In one composite scenario, a team built a reactive UI framework where each widget held an Rc<RefCell<Vec<Rc<Widget>>>> for its children. Parent-child cycles were inevitable, and memory grew unboundedly. Switching children to Weak references fixed the leak without changing the architecture much.

3. Concurrency Primitives: Choosing the Right Tool

Rust offers a rich set of concurrency primitives, but choosing the wrong one can lead to deadlocks, performance cliffs, or subtle correctness bugs. The standard library's Mutex, RwLock, and atomic types each have different characteristics. Beyond that, the ecosystem provides parking_lot for faster mutexes and crossbeam for lock-free structures.

Checklist Item: Match Primitive to Contention Level

For low-contention scenarios (most threads rarely access the lock), a standard Mutex is fine. For read-heavy workloads, RwLock can improve throughput, but it has higher overhead per operation and can cause writer starvation in some implementations. For very high contention, consider lock-free data structures like crossbeam::atomic::AtomicCell or segqueue. A common mistake is using RwLock when the critical section is short—the overhead of the read-write lock can be worse than a simple mutex.

Checklist Item: Avoid Deadlocks by Lock Ordering

When multiple locks are acquired, always acquire them in a consistent order across the codebase. A deadlock occurs when thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1. Enforce a global lock ordering policy and document it. In Rust, you can use a type-level ordering by wrapping locks in newtypes with a numeric priority. For example, always lock the database connection before the cache, never the reverse. Use std::sync::LockResult error handling to avoid poisoned locks, but don't rely on it for correctness—poisoning is a last resort.

Checklist Item: Check for Send and Sync Implementation

Rust's Send and Sync traits are automatically derived for most types, but manual implementations can introduce unsoundness. If you implement Send or Sync for a type, you are promising that it is safe to transfer or share across threads. Common mistakes: marking a type as Sync when it contains a Cell or RefCell (which are not Sync), or implementing Send for a type that holds a raw pointer without proper synchronization. Always add a safety comment explaining why the implementation is correct.

4. Async Rust: Pin, Send, and the Hidden State Machine

Async Rust is not just syntactic sugar over threads—it introduces a state machine that can be held across await points. This creates unique challenges: futures can be moved between threads after being polled, and references held across .await must be valid when the future resumes. The borrow checker enforces some rules, but not all.

Checklist Item: Verify That Futures Are Send

If you spawn a future onto a thread pool (e.g., with tokio::spawn), the future must implement Send. The compiler will catch most violations, but the error messages can be confusing. A common culprit is holding a non-Send type like Rc or a raw pointer across an .await. To debug, use the tokio::pin! macro and check the generated error. A good practice is to explicitly annotate your async functions with a Send bound in tests: fn assert_send<T: Send>(t: T) {} and call it with the future.

Checklist Item: Avoid Holding Locks Across .await

If you hold a Mutex lock while awaiting, you risk deadlocking the entire executor if another task tries to acquire the same lock. This is because the executor may run other tasks on the same thread while the lock is held. The fix is to restructure the code so that the lock is dropped before the .await. For example, instead of:

let data = my_mutex.lock().unwrap();
some_future.await;
// use data

Change to:

let data = my_mutex.lock().unwrap();
let processed = process(data);
drop(data);
some_future.await;
// use processed

Checklist Item: Use tokio::pin! or Box::pin for Self-Referential Futures

Some futures are self-referential—they hold a pointer to a field within themselves. These futures are not Unpin by default, meaning they cannot be safely moved after being polled. If you need to move such a future, you must pin it. The compiler will warn you, but it's easy to overlook. Always check that futures passed to tokio::spawn or select! are either Unpin or properly pinned.

5. FFI and Unsafe: The Boundary Where Safety Leaks

Foreign Function Interface (FFI) is where Rust's safety guarantees break down. When calling C code, you must manually ensure that the C library behaves correctly—Rust's borrow checker won't help. Similarly, exposing Rust functions to C requires careful handling of pointers and memory layouts.

Checklist Item: Validate Pointer Arguments in FFI Functions

Every pointer passed from C to Rust should be checked for null and alignment. Use std::ptr::NonNull to represent non-null pointers, and validate that the pointer points to a valid object of the expected type. For example, if a C function passes a char* that is supposed to be a null-terminated string, check that the pointer is not null and that the string is valid UTF-8 if you convert it to &str. A common failure is assuming that the C caller will always provide valid data—they won't.

Checklist Item: Ensure Correct Memory Layout for Structs

When sharing structs across FFI, use #[repr(C)] to guarantee a predictable layout. Without it, Rust may reorder fields for optimization, causing mismatches with the C side. Also check that the size and alignment match using std::mem::size_of and std::mem::align_of. A mismatch can cause silent data corruption. For complex structs, write a test that verifies the layout against the C header file.

Checklist Item: Manage Ownership of Allocated Memory

If Rust allocates memory and passes it to C, who frees it? The convention must be documented and enforced. A common pattern is to have Rust allocate and return a raw pointer, and provide a corresponding free function that the C caller must invoke. Use Box::into_raw and Box::from_raw to convert between safe and raw pointers. Never mix allocators (e.g., Rust's global allocator with C's malloc) unless they are the same.

6. Testing for Safety: What Unit Tests Miss

Unit tests are great for functional correctness, but they often miss concurrency bugs and memory safety issues. Data races and deadlocks may only appear under specific interleavings that are hard to reproduce. To catch these, you need a different testing strategy.

Checklist Item: Use Thread Sanitizer (TSan) in CI

ThreadSanitizer is a tool that detects data races at runtime. Enable it in your CI pipeline with RUSTFLAGS="-Z sanitizer=thread" (nightly only) or via the sanitizer crate. Run your test suite under TSan regularly. It will catch races that your tests might not trigger deterministically. Note that TSan can have false positives, so investigate each warning carefully.

Checklist Item: Write Stress Tests for Concurrent Code

Write tests that spawn many threads and perform operations concurrently. Use loom (a deterministic concurrency testing library) to model different interleavings. loom can simulate thread schedules and detect deadlocks, races, and atomicity violations. It's especially useful for testing custom concurrency primitives. For example, if you implement a lock-free queue, test it with loom to ensure correctness under all possible interleavings.

Checklist Item: Test with Miri for Undefined Behavior

Miri is an interpreter that detects undefined behavior in Rust code, including in unsafe blocks. Run cargo miri test on your test suite to catch violations like out-of-bounds memory access, use-after-free, and invalid alignment. Miri is slow, so run it as a nightly CI job for critical crates. It catches bugs that would otherwise manifest as mysterious segfaults.

7. Common Mistakes and Mini-FAQ

Over years of reviewing Rust code, we've seen the same patterns crop up. Here are the most frequent mistakes, along with quick answers to common questions.

Mistake: Using unsafe to Bypass the Borrow Checker

When the borrow checker rejects a valid pattern, the temptation is to use raw pointers and unsafe. But often the design can be refactored to satisfy the borrow checker without sacrificing performance. If you must use unsafe, isolate it in a small module and test thoroughly.

Mistake: Ignoring must_use Attributes

The standard library marks many functions with #[must_use], meaning the return value should not be ignored. For example, Mutex::lock returns a LockResult that you should handle. Ignoring it can lead to silent failures or deadlocks. Use clippy to enforce that all must_use results are used.

FAQ: Is it safe to use transmute?

transmute is one of the most dangerous functions in Rust. It reinterprets the bits of a value as another type, bypassing all type safety. Only use it when you have carefully verified that the source and target types have the same size and alignment, and that the bit pattern is valid for the target type. Prefer safe alternatives like From or as casts when possible.

FAQ: How do I prevent deadlocks in practice?

Use a consistent lock ordering, avoid holding locks across .await, and consider using lock-free data structures for simple cases. Also, set a timeout on lock acquisition (e.g., Mutex::try_lock) to detect potential deadlocks in testing. In production, use a watchdog that monitors thread progress.

FAQ: Should I use Arc<Mutex<T>> or channels?

Channels are often simpler and less error-prone for passing data between threads. Use Arc<Mutex<T>> when multiple threads need to share mutable state and the access pattern is not easily modeled as message passing. But be aware that shared mutable state makes reasoning about concurrency harder. Prefer channels by default, and only fall back to shared state when performance measurements show a bottleneck.

8. Putting It All Together: A Pre-Release Checklist

Before you ship, run through this final checklist. It doesn't guarantee perfect safety, but it catches the most common issues that slip through regular testing.

Step 1: Run Clippy with Pedantic Mode

cargo clippy -- -W clippy::pedantic will flag many unsafe patterns, unnecessary allocations, and potential bugs. Address each warning, or suppress it with a documented reason. Pay special attention to lints like unsafe_derive_deserialize and missing_safety_doc.

Step 2: Audit All unsafe Blocks

Print out all unsafe blocks in your codebase and review them with a colleague. For each one, verify that the safety invariants are documented and that the code is correct. If an unsafe block is more than a few lines, consider refactoring it into a safe abstraction.

Step 3: Run Miri and TSan

Run cargo miri test and cargo test --target x86_64-unknown-linux-gnu -- --test-threads=1 with TSan enabled. Fix any reported issues. If Miri reports undefined behavior, treat it as a release blocker.

Step 4: Stress Test with Loom

Write a loom test for your most complex concurrent data structures. Run it with --test-threads=1 to get deterministic interleavings. Aim for at least 1000 iterations to explore different schedules.

Step 5: Review Async Code for Send and Lock Holding

Check that all futures passed to tokio::spawn are Send. Use the assert_send trick. Also scan for Mutex locks held across .await—these are almost always a bug.

Step 6: Verify FFI Memory Management

If your code uses FFI, ensure that every allocation has a corresponding deallocation, and that the allocator is consistent. Write a test that calls the FFI functions repeatedly and checks for memory leaks with valgrind.

Rust gives you powerful tools for safety, but it's not a silver bullet. A disciplined review process, combined with the right tools, can catch the issues that the compiler misses. Make this checklist part of your CI pipeline, and your code will be safer for it.

Share this article:

Comments (0)

No comments yet. Be the first to comment!