Skip to content
← All articles

When not to use unsafe, and when not to use async

Every reason people reach for unsafe, and the safe answer to each; then the threads-versus-async decision — with both live community disagreements presented rather than resolved.

A course that only teaches how to write unsafe code produces people who write too much of it. The mark of expertise is the opposite: a small, well-defended unsafe surface, and the judgement to know when threads beat async.

This article comes last for a reason. You have now built Vec, Rc, RefCell, a ring buffer, a thread pool and an async runtime by hand. You have standing to judge. Delivered in week one, the same advice would have been superstition.

The budget framing

Start here, because it reframes every question below.

Every unsafe block imposes a permanent audit obligation on the whole module it lives in. Not on the block — on the module. Because the block’s soundness depends on private invariants, and any safe code with access to those invariants can break them. That is why a pub field, or an added #[derive(Clone)], can turn a correct unsafe abstraction unsound in a commit that never touches the word unsafe.

So the question is never “can I write this with unsafe?” You can. The question is “is this worth the ongoing cost?” — the cost being that every future change to that module has to be reviewed by someone holding the invariant in their head, forever, including the changes made by people who have never read this article.

#![forbid(unsafe_code)] at the crate root is the correct default policy for most application crates. It is the wrong policy for the ones that need FFI or that implement a data structure the language cannot express — and those crates should be small, separate, and named so it is obvious which ones they are.

The five reasons people reach for unsafe

“The borrow checker won’t let me build a graph”

The commonest one, and the answer is almost always an arena with indices: put every node in a Vec, refer to nodes by usize (or a generational handle, as in the audit capstone), and let the arena own everything. Cycles become fine, because an index is not a pointer and cannot dangle in the memory-safety sense. You get cheap serialisation and cache locality for free. You lose compile-time protection against stale indices — which is exactly what generational handles buy back.

The other answer is Rc<RefCell<_>>, and here the community genuinely disagrees, so take both:

One camp treats Rc<RefCell<_>> as a smell that usually signals a missing arena or a mis-modelled ownership tree: you reached for shared mutable state because you had not decided who owns what, and now every access can panic.

The other camp treats it as perfectly reasonable for GUI trees, interpreters, and observer patterns, where the runtime cost is irrelevant, the graph really is dynamic, and the alternative is a hand-rolled unsafe graph that will be worse in every way that matters.

Both are right about the cases they have in mind. The actual decision criterion: RefCell panics are a runtime failure mode you are choosing to accept in exchange for not having to prove the borrow pattern statically. That is a good trade when the borrow pattern is genuinely dynamic — an interpreter’s environment, a widget tree responding to events. It is a bad trade when you are using it to avoid thinking about ownership, because then you have taken on the panic risk and got nothing for it.

What is not in dispute: neither of these is a reason to write unsafe. A hand-rolled unsafe graph is the one option that is worse than both.

“It’s faster”

Measure first. Then measure the safe alternative, because it is usually closer than you think and sometimes ahead.

Bounds-check elimination — the usual motivation — mostly falls out of writing the code differently rather than unsafely. Iterators carry their own bounds proof. chunks_exact gives LLVM a length it can reason about. Slice patterns (let [a, b, c] = arr) check once. An assert!(a.len() == b.len()) before a loop over two slices is one comparison that removes two checks per iteration — measured at 1.48× in this course, from a single line of safe code.

The compiler is better at this than most hand-written unsafe, and it gets better every release, whereas your get_unchecked stays exactly as unsafe as the day you wrote it. If after measuring the unsafe version really is faster and the difference really matters, write it — with a benchmark in the repository proving the claim, because otherwise the next person will delete it and be right to.

“I need interior mutability”

You do not need unsafe for this; std has a type for every shape of it. Cell<T> for Copy values with no borrowing. RefCell<T> for single-threaded dynamic borrows. Mutex<T> and RwLock<T> across threads. OnceLock<T> and LazyLock<T> for initialise-once. Atomics for lock-free counters and flags.

UnsafeCell is the primitive underneath all of them, and reaching for it directly means you are building a new interior-mutability abstraction. That is a real thing to do, occasionally. It is not what “I need to mutate this through a shared reference” means.

“I need uninitialised memory”

Usually the answer is Vec::with_capacity plus extend, and the optimiser handles it well — it can see the capacity, elide the growth checks, and often vectorise the fill. MaybeUninit and set_len buy you a memset you probably were not paying for anyway, and cost you the single easiest way to get undefined behaviour in Rust: reading uninitialised memory, which is UB even for integers, even if you “only” compare it.

“I need to talk to C”

Legitimate. This is the one reason on the list that survives. There is no safe way to call an extern "C" function, and there never will be, because the guarantee has to come from outside the language.

What is still in your control is the shape: a thin sys layer that mirrors the header mechanically, a safe layer above it that establishes every precondition, and no pub unsafe fn escaping into the public API. That is what the FFI items in this track were for.

The async half

The second question, and it is answered with the same kind of judgement.

Threads are right for CPU-bound work, for low or moderate concurrency, and for anything where the code is easier to read as a straight line. They cost around 8 MiB of virtual address space and a few microseconds each; a few thousand of them is entirely ordinary on a modern machine. They compose with every library in the ecosystem, they show up correctly in a debugger and a profiler, and std::thread::scope lets them borrow from the stack.

Async is right for high-concurrency I/O waiting — tens of thousands of mostly-idle connections — where a thread per task would be dominated by stacks and context switches. Its cost is not runtime; it is the ecosystem tax. A runtime dependency, function colouring (an async fn can only be called from async code), Send bounds propagating through your whole call graph, cancellation-by-drop semantics you have to understand, and a profiler view that is much harder to read.

And the point that gets left out: most programs need neither to be complicated. A CLI that makes four HTTP requests does not need a runtime, and does not need threads either. A build tool that processes a thousand files needs std::thread::scope and a chunked slice, and that is the whole design. Reaching for async because it is what the ecosystem talks about is the same mistake as reaching for unsafe because it is what the fast code looks like.

Do not resolve either debate by fiat

Neither the Rc<RefCell<_>> argument nor the threads-versus-async argument has a correct answer independent of what you are building. Anyone who tells you otherwise is describing their own last project.

What you can carry, and what this course was for:

  • Ownership is the model, and most problems that look like they need an escape hatch are problems where the ownership tree was drawn wrong.
  • unsafe is a budget, spent per module and paid forever.
  • Measure before you optimise, and measure the safe version too.
  • The type system is where the proofs live. Every technique in this course — newtypes, TryFrom, guards, typestate, enums with data, Drop — is a way of moving a fact out of a comment and into a place the compiler can check it.

That last one is the whole language, and it is what you are actually taking with you.

The Expert Edge: Idiom, Review and Capstones · step 14 of 14

That's the end of this track. Review it or pick another.

← Back to The Expert Edge: Idiom, Review and Capstones