Skip to content
← All Rust articles

Rust explainer

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

About 14 min to read. Allow extra time to work through examples.

Common reasons for reaching for unsafe code and the safe alternative to each, then how to choose between threads and async, including two design questions on which experienced Rust programmers disagree.

This article uses Rust. You can make it your practice mode to browse related content across the site.

This article is about restraint. Rust lets you write unsafe code and async code, and each is sometimes the right tool. Each also has costs that are easy to underestimate. The goal is a small, well-justified amount of unsafe code and a deliberate choice between threads and async.

Two terms first. Unsafe code is code that the compiler accepts only inside an unsafe block, function, trait or implementation, because it can do things the compiler cannot check, such as dereferencing a raw pointer or calling a foreign function. If that code is wrong, the result can be undefined behavior (UB): the language no longer constrains what the program does, and it may appear to work. A safe API built on unsafe code is sound if no safe code using it can cause UB. Async code is written as functions that return futures, values that describe work to be done later. An executor (also called an async runtime) runs futures by repeatedly polling them, which lets one operating-system thread switch between many tasks while they wait for I/O.

Unsafe code is an ongoing cost

An unsafe block is usually correct only because of facts the compiler does not check, called invariants. Examples: "len never exceeds cap", or "this pointer came from Box::into_raw and is freed exactly once". Those facts usually live in private fields, and any code in the same module can read and write private fields. So the code that has to be reviewed together with an unsafe block is in practice its whole module, not only the block. (Moving the type and its fields into a smaller module narrows that region.)

That is why a commit that never mentions unsafe can make an abstraction unsound:

  • Making a field pub lets code outside the module set it to a value the unsafe code assumed impossible.
  • Adding #[derive(Clone)] to a type that owns a raw pointer can create two values that both believe they own it.

Here is a small type that owns a heap-allocated i32 through a raw pointer:

struct OwnedInt {
    ptr: *mut i32, // invariant: came from Box::into_raw, freed exactly once, by Drop
}

impl OwnedInt {
    fn new(v: i32) -> Self {
        OwnedInt { ptr: Box::into_raw(Box::new(v)) }
    }

    fn get(&self) -> i32 {
        // SAFETY: ptr came from Box::into_raw and Drop has not run yet.
        unsafe { *self.ptr }
    }
}

impl Drop for OwnedInt {
    fn drop(&mut self) {
        // SAFETY: ptr came from Box::into_raw, and Drop runs once per value.
        unsafe { drop(Box::from_raw(self.ptr)) };
    }
}

As written, this is sound. Adding #[derive(Clone)] above struct OwnedInt still compiles, but the derived clone copies the pointer. The original and the clone then both free the same allocation when they are dropped, which is a double free and therefore UB. The unsafe blocks did not change; the invariant was broken by safe code next to them.

So the useful question is not "can I write this with unsafe?" but "is the benefit worth checking every future change to this module against these invariants?"

A crate can rule unsafe code out with a lint. #![forbid(unsafe_code)] at the crate root makes unsafe blocks, unsafe functions and unsafe trait implementations in that crate compile errors. Unlike deny, a forbid level cannot be overridden by an inner #[allow(unsafe_code)]. The lint covers only the crate's own source; dependencies can still contain unsafe code. It is a reasonable default for many application crates. Code that does need unsafe, such as FFI bindings or a data structure safe Rust cannot express, can live in a small, separate crate or module with an obvious name, so that the rest of the code base keeps the lint.

Common reasons for reaching for unsafe

"The borrow checker won't let me build a graph"

A graph with cycles, or with nodes that have several parents, does not fit Rust's default of one owner per value. A common safe design is an arena with indices: store every node in one Vec, and refer to other nodes by their position in it.

struct Node {
    value: i32,
    edges: Vec<usize>, // indices into Graph::nodes
}

struct Graph {
    nodes: Vec<Node>,
}

impl Graph {
    fn add(&mut self, value: i32) -> usize {
        self.nodes.push(Node { value, edges: Vec::new() });
        self.nodes.len() - 1
    }

    fn connect(&mut self, from: usize, to: usize) {
        self.nodes[from].edges.push(to);
    }
}

fn main() {
    let mut g = Graph { nodes: Vec::new() };
    let a = g.add(1);
    let b = g.add(2);
    g.connect(a, b);
    g.connect(b, a); // a cycle, with no ownership problem
    let neighbours: i32 = g.nodes[a].edges.iter().map(|&i| g.nodes[i].value).sum();
    assert_eq!(neighbours, 2);
}

Graph owns every node, so a cycle is just two numbers. An index cannot cause a use-after-free: an index past the end makes self.nodes[i] panic instead of reading freed memory. What you give up is a check the compiler used to do for you. If nodes can be removed and their slots reused, an old index can silently refer to a different node. A generational index pairs the slot number with a counter that changes each time the slot is reused, so a lookup can detect a stale handle. Keeping nodes in one Vec can also help cache behavior and makes the graph easy to clone or serialize, though whether it is faster depends on how the graph is accessed.

The other safe design is Rc<RefCell<T>>: shared ownership through reference counting (Rc), plus borrow rules checked at run time instead of compile time (RefCell). Experienced Rust programmers disagree about it, and both positions have merit:

  • One view treats it as a warning sign. It often appears when nobody decided who owns what, and every borrow_mut() call panics if another borrow of the same value is still alive.
  • The other view treats it as a reasonable choice for structures such as GUI widget trees, interpreter environments and observer lists, where the sharing genuinely changes at run time and the cost of the checks is small next to the rest of the work.

A useful way to decide: RefCell turns borrow errors that would have been compile errors into run-time panics. That trade makes sense when the borrow pattern depends on run-time events and cannot reasonably be expressed in types. It makes less sense when it only postpones a decision about ownership, because you accept the panic risk and get nothing for it. Note also that Rc values that point at each other in a cycle are not freed automatically; either break the cycle by hand or make one direction of it a Weak reference, which does not keep its target alive.

Neither position is an argument for a graph built from raw pointers. That design has the same ownership questions and adds the possibility of UB.

"It's faster"

Measure first, and then measure a safe alternative too. Safe code is often as fast, and an unsafe version should be justified by measurements of your program on your target.

The usual motivation is avoiding bounds checks. The optimizer removes a bounds check when it can prove the index is in range, and several safe styles supply that proof:

  • Iterators such as iter(), zip and windows do not index, so there is no per-element bounds check to remove.
  • chunks_exact(n) yields slices of exactly n elements, so with a constant n the optimizer can usually prove that indexes 0 through n - 1 into a chunk are in range.
  • An array pattern checks the length at most once. let [a, b, c] = arr; needs no check at all for an array of type [T; 3]. For a slice it must be written let [a, b, c] = slice else { ... };, which checks the length once and binds three references.
  • An assertion before a loop can give the optimizer the fact it is missing:
fn dot(a: &[f64], b: &[f64]) -> f64 {
    assert_eq!(a.len(), b.len());
    let mut sum = 0.0;
    for i in 0..a.len() {
        sum += a[i] * b[i];
    }
    sum
}

After the assertion, i < a.len() implies i < b.len(), so the compiler is able to drop both checks inside the loop. Whether it actually does is an optimizer decision, not a language guarantee, so confirm it by reading the generated code or running a benchmark. (The loop can also be written as a.iter().zip(b).map(|(x, y)| x * y).sum(), which avoids indexing altogether. Note that zip stops at the shorter slice instead of panicking, so keep the assertion if unequal lengths are a bug.)

If, after measuring, an unchecked version (for example one using get_unchecked) really is faster and the difference matters, keep it. Put a // SAFETY: comment next to it explaining why every index is in range, and keep a benchmark in the repository that shows the gain, so that someone can recheck the claim when the code or the compiler changes.

"I need to mutate through a shared reference"

This is called interior mutability, and the standard library has safe types for the common cases:

  • Cell<T> moves whole values in and out and never hands out a reference to its contents through a shared &Cell<T> (only get_mut, which needs &mut Cell<T>, does). get requires T: Copy, but set and replace work for any T, and take works for any T: Default.
  • RefCell<T> gives out references, with borrow rules checked at run time, for single-threaded code.
  • Mutex<T> and RwLock<T> do the same job across threads, blocking a thread until access is available.
  • OnceCell<T> and LazyCell<T> initialize a value once; OnceLock<T> and LazyLock<T> are their thread-safe counterparts.
  • Atomic types such as AtomicUsize and AtomicBool provide counters and flags that threads can update without a lock.

All of these are built on UnsafeCell<T>, the primitive that makes mutation through a shared reference permitted at all. Using UnsafeCell directly means designing a new interior-mutability type, with invariants like the ones above to maintain. That is occasionally the right job, but "I need to change this through &self" is usually answered by one of the types in the list.

"I need uninitialized memory"

Usually Vec::with_capacity(n) followed by push or extend is enough. It allocates once and writes each element as it is produced, without first filling the buffer with placeholder values.

Writing through MaybeUninit<T> and then calling set_len can skip the capacity check that each push performs, which is rarely a measurable cost. In exchange it makes one kind of UB very easy: reading memory that was never initialized as an ordinary value, integers included, is UB, even if the value is only compared or printed. (Only types designed to hold uninitialized bytes, such as MaybeUninit<T> itself, are exempt.)

"I need to call C"

This reason holds up. Calling a function written in another language through a foreign function interface (FFI) depends on facts that Rust cannot check: that the declared signature matches the real one, and that the function itself does not break Rust's rules. In the 2024 edition every extern block must be written unsafe extern, and that unsafe is your promise that the declarations are correct. Items in the block are unsafe to call by default. An item can be marked safe if no argument values can make the call cause UB, and then it is called like an ordinary function:

use std::ffi::c_char;

unsafe extern "C" {
    // Matches C's `double sqrt(double)`. It has defined behavior for every
    // input (a negative argument gives NaN), so it is declared safe to call.
    safe fn sqrt(x: f64) -> f64;

    // Unsafe to call: s must point to a NUL-terminated string.
    fn strlen(s: *const c_char) -> usize;
}

fn main() {
    assert_eq!(sqrt(9.0), 3.0); // no unsafe block needed

    // SAFETY: a C string literal is NUL-terminated and lives for the whole program.
    let n = unsafe { strlen(c"hello".as_ptr()) };
    assert_eq!(n, 5);
}

Even the safe declaration rests on the promise made by unsafe extern, so FFI always involves some unsafe code. What you control is its shape: a thin layer of declarations that mirrors the C header (often a separate crate whose name ends in -sys), a safe wrapper above it that checks or establishes every precondition, and an unsafe fn in the public API only where the caller must promise something the wrapper cannot check.

Threads or async

The second decision uses the same reasoning: what each option costs, and whether the program needs what it provides.

Threads (std::thread::spawn and std::thread::scope) suit CPU-bound work and moderate numbers of concurrent tasks, and they let code read as a straight line. Each thread has its own stack. The default stack size for threads spawned by the standard library is platform-dependent and subject to change; the documentation currently gives 2 MiB on all Tier-1 platforms, and it can be changed with thread::Builder::stack_size or the RUST_MIN_STACK environment variable. Creating a thread also costs a system call and kernel bookkeeping, and switching between threads goes through the operating-system scheduler. These costs are small for tens or hundreds of threads; how many threads are practical depends on the operating system and its limits. Threads work with blocking libraries, debuggers and profilers in the usual way, and std::thread::scope lets them borrow local variables.

Async suits programs with many concurrent tasks that spend most of their time waiting for I/O, such as a server holding tens of thousands of mostly idle connections. A waiting task is stored as a future, whose size is set by the values it keeps across .await points, instead of a full thread stack, and the executor switches between tasks without involving the operating-system scheduler. The costs are mostly in complexity:

  • The standard library defines the Future trait but provides no executor, so you depend on a runtime crate or write one.
  • Calling an async fn only creates a future; none of its body runs until the future is polled. Ordinary (non-async) code can create a future but cannot .await it, so it has to hand the future to an executor, for example through a block_on function. This split between async and non-async functions is often called function coloring.
  • Executors that can move a task between threads, such as a multi-threaded runtime's spawn function, require the future to be Send. That requirement applies to every value the task holds across an .await, so a single Rc held across an .await makes the whole task unspawnable there.
  • Dropping a future before it completes cancels it: the code after the .await where it was paused never runs. Async code must therefore leave its state consistent at every .await.
  • Stack traces and profiles include executor and polling frames, which can make them harder to read.

Many programs need neither. A command-line tool that makes four HTTP requests can use a blocking HTTP client. A build tool that processes a thousand files can split the list into chunks and hand each chunk to a scoped thread. Choosing async because many libraries use it, or unsafe because fast code elsewhere uses it, skips the question of whether this program needs what they provide.

Questions without a single answer

The Rc<RefCell<T>> question and the threads-versus-async question have no answer that is independent of the program. Experienced programmers reach different conclusions because they have different workloads in mind.

Some guidance holds in both cases:

  • Many problems that seem to need unsafe or RefCell disappear once ownership is redrawn: who owns each value, and who only borrows it.
  • Treat unsafe code as a cost paid on every future change to its module, not once.
  • Measure before optimizing, and measure the safe version too.
  • Prefer rules the compiler can check. Newtypes (a wrapper struct around one value, such as struct Meters(f64)), TryFrom conversions that validate once, guard types (values such as MutexGuard whose Drop releases a resource), typestate (encoding an object's state in its type so that a call in the wrong state does not compile) and enums with data all move a rule out of a comment and into code the compiler checks.

step 14 of 14

The Expert Edge: Idiom, Review and Capstones

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks