Learn Rust
Rust is a systems programming language that delivers memory safety and data-race freedom without a garbage collector, using a compile-time ownership and borrowing model that the compiler verifies before your program ever runs. First released 1.0 in 2015 and stewarded today by the Rust Foundation, it has reached version 1.96 by June 2026 and is organized into backward-compatible editions, the latest being Rust 2024. Rust pairs low-level control (no runtime, predictable performance, C-compatible FFI) with high-level ergonomics: algebraic data types, exhaustive pattern matching, traits, generics, and a rich iterator-based standard library. Its tooling is famously cohesive - one tool, cargo, builds, tests, formats, lints, and publishes - and its compiler errors are designed to teach. This track takes you from installing the toolchain through ownership, the type system, error handling, collections and iterators, fearless concurrency, and the cargo ecosystem, with runnable examples and links to the official docs at rust-lang.org.
Setup and the Rust Toolchain
Install Rust with rustup, write your first program, and meet cargo.
Setup and the Rust Toolchain
Rust ships as a small family of tools managed by one installer, rustup. It puts the compiler (rustc), the build tool and package manager (cargo), the formatter (rustfmt), the linter (clippy), and documentation on your machine, and keeps them updated. This lesson takes you from nothing to a running, cargo-managed project.
Installing with rustup
The official, recommended way to install Rust on Linux or macOS is the one-line rustup script:
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
On Windows you download rustup-init.exe from the install page. After installation, restart your shell and confirm:
$ rustc --version
rustc 1.96.0 (... 2026-...)
$ cargo --version
cargo 1.96.0 (... 2026-...)
As of June 2026 the current stable release is Rust 1.96. Rust publishes a new stable version every six weeks, with an ironclad stability promise: code that compiles on stable keeps compiling. Update any time with:
$ rustup update
rustup also manages toolchains (stable, beta, nightly) and cross-compilation targets, so you can install, say, the WebAssembly target with rustup target add wasm32-unknown-unknown.
Your first program
You can compile a single file directly with rustc. Create hello.rs:
fn main() {
println!("Hello, Rust!");
}
Every executable starts at fn main(). println! is a macro (the ! is the giveaway), not a function - macros do compile-time work like checking format strings. Compile and run:
$ rustc hello.rs
$ ./hello
Hello, Rust!
Cargo: the tool you will actually use
In practice you rarely call rustc by hand. Cargo drives everything. Create a new project:
$ cargo new hello
Created binary (application) `hello` package
$ cd hello
This scaffolds a project:
hello/
Cargo.toml # manifest: name, version, edition, dependencies
src/
main.rs # entry point
The Cargo.toml manifest declares your package and pins the edition:
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
[dependencies]
Editions (2015, 2018, 2021, 2024) let Rust evolve syntax without breaking old code: each crate opts into one, and crates of different editions interoperate freely. New projects default to the latest, Rust 2024.
Build and run in one step:
$ cargo run
Compiling hello v0.1.0 (/path/hello)
Finished dev [unoptimized + debuginfo] target(s)
Running `target/debug/hello`
Hello, Rust!
The commands you will use daily
| Command | What it does |
|---|---|
cargo run |
Build and run the project |
cargo build |
Compile (add --release for optimized) |
cargo check |
Type-check fast without producing a binary |
cargo test |
Run all tests |
cargo fmt |
Reformat to the canonical style |
cargo clippy |
Lint for common mistakes and non-idiomatic code |
cargo doc --open |
Build and view your docs in the browser |
cargo check is the secret to a fast feedback loop: it runs the full borrow checker and type checker but skips code generation, so it is much quicker than a full build while you iterate.
Formatting and linting are built in
Like many modern languages, Rust has a canonical style enforced by rustfmt - run cargo fmt and stop arguing about braces. cargo clippy goes further, flagging hundreds of lints that nudge you toward idiomatic code ("prefer .iter() here", "this match can be an if let"). Both ship with the toolchain.
Where to go next
The canonical free book, The Rust Programming Language ("the book"), walks through these steps and the whole language. Rustlings gives you small compiler-checked exercises.
- Installation guide: https://doc.rust-lang.org/book/ch01-01-installation.html
- The Rust Programming Language (the book): https://doc.rust-lang.org/book/
With the toolchain working you are ready for the concept that makes Rust Rust: ownership.
Ownership, Borrowing, and Lifetimes
The rules that give Rust memory safety without a garbage collector.
Ownership, Borrowing, and Lifetimes
Ownership is Rust's central, defining idea - the mechanism that guarantees memory safety and data-race freedom at compile time, with no garbage collector and no manual free. Learn it first and the rest of the language clicks into place.
The three rules
The whole system rests on three rules:
- Each value has a single owner (a variable).
- There can be only one owner at a time.
- When the owner goes out of scope, the value is dropped (its memory freed).
Because cleanup is tied to scope, resources are released deterministically - this is RAII, the same idea as C++ destructors, enforced by the compiler.
Move semantics
For types that own heap data, like String, assignment moves ownership rather than copying:
let s1 = String::from("hello");
let s2 = s1; // ownership moves from s1 to s2
// println!("{s1}"); // ERROR: borrow of moved value: `s1`
println!("{s2}"); // fine
After the move, s1 is invalid; using it is a compile error, not a runtime crash. This prevents double-frees: only one variable is responsible for the data. Simple scalar types (i32, bool, char, etc.) implement the Copy trait and are duplicated instead of moved, so let x = 5; let y = x; leaves both usable.
Passing a value to a function also moves it. To keep using a value afterward you can clone it (an explicit, visible deep copy) - but usually you borrow instead.
Borrowing with references
A reference lets you access a value without taking ownership. &x borrows immutably; &mut x borrows mutably:
fn length(s: &String) -> usize { // borrows, does not take ownership
s.len()
}
fn push_bang(s: &mut String) {
s.push('!');
}
fn main() {
let mut greeting = String::from("hi");
println!("len = {}", length(&greeting)); // greeting still owned here
push_bang(&mut greeting);
println!("{greeting}"); // "hi!"
}
The borrowing rules
The borrow checker enforces two rules that together eliminate data races and dangling references:
- At any given time you may have either any number of immutable references (
&T) or exactly one mutable reference (&mut T) - never both. - Every reference must always be valid (it can never outlive the data it points to).
let mut v = vec![1, 2, 3];
let r1 = &v; // ok
let r2 = &v; // ok, many shared borrows
println!("{r1:?} {r2:?}");
let m = &mut v; // ok now: r1/r2 are no longer used
m.push(4);
"One writer xor many readers" is exactly the discipline you would impose by hand to avoid concurrency bugs - Rust makes the compiler check it for you. The check is purely compile-time, so it costs nothing at runtime.
Slices borrow part of a collection
A slice is a borrowed view into contiguous data - no copy, no ownership:
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[..i];
}
}
s
}
&str is a string slice; &[T] is a slice of any sequence. Borrowing a slice ties its lifetime to the data, so the compiler stops you from, say, clearing a String while a slice of it is still alive.
Lifetimes, briefly
When a function returns a reference, the compiler must know how long it stays valid. Usually it infers this. When it cannot, you annotate with a lifetime parameter - a name like 'a that ties inputs and outputs together:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The 'a says "the returned reference lives at least as long as both inputs." Lifetimes are not runtime values; they are purely a way to describe relationships the borrow checker already tracks. Thanks to lifetime elision rules you rarely write them in everyday code.
Why this matters
This model is what lets Rust be both fast and safe: no GC pauses, no use-after-free, no data races, all caught before the program runs. Fighting the borrow checker is a rite of passage; the errors are detailed and usually suggest the fix. Lean into them - they are catching real bugs.
Reference
- Understanding Ownership (the book): https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Validating References with Lifetimes: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
Next we will look at how Rust models data with structs, enums, and pattern matching.
Types, Structs, Enums, and Pattern Matching
Build data with structs and enums; destructure it with match.
Types, Structs, Enums, and Pattern Matching
Rust is statically typed with full type inference, so you get strong guarantees while writing surprisingly little type annotation. This lesson covers the scalar types, how to model data with structs and enums, and the pattern matching that ties it together.
Variables and inference
Bindings are immutable by default; opt into mutation with mut:
let x = 5; // inferred i32, immutable
let mut y = 10; // mutable
y += 1;
let z: u64 = 100; // explicit type when you want it
Immutability by default is a recurring Rust theme - it makes code easier to reason about and is required for safe sharing.
Scalar and compound types
- Integers:
i8..i128,u8..u128, and pointer-sizedisize/usize. Default isi32. - Floats:
f32,f64(default). bool, andchar(a 4-byte Unicode scalar value, so'山'is onechar).- Tuples:
(i32, f64); arrays of fixed length:[i32; 3].
Rust performs no implicit numeric coercion - convert explicitly with as or try_into:
let a: i64 = 10;
let b = a as i32; // explicit, may truncate
let c: u8 = 300i32.try_into().unwrap_or(255); // checked conversion
Structs
Structs aggregate named fields:
struct User {
name: String,
age: u32,
active: bool,
}
let u = User { name: String::from("Ada"), age: 36, active: true };
println!("{} is {}", u.name, u.age);
There are also tuple structs (struct Point(i32, i32);) and unit structs. Add behavior with an impl block:
impl User {
fn new(name: &str) -> Self { // associated function (like a constructor)
User { name: name.to_string(), age: 0, active: true }
}
fn birthday(&mut self) { // method, borrows self mutably
self.age += 1;
}
}
let mut u = User::new("Grace");
u.birthday();
&self, &mut self, and self mirror the borrowing rules: read-only, mutating, or consuming the receiver.
Enums: sum types with data
Rust enums are full algebraic data types - each variant can carry its own data:
enum Shape {
Circle { radius: f64 },
Rectangle(f64, f64),
Point,
}
Two enums are so central they are in the prelude: Option<T> (a value or nothing) and Result<T, E> (success or failure). There is no null in Rust - absence is modeled by Option, so the type system forces you to handle the "nothing" case.
let maybe: Option<i32> = Some(7);
let nothing: Option<i32> = None;
Pattern matching with match
match compares a value against patterns and is exhaustive - the compiler rejects your code if you miss a case, which is how Option makes null-safety airtight:
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle(w, h) => w * h,
Shape::Point => 0.0,
}
}
Patterns can bind data, match ranges and literals, and add guards:
let n = 5;
let label = match n {
0 => "zero",
1..=9 => "single digit",
x if x < 0 => "negative",
_ => "big",
};
The _ wildcard catches the rest. Because match is an expression, it returns a value.
if let and let else
When you care about only one pattern, if let is concise:
if let Some(v) = maybe {
println!("got {v}");
}
The let ... else form binds a pattern or diverges (returns/breaks) on the else branch - handy for early returns:
fn double(opt: Option<i32>) -> i32 {
let Some(v) = opt else {
return 0;
};
v * 2
}
Deriving common behavior
The #[derive(...)] attribute auto-generates trait implementations so you do not write boilerplate:
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
let p = Point { x: 1, y: 2 };
println!("{p:?}"); // Debug formatting: Point { x: 1, y: 2 }
assert_eq!(p, p.clone()); // PartialEq + Clone
Reference
- Using Structs to Structure Related Data: https://doc.rust-lang.org/book/ch05-00-structs.html
- Enums and Pattern Matching: https://doc.rust-lang.org/book/ch06-00-enums.html
Next: traits and generics, Rust's tools for shared behavior and code reuse.
Traits and Generics
Define shared behavior with traits; write reusable code with generics.
Traits and Generics
Rust has no classes and no inheritance. Instead it reuses code through generics (parametric polymorphism) and traits (shared behavior, like interfaces but more powerful). Together they give you zero-cost abstractions: high-level code that compiles to the same fast machine code you would write by hand.
Generics
A generic function or type is parameterized over one or more types written in angle brackets:
fn largest<T: PartialOrd>(items: &[T]) -> &T {
let mut biggest = &items[0];
for item in items {
if item > biggest {
biggest = item;
}
}
biggest
}
fn main() {
let nums = vec![3, 7, 2, 9, 4];
println!("{}", largest(&nums)); // 9
let words = vec!["pear", "apple", "fig"];
println!("{}", largest(&words)); // pear
}
Generics are monomorphized: the compiler stamps out a specialized copy for each concrete type used, so there is no runtime dispatch cost. Structs and enums are generic too - Vec<T>, Option<T>, and Result<T, E> are exactly this.
Traits define shared behavior
A trait is a set of method signatures a type can implement:
trait Summary {
fn summarize(&self) -> String;
fn preview(&self) -> String { // default method
format!("{}...", &self.summarize()[..5.min(self.summarize().len())])
}
}
struct Article { title: String, body: String }
impl Summary for Article {
fn summarize(&self) -> String {
format!("{}: {}", self.title, self.body)
}
}
A trait may provide default implementations (like preview above) that implementors inherit unless they override them. You can implement a trait for a type only if you own either the trait or the type - the "orphan rule" that keeps implementations coherent across crates.
Trait bounds
The T: PartialOrd syntax above is a trait bound: it constrains a generic type to those that implement a trait, which is what lets the function call those trait methods. Multiple bounds combine with +, and a where clause keeps complex signatures readable:
use std::fmt::Display;
fn announce<T>(item: T) where T: Summary + Display {
println!("News! {item}");
println!("{}", item.summarize());
}
The shorthand fn notify(item: &impl Summary) accepts any type implementing Summary - impl Trait in argument position is sugar for a generic bound.
Static vs. dynamic dispatch
Generics give static dispatch (resolved at compile time, monomorphized, fast). When you need a collection of different concrete types behind one trait, use a trait object with dyn, which uses dynamic dispatch through a vtable:
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Article { title: "Hi".into(), body: "there".into() }),
];
for it in &items {
println!("{}", it.summarize());
}
Reach for impl Trait/generics by default (faster, more flexible); use dyn Trait when you genuinely need heterogeneous values in one container.
The standard traits you will meet constantly
Much of Rust's ergonomics comes from standard-library traits:
Debug/Display- formatting with{:?}and{}.Clone/Copy- duplication semantics.PartialEq/Eq/PartialOrd/Ord- comparison and sorting.Iterator- the engine behindforloops and adapters (next lesson).From/Into- conversions; implementingFromgives youIntofor free.Default- a sensible zero value viaT::default().
Many can be auto-generated with #[derive(...)]:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Color { r: u8, g: u8, b: u8 }
Operator overloading is just traits
Even operators are traits. Implement std::ops::Add and + works on your type:
use std::ops::Add;
#[derive(Debug)]
struct V2 { x: f64, y: f64 }
impl Add for V2 {
type Output = V2;
fn add(self, other: V2) -> V2 {
V2 { x: self.x + other.x, y: self.y + other.y }
}
}
The type Output is an associated type - a placeholder type the trait defines, used heavily by Iterator (its Item).
Reference
- Generic Types, Traits, and Lifetimes: https://doc.rust-lang.org/book/ch10-00-generics.html
- Traits: Defining Shared Behavior: https://doc.rust-lang.org/book/ch10-02-traits.html
Next we apply these to error handling, where Result, Option, and the ? operator shine.
Error Handling with Result and Option
Recoverable errors as values, the ? operator, and panics.
Error Handling with Result and Option
Rust splits failure into two clean categories: recoverable errors, modeled by the Result type and handled with ordinary control flow, and unrecoverable errors, which panic! and abort. There are no exceptions - errors are values, so the type system forces you to confront them.
Option for absence
When a value might simply not be there, use Option<T>, which is Some(T) or None. Because None is a normal value and the type is distinct from T, you can never accidentally use a missing value as if it were present - Rust's answer to the null-pointer mistake:
fn find_even(v: &[i32]) -> Option<i32> {
for &x in v {
if x % 2 == 0 {
return Some(x);
}
}
None
}
match find_even(&[1, 3, 4, 5]) {
Some(n) => println!("first even: {n}"),
None => println!("none found"),
}
Result for fallible operations
Anything that can fail returns Result<T, E>: Ok(T) on success, Err(E) on failure. The standard library uses it everywhere - file I/O, parsing, networking:
use std::fs;
fn read_config(path: &str) -> Result<String, std::io::Error> {
let contents = fs::read_to_string(path)?;
Ok(contents)
}
Because Result is #[must_use], ignoring one triggers a warning - you cannot silently drop a possible error.
The ? operator
The single most important ergonomic in Rust error handling is the ? operator. Applied to a Result, it returns the Ok value or, on Err, returns early from the enclosing function with that error - propagating failure up the call stack with no boilerplate:
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let mut name = fs::read_to_string("user.txt")?; // returns Err early on failure
name = name.trim().to_string();
Ok(name)
}
That ? replaces a whole match that would otherwise return the error. It works on Option too (returning None early), and it automatically converts the error type using the From trait, which is how libraries unify many error types into one.
Combinators instead of match
Option and Result carry a rich set of methods so you rarely need an explicit match:
let n: i32 = "42".parse().unwrap_or(0); // default on error
let doubled = Some(21).map(|x| x * 2); // Some(42)
let text = std::env::var("NAME")
.unwrap_or_else(|_| "anonymous".to_string());
// chain fallible steps
fn parse_sum(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
let x: i32 = a.parse()?;
let y: i32 = b.parse()?;
Ok(x + y)
}
Useful methods include map, and_then, ok_or, unwrap_or, unwrap_or_else, and ?. They keep the happy path readable while still handling every failure.
unwrap and expect: for prototypes and the impossible
unwrap() extracts the Ok/Some value but panics on Err/None. expect("msg") does the same with a custom message. Use them in throwaway code, examples, and tests, or when a failure truly indicates a bug:
let config = read_config("config.toml").expect("config must exist");
In library and production code, prefer propagating with ? over unwrapping.
panic! for unrecoverable errors
panic! aborts the current thread, unwinds the stack running destructors, and prints a message and backtrace. Out-of-bounds indexing and integer overflow in debug builds panic too. Panics are for bugs and impossible states - not for ordinary failures, which should be Result:
fn checked_div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("division by zero".to_string());
}
Ok(a / b)
}
Set RUST_BACKTRACE=1 to get a full backtrace when debugging a panic.
Custom error types and the ecosystem
Real applications define their own error enum implementing std::error::Error, often with one variant per failure mode. Two crates dominate here: thiserror for ergonomically deriving error enums in libraries, and anyhow for a catch-all anyhow::Error in applications where you just want ? to work across many error types. They are not in the standard library but are near-universal in practice.
// with thiserror
use thiserror::Error;
#[derive(Error, Debug)]
enum DataError {
#[error("file not found: {0}")]
NotFound(String),
#[error("parse failed")]
Parse(#[from] std::num::ParseIntError),
}
The #[from] attribute makes ? auto-convert a ParseIntError into your DataError.
Reference
- Error Handling (the book): https://doc.rust-lang.org/book/ch09-00-error-handling.html
- std::result documentation: https://doc.rust-lang.org/std/result/
Next: the collections and iterators you will reach for in every program.
Collections and Iterators
Vec, String, HashMap, and Rust's lazy, composable iterators.
Collections and Iterators
The standard library's collections - Vec, String, and HashMap chief among them - store data on the heap and grow as needed, while iterators give you a lazy, composable way to process it. Iterators are where Rust feels like a high-level language while staying as fast as a hand-written loop.
Vec: the growable array
Vec<T> is the workhorse sequence. Create one with the vec! macro or Vec::new:
let mut v = vec![1, 2, 3];
v.push(4);
v.pop(); // Some(4)
let first = v[0]; // panics if out of bounds
let maybe = v.get(10); // None, no panic
println!("len = {}", v.len());
Indexing with [] panics on a bad index; .get(i) returns an Option for safe access. Iterating respects ownership: iter() yields &T, iter_mut() yields &mut T, and into_iter() consumes the vector yielding owned T.
for x in &v { print!("{x} "); } // borrows
for x in &mut v { *x *= 10; } // mutates in place
for x in v { /* takes ownership */ } // v is moved here
String and &str
Rust has two string types: the owned, growable String (heap-allocated, UTF-8) and the borrowed slice &str. String literals are &str. Convert with .to_string() or String::from:
let mut s = String::from("hello");
s.push_str(", world");
let slice: &str = &s[0..5]; // "hello"
for ch in s.chars() { /* Unicode chars */ }
for b in s.bytes() { /* raw bytes */ }
Strings are UTF-8, so you cannot index a String by integer (a character may span several bytes); iterate with .chars() or .bytes() instead.
HashMap: key-value storage
HashMap<K, V> maps keys to values. The entry API is the idiomatic way to insert-or-update:
use std::collections::HashMap;
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert("blue".to_string(), 10);
// insert a default then mutate it - classic word counter
let text = "the cat the dog the bird";
let mut counts: HashMap<&str, i32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
println!("{:?}", counts.get("the")); // Some(3)
get returns Option<&V>, forcing you to handle a missing key. The companion BTreeMap keeps keys sorted, and HashSet/BTreeSet store unique values.
Iterators: lazy and composable
An iterator produces a sequence of values via its single required method, next, which returns Option<Item>. The real power is the adapter methods that transform one iterator into another - and they are lazy, doing no work until consumed:
let nums = vec![1, 2, 3, 4, 5, 6];
let result: Vec<i32> = nums
.iter() // &i32
.filter(|&&x| x % 2 == 0)// keep evens
.map(|&x| x * x) // square them
.collect(); // run the pipeline -> [4, 16, 36]
Nothing happens until a consumer like collect, sum, count, or a for loop drives the chain. This laziness means chaining adapters allocates nothing in between, and the optimizer fuses the whole pipeline into a single tight loop - the famous zero-cost abstraction.
The adapters and consumers you will use most
let v = vec![1, 2, 3, 4, 5];
let sum: i32 = v.iter().sum(); // 15
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
let evens: Vec<&i32> = v.iter().filter(|x| *x % 2 == 0).collect();
let any_big = v.iter().any(|&x| x > 4); // true
let pos = v.iter().position(|&x| x == 3); // Some(2)
let total = v.iter().fold(0, |acc, x| acc + x); // 15
// enumerate gives (index, value); zip pairs two iterators
for (i, x) in v.iter().enumerate() {
println!("{i}: {x}");
}
Useful adapters: map, filter, filter_map, take, skip, chain, zip, enumerate, flat_map, rev. Useful consumers: collect, sum, product, count, max, min, find, any, all, fold, for_each.
collect is remarkably flexible
collect can build many types depending on the target - a Vec, a String, a HashMap from pairs, even a Result that short-circuits on the first error:
use std::collections::HashMap;
let map: HashMap<i32, i32> = (1..=3).map(|x| (x, x * x)).collect();
// collect into Result: stops at the first parse failure
let parsed: Result<Vec<i32>, _> =
vec!["1", "2", "3"].iter().map(|s| s.parse::<i32>()).collect();
That last trick - turning an iterator of Results into a single Result<Vec<_>, _> - is a Rust idiom worth memorizing.
Reference
- Common Collections (the book): https://doc.rust-lang.org/book/ch08-00-common-collections.html
- Processing a Series of Items with Iterators: https://doc.rust-lang.org/book/ch13-02-iterators.html
Next we reach a Rust headline: fearless concurrency.
Fearless Concurrency
Threads, channels, shared state, and compile-time data-race safety.
Fearless Concurrency
Rust calls its concurrency story fearless because the same ownership and borrowing rules that prevent memory bugs also prevent data races - at compile time. If concurrent code compiles, it is free of data races by construction. This lesson covers threads, message passing, shared state, and async.
Spawning threads
The standard library maps onto real OS threads. thread::spawn takes a closure and runs it concurrently; the returned handle lets you join (wait for) it:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("worker: {i}");
}
});
println!("main");
handle.join().unwrap(); // wait for the thread to finish
}
To use data from the environment inside the thread, add move so the closure takes ownership - the borrow checker requires this because the thread may outlive the current scope:
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{data:?}"); // data is moved into the thread
});
handle.join().unwrap();
Message passing with channels
The preferred way to share is to send messages, not share memory. A channel has a transmitter and a receiver; ownership of each sent value moves to the receiver:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..3 {
let tx = tx.clone(); // each thread gets its own sender
thread::spawn(move || {
tx.send(id * 10).unwrap();
});
}
drop(tx); // close the original so rx ends
for received in rx { // iterate until all senders drop
println!("got {received}");
}
}
mpsc means "multiple producer, single consumer." Because the sent value moves, two threads can never touch the same data at once.
Shared state: Arc and Mutex
When threads genuinely must share one piece of data, wrap it in a Mutex (mutual exclusion) for safe mutation and an Arc (atomically reference-counted pointer) so multiple threads can own a handle to it:
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);
handles.push(thread::spawn(move || {
let mut n = counter.lock().unwrap(); // lock; auto-unlocks on drop
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("total = {}", *counter.lock().unwrap()); // 10
}
lock() returns a guard that unlocks automatically when it goes out of scope - you cannot forget to unlock. Crucially, Mutex in Rust contains the data it protects, so you cannot access the data without holding the lock; the type system makes the mistake unrepresentable.
Send and Sync: the safety net
Two marker traits make all this sound. Send means a type can be moved to another thread; Sync means it can be shared by reference across threads. The compiler auto-implements them where safe and refuses to let you send a non-thread-safe type across a thread boundary. That is why this whole system is checked at compile time: try to share an Rc (single-threaded reference count) between threads and the code simply will not compile - it points you to Arc instead.
Async/await for high concurrency
For I/O-bound workloads with thousands of concurrent tasks (web servers, network clients), Rust offers async/await. An async fn returns a Future that does nothing until awaited and driven by a runtime. The standard library defines the Future trait and the syntax, but you need an executor crate - most commonly tokio - to run them:
// requires the tokio crate
#[tokio::main]
async fn main() {
let a = fetch("one");
let b = fetch("two");
let (r1, r2) = tokio::join!(a, b); // run concurrently on one thread
println!("{r1} {r2}");
}
async fn fetch(name: &str) -> String {
format!("result for {name}")
}
Threads are best for CPU-bound parallelism; async is best for keeping many I/O operations in flight cheaply. For data parallelism over collections, the popular rayon crate turns .iter() into .par_iter() and parallelizes automatically.
Reference
- Fearless Concurrency (the book): https://doc.rust-lang.org/book/ch16-00-concurrency.html
- Async Programming in Rust (the async book): https://rust-lang.github.io/async-book/
Finally, we will tour cargo and the ecosystem that ties projects together.
Cargo, Crates, Testing, and Tooling
Dependencies from crates.io, the test harness, and the built-in tools.
Cargo, Crates, Testing, and Tooling
Rust's tooling is unusually cohesive: one tool, cargo, builds, runs, tests, documents, formats, lints, and publishes. This lesson ties the workflow together for real projects.
Crates and packages
A crate is the unit of compilation (a binary or a library); a package is one or more crates plus a Cargo.toml manifest. The manifest declares metadata and dependencies:
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.8"
Add a dependency without editing the file by hand using cargo add, which resolves the latest compatible version from crates.io, the central registry:
$ cargo add serde --features derive
$ cargo add tokio --features full
Cargo writes a Cargo.lock recording the exact resolved versions for reproducible builds. Commit Cargo.lock for binaries; libraries typically do not. Version requirements follow semantic versioning, so "1" means ">=1.0.0, <2.0.0".
Building and running
$ cargo build # debug build into target/debug
$ cargo build --release # optimized build into target/release
$ cargo run -- arg1 arg2 # build + run, passing args after --
$ cargo check # type-check fast, no codegen
Debug builds compile quickly and include overflow checks and debug info; release builds are heavily optimized. Use cargo check for the tightest edit loop while coding.
Testing
The test harness is built in - no framework to add. Annotate functions with #[test]; they live alongside the code, usually in a #[cfg(test)] module so they compile only during testing:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_positives() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn handles_negatives() {
assert_eq!(add(-1, -2), -3);
}
#[test]
#[should_panic(expected = "overflow")]
fn detects_overflow() {
let _ = i32::MAX.checked_add(1).expect("overflow");
}
}
Run the whole suite:
$ cargo test
running 3 tests
test tests::adds_positives ... ok
test tests::handles_negatives ... ok
test tests::detects_overflow ... ok
Key assertions are assert!, assert_eq!, and assert_ne!. A test that returns Result<(), E> can use ?, failing on any Err. Tests run in parallel by default; pass -- --test-threads=1 to serialize, or cargo test name to filter by name.
Integration and doc tests
Integration tests live in a top-level tests/ directory and exercise your crate as an external user would. Doc tests are even better: code in /// doc comments is compiled and run as a test, so your examples can never drift out of date:
/// Adds two numbers.
///
/// ```
/// assert_eq!(myapp::add(2, 2), 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
cargo test runs unit, integration, and doc tests together.
Formatting, linting, and docs
Three more tools ship with the toolchain:
$ cargo fmt # canonical formatting via rustfmt
$ cargo clippy # hundreds of lints for idiomatic, correct code
$ cargo doc --open # build API docs from your /// comments and open them
rustfmt ends formatting debates; clippy is exceptional, catching real bugs and teaching idioms as it goes (treat its suggestions as a free mentor). cargo doc turns your doc comments into the same browsable HTML you see on docs.rs for every published crate.
Workspaces for multi-crate projects
Larger projects use a workspace - several related crates that share one Cargo.lock and target/ directory:
# top-level Cargo.toml
[workspace]
members = ["app", "core", "cli"]
Commands like cargo build and cargo test then operate across every member.
Publishing
To share a library, publish it to crates.io:
$ cargo login # paste your API token
$ cargo publish # upload the crate
Once published, a version is permanent (you can yank it but not delete it), so the ecosystem's builds stay reproducible.
A typical CI pipeline
Because the toolchain is uniform, the same pipeline works for any Rust project:
$ cargo fmt --check
$ cargo clippy -- -D warnings
$ cargo test
$ cargo build --release
Reference
- The Cargo Book: https://doc.rust-lang.org/cargo/
- Writing Automated Tests (the book): https://doc.rust-lang.org/book/ch11-00-testing.html
You now have the full arc: toolchain, ownership, types, traits, errors, collections and iterators, concurrency, and the ecosystem. The best next step is to cargo new something small and let the compiler - and clippy - coach you.