Standard Library, Concurrency, and async: Rust Chapter Finale

🔊

After the journey of the first four articles—ownership and borrowing, type system, error handling, and pattern matching—we have now reached the Rust chapter finale, time to tie the scattered knowledge together.

This article focuses on Rust’s standard library concurrency tools and the async/await ecosystem. These are the most interesting comparative points between Rust and Go in the concurrency domain: Go makes concurrency look simple through goroutines and channels, while Rust makes concurrency safe and reliable through strict type systems and ownership constraints.

Finally, we will bridge to the next protagonist—Zig—at the end of the article.

std:🧵 Direct Mapping to OS Threads

Rust’s std::thread provides a 1:1 mapping to operating system threads, which is fundamentally different from Go’s M:N goroutine scheduling model.

Creating Threads and join

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("Child thread: {}", i);
            thread::sleep(Duration::from_millis(100));
        }
    });

    // Main thread continues executing
    for i in 1..=3 {
        println!("Main thread: {}", i);
        thread::sleep(Duration::from_millis(100));
    }

    // Wait for child thread to finish
    handle.join().expect("Child thread panicked");
}

Note for Go Developers:

  • Go’s go func() are goroutines (lightweight threads), Rust’s thread::spawn are operating system threads (each goroutine has ~2KB stack, each OS thread has ~2MB stack).
  • Go’s main function returning causes all goroutines to exit, Rust’s main thread ending does not automatically wait for child threads (need explicit join).
  • Go’s scheduler is M:N, Rust’s is 1:1.

Closure Capture and move

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        // move transfers v's ownership to the closure
        println!("In thread: {:?}", v);
    });

    handle.join().expect("Thread panicked");
    // v's ownership has been transferred, cannot access here
}

The move keyword forces transferring ownership to the new thread. This is different from Go’s closure capture—Go’s closures capture variables by pointer (shared), Rust’s closures capture by reference (borrowing) by default, requiring move to transfer ownership.

Message Passing: Rust’s Channel Implementation

Rust’s std::sync::mpsc provides multi-producer single-consumer channels, sharing the same design philosophy as Go’s channels—“don’t communicate by sharing memory, share memory by communicating.”

mpsc::channel Basic Usage

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();  // Create channel

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
        }
        // tx goes out of scope, channel closes
    });

    for received in rx {
        println!("Received: {}", received);
    }
}

Go Comparison:

  • Go: ch := make(chan string) / ch <- "hello" / val := <-ch
  • Rust: (tx, rx) = mpsc::channel() / tx.send(val) / rx.recv()
  • Go’s channels can be read/written by multiple goroutines, Rust’s mpsc channels only support multiple senders and one receiver (mpsc = multi-producer, single-consumer).
  • Go’s channels support buffering and close detection, Rust’s mpsc channels also support capacity setting (mpsc::sync_channel(capacity)).

Multiple Producers

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    // Sender can be cloned
    let tx1 = tx.clone();
    let tx2 = tx.clone();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];
        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_millis(100));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];
        for val in vals {
            tx2.send(val).unwrap();
            thread::sleep(Duration::from_millis(100));
        }
    });

    // Drop original tx, so channel closes when both cloned txs are dropped
    drop(tx);

    for received in rx {
        println!("Received: {}", received);
    }
}

Note for Go Developers: Go’s channels don’t need explicit cloning because channels themselves are reference types. Rust’s Sender needs explicit cloning to be used in multiple threads.

Shared State: Mutex and Arc

Besides message passing, Rust also supports a concurrent model through shared state—implementing thread-safe shared mutable state through Mutex and Arc.

Mutex Basic Usage

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }  // Lock is released here

    println!("m = {:?}", m);
}

Multi-thread Shared Mutex (Requires Arc)

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 (Atomic Reference Counting) provides thread-safe reference counting, allowing multiple threads to share the same ownership.

Go Comparison:

  • Go doesn’t have explicit Mutex/Arc concepts—sync.Mutex is a value type, used directly.
  • Go’s channels can pass pointers to implement shared state, Rust’s Arc is an explicit shared ownership mechanism.
  • Go’s mutex.Lock() / mutex.Unlock() requires manual calling defer to release, Rust’s MutexGuard is automatically released when scope ends (RAII).

RwLock: Read-Write Lock

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(0));
    let mut handles = vec![];

    // Multiple readers
    for _ in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let r = data.read().unwrap();
            println!("Read lock: {}", *r);
        }));
    }

    // One writer
    let data = Arc::clone(&data);
    handles.push(thread::spawn(move || {
        let mut w = data.write().unwrap();
        *w = 1;
        println!("Write lock: set to 1");
    }));

    for handle in handles {
        handle.join().unwrap();
    }
}

Go Equivalent: Go’s sync.RWMutex, but Rust’s RwLock is stricter—read locks and write locks cannot be held simultaneously.

async/await: Zero-Cost Abstraction Concurrency

Rust 1.39 introduced async/await, providing a zero-cost abstraction concurrent model based on Future. This is fundamentally different from Go’s goroutines—Go’s concurrency is implemented through runtime scheduler, Rust’s async is implemented through state machines.

async Function Basics

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::time::Duration;
use std::thread;

async fn hello() -> String {
    println!("hello start");
    thread::sleep(Duration::from_millis(100));
    println!("hello end");
    String::from("hello")
}

async fn world() -> String {
    println!("world start");
    thread::sleep(Duration::from_millis(100));
    println!("world end");
    String::from("world")
}

#[tokio::main]
async fn main() {
    let h1 = hello();
    let h2 = world();

    let (s1, s2) = tokio::join!(h1, h2);
    println!("Result: {} {}", s1, s2);
}

Key Points:

  • async fn returns a Future, not the value directly.
  • .await actually executes this Future (not executed when calling the function).
  • tokio::join! executes multiple Futures concurrently.

Note for Go Developers:

  • Go’s goroutines execute immediately upon starting, Rust’s async functions don’t execute when called (return Future), need .await to execute.
  • Go’s go func() is true concurrency, Rust’s serial .await executes sequentially (need runtime for concurrency).
  • Go’s runtime schedules implicitly, Rust’s runtime needs explicit choice (tokio, async-std, smol).

Future Trait

The underlying async/await is the Future trait:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

struct Delay {
    when: Instant,
}

impl Future for Delay {
    type Output = &'static str;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if Instant::now() >= self.when {
            Poll::Ready("done")
        } else {
            // Register waker, wake when time arrives
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

async fn delay_example() {
    let delay = Delay { when: Instant::now() + Duration::from_millis(100) };
    let result = delay.await;
    println!("Result: {}", result);
}

Go Comparison:

  • Go’s goroutines are directly managed by runtime, no need to manually implement Future-like mechanisms.
  • Rust’s Future is more like a state machine, each poll returns Pending or Ready.

tokio runtime: Rust’s Concurrency Scheduler

tokio is the most popular async runtime in Rust, providing executors, timers, IO and other infrastructure.

Basic Usage

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use tokio::time::{sleep, Duration};

async fn task1() {
    sleep(Duration::from_millis(100)).await;
    println!("task1 done");
}

async fn task2() {
    sleep(Duration::from_millis(200)).await;
    println!("task2 done");
}

#[tokio::main]
async fn main() {
    tokio::spawn(task1());
    tokio::spawn(task2());

    sleep(Duration::from_millis(300)).await;
}

Go Comparison:

  • Go’s go func() executes concurrently, tokio’s tokio::spawn also executes concurrently.
  • Go’s main function returning ends all goroutines, tokio’s main async function returning doesn’t end spawned tasks (need explicit waiting).

select!: Multi-way Selection

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let (tx1, mut rx1) = mpsc::channel::<i32>(32);
    let (tx2, mut rx2) = mpsc::channel::<i32>(32);

    tokio::spawn(async move {
        sleep(Duration::from_millis(100)).await;
        tx1.send(1).await.unwrap();
    });

    tokio::spawn(async move {
        sleep(Duration::from_millis(200)).await;
        tx2.send(2).await.unwrap();
    });

    tokio::select! {
        Some(v) = rx1.recv() => println!("Received from rx1: {}", v),
        Some(v) = rx2.recv() => println!("Received from rx2: {}", v),
        else => println!("No data"),
    }
}

Go Equivalent: Go’s select statement, but Rust’s select! is a macro, generating matching logic at compile time.

Comparison Summary: Rust vs Go Concurrency Models

FeatureGoRust
Thread ModelM:N goroutine (lightweight)1:1 OS thread + async Future
SchedulerBuilt-in runtimeOptional runtime (tokio, async-std)
ChannelBuilt-in, bidirectional many-to-manystd::sync::mpsc (unidirectional many-to-one)
Shared Statesync.Mutex (direct use)Mutex<Arc> (explicit ownership)
Error Handlingpanic (exception-like)Result<T, E> (type safe)
Compile-time GuaranteesPartial (race detector)Complete (ownership + borrow checking)

Go’s Advantages:

  • Simpler concurrency model (goroutine + channel)
  • Lower concurrency barrier (less mental burden)
  • Built-in runtime, no selection needed
  • High development efficiency

Rust’s Advantages:

  • Complete concurrency safety (compile-time guarantees)
  • Zero-cost abstractions (async/await introduces no runtime overhead)
  • Finer-grained control (can choose different runtimes)
  • Stronger type safety and error handling

Rust Chapter Summary

Rust’s concurrency model embodies the language’s core philosophy: safety is not compromised.

Through ownership and borrow checking, Rust eliminates data races at compile time; through Future and async/await, Rust provides zero-cost concurrent abstractions; through type systems and trait mechanisms, Rust makes concurrent code both safe and efficient.

But safety comes at a price—Rust’s concurrent code is more verbose than Go’s, with higher mental burden. Which one to choose depends on your project needs: pursue development efficiency and simplicity choose Go, pursue safety and zero-cost abstractions choose Rust.

Transition to Zig

From Go’s simple and efficient to Rust’s safe zero-cost, we’ve seen two different concurrency philosophies. Go makes concurrency a language feature, Rust makes concurrency a library and type system.

Zig takes a third path: neither built-in runtime nor forced abstraction.

In Zig, concurrency is explicit—you use std.Thread and it’s an OS thread, use std.Io.Group and it’s a task group, use Io.Evented and it’s event-driven. Zig won’t package concurrency as simple as go func(), but won’t require understanding complex concepts like Pin, Unpin, Waker like Rust.

In the next chapter, we will dive deep into Zig’s world and see how this language finds balance between “explicit first” and “zero runtime.”

Rust eliminates concurrency problems through strict type systems, Zig makes concurrent behavior visible through explicit APIs. Different philosophies, the same goal—writing safe and efficient concurrent code.

Next, let’s welcome Zig.