Go hides complexity inside its runtime so you can move fast. Rust exposes that same complexity to you explicitly so the compiler can prove your code is correct before it ever runs. You’ve already proven you can navigate that — you're here.
Go vs Rust Concurrency Primitives: A Comprehensive Guide for Go Developers
I compiled this document so that in the future I, and anyone else, could easily reference common Go concurrency primitive patterns and see how they are expressed in Rust. I am ten days into learning Rust and I've built half a dozen pieces of software with it so far. It's incredibly powerful, and this document aims to help expand my knowledge base.
Basic Thread / Goroutine Spawning
The Concept
In Go, the runtime manages a pool of OS threads and multiplexes goroutines across them. You never think about threads. In Rust, you spawn OS threads explicitly — there is no runtime scheduler.
Go
// Go hides ALL of this from you:
// - OS thread allocation
// - Stack sizing (starts at 2KB, grows dynamically)
// - Scheduling across GOMAXPROCS threads
// - Cleanup when the goroutine exits
//
// From your perspective, this is all it takes:
go func() {
fmt.Println("I am a goroutine")
}()
// The runtime handles everything else silently.
Rust
use std::thread;
// Rust requires you to explicitly:
// - Spawn an OS thread (1:1 threading model, no runtime scheduler)
// - Define what data moves INTO the thread via `move`
// - Hold the JoinHandle if you care about completion
// - Join the thread manually or drop the handle
let handle = thread::spawn(move || {
// `move` transfers ownership of captured variables into this thread.
// The compiler ENFORCES that no other thread can access them.
// This is the borrow checker protecting you at compile time.
println!("I am an OS thread");
});
// Go would let you forget this. Rust makes you decide.
handle.join().expect("Thread panicked");
Key insight: Go’s goroutines are M:N (many goroutines, few OS threads). Rust’s threads are 1:1 (one thread per OS thread). Go is cheaper per unit of concurrency; Rust gives you direct hardware control.
Channels (Message Passing)
The Concept
Both languages favor message passing. Go’s channels are built into the language syntax. Rust’s are in stdlib and require explicit type plumbing.
Go
// Go channels are first-class language constructs.
// The runtime manages the internal buffer and blocking.
// You don't think about ownership — Go's GC handles it.
ch := make(chan int, 10) // buffered channel, buffer of 10
go func() {
ch <- 42 // send — Go blocks this goroutine if buffer is full
}()
val := <-ch // receive — Go blocks this goroutine if channel is empty
fmt.Println(val)
Rust
use std::sync::mpsc; // multi-producer, single-consumer
use std::thread;
// Rust's channel is explicitly typed and ownership-aware.
// `tx` is the sender (transmitter), `rx` is the receiver.
// You can clone `tx` for multiple producers — hence "multi-producer".
// There is only ONE receiver — hence "single-consumer".
// Ownership of sent values is TRANSFERRED across the channel.
// The compiler prevents you from using a value after sending it.
let (tx, rx) = mpsc::channel::<i32>();
// Clone the sender for use in another thread
let tx_clone = tx.clone();
thread::spawn(move || {
// `move` transfers ownership of tx_clone into this thread
tx_clone.send(42).expect("Failed to send");
// tx_clone is dropped here — channel knows one sender is gone
});
// Blocking receive — like <-ch in Go
let val = rx.recv().expect("Channel closed");
println!("{}", val);
Key insight: Go channels can be used by multiple goroutines freely. Rust enforces single ownership of the receiver at compile time. Go lets you accidentally share a receiver; Rust won’t compile if you try.
WaitGroups / Thread Joining
The Concept
Waiting for a collection of concurrent tasks to finish before proceeding.
Go
import "sync"
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1) // tell the WaitGroup to expect one more
go func(n int) {
defer wg.Done() // signal completion — Go lets you forget this,
// which causes wg.Wait() to hang forever.
// The compiler will NOT warn you.
fmt.Println(n)
}(i)
}
wg.Wait() // block until all Done() calls balance the Add() calls
Rust
use std::thread;
// Rust has no WaitGroup — instead, joining thread handles IS the primitive.
// The compiler forces you to handle every JoinHandle.
// If you drop a JoinHandle without joining, the thread detaches.
// There is no silent hang — behavior is explicit and deliberate.
let handles: Vec<thread::JoinHandle<()>> = (0..10)
.map(|n| {
thread::spawn(move || {
println!("{}", n);
})
})
.collect();
// Explicitly join every thread.
// If any thread panicked, this surfaces it.
for handle in handles {
handle.join().expect("A thread panicked");
}
Key insight: Go’s WaitGroup is manual accounting — you can get the count wrong and the compiler won’t save you. Rust’s join model ties completion to a value the type system tracks for you.
Mutexes (Shared Mutable State)
The Concept
Protecting shared data from concurrent modification.
Go
import "sync"
// In Go, a Mutex is SEPARATE from the data it protects.
// Nothing enforces the pairing — convention only.
// You can access the data without locking and the compiler won't stop you.
var mu sync.Mutex
var counter int
go func() {
mu.Lock()
defer mu.Unlock()
counter++ // protected — but only by developer discipline
}()
// Nothing stops this:
// counter++ // unprotected access — data race, no compile error
Rust
use std::sync::{Mutex, Arc};
use std::thread;
// In Rust, the Mutex OWNS the data.
// You CANNOT access the data without locking — the type system enforces it.
// Arc (Atomic Reference Counting) allows shared ownership across threads.
// Without Arc, the compiler rejects sharing across thread boundaries.
let counter = Arc::new(Mutex::new(0i64));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter); // clone the Arc, not the data
let handle = thread::spawn(move || {
let mut val = counter.lock().expect("Mutex poisoned");
// `val` is a MutexGuard — it unlocks automatically when dropped
// You are holding a mutable reference to the inner data
// The compiler PROVES no other thread can access it right now
*val += 1;
}); // MutexGuard dropped here — lock released
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
Key Concepts: Go separates the lock from the data — discipline enforced by humans. Rust wraps the data inside the lock — discipline enforced by the compiler. A data race in Go is a runtime bug. In Rust it is a compile error.
Atomic Operations
The Concept
Lock-free concurrent counters and flags using CPU-level atomic instructions.
Go
import "sync/atomic"
// Go's atomic package operates on plain integer variables.
// The connection between "this variable is atomic" and its usage
// is purely by convention — nothing stops non-atomic access.
var counter int64
atomic.AddInt64(&counter, 1) // atomic increment
val := atomic.LoadInt64(&counter) // atomic read
atomic.StoreInt64(&counter, 0) // atomic write
atomic.CompareAndSwapInt64(&counter, 0, 1) // CAS
Rust
use std::sync::atomic::{AtomicI64, AtomicBool, Ordering};
use std::sync::Arc;
// In Rust, atomics ARE a type — AtomicI64, AtomicU64, AtomicBool, etc.
// The atomic nature is baked into the type, not a function call convention.
// Ordering is EXPLICIT — you declare your memory ordering guarantee
// at every operation site. Go hides this entirely.
let counter = Arc::new(AtomicI64::new(0));
// Ordering::SeqCst = Sequential Consistency — strongest, safest, slowest
// Ordering::Relaxed = no ordering guarantees — fastest, use for counters
// where you only care about the final value
// Ordering::Acquire/Release = used in pairs for producer/consumer patterns
counter.fetch_add(1, Ordering::Relaxed); // atomic increment
let val = counter.load(Ordering::Relaxed); // atomic read
counter.store(0, Ordering::SeqCst); // atomic write
counter.compare_exchange(0, 1, // CAS
Ordering::SeqCst,
Ordering::Relaxed
).ok();
Key Concepts: Go hides memory ordering from you entirely — it picks for you. Rust exposes it and makes you choose. This feels like overhead until you realize you can pick Relaxed for a hot counter and get measurable performance gains.
RWMutex (Read-Write Locks)
The Concept
Allow multiple concurrent readers OR one exclusive writer.
Go
import "sync"
var rwmu sync.RWMutex
var data map[string]string
// Multiple goroutines can hold RLock simultaneously
rwmu.RLock()
val := data["key"] // safe concurrent read
rwmu.RUnlock()
// Only one goroutine can hold Lock
rwmu.Lock()
data["key"] = "value" // exclusive write
rwmu.Unlock()
Rust
use std::sync::{RwLock, Arc};
// Same concept, but again: the data lives INSIDE the RwLock.
// You cannot read or write without going through the lock.
// read() returns a RwLockReadGuard — multiple can coexist
// write() returns a RwLockWriteGuard — exclusive, blocks readers
let data = Arc::new(RwLock::new(std::collections::HashMap::<String, String>::new()));
// Read path — multiple threads can hold this simultaneously
{
let readable = data.read().expect("RwLock poisoned");
let val = readable.get("key");
} // RwLockReadGuard dropped here — read lock released
// Write path — exclusive
{
let mut writable = data.write().expect("RwLock poisoned");
writable.insert("key".to_string(), "value".to_string());
} // RwLockWriteGuard dropped here — write lock released
Select / Select-like Patterns
The Concept
Waiting on multiple concurrent operations simultaneously.
Go
// Go's select is a language keyword — first-class syntax.
// It blocks until one of the cases is ready, then executes it.
// If multiple are ready simultaneously, Go picks one at random.
ch1 := make(chan string)
ch2 := make(chan string)
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
Rust
// Rust stdlib has NO select equivalent.
// You need the `crossbeam` crate for channel selection,
// or `tokio::select!` in async contexts.
// This is one area where Go's language-level support is genuinely superior.
// With crossbeam:
use crossbeam::channel::{select, unbounded, after};
use std::time::Duration;
let (tx1, rx1) = unbounded::<String>();
let (tx2, rx2) = unbounded::<String>();
select! {
recv(rx1) -> msg => println!("rx1: {:?}", msg),
recv(rx2) -> msg => println!("rx2: {:?}", msg),
recv(after(Duration::from_secs(1))) -> _ => println!("timeout"),
}
Key Concepts: This is a genuine Go advantage. select being a language keyword means zero overhead and clean syntax. Rust requires a third-party crate to match this pattern.
Once (Single Initialization)
The Concept
Ensuring something runs exactly once across all goroutines/threads.
Go
import "sync"
var once sync.Once
var instance *MyService
func getInstance() *MyService {
once.Do(func() {
// This runs exactly once, even if 1000 goroutines call getInstance()
// simultaneously. Go's runtime handles the synchronization.
instance = &MyService{}
})
return instance
}
Rust
use std::sync::OnceLock; // stable since Rust 1.70
// OnceLock wraps the value AND the initialization guarantee together.
// Like Mutex, the data lives inside the synchronization primitive.
static INSTANCE: OnceLock<MyService> = OnceLock::new();
fn get_instance() -> &'static MyService {
INSTANCE.get_or_init(|| {
// Runs exactly once — guaranteed by the type system
MyService::new()
})
}
Context / Cancellation
The Concept
Propagating cancellation signals across concurrent work.
Go
import "context"
// Go's context is idiomatic and universal — every stdlib network/IO
// function accepts a context. Cancellation propagates through the tree.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
select {
case <-ctx.Done():
// cancelled or timed out — clean up
return
case result := <-doWork():
fmt.Println(result)
}
}()
Rust
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
// Rust stdlib has no context/cancellation primitive.
// The idiomatic pattern is a shared AtomicBool flag.
// This is exactly what you used in your XRP finder with RUNNING.
static RUNNING: AtomicBool = AtomicBool::new(true);
// In your worker thread:
while RUNNING.load(Ordering::Relaxed) {
// do work
}
// To cancel from anywhere:
RUNNING.store(false, Ordering::SeqCst);
// For async Rust, tokio provides CancellationToken which
// is much closer to Go's context pattern.
Key Concepts: Go’s context is one of its strongest features — universally adopted, composable, and built into the standard library. Rust’s stdlib answer is manual flag passing. This is another genuine Go advantage for networked/IO-heavy code.
Goroutine Pools / Thread Pools
The Concept
Bounding the number of concurrent workers to avoid resource exhaustion.
Go
// Go's pattern: a fixed number of goroutines draining a shared channel
jobs := make(chan int, 100)
const workers = 8
for w := 0; w < workers; w++ {
go func() {
for job := range jobs {
// process job
// `range` over channel blocks until next item or channel close
fmt.Println(job)
}
// goroutine exits cleanly when channel is closed
}()
}
for i := 0; i < 100; i++ {
jobs <- i
}
close(jobs) // signals all workers to exit after draining
Rust
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
// Rust stdlib has no thread pool — you build one or use `rayon`/`threadpool` crate.
// Here is the manual pattern that mirrors Go's worker pool:
let (tx, rx) = mpsc::channel::<i32>();
let rx = Arc::new(Mutex::new(rx)); // wrap receiver for shared access
const WORKERS: usize = 8;
let mut handles = vec![];
for _ in 0..WORKERS {
let rx = Arc::clone(&rx);
handles.push(thread::spawn(move || {
loop {
// Lock the receiver, try to get a job
let job = rx.lock().unwrap().recv();
match job {
Ok(j) => println!("{}", j),
Err(_) => break, // channel closed — exit like `range` in Go
}
}
}));
}
for i in 0..100 {
tx.send(i).unwrap();
}
drop(tx); // dropping sender closes the channel — equivalent to close(jobs)
for handle in handles {
handle.join().unwrap();
}
Condition Variables
The Concept
Blocking a thread until a specific condition becomes true.
Go
import "sync"
// Go's sync.Cond — rarely used directly because channels usually suffice,
// but important for complex state signaling.
var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false
go func() {
mu.Lock()
for !ready { // always loop — spurious wakeups exist
cond.Wait() // releases lock, sleeps, reacquires lock on wake
}
mu.Unlock()
}()
// From another goroutine:
mu.Lock()
ready = true
cond.Signal() // wake one waiter
// cond.Broadcast() // wake all waiters
mu.Unlock()
Rust
use std::sync::{Arc, Mutex, Condvar};
use std::thread;
// Rust pairs Condvar explicitly with a Mutex — they are separate types
// but always used together. The data lives in the Mutex as always.
let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);
thread::spawn(move || {
let (lock, cvar) = &*pair2;
let mut ready = lock.lock().unwrap();
while !*ready {
// wait() releases the lock and sleeps atomically
// reacquires lock before returning
ready = cvar.wait(ready).unwrap();
}
println!("condition met");
});
let (lock, cvar) = &*pair;
let mut ready = lock.lock().unwrap();
*ready = true;
cvar.notify_one(); // equivalent to Signal()
// cvar.notify_all() // equivalent to Broadcast()
Async / Non-blocking Patterns
The Concept
Concurrency without threads — cooperative multitasking for IO-bound work.
Go
// Go has no async/await — goroutines ARE the answer to async IO.
// The runtime uses non-blocking syscalls under the hood and parks
// goroutines transparently while waiting for IO.
// From your perspective, everything looks synchronous and blocking.
// This is Go's greatest magic trick.
resp, err := http.Get("https://example.com") // "blocks" the goroutine
// but the OS thread is free to run other goroutines while waiting
// You never write async code — the runtime handles it invisibly.
Rust
// Rust's async is explicit — you opt in at every level.
// There is no runtime in stdlib — you bring your own (tokio, async-std).
// `async fn` returns a Future — it does nothing until awaited.
// `.await` yields control back to the runtime if the operation would block.
// This is the tradeoff: more control, more verbosity, zero hidden cost.
use tokio; // external runtime crate — not in stdlib
#[tokio::main]
async fn main() {
let resp = reqwest::get("https://example.com")
.await // explicitly yield here while waiting for IO
.expect("request failed");
println!("{}", resp.status());
}
// Every async function must be awaited.
// The compiler will warn you if you forget to await a Future.
// Go would just silently not execute the goroutine if you forgot `go`.
Key Concepts: Go’s approach is simpler and works beautifully for most networked software. Rust’s async gives you precise control over scheduling and zero runtime overhead — critical for embedded systems or extremely high-performance networking.
Top comments (0)