Unsafe Rust Patterns and How to Avoid Them

Rust is renowned for its memory safety guarantees, eliminating entire classes of bugs that plague other languages. However, Rust also provides an unsafe escape hatch for situations where those guarantees need to be bypassed, such as low-level systems programming, FFI (Foreign Function Interface) interactions, or performance-critical optimizations. While unsafe is a powerful tool, it can easily lead to memory safety issues if not used with extreme care. This post will explore common unsafe Rust patterns and provide strategies for avoiding them.

What is Unsafe Rust?

In Rust, the unsafe keyword can be applied to functions, methods, traits, and even entire modules. Code within an unsafe block or function is exempt from certain Rust compiler checks, allowing operations that the compiler cannot prove are memory-safe. These operations include:

  • Dereferencing raw pointers (*const T and *mut T).
  • Calling unsafe functions or methods (including FFI functions).
  • Accessing or modifying mutable static variables.
  • Implementing unsafe traits.
  • Accessing fields of unions.

While unsafe grants more power, it shifts the burden of memory safety from the compiler to the programmer. It's crucial to understand that unsafe does not turn off Rust's safety checks entirely; it merely allows you to perform operations that Rust cannot verify automatically.

Common Unsafe Rust Patterns and How to Avoid Them

1. Dereferencing Raw Pointers

Raw pointers are C-style pointers that don't have the same guarantees as Rust's references. Dereferencing a raw pointer is unsafe because the pointer might be null, dangling, or unaligned.

The Problem:

let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
}

How to Avoid/Mitigate:

  • Ensure Validity: Before dereferencing, always ensure the pointer is valid, non-null, and points to a valid memory location. This often involves careful management of the pointer's lifetime and validity.
  • Use Rust References When Possible: Whenever you can use Rust references (& and &mut), do so. They carry lifetime and borrowing information that the compiler uses to enforce safety.
  • Abstract Raw Pointers: Encapsulate raw pointer operations within safe abstractions. For example, if you're creating a data structure that needs raw pointers internally, expose a safe API to the outside world.

2. Calling FFI Functions

Interfacing with code written in other languages, particularly C, often requires using unsafe blocks because the Rust compiler cannot guarantee the safety of external C functions.

The Problem:

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    let number = -3;
    let result = unsafe { abs(number) };
    println!('The absolute value of {} is {}', number, result);
}

How to Avoid/Mitigate:

  • Create Safe Wrappers: The idiomatic Rust way to handle FFI is to create safe Rust wrappers around the unsafe FFI calls. These wrappers perform necessary checks and conversions, ensuring that only safe Rust code interacts with the FFI boundary.
  • Use Crates: Many crates already provide safe abstractions over common C libraries (e.g., libc, openssl, png). Prefer using these well-tested crates over direct FFI calls.
  • Document Thoroughly: If you must use direct FFI, document precisely which invariants must hold for the FFI call to be safe. This includes memory safety, thread safety, and correct argument types.

3. Mutable Static Variables

Mutable static variables are global variables that can be modified. Accessing or modifying them is unsafe because multiple threads could access them concurrently, leading to data races.

The Problem:

static mut COUNTER: u32 = 0;

fn increment_counter() {
    unsafe { COUNTER += 1; }
}

How to Avoid/Mitigate:

  • Use Mutex or RwLock: For mutable global state, use thread-safe synchronization primitives like std::sync::Mutex or std::sync::RwLock wrapped in a static variable.
    use std::sync::Mutex;
    static COUNTER: Mutex<u32> = Mutex::new(0);
    
    fn increment_counter() {
        let mut num = COUNTER.lock().unwrap();
        *num += 1;
    }
    
  • Prefer Immutable Statics: If the global data doesn't need to be mutable, declare it as static (immutable) which is inherently safe.
  • Use Thread-Local Storage: If mutation is necessary but only within a single thread, consider thread-local storage (std::thread::LocalKey).

4. Dereferencing Pointers from External Sources (e.g., Collections)

Sometimes, you might obtain raw pointers from data structures or external libraries. Dereferencing these requires careful validation.

The Problem:

Consider a scenario where a Vec might be converted to a raw pointer, and this pointer is then used unsafely.

let mut vec = vec![1, 2, 3];
let ptr = vec.as_ptr(); // This is a raw pointer

// If vec is dropped or reallocated before ptr is used...

unsafe {
    // This could be a use-after-free if vec was modified or dropped!
    // println!('Value: {}', *ptr);
}

How to Avoid/Mitigate:

  • Maintain Ownership: Ensure that the memory the raw pointer points to remains valid for the entire duration it is used. This often means keeping the original owner (e.g., the Vec) alive.
  • Use Safe Alternatives: Rust's standard library provides safe methods for accessing collection elements (e.g., vec[index], vec.get(index)). Use these whenever possible.
  • Clear Lifetimes: When passing raw pointers, be extremely clear about their intended lifetime and validity.

5. Implementing Unsafe Traits

Certain low-level traits are marked as unsafe because their correct implementation requires upholding invariants that the compiler cannot check. Examples include Send and Sync for custom types that might involve shared mutable state.

The Problem:

Implementing Send for a type that internally uses a raw mutable pointer without proper synchronization.

How to Avoid/Mitigate:

  • Understand the Invariants: Deeply understand the safety invariants required by the unsafe trait. For Send and Sync, this means ensuring that sending or sharing instances of your type across threads is safe.
  • Use Safe Abstractions: Leverage existing safe abstractions for concurrency and shared state (like Mutex, Arc) within your type before considering implementing unsafe traits directly.
  • Consult Documentation: Refer to the Rustonomicon or the official documentation for detailed explanations of the requirements for specific unsafe traits.

Best Practices for Using Unsafe Rust

  • Minimize the Scope: Keep unsafe blocks as small and localized as possible. This makes it easier to audit and reason about the safety guarantees.
  • Create Safe Abstractions: The primary goal of using unsafe should be to build safe abstractions that can be used by the rest of your codebase without unsafe.
  • Write Extensive Tests: unsafe code is more prone to subtle bugs. Write thorough unit and integration tests, especially those that stress concurrency and edge cases.
  • Use Tools: Leverage tools like Miri (a hypothetical execution engine for Rust) to detect undefined behavior in your unsafe code.
  • Seek Code Review: Have experienced Rustaceans review your unsafe code. Different perspectives can catch potential issues.
  • Document Everything: Clearly document why a particular piece of code needs to be unsafe and what invariants must be upheld for it to remain safe.

Conclusion

unsafe Rust is a necessary feature for certain low-level programming tasks, enabling Rust to compete in areas where performance and direct hardware interaction are paramount. However, it comes with the significant responsibility of ensuring memory safety manually. By understanding the common pitfalls, employing safe abstractions, utilizing thread-safe primitives, and adhering to best practices like minimizing scope and thorough testing, developers can harness the power of unsafe Rust while mitigating its inherent risks. The goal is always to contain unsafe and build safe, robust interfaces on top of it.

Resources

Next Steps

  • Explore how unsafe is used in popular Rust crates.
  • Experiment with Miri on a small unsafe Rust project to see it in action.
  • Deep dive into the safety invariants of Send and Sync traits.
← Back to rust tutorials