Memory Safety
This document explores how Rust's ownership system provides memory safety guarantees at compile time, in contrast to the runtime approach used by .NET. You'll analyze specific code examples that demonstrate these differences.
Conceptual Overview
.NET's Approach to Memory Safety
In .NET, memory safety is enforced through several runtime mechanisms:
- Garbage Collection: Automatically reclaims memory when objects are no longer in use
- Reference Tracking: The runtime keeps track of all references to objects
- Runtime Checks: Null reference exceptions, array bounds checking, and more.
- Thread Synchronization: Various locking mechanisms to prevent data races
This approach ensures memory safety but comes with some tradeoffs:
- Runtime overhead for garbage collection and checks
- Possibility of race conditions in multithreaded code
- Difficulty predicting exactly when memory will be freed
Rust's Approach to Memory Safety
Rust takes a fundamentally different approach by enforcing memory safety through compile-time checks:
- Ownership System: Each value has exactly one owner
- Borrowing Rules: References must follow strict rules
- Either one mutable reference OR multiple immutable references
- References must never outlive the data they read
- Static Analysis: The compiler analyzes your code to ensure these rules are followed
- Zero Runtime Overhead: No garbage collection or runtime checks needed
This approach provides:
- Memory safety without runtime overhead
- Prevention of data races at compile time
- Predictable resource cleanup through RAII (Resource Acquisition Is Initialization)
Analyzing the Memory Safety Examples
You'll first look at specific examples from the codebase that demonstrate these differences. You can find them in GitHub.
.NET Example
var user = new User(){
Name = "James"
};
var task1 = Task.Run(() => user.UpdateName("John"));
var task2 = Task.Run(() => user.UpdateName("Doe"));
await Task.WhenAll(task1, task2);
Console.WriteLine(user.Name);
class User {
private bool isFirst = true;
private static Random random = new Random();
public string Name { get; set; }
public async Task UpdateName(string newName) {
await Task.Delay(isFirst ? 5000 : 1000);
Name = newName;
}
}
This C# code creates a User object and then attempts to update its name from two different tasks running concurrently. The key memory safety concerns here are:
- Data Race: Both tasks are modifying the same
Nameproperty without synchronization - Non-Deterministic Behavior: The final value of
Namedepends on which task finishes last - Implicit Sharing: The
userinstance is implicitly shared between tasks
.NET allows this code to compile and run, but it has a race condition. The program output will be either "John" or "Doe" depending on timing, making the behavior unpredictable. With the simplicity of the code, it'll almost always finish in the same order, but it MIGHT not which is the key.
Rust Example
#[tokio::main]
async fn main() {
// Create a user that you want to modify from multiple async tasks
let mut user = User{
name: "James".to_string(),
};
// This task takes ownership of 'user' using 'move'
// The value is now owned by this async task and can no longer be accessed outside it
let handle = tokio::spawn(async move {
println!("First task modifying user");
user.update_name("John");
});
// COMPILER ERROR! The line below will not compile because:
// - 'user' was moved into the previous task
// - Ownership rules prevent using a value after it's been moved
// - This is how Rust catches data races at compile time
let handle_2 = tokio::spawn(async move {
println!("Second task trying to modify user");
user.update_name("Doe"); // Error: use of moved value: `user`
});
}
The Rust example attempts something similar but with a crucial difference: it will not compile. You can try if you want:
cd src/demos/1-memory-safety/rust_app
cargo run
The Rust compiler prevents the data race at compile time through ownership rules:
- The first task takes ownership of
userthrough themovekeyword - After this, the
uservalue is no longer available in the main function - The second task cannot use the moved value
The compiler error would be something like: error[E0382]: use of moved value: 'user'
Safe Concurrent Access in Rust
To safely share mutable state between threads in Rust, you need to be explicit about it. Here's how you could fix the above code:
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
// Arc = Atomic Reference Count: Allows multiple ownership across threads
// Mutex = Mutual Exclusion: Ensures only one thread can access the data at a time
let user = Arc::new(Mutex::new(User {
name: "James".to_string(),
}));
// Create clones of the Arc to share ownership with multiple tasks
let user_clone1 = Arc::clone(&user);
let user_clone2 = Arc::clone(&user);
// Spawn two tasks that will try to modify the user concurrently
let handle1 = tokio::spawn(async move {
// Safely modify the user by acquiring the mutex
let mut locked_user = user_clone1.lock().unwrap();
println!("Task 1: Updating name to 'John'");
locked_user.name = "John".to_string();
// Lock is released when 'locked_user' goes out of scope
});
let handle2 = tokio::spawn(async move {
// Safely modify the user by acquiring the mutex
let mut locked_user = user_clone2.lock().unwrap();
println!("Task 2: Updating name to 'Doe'");
locked_user.name = "Doe".to_string();
// Lock is released when 'locked_user' goes out of scope
});
// Wait for both tasks to complete
_ = handle1.await;
_ = handle2.await;
}
In this corrected version:
- You use
Arc(Atomic Reference Counting) to share ownership of the user between threads - You use
Mutexto ensure only one thread can modify the user at a time - Each task must explicitly acquire the lock before modifying the data
- The lock is automatically released when the reference goes out of scope
Yes, I realise I've introduced a bunch of new terms there (what the heck is an Arc and a Mutex). The key thing to take away there is that the key difference is that you're being explicit in Rust that you have multiple threads accessing the same piece of data.
Another Example: Preventing Use-After-Move
Another memory safety issue Rust prevents is use-after-move errors, as shown in this example:
fn example1() {
let user = User {
name: "James".to_string(),
};
// The 'say_hello' function takes ownership of 'user'
say_hello(user);
// COMPILER ERROR: This line won't compile because 'user' was moved
// user.update_name("John"); // Error: use of moved value
}
// This function takes ownership of the User value
fn say_hello(user: User) {
println!("Hello, {}", user.name);
// When this function ends, 'user' is dropped (memory freed)
}
In this example:
useris created and then passed tosay_hello- The
say_hellofunction takes ownership of the value - After this call, the original variable can no longer be used
- The compiler prevents use-after-move errors at compile time
Borrowing
Borrowing is a core concept in Rust that allows you to temporarily access data without taking ownership of it. Think of it like borrowing a book from a library - you can read it, but you need to return it eventually without destroying it.
Borrowing in Rust means creating a reference to a value, rather than moving ownership. References are like pointers that promise not to take ownership or outlive the original data.
Borrowing Syntax
Immutable borrowing: &T - allows reading but not modifying
Mutable borrowing: &mut T - allows both reading and modifying
Basic Example
fn main() {
let s = String::from("hello");
// Immutable borrow - looking at the value
let len = calculate_length(&s); // Note the & here
println!("The length of '{}' is {}.", s, len);
// Note: s is still valid here because you only borrowed it
}
// This function borrows the string (doesn't take ownership)
fn calculate_length(s: &String) -> usize { // Note the & here too
s.len()
} // s goes out of scope, but since it's a reference, nothing happens to the original value
Mutable Borrowing Example:
fn main() {
let mut s = String::from("hello");
// Mutable borrow - allows modification
add_world(&mut s); // Note the &mut here
println!("{}", s); // Prints "hello world"
}
fn add_world(s: &mut String) { // Note the &mut here too
s.push_str(" world");
}
The borrowing rules you mentioned (one mutable OR many immutable references, no dangling references) are how Rust prevents data races and use-after-free bugs at compile time.
Rust's borrowing rules also prevent data races within a single thread:
fn example2() {
let mut user = User {
name: "James".to_string(),
};
// Create an immutable reference to 'user'
let name_ref = &user.name;
// COMPILER ERROR: Can't borrow as mutable while already borrowed as immutable
// user.update_name("John"); // Error: cannot borrow as mutable
println!("Name is still: {}", name_ref);
// After the last use of 'name_ref', you can now borrow as mutable
user.update_name("John");
println!("Name updated to: {}", user.name);
}
The borrowing rules state:
- You can have either ONE mutable reference OR multiple immutable references
- References must never outlive the data they point to
These rules are enforced by the compiler, making it impossible to create certain classes of bugs.
Key Differences and Their Implications
Comparison Table
| Feature | .NET | Rust |
|---|---|---|
| Memory Safety Enforcement | Runtime | Compile time |
| Memory Management | Garbage collection | Ownership & RAII |
| Data Race Prevention | Manual (locks) | Compiler enforced |
| Null Reference Handling | Nullable types + runtime checks | Option + compile-time checks |
| Resource Cleanup | Finalizers + IDisposable | Deterministic Drop |
| Concurrency Model | Shared mutable state | Ownership transfer or explicit sharing |
Implications for Developers
-
Bug Detection Timing
- .NET: Many bugs are found at runtime through testing
- Rust: Many bugs are caught at compile time
-
Concurrency Safety
- .NET: Requires discipline and careful use of synchronization primitives
- Rust: Enforces thread safety through the type system
-
Learning Curve
- .NET: Easier to get started but harder to write thread-safe code
- Rust: Steeper learning curve but safer concurrent code
-
Performance Predictability
- .NET: GC pauses can cause unpredictable latency spikes
- Rust: Deterministic resource cleanup leads to more consistent performance
Conclusion
Rust's approach to memory safety represents a fundamental shift from the runtime checking model used by .NET and other garbage-collected languages. By enforcing ownership and borrowing rules at compile time, Rust prevents entire classes of memory safety bugs, including null reference exceptions, use-after-free bugs, and data races.
While this approach requires more upfront effort from the developer to satisfy the compiler, it results in safer, more reliable programs, especially in concurrent contexts. The compiler becomes an ally that helps you catch bugs early rather than letting them manifest at runtime.
For .NET developers, understanding Rust's ownership system is the key to learning the language effectively. While it may initially seem restrictive, it's this constraint that enables Rust to guarantee memory safety without runtime overhead.