Welcome to the first post in my Rust Basics series! This collection explores the Rust programming language through explanations and practical examples. I’m documenting my learning journey while creating a resource that might help others understand Rust’s powerful features. While I’ve used AI to expand some examples and clarify technical details, all content has been carefully reviewed for accuracy.
Memory model
Rust follows a compile-time memory management model with ownership, borrowing, and lifetimes. It enforces strict rules at compile time to prevent memory errors (like use-after-free, double-free, data races).
Ownership: Each value has a single owner, and ownership can be transferred.
Borrowing: References (&T, &mut T) allow temporary access without ownership.
Lifetimes: Ensure references are always valid.
Threading
Rust is multi-threaded and enforces thread safety through ownership, borrowing rules, and Send/Sync traits.
Heap vs Stack vs Where Else?
Stack
- Fast, fixed-size allocations with LIFO (Last-In-First-Out) structure
- Automatically managed by the compiler, no manual allocation/deallocation needed
- Used for local variables, function calls, and primitive types with known sizes
fn example_stack() {
let x = 42; // Integer stored on stack
let y = true; // Boolean stored on stack
let z = [1, 2, 3, 4, 5]; // Fixed-size array stored on stack
} // All values automatically deallocated when they go out of scope
Heap
- Dynamic, variable-size allocations for data whose size might change or is unknown at compile time
- Managed through smart pointers (
Box<T>
,Vec<T>
,String
, etc.) - More flexible but slower than stack due to allocation/deallocation overhead
fn example_heap() {
let s = String::from("hello"); // String data stored on heap
let v = vec![1, 2, 3]; // Vector contents stored on heap
let b = Box::new(42); // Boxed integer stored on heap
} // automatically frees heap memory when owners go out of scope
Where else?
Static/Global Memory
- Lives for the entire duration of the program
- Declared using the
static
keyword or as compile-time constants - Cannot generally be modified unless marked as
mut
, which requires unsafe code
// Compile-time constant
const MAX_USERS: usize = 100;
// Static variable with 'static lifetime
static PROGRAM_NAME: &str = "My Rust App";
// Mutable static (requires unsafe)
static mut COUNTER: u32 = 0;
fn use_static() {
// Using const and immutable static is safe
println!("Max users: {}, Program: {}", MAX_USERS, PROGRAM_NAME);
// Modifying mutable static requires unsafe block
unsafe {
COUNTER += 1;
println!("Counter: {}", COUNTER);
}
}
Thread-Local Storage (TLS)
- Storage specific to each thread
- Declared using the
thread_local!
macro
use std::cell::Cell;
use std::thread;
thread_local! {
static THREAD_COUNTER: Cell<u32> = Cell::new(0);
}
fn thread_local_example() {
THREAD_COUNTER.with(|counter| {
counter.set(counter.get() + 1);
println!("Thread counter: {}", counter.get());
});
// Each thread has its own independent copy
thread::spawn(|| {
THREAD_COUNTER.with(|counter| {
println!("New thread counter: {}", counter.get()); // Will be 0
counter.set(100);
});
}).join().unwrap();
}
Memory-Mapped Files/Regions
- Memory that maps directly to files or device memory
- Accessed via libraries like
memmap
or through OS-specific APIs - Allows treating file content as if it were in memory without loading the entire file
Usage of mmap files
-
Performance: Memory mapping can be more efficient than traditional file I/O because:
- It reduces copying between kernel and user space
- It enables OS-level page caching
- It allows accessing only the parts of a file that you need
-
Convenience: Access file data using simple memory operations rather than read/write calls
-
Shared memory: Multiple processes can share the same mapped region for efficient inter-process communication
Real-world use cases:
- Large file processing: Applications like video editors, database engines, or scientific data analyzers that work with files too large to fit in RAM
- Database systems: Many databases (SQLite, PostgreSQL) use memory mapping for efficient data access
- High-performance caches: For fast access to frequently used data
- Real-time data visualization: When analyzing large datasets without loading everything at once
- Game development: For loading large assets or level data on demand
use memmap2::MmapOptions;
use std::fs::File;
use std::io::Result;
fn memory_map_example() -> Result<()> {
// Open a file
let file = File::open("data.bin")?;
// Create a read-only memory map
let mmap = unsafe { MmapOptions::new().map(&file)? };
// Access it like normal memory - the OS loads pages on demand
if mmap.len() > 20 {
println!("First 20 bytes: {:?}", &mmap[0..20]);
}
// Random access is efficient - this might be gigabytes into the file
// but only the needed pages are loaded
if mmap.len() > 1_000_000 {
println!("Byte at position 1M: {}", mmap[1_000_000]);
}
// The memory map is automatically unmapped when dropped
Ok(())
}
Example: Shared memory between processes
use memmap2::{MmapMut, MmapOptions};
use std::fs::{File, OpenOptions};
use std::io::Result;
fn shared_memory_example() -> Result<()> {
// Create or open a file to back the shared memory
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open("shared.bin")?;
// Set the file size
file.set_len(1024)?;
// Create a writable memory map
let mut mmap = unsafe { MmapOptions::new().map_mut(&file)? };
// Write to the shared memory
mmap[0] = 42;
// Another process could open the same file and see these changes
// Or we can create another map to the same file:
let reader_map = unsafe { MmapOptions::new().map(&file)? };
assert_eq!(reader_map[0], 42);
Ok(())
}
Kernel-Space Memory
- We might interact with kernel memory through syscalls
- Used in embedded systems, OS development, and device drivers
// Example using mmap syscall (simplified)
#[cfg(unix)]
fn kernel_mem_example() {
use std::ptr;
use libc::{mmap, PROT_READ, PROT_WRITE, MAP_PRIVATE, MAP_ANONYMOUS, munmap};
unsafe {
// Request 1024 bytes of memory from kernel
let size = 1024;
let addr = mmap(
ptr::null_mut(),
size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0
);
// Use the memory...
// Free the memory when done
munmap(addr, size);
}
}
Memory layouf of Box, Vector and String
Box
Box<T>
is a simple heap allocation - it stores a single value of type T on the heap.
- Stack: Contains a single pointer (typically 8 bytes on 64-bit systems)
- Heap: Contains the actual data of type T
fn box_example() {
// Memory layout:
// Stack: Pointer (8 bytes) → Heap: i32 (4 bytes)
let boxed_int = Box::new(42);
// Stack: Pointer (8 bytes) → Heap: Person struct (size depends on fields)
struct Person { name: String, age: u32 }
let boxed_person = Box::new(Person {
name: "Alice".to_string(),
age: 30
});
// When boxed_int goes out of scope, both the stack pointer
// and the heap memory it points to are freed
}
Vec
Vec<T>
has a more complex memory layout with three components stored on the stack:
- ptr: Pointer to the heap memory containing the elements
- capacity: The amount of space allocated on the heap (in elements)
- length: The number of elements currently in the vector
fn vector_example() {
// Empty vector:
// Stack: ptr (8 bytes), capacity (8 bytes), length (8 bytes)
// Heap: Nothing yet (no allocation until elements are added)
let mut v: Vec<i32> = Vec::new();
// After pushing elements:
// Stack: Same three fields
// Heap: Contiguous block with elements [10, 20, 30]
v.push(10);
v.push(20);
v.push(30);
// Capacity management
println!("Length: {}, Capacity: {}", v.len(), v.capacity());
// When capacity is exceeded, Vec allocates a larger block,
// copies the elements, and frees the old block
for i in 0..10 {
v.push(i);
println!("Added {}, Capacity: {}", i, v.capacity());
}
}
String
String
is actually a wrapper around Vec<u8>
that guarantees UTF-8 encoding:
- Stack: Same three components as Vec
(ptr, capacity, length) - Heap: Contiguous block of UTF-8 encoded bytes
fn string_example() {
// Empty string:
// Stack: ptr (8 bytes), capacity (8 bytes), length (8 bytes)
// Heap: No allocation yet
let mut s = String::new();
// After assignment:
// Stack: Same three fields
// Heap: UTF-8 bytes for "Hello"
s = String::from("Hello");
// Adding to the string might cause reallocation
s.push_str(", world!");
// String slice (&str) vs String:
let slice: &str = &s[0..5]; // "Hello"
// slice is just two values on stack: pointer to s's buffer + length (no heap allocation)
// Multi-byte characters
let multi = String::from("愛");
println!("Character: {}, Bytes: {}", multi, multi.len()); // len() returns 3 (bytes), not 1
// When String goes out of scope, the heap memory is freed
}
RAII and how Rust implements it
RAII (Resource Acquisition Is Initialization) is a programming idiom where resource management is tied to object lifetime. Resources are acquired during initialization and released during destruction.
Rust fully embraces RAII through its ownership system:
- Resources are acquired when variables are created
- Resources are automatically released when variables go out of scope
- The compiler tracks object lifetimes and inserts cleanup code (via
Drop
trait)
- Memory Safety: Prevents leaks and use-after-free errors
- Deterministic Cleanup: Resources are freed at predictable points
- Exception Safety: Resources are released even if errors occur
- Simpler Code: No manual resource tracking or explicit cleanup calls
Basic RAII with File
:
fn read_file() -> std::io::Result<()> {
// File is automatically opened when created
let file = std::fs::File::open("example.txt")?;
// Use the file...
// No need to close explicitly - file is automatically closed
// when it goes out of scope at the end of the function
Ok(())
}
Custom RAII with Drop
trait:
struct DatabaseConnection {
conn_string: String,
}
impl DatabaseConnection {
fn new(conn_string: &str) -> Self {
println!("Opening connection to {}", conn_string);
// In real code: actual connection would be established here
DatabaseConnection {
conn_string: conn_string.to_string(),
}
}
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
// Cleanup code runs automatically when object is dropped
println!("Closing connection to {}", self.conn_string);
// In real code: actual connection would be closed here
}
}
fn main() {
{
let db = DatabaseConnection::new("localhost:5432");
// Use the connection...
} // db goes out of scope here, Drop::drop is automatically called
println!("Connection has been closed");
}
RAII with smart pointers:
fn managed_resources() {
// Box automatically frees heap memory
let heap_data = Box::new([1, 2, 3, 4]);
// Mutex automatically unlocks when guard goes out of scope
let counter = std::sync::Mutex::new(0);
{
let mut guard = counter.lock().unwrap();
*guard += 1;
} // Lock is automatically released here
// Arc/Rc automatically decrease reference counts and free when zero
let shared = std::sync::Arc::new(String::from("shared data"));
// All resources are automatically freed at function end
}