DEV Community

Cover image for Go Concurrency in Rust
Andrei Merlescu
Andrei Merlescu

Posted on

Go Concurrency in Rust

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.
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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"),
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
    })
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

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)