After years of writing systems-level code in C and C++, our team embarked on a journey to rewrite critical infrastructure components in Rust. This wasn’t a decision made lightly—it came after careful evaluation of the performance, safety, and maintainability tradeoffs. This post shares our experience, patterns, and lessons learned from this transition.

Why Rust?

The decision to adopt Rust was driven by three primary factors:

1. Memory Safety Without Garbage Collection

// This won't compile - Rust prevents use-after-free at compile time
fn dangling_reference() -> &String {
    let s = String::from("hello");
    &s  // Error: `s` does not live long enough
}

// Correct: return owned value
fn valid_string() -> String {
    let s = String::from("hello");
    s  // Ownership transferred to caller
}

In C/C++, this category of bugs has caused countless security vulnerabilities and production incidents. Rust’s ownership system eliminates them at compile time, with zero runtime overhead.

2. Concurrency Without Data Races

use std::sync::Arc;
use std::thread;

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

    for i in 0..3 {
        let data_clone = Arc::clone(&data);
        let handle = thread::spawn(move || {
            let sum: i32 = data_clone.iter().sum();
            println!("Thread {}: sum = {}", i, sum);
        });
        handles.push(handle);
    }

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

// This won't compile - can't have mutable aliasing
fn data_race_prevented() {
    let mut data = vec![1, 2, 3];
    let reference1 = &mut data;
    let reference2 = &mut data;  // Error: cannot borrow as mutable more than once

    reference1.push(4);
    reference2.push(5);
}

The compiler enforces that shared data is immutable, and mutable data is exclusive. This eliminates entire classes of concurrency bugs.

3. Performance Comparable to C/C++

Rust’s zero-cost abstractions mean high-level code compiles to the same assembly as hand-optimized C:

// High-level iterator code
fn sum_even(numbers: &[i32]) -> i32 {
    numbers.iter()
        .filter(|&&n| n % 2 == 0)
        .sum()
}

// Compiles to tight loop, equivalent to:
// int sum = 0;
// for (int i = 0; i < len; i++) {
//     if (numbers[i] % 2 == 0) sum += numbers[i];
// }

Real-World Use Case: High-Performance Packet Processing

Our first major Rust project was rewriting a packet processing pipeline that needed to handle millions of packets per second with sub-microsecond latency.

The C++ Baseline

// Original C++ code (simplified)
class PacketProcessor {
    std::vector<uint8_t*> packet_buffers;
    std::mutex buffer_lock;

public:
    void process_packet(const uint8_t* data, size_t len) {
        std::lock_guard<std::mutex> lock(buffer_lock);

        // Parse packet
        auto* header = reinterpret_cast<const PacketHeader*>(data);

        // Process based on protocol
        if (header->protocol == PROTOCOL_TCP) {
            process_tcp(data + sizeof(PacketHeader), len - sizeof(PacketHeader));
        }

        // Potential issues:
        // - No bounds checking on reinterpret_cast
        // - Mutex contention under high load
        // - Manual memory management prone to leaks
    }
};

The Rust Rewrite

use std::sync::Arc;
use crossbeam::channel::{bounded, Sender, Receiver};
use bytes::Bytes;

#[repr(C, packed)]
struct PacketHeader {
    protocol: u8,
    flags: u8,
    length: u16,
}

enum Protocol {
    Tcp,
    Udp,
    Unknown(u8),
}

impl From<u8> for Protocol {
    fn from(value: u8) -> Self {
        match value {
            6 => Protocol::Tcp,
            17 => Protocol::Udp,
            _ => Protocol::Unknown(value),
        }
    }
}

struct PacketProcessor {
    workers: Vec<std::thread::JoinHandle<()>>,
    sender: Sender<Bytes>,
}

impl PacketProcessor {
    fn new(num_workers: usize) -> Self {
        let (sender, receiver) = bounded(10000);
        let receiver = Arc::new(receiver);

        let workers: Vec<_> = (0..num_workers)
            .map(|id| {
                let receiver = Arc::clone(&receiver);
                std::thread::spawn(move || {
                    Self::worker_loop(id, receiver);
                })
            })
            .collect();

        PacketProcessor { workers, sender }
    }

    fn process_packet(&self, data: Bytes) -> Result<(), String> {
        self.sender.send(data)
            .map_err(|_| "Channel full".to_string())
    }

    fn worker_loop(id: usize, receiver: Arc<Receiver<Bytes>>) {
        loop {
            match receiver.recv() {
                Ok(packet) => {
                    if let Err(e) = Self::handle_packet(&packet) {
                        eprintln!("Worker {}: Error processing packet: {}", id, e);
                    }
                }
                Err(_) => break, // Channel closed
            }
        }
    }

    fn handle_packet(data: &[u8]) -> Result<(), String> {
        // Safe parsing with bounds checking
        if data.len() < std::mem::size_of::<PacketHeader>() {
            return Err("Packet too small".to_string());
        }

        // Safe transmutation
        let header = unsafe {
            &*(data.as_ptr() as *const PacketHeader)
        };

        let protocol = Protocol::from(header.protocol);
        let payload_offset = std::mem::size_of::<PacketHeader>();

        if data.len() < payload_offset {
            return Err("Invalid packet length".to_string());
        }

        let payload = &data[payload_offset..];

        match protocol {
            Protocol::Tcp => Self::process_tcp(payload),
            Protocol::Udp => Self::process_udp(payload),
            Protocol::Unknown(p) => {
                Err(format!("Unknown protocol: {}", p))
            }
        }
    }

    fn process_tcp(payload: &[u8]) -> Result<(), String> {
        // TCP-specific processing
        // All bounds are checked, no buffer overflows possible
        Ok(())
    }

    fn process_udp(payload: &[u8]) -> Result<(), String> {
        // UDP-specific processing
        Ok(())
    }
}

Performance Improvements

The Rust version delivered:

  • 30% higher throughput: Lock-free channels instead of mutexes
  • 50% lower latency variance: Predictable performance without GC pauses
  • Zero memory safety issues: Caught 7 potential buffer overflows during compilation

Pattern: Zero-Copy Parsing

One of Rust’s strengths is zero-copy parsing of binary protocols:

use bytes::{Buf, BytesMut};

struct HttpRequest<'a> {
    method: &'a str,
    path: &'a str,
    headers: Vec<(&'a str, &'a str)>,
    body: &'a [u8],
}

impl<'a> HttpRequest<'a> {
    fn parse(input: &'a [u8]) -> Result<Self, String> {
        let mut lines = input.split(|&b| b == b'\n');

        // Parse request line
        let request_line = lines.next()
            .ok_or("Empty request")?;

        let mut parts = request_line.split(|&b| b == b' ');
        let method = std::str::from_utf8(
            parts.next().ok_or("Missing method")?
        ).map_err(|_| "Invalid UTF-8")?;

        let path = std::str::from_utf8(
            parts.next().ok_or("Missing path")?
        ).map_err(|_| "Invalid UTF-8")?;

        // Parse headers (zero-copy)
        let mut headers = Vec::new();
        for line in lines.by_ref() {
            if line.is_empty() || line == b"\r" {
                break;
            }

            if let Some(pos) = line.iter().position(|&b| b == b':') {
                let name = std::str::from_utf8(&line[..pos])
                    .map_err(|_| "Invalid header name")?;
                let value = std::str::from_utf8(&line[pos+1..].trim_ascii())
                    .map_err(|_| "Invalid header value")?;
                headers.push((name, value.trim()));
            }
        }

        // Body is everything remaining
        let body_start = input.len() - lines.as_slice().len();
        let body = &input[body_start..];

        Ok(HttpRequest {
            method,
            path,
            headers,
            body,
        })
    }
}

// No allocations, all references into original buffer
let request_data = b"GET /api/users HTTP/1.1\r\nHost: example.com\r\n\r\n";
let request = HttpRequest::parse(request_data).unwrap();

Async Rust for High-Concurrency Services

For I/O-bound services, Rust’s async/await enables high concurrency without thread overhead:

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use std::sync::Arc;

struct Server {
    listener: TcpListener,
    handler: Arc<dyn RequestHandler + Send + Sync>,
}

#[async_trait::async_trait]
trait RequestHandler {
    async fn handle(&self, request: &[u8]) -> Vec<u8>;
}

impl Server {
    async fn new(addr: &str, handler: Arc<dyn RequestHandler + Send + Sync>) -> tokio::io::Result<Self> {
        let listener = TcpListener::bind(addr).await?;
        Ok(Server { listener, handler })
    }

    async fn run(&self) -> tokio::io::Result<()> {
        loop {
            let (socket, addr) = self.listener.accept().await?;
            let handler = Arc::clone(&self.handler);

            // Spawn task for each connection (cheap: ~2KB stack)
            tokio::spawn(async move {
                if let Err(e) = Self::handle_connection(socket, handler).await {
                    eprintln!("Error handling connection from {}: {}", addr, e);
                }
            });
        }
    }

    async fn handle_connection(
        mut socket: TcpStream,
        handler: Arc<dyn RequestHandler + Send + Sync>,
    ) -> tokio::io::Result<()> {
        let mut buffer = vec![0u8; 8192];

        loop {
            let n = socket.read(&mut buffer).await?;
            if n == 0 {
                break; // Connection closed
            }

            let response = handler.handle(&buffer[..n]).await;
            socket.write_all(&response).await?;
        }

        Ok(())
    }
}

// Example handler
struct EchoHandler;

#[async_trait::async_trait]
impl RequestHandler for EchoHandler {
    async fn handle(&self, request: &[u8]) -> Vec<u8> {
        request.to_vec()
    }
}

// Usage: handles 100K+ concurrent connections on single thread
#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    let handler = Arc::new(EchoHandler);
    let server = Server::new("0.0.0.0:8080", handler).await?;
    server.run().await
}

Interoperability with Existing C/C++ Code

Migration doesn’t require a big bang rewrite. Rust has excellent C FFI:

// Rust code exposed to C
#[no_mangle]
pub extern "C" fn process_data(
    data: *const u8,
    len: usize,
    output: *mut u8,
    output_len: usize,
) -> i32 {
    // Convert raw pointers to safe Rust slices
    let input = unsafe {
        if data.is_null() {
            return -1;
        }
        std::slice::from_raw_parts(data, len)
    };

    let output_slice = unsafe {
        if output.is_null() {
            return -1;
        }
        std::slice::from_raw_parts_mut(output, output_len)
    };

    // Safe Rust code
    match process_safely(input, output_slice) {
        Ok(bytes_written) => bytes_written as i32,
        Err(_) => -1,
    }
}

fn process_safely(input: &[u8], output: &mut [u8]) -> Result<usize, String> {
    // Actual processing logic in safe Rust
    if output.len() < input.len() {
        return Err("Output buffer too small".to_string());
    }

    output[..input.len()].copy_from_slice(input);
    Ok(input.len())
}
// C code calling Rust
#include <stdint.h>
#include <stdio.h>

extern int32_t process_data(
    const uint8_t* data,
    size_t len,
    uint8_t* output,
    size_t output_len
);

int main() {
    uint8_t input[] = {1, 2, 3, 4, 5};
    uint8_t output[10];

    int32_t result = process_data(input, 5, output, 10);
    if (result >= 0) {
        printf("Processed %d bytes\n", result);
    }

    return 0;
}

Error Handling: Result Type vs Exceptions

Rust’s Result type forces explicit error handling:

use std::fs::File;
use std::io::{self, Read};

// Errors must be handled explicitly
fn read_config(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?;  // ? propagates errors
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

// Caller must handle errors
fn main() {
    match read_config("config.toml") {
        Ok(config) => println!("Config: {}", config),
        Err(e) => eprintln!("Failed to read config: {}", e),
    }
}

// Can't forget error handling - won't compile
fn bad_example() {
    let file = File::open("config.toml");  // Error: unused Result
}

Compare to C++ exceptions which can be silently ignored:

// C++: Easy to forget error handling
std::string read_config(const std::string& path) {
    std::ifstream file(path);  // Exception might be thrown, might not be caught
    std::stringstream buffer;
    buffer << file.rdbuf();
    return buffer.str();
}

Testing and Benchmarking

Rust’s built-in testing framework makes it easy to maintain quality:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_packet_parsing() {
        let data = vec![
            6, 0, 0, 20,  // TCP protocol, length 20
            /* payload */
        ];

        let result = PacketProcessor::handle_packet(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_invalid_packet() {
        let data = vec![6, 0];  // Too short
        let result = PacketProcessor::handle_packet(&data);
        assert!(result.is_err());
    }
}

// Benchmarking with criterion
#[cfg(test)]
mod benches {
    use criterion::{black_box, criterion_group, criterion_main, Criterion};
    use super::*;

    fn benchmark_packet_processing(c: &mut Criterion) {
        let packet = vec![6, 0, 0, 100, /* 100 bytes payload */];

        c.bench_function("process_packet", |b| {
            b.iter(|| {
                PacketProcessor::handle_packet(black_box(&packet))
            })
        });
    }

    criterion_group!(benches, benchmark_packet_processing);
    criterion_main!(benches);
}

Lessons Learned

What Worked Well

  1. Gradual Migration: Started with new components, then rewrote hot paths
  2. Team Training: Invested 2 weeks in Rust workshops before production code
  3. Cargo Ecosystem: Excellent package management and build system
  4. Compile-Time Guarantees: Caught bugs that would have been production incidents

Challenges

  1. Learning Curve: Borrow checker takes time to internalize (2-4 weeks)
  2. Compile Times: Larger projects can have long compile times
  3. Async Ecosystem: Still maturing, some rough edges
  4. Library Gaps: Some niche libraries not yet available in Rust

Best Practices

// 1. Use type system to encode invariants
struct NonEmptyVec<T> {
    head: T,
    tail: Vec<T>,
}

impl<T> NonEmptyVec<T> {
    fn new(head: T) -> Self {
        NonEmptyVec { head, tail: Vec::new() }
    }

    fn first(&self) -> &T {
        &self.head  // Always safe, guaranteed non-empty
    }
}

// 2. Prefer iterators over manual indexing
fn process_items(items: &[Item]) {
    // Instead of:
    // for i in 0..items.len() { items[i].process(); }

    // Use iterators:
    items.iter().for_each(|item| item.process());
}

// 3. Use Cow for flexible ownership
use std::borrow::Cow;

fn process_string(input: Cow<str>) -> Cow<str> {
    if input.contains("special") {
        Cow::Owned(input.replace("special", "SPECIAL"))
    } else {
        input  // No allocation if no changes needed
    }
}

Conclusion

Migrating to Rust has been one of the best technical decisions we’ve made. The combination of memory safety, performance, and excellent tooling has significantly improved our code quality and developer productivity.

When to consider Rust:

  • Systems programming requiring high performance
  • Safety-critical code where bugs are expensive
  • High-concurrency services
  • Long-lived infrastructure that needs maintainability

When to stick with other languages:

  • Rapid prototyping where safety isn’t critical
  • Teams without time for learning curve
  • Domains with mature ecosystems in other languages

Rust isn’t a silver bullet, but for systems programming, it’s the best tool we’ve found for building reliable, performant software.