Unsafe, FFI and performance

When unsafe is justified, raw pointers and the edition 2024 unsafe rules, calling C through extern blocks, avoiding unnecessary unsafe, and profiling release builds.

When unsafe is justified

unsafe does not turn off the borrow checker or the type system. It grants exactly five extra powers: dereferencing a raw pointer, calling an unsafe function, reading or writing a mutable static, implementing an unsafe trait, and accessing a union field.

// 1. a documented invariant the compiler cannot check
pub fn split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    assert!(mid <= len, "mid out of bounds");
    // SAFETY: mid <= len, and the two ranges [0, mid) and [mid, len) are disjoint,
    // so the two returned slices never alias.
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

// 2. unchecked parsing when profiling proves the check is the cost
pub fn parse_unchecked(bytes: &[u8]) -> &str {
    // SAFETY: callers guarantee the input is valid UTF-8; see the doc comment.
    unsafe { std::str::from_utf8_unchecked(bytes) }
}

pub fn safe_alternative(bytes: &[u8]) -> Result<&str, std::str::Utf8Error> {
    std::str::from_utf8(bytes)          // the version you should start with
}
  • Every unsafe block needs a // SAFETY: comment stating why the invariants hold. If the comment is hard to write, the code is not ready.
  • Keep unsafe blocks as small as possible: a one-line block inside a safe function with a safe signature. The caller should never need to reason about your invariants.
  • Wrap unsafe internals in a safe API and document the contract in the function's rustdoc. An unsafe fn pushes the obligation onto every caller, which is a much larger commitment.
  • Edition 2024 requires the unsafe keyword on the operations themselves — unsafe extern blocks, unsafe attribute for attributes such as #[no_mangle] — so it is now visible exactly which step is unchecked.
  • Reach for unsafe only after profiling, or because the platform demands it. Safe Rust with sensible data structures is fast; the remaining gap is usually in allocation, not in bounds checks.

Calling C

use std::ffi::{CStr, CString, c_char, c_int};

// edition 2024: the extern block itself must be marked unsafe
unsafe extern "C" {
    fn strlen(s: *const c_char) -> usize;
    fn abs(input: c_int) -> c_int;
    fn getenv(name: *const c_char) -> *mut c_char;
}

pub fn c_strlen(s: &str) -> Option<usize> {
    let c = CString::new(s).ok()?;      // fails if s contains a NUL byte
    // SAFETY: c is a valid NUL-terminated string for the duration of the call.
    Some(unsafe { strlen(c.as_ptr()) })
}

pub fn env_var(name: &str) -> Option<String> {
    let c = CString::new(name).ok()?;
    // SAFETY: getenv returns a pointer into the process environment, or null.
    let raw = unsafe { getenv(c.as_ptr()) };
    if raw.is_null() {
        return None;
    }
    // SAFETY: a non-null result from getenv is NUL-terminated and still alive.
    Some(unsafe { CStr::from_ptr(raw) }.to_string_lossy().into_owned())
}

// #[no_mangle] is an unsafe attribute in edition 2024
#[unsafe(no_mangle)]
pub extern "C" fn add(a: c_int, b: c_int) -> c_int {
    a + b
}
TypeWhat it is
CStringAn owned, NUL-terminated buffer; CString::new rejects interior NUL bytes
CStrA borrowed view of a C string; convert with to_str() or to_string_lossy()
*const T / *mut TRaw pointers: no lifetime, no aliasing guarantee, nullable
std::ptr::null() / is_null()The null check you must perform before dereferencing
bindgenGenerates the declarations from a C header, so you do not transcribe them by hand
cbindgenGenerates a C header for your Rust exports, the other direction
  • Prefer a maintained crate over hand-written bindings: libc, windows-sys or a crate that already wraps the library. Hand-written declarations are a source of silent ABI mismatch.
  • Panics must not unwind across an FFI boundary. A Rust function exported to C should catch panics with catch_unwind and return an error code instead.
  • Memory ownership must be explicit in both directions: document who frees what. A pointer returned by C must be freed by the C library, not by Rust, unless the API says otherwise.
  • Rust types are not C types. bool, char, usize and enum discriminants have no guaranteed C representation; use c_int, u32 and #[repr(C)].

Profiling and avoiding unnecessary unsafe

# always measure an optimised build with symbols
cargo build --release
cargo build --release --config 'profile.release.debug=true'

perf record -g ./target/release/notes run
perf report

# flamegraph: cargo install flamegraph
cargo flamegraph --bin notes

# what the optimiser decided
cargo asm --rust notes::encode

# allocation and time comparisons between two versions
cargo bench
  • A debug build is five to fifty times slower than a release build, so never profile debug. Keep a release profile with debug symbols for the runs you intend to measure.
  • Most Rust performance wins are allocation and data layout, not unsafe: preallocate, avoid needless clone, store small values inline, and choose Vec or a SmallVec over Box for hot paths.
  • Bound checking is rarely the bottleneck. Before replacing safe indexing with raw pointers, confirm with a profiler that it appears at all.
  • #[inline] is usually unnecessary: the compiler inlines across crates with generics and LTO. Adding it everywhere increases compile time and binary size for no measurable gain.
  • codegen-units = 1 plus lto = "fat" typically gains a few percent and costs a much slower build. It belongs in the release pipeline, not in the edit-compile loop.
  • Measure with black_box around inputs and outputs, and compare medians over several runs. A single run on a busy laptop measures the laptop.
⚠️
Soundness is a promise to every future caller of your crate. An unsafe block that is correct today becomes undefined behaviour the moment an invariant changes somewhere else, and the failure may appear as a miscompile in a release build months later. If you cannot keep the invariant true under refactoring, do not expose the unsafe API — even if it makes the benchmark slightly better.

FAQ

Does unsafe make my code faster?
Rarely by itself. Removing bounds checks or UTF-8 validation is measurable only in a proven hot loop. Most production performance work is algorithm choice, allocation reduction and data layout, all of which are safe Rust.
How do I stop a panic from crashing a C caller?
Wrap the body of every exported function in std::panic::catch_unwind, and return an error code or a null pointer instead of unwinding. Set panic = "abort" in the release profile if you would rather the process die than risk unwinding across the boundary.

Concurrency and async Rust A command-line tool end to end

Last refreshed 2026-09-18.