Most Rust introductions spend the first three chapters on memory safety theory and the borrow checker rules before you have written a single useful program. This is not that post. The ownership model is genuinely important and you will need to understand it, but the fastest path to that understanding is writing code that touches real problems — network services, CLI tools, system calls — and encountering the compiler’s feedback in context.
This post is aimed at engineers who already know at least one systems language (Go, C, C++, Python) and want to build a working mental model of Rust quickly. It covers the ownership model through examples rather than rules, practical async networking with Tokio, building a CLI with clap, calling C from Rust, and an honest comparison with Go for the workloads systems engineers actually build.
The Ownership Model: What the Compiler Is Actually Checking
Rust has no garbage collector and no runtime memory management. Memory is freed when the owning variable goes out of scope — at compile time, statically determined, zero runtime overhead. The borrow checker is the mechanism that makes this safe.
Three rules, which the compiler enforces:
- Every value has exactly one owner.
- When the owner goes out of scope, the value is dropped (memory freed).
- You can have either one mutable reference or any number of immutable references — never both simultaneously.
The reason for rule 3 is data races. A mutable reference and an immutable reference to the same data held by different parts of the program simultaneously can produce undefined behavior. The borrow checker prevents this at compile time.
Working through this concretely:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
fn main() {
// s1 owns the String on the heap
let s1 = String::from("hello");
// Move: ownership transfers to s2. s1 is now invalid.
let s2 = s1;
// Compile error: value borrowed here after move
// println!("{}", s1);
// Borrow: s3 and s4 both have read access. Neither owns.
let s3 = &s2;
let s4 = &s2;
println!("{} {}", s3, s4); // fine — two immutable borrows
// Compile error: cannot borrow s2 as mutable because it's
// also borrowed as immutable
// let s5 = &mut s2;
// Clone: explicit deep copy, both s2 and s6 own their data
let s6 = s2.clone();
println!("{} {}", s2, s6); // both valid
}
|
The distinction between move and borrow is the concept that confuses Go engineers most. In Go, passing a value copies it (for value types) or passes a reference (for pointers). In Rust, passing a non-Copy value to a function transfers ownership — the caller can no longer use it unless the function returns it back, or the caller passes a reference instead.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
fn takes_ownership(s: String) {
println!("{}", s);
} // s is dropped here
fn borrows(s: &str) {
println!("{}", s);
} // s goes out of scope but the original data is unaffected
fn main() {
let s = String::from("hello");
borrows(&s); // lend it — s still valid
borrows(&s); // lend it again — still fine
takes_ownership(s); // give it away — s invalid after this line
// Compile error: s was moved
// println!("{}", s);
}
|
The practical implication: when you write a function, decide whether it needs to own the data (take String, Vec<T>) or just read it (take &str, &[T]). For most functions that just read, use references. The compiler tells you when you get this wrong — the error messages are specific about what moved where.
Lifetimes: When the Compiler Needs a Hint
Lifetimes annotate how long references are valid. The compiler infers them in most cases. You only annotate explicitly when a function returns a reference and the compiler cannot determine which input it came from:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Compiler error: which input does the return reference come from?
// fn longest(s1: &str, s2: &str) -> &str { ... }
// Explicit lifetime: the return reference lives as long as
// whichever input reference lives shorter
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() { s1 } else { s2 }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xy");
result = longest(&s1, &s2);
println!("{}", result); // fine — both s1 and s2 in scope
}
// Compile error: s2 dropped here, result might reference it
// println!("{}", result);
}
|
Lifetime annotations do not change how long anything lives — they describe relationships so the compiler can verify safety. For most application code you will rarely write them explicitly. They appear most often in library code that stores references in structs.
Structs, Enums, and Error Handling
Rust’s enum is a tagged union — each variant can carry different data. Combined with pattern matching, it replaces the class hierarchy, exception handling, and null checks of other languages.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
use std::fmt;
// Result<T, E> is the core error handling type — either Ok(value) or Err(error)
// Option<T> replaces null — either Some(value) or None
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {}", e),
AppError::Parse(e) => write!(f, "Parse error: {}", e),
AppError::NotFound(s) => write!(f, "Not found: {}", s),
}
}
}
// The ? operator: if Err, return early propagating the error
// (requires From<SourceError> impl on the return type's error variant)
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_port_from_file(path: &str) -> Result<u16, AppError> {
let contents = std::fs::read_to_string(path)?; // ? converts io::Error → AppError
let port: u16 = contents.trim().parse()?; // ? converts ParseIntError → AppError
if port < 1024 {
return Err(AppError::NotFound(format!("port {} is privileged", port)));
}
Ok(port)
}
|
The ? operator is the idiomatic replacement for if err != nil { return err }. It propagates errors up the call stack automatically, converting between error types via From implementations. The anyhow and thiserror crates simplify error type boilerplate for applications and libraries respectively.
clap is the standard argument parsing library. Its derive API generates parsers from struct field annotations:
1
2
3
4
5
6
7
8
9
10
11
|
# Cargo.toml
[package]
name = "portwatch"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
// src/main.rs — a tool that checks if ports are open on a host
use clap::{Parser, Subcommand, ValueEnum};
use std::net::TcpStream;
use std::time::Duration;
use anyhow::{Context, Result};
#[derive(Parser)]
#[command(name = "portwatch", about = "Check TCP port availability")]
#[command(version, propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Check if a single port is open
Check {
/// Target host
host: String,
/// Port number
#[arg(short, long)]
port: u16,
/// Timeout in milliseconds
#[arg(short, long, default_value_t = 2000)]
timeout: u64,
},
/// Scan a range of ports
Scan {
host: String,
#[arg(long, default_value_t = 1)]
start: u16,
#[arg(long, default_value_t = 1024)]
end: u16,
/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
}
#[derive(ValueEnum, Clone)]
enum OutputFormat {
Text,
Json,
}
fn check_port(host: &str, port: u16, timeout_ms: u64) -> bool {
let addr = format!("{}:{}", host, port);
TcpStream::connect_timeout(
&addr.parse().unwrap(),
Duration::from_millis(timeout_ms),
).is_ok()
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Check { host, port, timeout } => {
let open = check_port(&host, port, timeout);
if open {
println!("{}:{} is OPEN", host, port);
} else {
println!("{}:{} is CLOSED", host, port);
std::process::exit(1);
}
}
Commands::Scan { host, start, end, format } => {
let open_ports: Vec<u16> = (start..=end)
.filter(|&p| check_port(&host, p, 500))
.collect();
match format {
OutputFormat::Text => {
for port in &open_ports {
println!("{}:{} OPEN", host, port);
}
println!("{} open ports found", open_ports.len());
}
OutputFormat::Json => {
let output = serde_json::json!({
"host": host,
"open_ports": open_ports,
});
println!("{}", serde_json::to_string_pretty(&output)?);
}
}
}
}
Ok(())
}
|
1
2
3
|
cargo build --release
./target/release/portwatch check --host 192.168.1.1 --port 22
./target/release/portwatch scan 192.168.1.1 --start 20 --end 100 --format json
|
The binary is statically linked by default on Linux with musl, self-contained, and starts instantly. No runtime, no JVM warm-up, no Python interpreter overhead.
Async Networking with Tokio
Tokio is the de facto async runtime for network services in Rust. It provides async I/O, timers, channels, and task spawning. The async/await syntax looks similar to Go’s goroutines but the execution model is different: Tokio uses cooperative multitasking on a thread pool rather than Go’s preemptive scheduler.
A TCP server that handles concurrent connections:
1
2
3
|
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1"
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use anyhow::Result;
// #[tokio::main] bootstraps the runtime and turns main() into an async entry point
#[tokio::main]
async fn main() -> Result<()> {
let listener = TcpListener::bind("0.0.0.0:8080").await?;
println!("Listening on :8080");
loop {
// accept() yields to the runtime while waiting — zero thread blocking
let (socket, addr) = listener.accept().await?;
println!("Connection from {}", addr);
// Spawn a new task for each connection — lightweight, not a new OS thread
tokio::spawn(async move {
if let Err(e) = handle_connection(socket).await {
eprintln!("Connection error: {}", e);
}
});
}
}
async fn handle_connection(mut socket: TcpStream) -> Result<()> {
let mut buf = vec![0u8; 4096];
loop {
let n = socket.read(&mut buf).await?;
if n == 0 {
return Ok(()); // connection closed
}
// Echo back
socket.write_all(&buf[..n]).await?;
}
}
|
For HTTP services, axum (built on Tokio and Hyper) is the ergonomic choice:
1
2
3
4
5
|
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
// Shared application state
#[derive(Clone)]
struct AppState {
store: Arc<Mutex<HashMap<String, String>>>,
}
#[derive(Serialize, Deserialize)]
struct KvEntry {
key: String,
value: String,
}
async fn get_key(
Path(key): Path<String>,
State(state): State<AppState>,
) -> Result<Json<KvEntry>, StatusCode> {
let store = state.store.lock().unwrap();
match store.get(&key) {
Some(value) => Ok(Json(KvEntry { key, value: value.clone() })),
None => Err(StatusCode::NOT_FOUND),
}
}
async fn set_key(
State(state): State<AppState>,
Json(entry): Json<KvEntry>,
) -> StatusCode {
let mut store = state.store.lock().unwrap();
store.insert(entry.key, entry.value);
StatusCode::CREATED
}
#[tokio::main]
async fn main() {
let state = AppState {
store: Arc::new(Mutex::new(HashMap::new())),
};
let app = Router::new()
.route("/kv/:key", get(get_key))
.route("/kv", post(set_key))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on :3000");
axum::serve(listener, app).await.unwrap();
}
|
The Arc<Mutex<T>> pattern for shared mutable state is the Rust equivalent of a mutex-protected value. Arc is an atomically reference-counted pointer — safe to clone and share across threads. Mutex provides the exclusive access guarantee. The borrow checker ensures you cannot access the inner value without holding the lock.
Concurrency: Channels and Tasks
Tokio’s channels replace Go’s channels for async task communication. The mental model is identical; the types differ:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
use tokio::sync::{mpsc, oneshot};
// mpsc: multi-producer, single-consumer — many senders, one receiver
// oneshot: single message, reply channel pattern
#[tokio::main]
async fn main() {
// Buffer of 32 messages
let (tx, mut rx) = mpsc::channel::<String>(32);
// Spawn a worker that processes messages
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
println!("Worker received: {}", msg);
}
println!("Channel closed, worker exiting");
});
// Send messages from multiple tasks
let tx2 = tx.clone();
tokio::spawn(async move {
tx2.send("from task 2".to_string()).await.unwrap();
});
tx.send("from main".to_string()).await.unwrap();
// Drop all senders — receiver loop terminates
drop(tx);
// Give the worker task time to drain
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
|
For CPU-bound work, tokio::task::spawn_blocking offloads to a dedicated thread pool, preventing the async executor from blocking:
1
2
3
4
|
let result = tokio::task::spawn_blocking(|| {
// CPU-intensive work here — runs on a blocking thread pool
expensive_computation()
}).await?;
|
Calling C from Rust (FFI)
Rust’s FFI lets you call C libraries directly. The unsafe block acknowledges that the compiler cannot verify the C code’s memory safety — you are taking responsibility for correctness at the boundary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// Calling libz (zlib) to compress data
use std::ffi::c_int;
use std::os::raw::{c_uchar, c_ulong};
// Declare the C function signatures
extern "C" {
fn compress(
dest: *mut c_uchar,
dest_len: *mut c_ulong,
source: *const c_uchar,
source_len: c_ulong,
) -> c_int;
fn compressBound(source_len: c_ulong) -> c_ulong;
}
fn compress_data(input: &[u8]) -> Vec<u8> {
unsafe {
let bound = compressBound(input.len() as c_ulong);
let mut output = vec![0u8; bound as usize];
let mut output_len = bound;
let result = compress(
output.as_mut_ptr(),
&mut output_len,
input.as_ptr(),
input.len() as c_ulong,
);
assert_eq!(result, 0, "compression failed");
output.truncate(output_len as usize);
output
}
}
|
The build.rs build script links the C library:
1
2
3
4
|
// build.rs
fn main() {
println!("cargo:rustc-link-lib=z"); // links -lz
}
|
For larger C projects, bindgen generates Rust FFI bindings automatically from C header files:
1
2
|
cargo install bindgen-cli
bindgen /usr/include/zlib.h -o src/bindings.rs
|
The pattern for safe FFI wrappers: keep unsafe confined to the smallest possible scope, validate all inputs before passing to C, and wrap the raw FFI in a safe Rust API that enforces invariants:
1
2
3
4
5
6
7
8
|
// Safe wrapper that hides the unsafe FFI
pub fn compress(data: &[u8]) -> Result<Vec<u8>, &'static str> {
if data.is_empty() {
return Ok(Vec::new());
}
// unsafe confined here — caller sees only safe API
Ok(compress_data(data))
}
|
The Type System as Documentation
Rust’s type system encodes invariants that would be runtime checks or documentation in other languages. The newtype pattern creates distinct types for semantically different values that share the same representation:
1
2
3
4
5
6
7
8
9
10
|
// Without newtypes: easy to swap arguments accidentally
fn create_user(username: &str, user_id: u64, org_id: u64) { /* ... */ }
create_user("alice", org_id, user_id); // compiles, wrong at runtime
// With newtypes: compiler rejects the swap
struct UserId(u64);
struct OrgId(u64);
fn create_user(username: &str, user_id: UserId, org_id: OrgId) { /* ... */ }
create_user("alice", OrgId(42), UserId(1)); // compile error: type mismatch
|
State machines encoded as enums prevent invalid state transitions at compile time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// A connection that can only be used while open
struct Connection<State> {
addr: String,
_state: std::marker::PhantomData<State>,
}
struct Closed;
struct Open;
impl Connection<Closed> {
fn new(addr: &str) -> Self {
Connection { addr: addr.to_string(), _state: std::marker::PhantomData }
}
fn connect(self) -> Connection<Open> {
// perform TCP connect...
Connection { addr: self.addr, _state: std::marker::PhantomData }
}
}
impl Connection<Open> {
fn send(&self, data: &[u8]) { /* ... */ }
fn close(self) -> Connection<Closed> {
Connection { addr: self.addr, _state: std::marker::PhantomData }
}
}
fn main() {
let conn = Connection::<Closed>::new("127.0.0.1:8080");
// conn.send(b"hello"); // compile error: send() only on Connection<Open>
let conn = conn.connect();
conn.send(b"hello"); // fine
let conn = conn.close();
// conn.send(b"world"); // compile error again
}
|
This is zero-cost — the state is entirely a compile-time concept. PhantomData has no runtime representation. You get the safety guarantee with no overhead.
Where Rust Beats Go, and Where It Does Not
This is worth being direct about rather than hedging.
Rust has a genuine, measurable advantage when:
- You need deterministic latency. Go’s GC introduces stop-the-world pauses (short but nonzero). Rust has no GC — latency tail is lower and more predictable. For real-time systems, trading latency, or any workload where p99.9 matters more than average, this is significant.
- Memory consumption matters. Rust programs typically use 20-50% less memory than equivalent Go programs for the same workload. On constrained hardware (edge devices, embedded systems, tight cloud instances) this is real money.
- You are writing something that runs inside another process. Shared libraries, WASM modules, kernel modules, eBPF programs — contexts where you cannot bring a runtime. Rust is the only safe systems language that works here.
- You are building a CLI tool that users will interact with constantly. Binary startup in sub-millisecond time, tight binary sizes (with LTO), no allocator churn on hot paths.
- You need fine-grained control over data layout.
#[repr(C)], manual SIMD, zero-copy parsing — Rust gives you the control without the undefined behavior risk of C.
Go has a genuine advantage when:
- Developer velocity matters more than runtime performance. Go’s simplicity — one obvious way to do most things, fast compile times, readable goroutine concurrency — means faster onboarding and iteration. A small team shipping a microservice gets there faster in Go.
- You are building network services in the typical range (thousands of concurrent connections, millisecond latencies). Go’s HTTP stack, net/http, and the goroutine model are production-proven at enormous scale. Rust’s async ecosystem is excellent but more complex to reason about.
- Your team does not have Rust experience. The borrow checker has a real learning curve — plan on 4-6 weeks before engineers are productive without constant compiler friction. Go is learnable in a long weekend.
- You live in the Kubernetes/cloud-native ecosystem. Go generated most of it (Kubernetes, Docker, Terraform, Prometheus, etcd). The tooling, patterns, and community expertise are Go-native.
The honest framing: Go is the right default for most services infrastructure. Reach for Rust when you have a specific reason — latency SLAs, memory constraints, cross-language embedding, or a performance-critical component inside a larger Go/Python/Java system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# Install Rust via rustup (manages toolchain versions)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Essential tools
rustup component add clippy # linter — stricter than the compiler
rustup component add rustfmt # formatter
cargo install cargo-watch # auto-rebuild on file changes
cargo install cargo-audit # scan dependencies for known vulnerabilities
cargo install cargo-flamegraph # profiling via perf + flamegraph
cargo install cargo-expand # expand macros to see generated code
# Workflow
cargo new myproject # create new binary project
cargo new --lib mylib # create new library project
cargo build # debug build
cargo build --release # optimized build (much faster, much smaller)
cargo test # run tests
cargo clippy # lint
cargo fmt # format
cargo doc --open # generate and open documentation
|
Cargo is one of Rust’s genuine strengths. Dependency management, build configuration, testing, benchmarking, documentation generation, and workspace management are all built in. There is no equivalent of the Go ecosystem’s historical fragmentation around dependency management (GOPATH, dep, modules). Cargo.toml and Cargo.lock are the complete story from day one.
The compile-time overhead is real — a fresh build of a project with many dependencies is measured in minutes, not seconds. Incremental builds are fast once the dependency cache is warm. For development, cargo watch -x check (type-check without full compilation) runs in seconds and catches most errors. cargo watch -x test for test-driven workflows. Reserve cargo build --release for CI and deployment — it is slower but the output is 10-100x smaller and faster.
A Note on the Learning Curve
The borrow checker rejects programs that would be accepted by Go, Python, or C compilers. This friction is not accidental — it is the mechanism by which Rust provides its guarantees. The programs it rejects are programs that have memory safety bugs; the question is whether those bugs manifest at compile time (Rust) or at runtime (everything else).
The practical experience: the first two weeks involve frequent “it won’t compile and I don’t know why.” The next two weeks involve understanding why it was right to refuse. After that, the compiler’s feedback model starts to feel like a collaborator rather than an obstacle. Engineers with this experience consistently report that they miss the borrow checker when working in other languages — not because other languages are wrong but because they no longer have a machine that catches a category of bugs before they reach production.
Give it six weeks before evaluating whether the trade is worth it for your context. The productivity curve is J-shaped: slower initially, faster eventually, particularly for correctness-critical code.
Comments