Rust for Systems Programming: Memory Safety Without Garbage Collection
Rust has become the language of choice for systems programming, offering memory safety without sacrificing performance. This guide covers the core concepts that make Rust unique and how to apply them in real-world systems code.
Ownership: Rust’s Secret Sauce
Rust’s ownership system ensures memory safety at compile time without a garbage collector. Every value has exactly one owner, and when the owner goes out of scope, the value is dropped.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 is moved to s2
// println!("{}", s1); // ERROR: s1 is no longer valid
println!("{}", s2); // OK: s2 owns the string
}
Move Semantics
By default, Rust moves values rather than copying them:
struct Data {
value: Vec<i32>,
}
fn process(data: Data) {
// data is moved here
println!("{:?}", data.value);
}
fn main() {
let d = Data { value: vec![1, 2, 3] };
process(d);
// d is no longer valid here
}
Borrowing and References
Borrowing allows you to reference values without taking ownership:
fn calculate_length(s: &String) -> usize {
s.len()
} // s goes out of scope, but since it doesn't own the value, nothing happens
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // Borrow s1
println!("Length of '{}' is {}", s1, len); // s1 still valid
}
Mutable References
Only one mutable reference is allowed at a time:
fn modify(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
modify(&mut s);
println!("{}", s); // "hello, world"
}
Lifetimes
Lifetimes ensure references are valid for as long as they’re used:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The 'a lifetime annotation says the returned reference will be valid as long as both input references are valid.
Error Handling
Rust uses Result and Option for explicit error handling:
use std::fs::File;
use std::io::Read;
fn read_file(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file("config.txt") {
Ok(contents) => println!("Read: {}", contents),
Err(e) => eprintln!("Error: {}", e),
}
}
Concurrency
Rust’s ownership system prevents data races at compile time:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hello");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
Shared State
Use Arc (atomically reference counted) and Mutex for shared 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());
}
Performance Optimization
Zero-Cost Abstractions
Rust’s abstractions compile to the same code as hand-written C:
// Iterator chain - compiles to efficient loop
let sum: i32 = (1..1000)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.sum();
Inline Assembly
For maximum performance, use inline assembly:
use std::arch::asm;
unsafe fn atomic_add(ptr: *mut i32, val: i32) -> i32 {
let old: i32;
asm!(
"lock xadd [{ptr}], {val}",
ptr = in(reg) ptr,
val = inout(reg) val => old,
options(nostack)
);
old
}
Real-World Example: High-Performance TCP Server
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => break, // Connection closed
Ok(n) => {
// Echo back
stream.write_all(&buffer[..n]).unwrap();
}
Err(_) => break,
}
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
println!("Server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(|| handle_client(stream));
}
Err(e) => eprintln!("Error: {}", e),
}
}
}
Working with Unsafe Code
Sometimes you need to step outside the borrow checker’s guarantees — interfacing with C libraries, implementing certain data structures, or hitting performance targets the safe abstractions can’t reach. Rust makes this explicit with unsafe blocks:
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1); // Dereferencing raw pointer requires unsafe
*r2 = 10; // Mutation through raw pointer
}
}
The key discipline is keeping unsafe blocks small and wrapping them in safe abstractions. The standard library itself does this constantly — Vec and String contain plenty of unsafe internally, but expose safe interfaces. When you write unsafe, document the invariant you’re maintaining and why the borrow checker can’t verify it. This makes review possible and keeps the unsafe surface auditable.
The Cargo Ecosystem
Cargo is more than a package manager — it’s the build system, test runner, and documentation generator in one:
cargo new myproject # Scaffold a new project
cargo build --release # Optimized build
cargo test # Run tests
cargo clippy # Lint for common mistakes
cargo fmt # Format code
cargo doc --open # Generate and open documentation
cargo audit # Check dependencies for known vulnerabilities
Add cargo clippy and cargo fmt to CI from day one. Clippy catches hundreds of common mistakes — from redundant clones to suspicious operator precedence — and the fixes it suggests are usually the idiomatic ones. The Cargo.toml manifest declares dependencies with semver ranges, and the Cargo.lock file pins exact versions for reproducible builds. Commit the lockfile for applications, not for libraries.
Traits and Generics
Traits are Rust’s mechanism for shared behavior — closer to typeclasses than to classical inheritance:
trait Summary {
fn summarize(&self) -> String;
fn summarize_default(&self) -> String {
String::from("(Read more...)")
}
}
fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Generics with trait bounds compile to monomorphized code — a specialized copy for each concrete type — so there’s no runtime dispatch cost. Use dynamic dispatch with &dyn Trait only when you genuinely need heterogeneous collections.
When Not to Use Rust
Honesty matters: Rust is not the right tool for every job. The borrow checker has a learning curve that slows down prototyping, and for a quick script or a CRUD web app, the safety guarantees buy you little while costing development speed. Rust shines when correctness and performance justify the upfront investment — CLI tools, network services, embedded systems, and anything that would otherwise be written in C or C++. If you’re building a microservice in a larger polyglot system, our Go microservices guide covers a gentler on-ramp.
Conclusion
Rust’s ownership model provides memory safety without runtime overhead. The learning curve is steep, but the payoff is systems code that’s both fast and safe.
Start with small projects to internalize ownership and borrowing. The compiler’s error messages are excellent teachers — read them carefully and they’ll guide you to correct, efficient code.
For systems programming where safety and performance matter, Rust is increasingly the obvious choice.