Understanding Memory Management – Rust

Understanding Memory Management in Rust: Stack, Heap, and Object Creation

Understanding Memory Management in Rust

Stack, Heap, and Object Creation

Introduction

Before diving in, you may want to refer to my earlier post on Rust for a more foundational overview.

Rust’s memory model represents a significant departure from garbage-collected languages like Java, Python, or JavaScript. While these languages abstract away memory management, Rust provides fine-grained control without sacrificing safety. Understanding how Rust manages memory is fundamental to writing efficient and correct programs.

The Fundamental Question

Consider this simple declaration:

let s = String::from("hello");

This raises several important questions:

  • Where does this data reside? Stack? Heap? Both?
  • How does the compiler determine allocation strategy?
  • What is the memory layout of this structure?

Stack vs Heap: Core Concepts

Computer memory can be conceptualized as two distinct storage systems with different characteristics.

The Stack

The Stack operates as a LIFO (Last In, First Out) data structure. It provides fast, predictable memory allocation with automatic deallocation when variables go out of scope.

Stack Characteristics:

  • Extremely fast allocation and deallocation (pointer manipulation)
  • Automatic memory management through scope-based cleanup
  • Fixed-size data only
  • Sequential memory layout

The Heap

The Heap provides dynamic memory allocation for data with unknown or variable sizes. While slower than stack allocation, it offers flexibility for growable data structures and long-lived allocations.

Heap Characteristics:

  • Slower allocation (requires finding suitable memory blocks)
  • Manual management (though Rust automates this through ownership)
  • Supports dynamic-size data
  • Data can outlive function scope

Allocation Examples

// Stack allocation: Fixed size, compile-time known
let x: i32 = 42;                      // 4 bytes
let y: bool = true;                   // 1 byte
let arr: [i32; 5] = [1, 2, 3, 4, 5]; // 20 bytes

// Heap allocation: Dynamic size
let s = String::from("hello");        // Growable string
let v = vec![1, 2, 3];               // Growable vector
Allocation Rule: Types with compile-time known sizes are allocated on the stack. Types with dynamic sizes or explicit heap allocation use the heap.

Performance Comparison

Feature Stack Heap
Speed Fast – Just move a pointer Slower – Search for space
Allocation Cost ~1 CPU cycle ~100+ CPU cycles
Deallocation Automatic (pointer adjustment) Must be explicitly freed
Size Must be known at compile time Can be dynamic/unknown
Lifetime Tied to function scope Can live beyond function
Cache Locality Excellent (contiguous) Poor (scattered)

String Internals: A Detailed Analysis

The String type exemplifies Rust’s hybrid approach to memory management:

let s = String::from("hello");

This single line creates a structure that spans both stack and heap.

Memory Layout

┌─────────── STACK ───────────┐
│ Variable: s                 │
│  ptr:      0x1234 ────────┐ │  (pointer to heap)
│  capacity: 5               │ │  (allocated space)
│  length:   5               │ │  (used space)
└────────────────────────────┘ │
                               │
┌─────────── HEAP ────────────┐ │
│  Address 0x1234:            │ │
│  [ h | e | l | l | o ]  ◄───┘
└─────────────────────────────┘

Stack Component (24 bytes on 64-bit systems):

  • Pointer to heap allocation (8 bytes)
  • Capacity field (8 bytes)
  • Length field (8 bytes)

Heap Component (5 bytes):

  • UTF-8 encoded character data

Design Rationale

This design optimizes for common operations. When assigning or passing strings:

let s1 = String::from("hello");
let s2 = s1;  // Copies only 24 bytes (stack metadata)

The heap data remains in place; only the stack metadata is copied. However, Rust’s ownership system ensures memory safety by invalidating s1 after the move:

let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);  // ❌ Compile error: value borrowed after move
Safety Guarantee: This prevents double-free errors where multiple owners attempt to deallocate the same heap memory.

Compiler-Driven Allocation Strategy

The Rust compiler determines allocation strategy through type analysis.

Type-Based Allocation

The compiler examines type definitions to determine memory layout. Consider the String type (simplified):

pub struct String {
    vec: Vec<u8>,
}

pub struct Vec<T> {
    ptr: *mut T,        // Raw pointer indicates heap allocation
    cap: usize,         // Capacity
    len: usize,         // Length
}

The presence of a raw pointer (*mut T) signals to the compiler that this type manages heap memory. The compiler then:

  1. Allocates the struct’s metadata on the stack
  2. Generates code for heap allocation of the pointed-to data
  3. Implements Drop trait for automatic cleanup

Contrast this with a simple struct:

struct Point {
    x: i32,
    y: i32,
}

No pointers, no indirection. The compiler places this entirely on the stack (8 bytes total).

Automatic Type Analysis

The compiler performs this analysis automatically:

let p = Point { x: 10, y: 20 };  // Compiler: 8 bytes, stack
let s = String::from("hi");      // Compiler: 24 bytes stack + heap
let v = vec![1, 2, 3];          // Compiler: 24 bytes stack + heap
Key Insight: The type system encodes all information needed for optimal memory layout.

Object Creation Patterns in Rust

Rust doesn’t have classes in the traditional object-oriented sense. Instead, it uses structs with associated implementations.

Type Definition

struct User {
    name: String,
    age: i32,
    email: String,
}

Pattern 1: Direct Instantiation

let user = User {
    name: String::from("Alice"),
    age: 30,
    email: String::from("[email protected]"),
};

Memory Layout:

┌─────────── STACK ───────────┐
│ user:                       │
│   name:  (String, 24 bytes) │ ──➤ heap: "Alice"
│   age:   30 (4 bytes)       │
│   email: (String, 24 bytes) │ ──➤ heap: "[email protected]"
└─────────────────────────────┘

Pattern 2: Constructor Functions

impl User {
    // Associated function (static method)
    fn new(name: String, age: i32, email: String) -> Self {
        Self { name, age, email }
    }

    // Factory with defaults
    fn default() -> Self {
        Self {
            name: String::from("Unknown"),
            age: 0,
            email: String::from("no-email"),
        }
    }

    // Factory with validation
    fn create(name: String, age: i32, email: String) -> Result<Self, String> {
        if age < 0 {
            return Err("Age cannot be negative".to_string());
        }
        Ok(Self { name, age, email })
    }
}

// Usage
let user1 = User::new(
    String::from("Alice"),
    30,
    String::from("[email protected]")
);

let user2 = User::default();

let user3 = User::create(
    String::from("Bob"),
    -5,
    String::from("[email protected]")
)?;  // Returns Err

Pattern 3: Builder Pattern

For complex types with many optional parameters, the builder pattern provides ergonomic construction:

struct UserBuilder {
    name: Option<String>,
    age: Option<i32>,
    email: Option<String>,
}

impl UserBuilder {
    fn new() -> Self {
        Self {
            name: None,
            age: None,
            email: None,
        }
    }

    fn name(mut self, name: String) -> Self {
        self.name = Some(name);
        self
    }

    fn age(mut self, age: i32) -> Self {
        self.age = Some(age);
        self
    }

    fn email(mut self, email: String) -> Self {
        self.email = Some(email);
        self
    }

    fn build(self) -> Result<User, String> {
        Ok(User {
            name: self.name.ok_or("Name is required")?,
            age: self.age.ok_or("Age is required")?,
            email: self.email.ok_or("Email is required")?,
        })
    }
}

// Usage
let user = UserBuilder::new()
    .name(String::from("Alice"))
    .age(30)
    .email(String::from("[email protected]"))
    .build()?;

Explicit Heap Allocation with Box

The Box<T> smart pointer provides explicit heap allocation:

let user = Box::new(User {
    name: String::from("Alice"),
    age: 30,
    email: String::from("[email protected]"),
});

Memory Layout:

┌─────────── STACK ───────────┐
│ user: Box (8 bytes)         │
│   ptr: 0x5678 ──────────────┼─┐
└─────────────────────────────┘ │
                                │
┌─────────── HEAP ────────────┐ │
│ Address 0x5678:             │ │
│   User {                    │◄┘
│     name:  (String) ────────┼──➤ heap: "Alice"
│     age:   30               │
│     email: (String) ────────┼──➤ heap: "[email protected]"
│   }                         │
└─────────────────────────────┘

Use Cases for Box

  • Large types: Moving large structs on the stack is expensive; Box enables cheap pointer moves
  • Recursive types: Types that contain themselves must use indirection
  • Trait objects: Dynamic dispatch requires heap allocation

Automatic Memory Management Through RAII

Rust implements automatic cleanup through RAII (Resource Acquisition Is Initialization), also known as scope-based resource management:

{
    let s = String::from("hello");
    let v = vec![1, 2, 3];
    // ... operations ...
}  // ← Compiler inserts Drop calls here
   //   1. Deallocates heap memory for s
   //   2. Deallocates heap memory for v
   //   3. No garbage collector needed

When variables go out of scope, Rust automatically calls their Drop implementation, which handles cleanup. This provides:

  • Deterministic cleanup: Resources freed immediately at scope exit
  • Zero overhead: No garbage collector runtime
  • Guaranteed cleanup: Impossible to forget to free resources

The Ownership System

Rust’s ownership system is the cornerstone of its memory safety guarantees. It enforces three fundamental rules at compile time, eliminating entire classes of bugs without runtime overhead.

The Three Rules of Ownership

  1. Each value in Rust has a single owner
  2. There can only be one owner at a time
  3. When the owner goes out of scope, the value is dropped

Ownership Transfer (Move Semantics)

When assigning or passing values, ownership transfers to the new location:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // Ownership moves from s1 to s2

    // println!("{}", s1);  // ❌ Compile error: s1 no longer valid
    println!("{}", s2);  // ✓ Works: s2 owns the string
}

Real-Time Example: File Handle Ownership

Consider opening a file. The file handle must have exactly one owner to prevent double-close errors:

use std::fs::File;
use std::io::{Write, Result};

fn write_log(mut file: File) -> Result<()> {
    file.write_all(b"Log entry")?;
    Ok(())
}  // File automatically closed when it goes out of scope

fn main() -> Result<()> {
    let log_file = File::create("app.log")?;
    write_log(log_file);  // Ownership transferred to function

    // log_file.write_all(b"more");  // ❌ Error: log_file moved
    Ok(())
}
Safety Guarantee: The compiler prevents using the file after it has been closed, eliminating use-after-free bugs.

Copy vs Move Types

Types that implement the Copy trait are duplicated rather than moved:

// Integers implement Copy - they're duplicated
let x = 5;
let y = x;  // x is copied, both x and y are valid
println!("x = {}, y = {}", x, y);  // ✓ Both work

// String doesn't implement Copy - it's moved
let s1 = String::from("hello");
let s2 = s1;  // s1 is moved, only s2 is valid
Copy Types: Simple types stored entirely on the stack (integers, floats, booleans, chars, tuples/arrays of Copy types) implement Copy automatically.

Clone: Explicit Deep Copy

When deep copying is needed, use the clone() method:

let s1 = String::from("hello");
let s2 = s1.clone();  // Explicit deep copy

println!("s1 = {}, s2 = {}", s1, s2);  // ✓ Both valid
Performance Note: Cloning allocates new heap memory and copies data. Use sparingly for large structures.

Borrowing and References

Borrowing allows code to reference a value without taking ownership. Rust enforces borrowing rules at compile time to prevent data races and dangling references.

Immutable Borrowing

Multiple immutable references can exist simultaneously:

fn calculate_length(s: &String) -> usize {
    s.len()  // Can read, but not modify
}

fn main() {
    let s = String::from("hello");

    let len = calculate_length(&s);  // Borrow s
    println!("Length of '{}' is {}", s, len);  // s still valid
}

Mutable Borrowing

Only one mutable reference can exist at a time, preventing data races:

fn append_world(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut s = String::from("hello");

    append_world(&mut s);  // Mutable borrow
    println!("{}", s);  // Prints: hello, world
}

The Borrowing Rules

  • At any given time, you can have either one mutable reference or any number of immutable references
  • References must always be valid (no dangling references)

Real-Time Example: Thread-Safe Configuration

Borrowing rules prevent common concurrency bugs:

struct Config {
    max_connections: u32,
    timeout: u64,
}

fn read_config(config: &Config) -> u32 {
    config.max_connections
}

fn update_config(config: &mut Config, new_max: u32) {
    config.max_connections = new_max;
}

fn main() {
    let mut config = Config {
        max_connections: 100,
        timeout: 30,
    };

    // Multiple readers - safe
    let conn1 = read_config(&config);
    let conn2 = read_config(&config);

    // Single writer - safe
    update_config(&mut config, 200);

    // ❌ This would fail to compile:
    // let ref1 = &config;
    // let ref2 = &mut config;  // Error: cannot borrow as mutable
}
Compiler Guarantee: Data races are impossible. The compiler enforces that readers and writers cannot access data simultaneously.

Slices: Borrowing Parts of Collections

Slices let you reference a contiguous sequence without owning it:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];  // Slice from start to space
        }
    }

    &s[..]  // Return entire string
}

fn main() {
    let s = String::from("hello world");
    let word = first_word(&s);

    println!("First word: {}", word);  // Prints: hello
}

Lifetimes: Ensuring Reference Validity

Lifetimes are Rust’s mechanism for ensuring that references remain valid. They prevent dangling references at compile time.

The Dangling Reference Problem

In languages without lifetime checking, this code would compile but crash:

// ❌ This doesn't compile in Rust
fn dangling_reference() -> &String {
    let s = String::from("hello");
    &s  // Error: s dropped at end of function
}
Compiler Error: “s does not live long enough” – Rust prevents returning references to dropped values.

Lifetime Annotations

When the compiler cannot infer lifetimes, explicit annotations are required:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string");
    let string2 = String::from("short");

    let result = longest(&string1, &string2);
    println!("Longest: {}", result);
}
Lifetime Annotation: 'a indicates that the returned reference will be valid as long as both input references are valid.

Real-Time Example: Cache with References

Lifetimes enable safe caching of borrowed data:

struct Context<'a> {
    user_agent: &'a str,
    ip_address: &'a str,
}

impl<'a> Context<'a> {
    fn new(user_agent: &'a str, ip_address: &'a str) -> Self {
        Context { user_agent, ip_address }
    }

    fn log_info(&self) {
        println!("User-Agent: {}, IP: {}", self.user_agent, self.ip_address);
    }
}

fn main() {
    let ua = String::from("Mozilla/5.0");
    let ip = String::from("192.168.1.1");

    let ctx = Context::new(&ua, &ip);
    ctx.log_info();
}  // ua and ip must outlive ctx
Safety: The compiler ensures that Context cannot outlive the data it references, preventing use-after-free.

Static Lifetime

The 'static lifetime indicates data that lives for the entire program duration:

let s: &'static str = "String literals have static lifetime";

const VERSION: &str = "1.0.0";  // Implicitly 'static

Concurrency and Multi-threading

Rust’s ownership and type system enable fearless concurrency – preventing data races at compile time without runtime overhead.

Thread Safety Basics

Rust uses the Send and Sync marker traits to ensure thread safety:

  • Send: Type can be transferred between threads
  • Sync: Type can be shared between threads (safe for concurrent access)

Creating Threads

Spawning threads with ownership transfer:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Thread: {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Main: {}", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();  // Wait for thread to finish
}

Message Passing with Channels

Channels provide safe communication between threads:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let messages = vec![
            String::from("hello"),
            String::from("from"),
            String::from("thread"),
        ];

        for msg in messages {
            tx.send(msg).unwrap();
        }
    });

    for received in rx {
        println!("Received: {}", received);
    }
}

Shared State with Mutex

Mutex provides mutual exclusion for shared mutable state:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());  // Prints: 10
}
  • Arc: Atomic Reference Counted pointer for thread-safe shared ownership
  • Mutex: Ensures only one thread can access data at a time

Real-World Example: Concurrent Web Scraper

Parallel processing with thread pool:

use std::sync::{Arc, Mutex};
use std::thread;

struct ScraperStats {
    pages_processed: usize,
    errors: usize,
}

fn scrape_url(url: &str) -> Result<String, String> {
    // Simulate scraping
    if url.contains("error") {
        Err(String::from("Failed to fetch"))
    } else {
        Ok(format!("Content from {}", url))
    }
}

fn main() {
    let urls = vec![
        "https://example.com/1",
        "https://example.com/2",
        "https://error.com/3",
        "https://example.com/4",
    ];

    let stats = Arc::new(Mutex::new(ScraperStats {
        pages_processed: 0,
        errors: 0,
    }));

    let mut handles = vec![];

    for url in urls {
        let stats = Arc::clone(&stats);
        let handle = thread::spawn(move || {
            match scrape_url(url) {
                Ok(content) => {
                    println!("✓ {}", content);
                    let mut s = stats.lock().unwrap();
                    s.pages_processed += 1;
                }
                Err(e) => {
                    println!("✗ Error for {}: {}", url, e);
                    let mut s = stats.lock().unwrap();
                    s.errors += 1;
                }
            }
        });
        handles.push(handle);
    }

    // Wait for all threads
    for handle in handles {
        handle.join().unwrap();
    }

    let final_stats = stats.lock().unwrap();
    println!("\nProcessed: {}, Errors: {}",
        final_stats.pages_processed,
        final_stats.errors
    );
}
Deadlock Prevention: Rust cannot prevent all deadlocks, but the type system makes them less likely by enforcing clear ownership patterns.

Read-Write Lock for Concurrent Reads

RwLock allows multiple readers or one writer:

use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));
    let mut handles = vec![];

    // Multiple reader threads
    for i in 0..5 {
        let data = Arc::clone(&data);
        let handle = thread::spawn(move || {
            let r = data.read().unwrap();
            println!("Reader {}: {:?}", i, *r);
        });
        handles.push(handle);
    }

    // One writer thread
    let data_clone = Arc::clone(&data);
    let writer = thread::spawn(move || {
        let mut w = data_clone.write().unwrap();
        w.push(4);
        println!("Writer: added element");
    });
    handles.push(writer);

    for handle in handles {
        handle.join().unwrap();
    }
}

Common Memory Patterns

Pattern 1: Stack-Only Struct

struct Point {
    x: i32,
    y: i32,
}

let p = Point { x: 10, y: 20 };  // 8 bytes, all stack
Performance: Optimal. No heap allocation overhead.

Pattern 2: Struct with Heap Fields

struct Person {
    name: String,      // Heap-allocated string data
    age: i32,          // Stack value
}

let person = Person {
    name: String::from("Alice"),
    age: 30,
};
Performance: Mixed. Struct metadata on stack, string data on heap.

Pattern 3: Boxed Struct

let person = Box::new(Person {
    name: String::from("Alice"),
    age: 30,
});
Performance: Entire struct on heap. Extra indirection but enables cheap moves.

Pattern 4: Reference Counted (Single-threaded)

use std::rc::Rc;

let data = Rc::new(vec![1, 2, 3]);
let data2 = Rc::clone(&data);
let data3 = Rc::clone(&data);
// Shared ownership: deallocated when last Rc drops
Use case: Multiple owners, single-threaded contexts.

Pattern 5: Atomic Reference Counted (Multi-threaded)

use std::sync::Arc;

let data = Arc::new(vec![1, 2, 3]);
// Safe to clone and send across threads
Use case: Multiple owners, thread-safe sharing.

Practical Example: Cache Implementation

A simple cache demonstrates these memory concepts in practice:

use std::collections::HashMap;

struct Cache {
    data: HashMap<String, String>,
    max_size: usize,
}

impl Cache {
    fn new(max_size: usize) -> Self {
        Self {
            data: HashMap::new(),
            max_size,
        }
    }

    fn put(&mut self, key: String, value: String) {
        if self.data.len() >= self.max_size {
            // Simple eviction: remove arbitrary entry
            if let Some(k) = self.data.keys().next().cloned() {
                self.data.remove(&k);
            }
        }
        self.data.insert(key, value);
    }

    fn get(&self, key: &str) -> Option<&String> {
        self.data.get(key)
    }
}

// Usage
fn main() {
    let mut cache = Cache::new(100);
    cache.put("user:1".to_string(), "Alice".to_string());

    if let Some(name) = cache.get("user:1") {
        println!("Found: {}", name);
    }
}

Memory Analysis:

  • Cache struct: Stack (~24 bytes)
  • HashMap internal structure: Heap
  • Each key String: Heap-allocated characters
  • Each value String: Heap-allocated characters
  • Automatic cleanup: All heap memory freed when cache goes out of scope

Real-World Example: HTTP Request Pool

This comprehensive example demonstrates ownership, borrowing, lifetimes, and concurrency in a production-style HTTP request pooling system.

Architecture Overview

The request pool manages concurrent HTTP requests with connection reuse, timeout handling, and thread-safe statistics tracking.

use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::{Duration, Instant};

// Connection with lifetime bound to host string
struct Connection<'a> {
    host: &'a str,
    created_at: Instant,
    request_count: usize,
}

impl<'a> Connection<'a> {
    fn new(host: &'a str) -> Self {
        Connection {
            host,
            created_at: Instant::now(),
            request_count: 0,
        }
    }

    fn is_stale(&self, max_age: Duration) -> bool {
        self.created_at.elapsed() > max_age
    }

    fn send_request(&mut self, path: &str) -> String {
        self.request_count += 1;
        format!("Response from {}{} (req #{})",
            self.host, path, self.request_count)
    }
}

// Thread-safe statistics
struct PoolStats {
    total_requests: usize,
    active_connections: usize,
    cache_hits: usize,
}

impl PoolStats {
    fn new() -> Self {
        PoolStats {
            total_requests: 0,
            active_connections: 0,
            cache_hits: 0,
        }
    }
}

// Connection pool with shared ownership
struct RequestPool {
    // RwLock: multiple readers, single writer
    connections: Arc<RwLock<HashMap<String, Vec<Connection<'static>>>>>,
    // Mutex: exclusive access for modifications
    stats: Arc<Mutex<PoolStats>>,
    max_conn_age: Duration,
}

impl RequestPool {
    fn new() -> Self {
        RequestPool {
            connections: Arc::new(RwLock::new(HashMap::new())),
            stats: Arc::new(Mutex::new(PoolStats::new())),
            max_conn_age: Duration::from_secs(30),
        }
    }

    // Shared reference - multiple threads can call
    fn get_connection(&self, host: String) -> Option<Connection<'static>> {
        let mut connections = self.connections.write().unwrap();

        if let Some(pool) = connections.get_mut(&host) {
            // Remove stale connections
            pool.retain(|conn| !conn.is_stale(self.max_conn_age));

            if let Some(conn) = pool.pop() {
                let mut stats = self.stats.lock().unwrap();
                stats.cache_hits += 1;
                return Some(conn);
            }
        }

        None
    }

    fn return_connection(&self, conn: Connection<'static>) {
        let mut connections = self.connections.write().unwrap();
        let host = conn.host.to_string();

        connections
            .entry(host)
            .or_insert_with(Vec::new)
            .push(conn);
    }

    fn execute_request(&self, host: String, path: &str) -> String {
        // Try to reuse connection
        let mut conn = self
            .get_connection(host.clone())
            .unwrap_or_else(|| {
                // Create new connection (requires 'static lifetime)
                let static_host: &'static str = Box::leak(host.clone().into_boxed_str());
                Connection::new(static_host)
            });

        // Update stats
        {
            let mut stats = self.stats.lock().unwrap();
            stats.total_requests += 1;
        }

        // Send request
        let response = conn.send_request(path);

        // Return connection to pool
        self.return_connection(conn);

        response
    }

    fn print_stats(&self) {
        let stats = self.stats.lock().unwrap();
        let connections = self.connections.read().unwrap();

        let total_pooled: usize = connections
            .values()
            .map(|pool| pool.len())
            .sum();

        println!("\n📊 Pool Statistics:");
        println!("  Total Requests: {}", stats.total_requests);
        println!("  Cache Hits: {}", stats.cache_hits);
        println!("  Pooled Connections: {}", total_pooled);
        println!("  Hit Rate: {:.1}%",
            (stats.cache_hits as f64 / stats.total_requests as f64) * 100.0
        );
    }
}

Using the Pool Concurrently

Demonstrate concurrent access with multiple threads:

fn main() {
    let pool = Arc::new(RequestPool::new());
    let mut handles = vec![];

    // Simulate concurrent requests from multiple threads
    for thread_id in 0..5 {
        let pool = Arc::clone(&pool);

        let handle = thread::spawn(move || {
            // Each thread makes requests to different hosts
            let hosts = vec![
                String::from("api.example.com"),
                String::from("cdn.example.com"),
            ];

            for i in 0..3 {
                let host = &hosts[i % hosts.len()];
                let path = format!("/data/{}", i);

                let response = pool.execute_request(
                    host.clone(),
                    &path
                );

                println!("[Thread {}] {}", thread_id, response);

                // Simulate processing time
                thread::sleep(Duration::from_millis(10));
            }
        });

        handles.push(handle);
    }

    // Wait for all threads to complete
    for handle in handles {
        handle.join().unwrap();
    }

    // Print final statistics
    pool.print_stats();
}

Key Concepts Demonstrated

1. Ownership

  • Connection ownership transferred when returned to pool
  • Each host string has clear ownership via 'static lifetime

2. Borrowing

  • &self allows concurrent reads via RwLock
  • Mutable borrows protected by Mutex for exclusive access

3. Lifetimes

  • Connection<'a> ensures host string outlives connection
  • 'static lifetime for pooled connections (must live entire program)

4. Concurrency

  • Arc enables shared ownership across threads
  • RwLock allows multiple concurrent readers
  • Mutex ensures exclusive access for statistics updates

Memory Layout Analysis

Stack Allocations:

  • Arc pointer: 8 bytes
  • Duration: 16 bytes
  • Instant: 16 bytes

Heap Allocations:

  • HashMap: Dynamic, grows as needed
  • Vec<Connection>: One per host
  • Host strings: Leaked to obtain 'static lifetime
  • RwLock and Mutex data

Performance Characteristics

  • Connection Reuse: Eliminates expensive setup costs
  • Lock Granularity: RwLock allows concurrent reads, Mutex for writes
  • Stale Detection: Automatic removal of expired connections
  • Zero-Copy: Connections moved, not cloned

Memory Layout Reference

Type Location Size Example
i32, f64, bool, char Stack Fixed (4-8 bytes) let x: i32 = 42;
[T; N] (array) Stack Fixed (N × sizeof(T)) let a = [1, 2, 3];
String Stack + Heap Dynamic let s = String::from("hi");
Vec<T> Stack + Heap Dynamic let v = vec![1, 2, 3];
Box<T> Stack + Heap Fixed (ptr size) let b = Box::new(42);
&T (reference) Stack Fixed (ptr size) let r = &x;
Rc<T> / Arc<T> Stack + Heap Fixed (ptr size) let a = Arc::new(data);

Decision Heuristics

Choose Stack When:

  • Size is known at compile time
  • Data is small (typically < 1KB)
  • Data doesn’t need to outlive the current scope
  • Maximum performance is required

Choose Heap When:

  • Size is unknown or varies at runtime
  • Data must outlive the current scope
  • Data is large and copying would be expensive
  • Shared ownership is needed

Key Principles

  1. Stack allocation: Fast, automatic, fixed-size data
  2. Heap allocation: Flexible, dynamic-size, requires ownership management
  3. Compiler-driven: Allocation strategy determined by type analysis
  4. Zero-cost abstraction: No runtime overhead for memory safety
  5. RAII: Automatic cleanup through scope-based resource management
  6. Ownership system: Compile-time prevention of memory errors

Further Reading

Understanding Rust’s memory model provides the foundation for writing efficient, safe systems software. The combination of compile-time safety checks and zero-cost abstractions enables performance comparable to C/C++ while eliminating entire classes of memory bugs.