Allocation, allocators, and mixing them
In the previous section you saw how to replace a whole C module with Rust while
keeping the interface identical. But bm passes more than integers and borrowed
strings across the boundary: it passes ownership. bookmark_new allocates a
Bookmark that someone else will free later. Who frees it, and with what? This
is where mixed-language codebases hide some of their nastiest bugs and most
difficult design challenges.
The golden rule
Every allocation must be released through its corresponding deallocation API.
That’s the whole rule. C’s malloc/free1 and Rust’s global allocator are
two independent bookkeeping systems2. Handing a Rust-allocated pointer to C’s
free (or a malloc’d pointer to Box::from_raw) is undefined behavior, even
if it happens to “work” on your machine because both forward to the same
underlying malloc today. They might stop doing so tomorrow, or not do so at
all on a different architecture.
┌──────────────┐ Box::into_raw ┌──────────────┐
│ Rust global │ ───────────────► │ C code │
│ allocator │ ◄─────────────── │ (borrows) │
└──────────────┘ Box::from_raw └──────────────┘
Crossing the boundary in practice
There are several workable strategies:
-
Use C allocation APIs from Rust. Rust can call a C constructor such as
bookmark_newand wrap the returned pointer in an owning Rust type. ItsDropimplementation releases the allocation by calling the matching C function,bookmark_free. Keep allocation and deallocation within the same C API; export the functions through FFI to Rust.Here is the complete shape of that wrapper.
OwnedCBookmarkowns the C allocation, so callers cannot accidentally pass it to Rust’s allocator. A null pointer from C becomesNone; dropping a successfully created wrapper always calls back into C to free it.use std::ffi::CStr; use std::ptr::{self, NonNull}; #[repr(C)] // An opaque type, C owns the layout and allocation. struct CBookmark { _data: (), _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>, } // FFI (de)allocation functions unsafe extern "C" { fn bookmark_new( url: *const std::ffi::c_char, tags: *const *const std::ffi::c_char, n_tags: usize, ) -> *mut CBookmark; fn bookmark_free(bookmark: *mut CBookmark); } // The Rust wrapper struct OwnedCBookmark(NonNull<CBookmark>); impl OwnedCBookmark { fn new(url: &CStr) -> Option<Self> { // SAFETY: `url` is NUL-terminated. This example passes no tags, // matching the C function's documented null-and-zero contract. let bookmark = unsafe { bookmark_new(url.as_ptr(), ptr::null(), 0) }; NonNull::new(bookmark).map(Self) } } impl Drop for OwnedCBookmark { fn drop(&mut self) { // SAFETY: this pointer came from `bookmark_new` and this wrapper is // its unique owner, so `bookmark_free` is called exactly once. unsafe { bookmark_free(self.0.as_ptr()) }; } } -
Use Rust allocators from C. A Rust type that crosses the boundary will need a matching
_new/_freepair exported through FFI._newin Rust is paired with_freein Rust; C only ever holds the pointer and calls the Rust functions to allocate or free an object of that type.The basic pattern for this is to convert a
Boxto and from a raw pointer withinto_rawandfrom_raw:3let foo = Box::new(42); let ptr = Box::into_raw(foo); // This will convert the raw pointer back into a Box and when dropped free the memory: let _foo = unsafe { Box::from_raw(ptr) };And a full FFI split would look something like this:
use std::ptr::NonNull; pub struct RustType { foo: i32, bar: i32, } #[unsafe(no_mangle)] pub extern "C" fn rust_type_new(foo: i32, bar: i32) -> *mut RustType { Box::into_raw(Box::new(RustType { foo, bar })) } #[unsafe(no_mangle)] pub extern "C" fn rust_type_free(b: Option<NonNull<RustType>>) { // To match C's free(NULL) semantics, return immediately when b is null. let Some(b) = b else { return; }; // SAFETY: `b` was created by `rust_type_new` via Box::into_raw and is // not used again after this call (documented in bookmark.h). drop(unsafe { Box::from_raw(b.as_ptr()) }); } -
Transfer ownership of a C allocation across the boundary. Use this when one language calls a C allocation API, transfers ownership of the result to the other language, and the receiving side later calls the matching C deallocation API. For example, Rust can allocate a NUL-terminated string with libc::malloc and pass it to C code that retains it and later calls
free.The reverse direction is valid too: C can transfer ownership of a value it allocated, for example with
malloc, to Rust, and Rust can later call the matching deallocation function through FFI. In both cases, the allocation and deallocation calls originate on opposite sides of the FFI boundary. The layout and ownership contract must exactly match what the receiving API expects. -
Use a
malloc/freeadapter as Rust’s global allocator. On Linux, Rust’s default global allocator typically already uses the system allocator, so this is usually unnecessary. It can still be useful when Rust must use a specific C allocation API. Implementing the adapter is non-trivial: it’s inherentlyunsafe, and you need to carefully uphold all the safety guarantees required by the GlobalAlloc trait. Do not pass Rust-owned values to C for it to free. Even with amalloc/freeglobal allocator, C cannot run Rust destructors; freeing a value containing aString,Vec, or other owned resource leaks what that value owns.Avoid if possible. An allocation adapter is trickier than it seems. The code is small, but getting its allocation contract right requires a lot of implicit knowledge. Only use one when you have a specific interoperability need.
-
Use the Allocator API. As of this writing, the API is still experimental and only available on nightly Rust. It functions similarly to
GlobalAlloc: you have to implement your own allocator, but unlike the global one, it is a lot more flexible and can be used on a per-variable basis. The new API adds an Allocator trait andAllocatortype parameters to many allocation-owning types, such asBoxorVec.pub struct Box<T, A = Global>() // Global is the global allocator implementing the Allocator trait where A: Allocator, T: ?Sized;Additionally, it adds a few new functions where you can pass an allocator, for example
new_in:#![feature(allocator_api)] use std::alloc::System; let foo = Box::new_in(1, System); let custom_alloc = SomeCustomAlloc::new(); let bar = Box::new_in(2, custom_alloc);fooandbarnow use different allocators.Avoid if possible. The same warning as for
GlobalAllocapplies here: implementing an allocator is a non-trivial undertaking and is only worth it if you have a specific use case.This API is primarily useful for arena or bump allocators, where many related values are allocated from the same memory region and released together. Another useful case is selectively integrating parts of your program with a different allocation system while still using standard owning types such as
BoxandVec.
Spotting allocator mismatches
Tools like Valgrind give every allocation a birth certificate: run the test
binary under valgrind --leak-check=full and it will tell you not just that a
block leaked, but which call stack allocated it.
Valgrind has no Apple Silicon support, so on an M-series Mac reach for leaks,
for AddressSanitizer via -Zsanitizer=address, or, for the Rust side of the
boundary, for Miri, which we cover in a later section.
Head to the exercise
You’ll port bm’s Bookmark type, including its allocation and deallocation
functions, to Rust without leaking or double-freeing a single byte.
You’ll practice both ownership directions from this section in
exercises/02_intermediate/02_allocators: C holding a Rust allocation, and Rust
holding a C allocation.
Exercise
The exercise for this section is located in 02_intermediate/02_allocators
-
malloc/freeare standard C allocation functions, but nothing prevents you from using other allocation functions or even rolling your own. For the sake of simplicity, we will always refer tomalloc/freein this section, but read it asmalloc/freeand all other custom (de)allocation functions. ↩ -
Currently, Rust’s default global allocator is unspecified, but on many platforms it is the system
malloc. Libraries such as cdylibs and staticlibs, however, are guaranteed to use theSystemallocator by default. Beware though: C’smalloc/freeAPI and Rust’s global-allocation API are not interchangeable, even when both happen to use the same underlying system allocator. ↩ -
There are some other useful structs, besides
Box, that come with their ownfrom_rawandinto_rawfunctions, likeVecorCString. The same pattern as forBoxapplies here: allocate in Rust → convert into raw pointer and transfer ownership to C → use the pointer in C → free the pointer by transferring ownership back to Rust. ↩