Sitemap

Concurrency in Rust: A First-Timer’s Honest Account

14 min readMay 13, 2026

--

Press enter or click to view image in full size

Rust is my first programming language. Not my first “serious” one — my first one. So when the Rust Programming Language Book dropped me into Chapter 16 and said “let’s talk about concurrency,” I had no pre-existing mental model to fall back on. No Python habits. No JavaScript callbacks. Just me, a blank mind, and a very patient compiler.

What followed was one of the most genuinely interesting learning experiences I’ve had. This is my attempt to write it all down — not as a reference document, but as the blog post I wish I had found before I started.

What Even Is Concurrency?

The textbook definition is something like: the ability of a system to handle multiple tasks by starting, running, and completing them in overlapping time periods.

That sounds clean and precise, but it took me a real analogy to actually feel what it meant.

Imagine a single chef trying to prepare a complex meal — pasta, a vegetable sauté, and a sauce. The chef isn’t doing all three things at the same microsecond. That would require three pairs of hands. Instead, they start the water boiling, and while it heats up, they begin chopping vegetables. While the vegetables sauté, they prepare the sauce. The chef is making progress on multiple dishes at once — not by doing them simultaneously, but by switching between them intelligently.

That is concurrency. The primary goal isn’t speed in the raw sense; it’s responsiveness. We don’t want to stand idle, watching water boil, before we pick up a knife.

It is also worth separating concurrency from parallelism here, because the two are easily confused. Concurrency is about structure — dealing with a lot of things at once. Parallelism is about execution — doing a lot of things at the same time. You can have concurrency on a single-core processor. Parallelism requires multiple cores. The chef analogy is concurrency. A kitchen with four chefs working simultaneously is parallelism.

The CPU Is the Chef. The Thread Is the Recipe.

When I first thought about the chef analogy, something confused me. It’s the same chef, right? So how does this map to a computer, where a thread is supposed to be running? If the CPU gets “busy” with another thread, what happens to the original one?

The confusion was in my mental model of what a thread is. I was imagining threads as workers. They are not. The CPU is the worker (the chef), and the thread is the work (the recipe).

On a single-core CPU, there is exactly one chef. When you run a program that has three threads, you have three recipes, but only one pair of hands. The chef cannot chop onions (Thread A) while simultaneously stirring soup (Thread B). So how does it look like everything is happening at once?

The answer is context switching.

The operating system acts like a head chef with a stopwatch. Every few milliseconds — a time slice — it yells “SWITCH.” The CPU stops executing Thread A, saves its current state (where it was, what values it was holding), and starts executing Thread B. A few milliseconds later, it switches back. Because the CPU does this so fast, what you perceive is smooth, simultaneous progress across all threads. In reality, each thread is being paused for fractions of a second constantly.

This leads to a natural follow-up question: why bother switching at all? Why not let one thread finish, then the next, one by one?

Why Concurrency? Three Real Reasons.

If every task demanded 100% of the CPU’s attention with no breaks, sequential execution would be just as efficient as concurrent execution, and considerably simpler. But real programs are not like that.

Reason 1: The Waiting Problem. Most programs spend a surprising amount of time doing nothing — waiting for a file to load from disk, waiting for a network response, waiting for user input. These are I/O-bound tasks. The CPU itself requires almost no effort to manage them; it’s the slow hardware (the disk, the network card) doing the actual work. In a sequential model, your program sits completely idle during this waiting. In a concurrent model, the CPU uses that dead time to make progress on something else. This is resource utilization — you stop paying for a CPU to watch paint dry.

Reason 2: Responsiveness. Imagine hitting “Save” on a 500-page document in a text editor. In a sequential model, the save operation would block the entire UI thread — the one listening for your mouse and keyboard — for the entire duration. Your screen would freeze. You couldn’t type, scroll, or click “Cancel.” In a concurrent model, the save process yields the CPU every few milliseconds so the UI can remain responsive. The task takes the same total time, but the experience is fundamentally different.

Reason 3: Fairness. Picture a grocery store with one cashier. Someone with 500 items is at the front. Behind them is a person with a single loaf of bread. In a purely sequential first-in, first-out model, the person with bread waits twenty minutes. Concurrency allows the cashier to process five items from the big cart, handle the bread, then return. The total work is identical. The average wait time drops dramatically.

Processes and Threads: The Kitchen and the Recipe

Before we get into Rust’s specific threading tools, the distinction between a process and a thread needs to be clear. I’ve found it easy to conflate them.

A process is the entire kitchen. When you open Chrome, or Spotify, or run a Python script, the operating system creates a process for it. A process is an isolated container that holds the program’s code, its own private memory, and its resource handles (file descriptors, network connections, permissions). The critical property of a process is isolation. If Chrome crashes, Obsidian keeps running because they are independent kitchens. What happens in one cannot touch the other.

A thread, by contrast, lives inside a process and shares everything with every other thread in that same process. Threads within one process share the same memory space. This is what makes them fast for collaboration — they can pass data to each other instantly, without the slow overhead of crossing process boundaries. But that shared memory is also what makes them dangerous.

Rust uses what’s called a 1:1 threading model: every thread you create in your Rust code corresponds to exactly one operating system thread. There’s no hidden runtime magic managing a pool of virtual threads underneath you. What you write is what runs.

Creating Threads in Rust

Spawning a thread in Rust is done with thread::spawn, which takes a closure containing the code you want to run on the new thread.

use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}

There is a trap here that caught me off guard: when the main thread finishes, the entire process ends, taking any spawned threads with it — regardless of whether they finished their work.

Also, if you run this program ten times, you might see the output in a slightly different order each time. This is called non-determinism, and it has a few causes. The OS scheduler is serving many processes simultaneously; a tiny hiccup in timing changes which sleeping thread wakes up first. Modern CPUs adjust their speed based on heat and power load. The time it takes to spin up a new thread isn’t a fixed number — it depends on memory availability at that exact nanosecond. There is no guarantee in the order in which threads run. This is one of the fundamental truths of concurrent programming you have to simply accept and design around.

JoinHandle<T>: The Thread's Receipt

The fix for the premature shutdown problem is to save the return value of thread::spawn into a variable. That return type is JoinHandle<T> — an owned handle to the spawned thread. Think of it as a receipt, or a tether, keeping you connected to the thread you just created.

let handle = thread::spawn(|| {
// ... thread work ...
});
// Main thread's own work happens here.handle.join().unwrap(); // Wait for the spawned thread to finish.

Calling .join() on the handle does two things. First, it blocks the current thread until the associated spawned thread finishes. Second, it returns the result: Ok(T) if the thread completed successfully, or Err if the thread panicked. This means a child thread's panic doesn't automatically kill your main thread — you can catch and handle it.

A subtle but important point: where you call .join() matters enormously. If you call it immediately after spawning — before the main thread does its own work — the main thread will sit and wait for the spawned thread to complete entirely before moving on. That effectively makes them sequential, not concurrent. The right approach is usually to do all the work you can in the main thread first, then join at the end.

The move Keyword: Why Threads Must Own Their Data

Here’s something that confused me deeply when I first encountered it: why does a closure passed to thread::spawn usually need the move keyword? Why can't it just borrow values from the surrounding environment, the way closures normally can?

The answer is rooted in Rust’s memory safety guarantees.

thread::spawn requires that the closure it receives has a 'static lifetime — meaning it must be valid for the entire duration of the program, because the thread might outlive the scope where it was created. If the closure borrows a local variable from the main thread, Rust has no way to guarantee that variable will still be alive when the spawned thread goes to use it.

Consider this scenario: the main thread creates a variable, spawns a thread that borrows it, and then the main thread finishes and drops the variable. The spawned thread is now looking at garbage memory. In a language like C++, this is the kind of bug that manifests as an intermittent crash that takes days to locate. Rust simply refuses to compile the code.

The move keyword forces the closure to take ownership of any variables it references from the enclosing scope. That data is transferred into the thread. The main thread loses access to it. The spawned thread now owns it, and the data's lifetime is tied to the thread's lifetime. No dangling references, no garbage memory.

let ligands = vec!["ligand_A", "ligand_B", "ligand_C"];
let handle = thread::spawn(move || {
println!("Processing: {}", ligands[0]); // ligands is owned here now
});
// ligands is gone from main thread — it was moved.
handle.join().unwrap();

If you genuinely need both threads to see the same data, move alone isn't the answer. You need a smart pointer — specifically Arc — which is discussed later.

Message Passing: Communicating by Sending

Rust’s standard library offers a second concurrency model entirely: channels. The guiding philosophy here is worth quoting directly: “Do not communicate by sharing memory; instead, share memory by communicating.”

Get Riz Baruah’s stories in your inbox

Join Medium for free to get updates from this writer.

A channel is a one-directional pipe for data. It has two halves — a transmitter (the upstream end where data enters) and a receiver (the downstream end where data arrives). You create one with mpsc::channel(), where mpsc stands for multiple producer, single consumer.

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
// val is gone — it was moved into the channel.
});
let received = rx.recv().unwrap();
println!("Got: {received}");
}

The receiver side offers two methods. recv() blocks the calling thread until a message arrives, then returns it. try_recv() returns immediately with either Ok (a message was waiting) or Err (nothing yet), which is useful if your receiving thread has other work to do between checks.

Notice that after tx.send(val), you cannot use val anymore. The channel takes ownership of whatever you send. This is intentional — it ensures that once a value is transmitted, exactly one thread (the receiver) owns it. The ownership model that governs all of Rust extends neatly into concurrent communication.

The “multiple producer” part of mpsc refers to the ability to clone the transmitter and distribute those clones across several threads. Each of those threads can independently send messages down the same channel, all of which arrive at the single receiver. This is the classic pattern of parallel workers reporting results back to one coordinator.

Shared State Concurrency: Mutexes

Message passing is elegant, but sometimes you genuinely need multiple threads to look at and modify the same piece of data. For this, Rust offers Mutex<T>.

Mutex stands for mutual exclusion. At any given moment, only one thread is permitted to access the data inside. To do so, a thread must first acquire the lock. If another thread already holds it, the requesting thread will block until the lock is released.

use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // Lock is automatically released here.
println!("m = {m:?}");
}

When you call .lock(), it returns a LockResult. Unwrapping that gives you a MutexGuard<T> — a smart pointer that acts as your key to the data. It implements Deref, so you can interact with it as though it were the underlying value. More importantly, it implements Drop: when the guard goes out of scope, the lock is released automatically. You cannot forget to unlock. The compiler guarantees it.

LockResult also handles poisoning — the case where a thread panicked while holding the lock, potentially leaving the protected data in a broken state. If a mutex is poisoned, any subsequent attempt to lock it returns an Err, giving you a chance to handle the situation rather than silently reading corrupt data.

Sharing a Mutex Across Threads: Arc<T>

Here is the problem you run into the moment you try to share a Mutex across multiple threads. A single Mutex::new(value) gives you one owner. When you try to move it into a loop that spawns ten threads, the first iteration takes ownership and the remaining nine fail to compile.

The natural instinct is to reach for Rc<T>, Rust's reference-counting smart pointer for shared ownership. But Rc<T> is explicitly not thread-safe. Its reference counter is not atomic — it can be corrupted by simultaneous updates from multiple threads — so the compiler refuses to let it cross thread boundaries.

The solution is Arc<T>: Atomic Reference Counting. Functionally identical to Rc<T>, but with an atomic counter that remains correct even under concurrent modification. You pay a small performance cost for that atomicity, which is exactly why Rc<T> still exists — if you're in a single-threaded context, you don't need to pay for what you don't use.

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());
}

Arc::clone doesn't copy the data. It increments the reference counter and hands you another pointer to the same allocation. You move that clone into the thread. The underlying data lives until every single Arc pointing to it has been dropped. It's a clean, compiler-enforced solution to the problem of shared ownership across threads.

The combination of Arc<Mutex<T>> in concurrent code mirrors the combination of Rc<RefCell<T>> in single-threaded code. Both give you shared ownership of data that can be mutated from multiple places. The difference is that Arc<Mutex<T>> does it safely across threads, while Rc<RefCell<T>> does it safely within a single thread.

Deadlocks: The Bug Rust Can’t Save You From

Rust’s compiler is extraordinary. It prevents data races at compile time — a feat that most languages can only address at runtime, if at all. But there is one class of concurrency bug it cannot catch: deadlocks.

A deadlock occurs when two or more threads are each waiting on a resource held by the other. Neither can proceed. The program freezes, silently.

The classic form looks like this:

  1. Thread 1 locks Mutex A.
  2. Thread 2 locks Mutex B.
  3. Thread 1 tries to lock Mutex B — but Thread 2 holds it. Thread 1 waits.
  4. Thread 2 tries to lock Mutex A — but Thread 1 holds it. Thread 2 waits.

Both threads are now frozen forever. The compiler had no way to know this would happen because there was nothing syntactically or type-theoretically wrong with the code. The error is purely logical.

The most reliable prevention is consistent lock ordering: always acquire multiple locks in the same predetermined order, across every thread, without exception. If every thread agrees to lock Mutex A before Mutex B, the second thread will wait patiently at the first lock while the first thread finishes its work — no circular dependency can form.

Other useful habits: hold locks for as short a duration as possible, avoid doing heavy computation or I/O while holding a MutexGuard, and consider try_lock() when you cannot guarantee ordering — it returns immediately with an error if the lock is currently held, letting your thread release its own locks and try again rather than blocking indefinitely.

Send and Sync: The Traits That Make It All Formal

Underlying everything we’ve discussed is a pair of marker traits: Send and Sync. A marker trait is a trait with no methods. It exists purely to tag a type with a property that the compiler can reason about and enforce.

Send marks a type as safe to transfer to another thread. Almost every Rust type implements Send. The notable exceptions are Rc<T> (whose non-atomic reference counter makes it dangerous to send) and raw pointers. If a type is Send, you can move it into a thread::spawn closure without complaint.

Sync marks a type as safe to share by reference across multiple threads simultaneously. Formally: a type T is Sync if &T implements Send. If you can safely hand a reference to T to another thread, T is Sync.

These two traits have a subtle but important relationship. Some types implement one but not the other:

  • Rc<T> implements neither — you can't move it to another thread, and you certainly can't share a reference to it.
  • RefCell<T> implements Send but not Sync — you can move it to a thread, but you can't share a reference to it across threads simultaneously (its borrow checking is not thread-safe).
  • MutexGuard<T> implements Sync but not Send — on some platforms, a mutex must be unlocked by the same thread that locked it, so the guard itself cannot be sent to another thread.
  • Mutex<T> implements both — the locking mechanism makes it safe to both send and share.

These traits are so fundamental that they are part of Rust’s prelude — automatically available in every program without any use statement. You do not write them manually for your own types in ordinary circumstances. Rust's auto-trait system will implement them for you if every field of your struct implements them. If even one field does not, your type does not implement the trait, and the compiler will tell you precisely why.

Manually implementing Send or Sync for a type that doesn't naturally qualify requires writing unsafe code. This is intentional. These traits are the formal, type-system-level guarantees that the entire concurrent safety model rests on. Bypassing them is possible, but it is on you to prove to yourself — without the compiler's help — that it is safe to do so.

Closing Thoughts

Learning concurrency as a first-timer in Rust is a strange experience. The concepts themselves — context switching, race conditions, deadlocks, ownership across threads — are genuinely difficult. They would be difficult in any language.

What is different in Rust is the feedback loop. The compiler is not your enemy. It is a very patient instructor that refuses to let you turn in work with certain categories of mistake. Data races don’t appear at runtime after you’ve deployed your code to production. They appear at compile time, in your terminal, with a message that often tells you exactly what went wrong and occasionally suggests how to fix it.

You still have to think clearly. The compiler cannot prevent deadlocks. It cannot tell you if your channel design makes logical sense. It cannot verify that your lock ordering is globally consistent. These require careful thought on your part.

But for the class of bugs that have historically made concurrent programming a nightmare — the use-after-free, the simultaneous read-write, the mismanaged ownership across thread boundaries — Rust makes them structurally impossible to write. You don’t have to be disciplined enough to avoid them. The language simply does not let you make them.

For someone learning their first programming language, that is not a small thing. It is, in the truest sense of the term, fearless concurrency.

--

--

Riz Baruah
Riz Baruah

Chemist delving into Computer Science