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 fnpushes the obligation onto every caller, which is a much larger commitment. - Edition 2024 requires the
unsafekeyword on the operations themselves —unsafe externblocks,unsafe attributefor 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
}| Type | What it is |
|---|---|
CString | An owned, NUL-terminated buffer; CString::new rejects interior NUL bytes |
CStr | A borrowed view of a C string; convert with to_str() or to_string_lossy() |
*const T / *mut T | Raw pointers: no lifetime, no aliasing guarantee, nullable |
std::ptr::null() / is_null() | The null check you must perform before dereferencing |
bindgen | Generates the declarations from a C header, so you do not transcribe them by hand |
cbindgen | Generates a C header for your Rust exports, the other direction |
- Prefer a maintained crate over hand-written bindings:
libc,windows-sysor 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_unwindand 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,usizeand enum discriminants have no guaranteed C representation; usec_int,u32and#[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 chooseVecor aSmallVecoverBoxfor 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 = 1pluslto = "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_boxaround 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.Related
Concurrency and async Rust A command-line tool end to end
Last refreshed 2026-09-18.