Learning Rust - Concurrency, Async, and Parallelism
Episode 12 of 19

Learning Rust - Concurrency, Async, and Parallelism

This episode breaks down the async/await model and the tokio runtime, task spawning and channels with tokio::sync, thread safety through the Send and Sync traits, and the actor-like pattern for managing state shared between tasks.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

The server in episode 10 could already handle many connections, but we have not broken down how that is possible. The answer is in episode 12: concurrency. Rust has one of the safest concurrency models in the world — its safety is guaranteed by the type system, not just by convention.

This episode breaks down async/await with the tokio runtime, task creation, communication between tasks with channels, thread safety through the Send and Sync traits, and the actor-like pattern for shared state. After this episode, you will be able to process many jobs concurrently without data races.

Async/Await and the Tokio Runtime

Why Async

Operations such as waiting for the network or disk do not need CPU. Async allows a single thread to handle thousands of operations that are waiting: while one task waits, other tasks use the CPU. Tokio is the runtime that runs this model; add it with cargo add tokio --features full.

An async function
cat > src/main.rs <<'EOF'
async fn kerja_panjang(n: u64) -> u64 {
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    n * n
}
 
#[tokio::main]
async fn main() {
    let mulai = std::time::Instant::now();
 
    let a = tokio::spawn(kerja_panjang(2));
    let b = tokio::spawn(kerja_panjang(3));
 
    println!("hasil: {} dan {}", a.await.unwrap(), b.await.unwrap());
    println!("waktu: {:?}", mulai.elapsed());
}
EOF
cargo run

tokio::spawn runs tasks concurrently. Two tasks that each sleep 100 ms finish in ~100 ms, not 200 ms — because sleep does not block the thread. .await waits for a task's result without stopping the runtime. Tokio tasks are scheduled by a multi-threaded executor, so thousands of tasks can live in one process, and the JoinHandle from spawn carries a Result — an error in a task does not silently bring down the program.

Channels with tokio::sync

Communication Between Tasks

A channel connects producers and consumers. mpsc (multi-producer, single-consumer) is the most common:

mpsc channel
cat > src/main.rs <<'EOF'
use tokio::sync::mpsc;
 
#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(16);
 
    for i in 0..3 {
        let tx = tx.clone();
        tokio::spawn(async move {
            let _ = tx.send(format!("pesan {}", i)).await;
        });
    }
 
    drop(tx);
 
    while let Some(pesan) = rx.recv().await {
        println!("terima: {}", pesan);
    }
}
EOF
cargo run

mpsc::channel(16) creates a channel with a buffer of 16. Each producer clones its sender, and rx.recv().await waits for the next message. drop(tx) closes the channel so the loop ends. This pattern routes results from many tasks into a single consumer.

Watch and Broadcast

Besides mpsc, tokio provides watch for state followed by many tasks and broadcast for sending to all subscribers. tokio::sync::watch is used, for example, to distribute changing configuration status, while broadcast delivers events to many receivers.

Thread Safety: Send and Sync

Two Key Traits

Two traits determine whether a value can safely be moved or shared between threads:

  • Send: the value can safely be moved to another thread.
  • Sync: the value can safely be referenced from many threads at once.

The compiler enforces them automatically based on the contents of the type. This is why Rust prevents data races: code that moves an unsafe value to another thread is rejected at compile time.

Sharing State with Mutex

To share mutable state between tasks, wrap it in a Mutex inside an Arc (shared ownership):

Shared state
cat > src/main.rs <<'EOF'
use std::sync::Arc;
use tokio::sync::Mutex;
 
#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0u32));
 
    let mut handles = Vec::new();
    for _ in 0..4 {
        let counter = counter.clone();
        handles.push(tokio::spawn(async move {
            let mut nilai = counter.lock().await;
            *nilai += 1;
        }));
    }
 
    for h in handles {
        h.await.unwrap();
    }
 
    println!("nilai akhir: {}", *counter.lock().await);
}
EOF
cargo run

Arc lets many tasks hold a reference to the same data, and Mutex ensures only one task modifies it at a time. In tokio, use tokio::sync::Mutex when the guard crosses an .await point, and std::sync::Mutex for short critical sections.

The Actor-Like Pattern

Actor: A Task That Owns State

The actor pattern: state is owned exclusively by one task, and other tasks interact through channels. No mutex is needed because the state is only touched by its owner.

A simple actor
cat > src/main.rs <<'EOF'
use tokio::sync::{mpsc, oneshot};
 
enum Perintah {
    Tambah(u32, oneshot::Sender<u32>),
}
 
async fn actor(mut rx: mpsc::Receiver<Perintah>) {
    let mut nilai = 0;
    while let Some(perintah) = rx.recv().await {
        match perintah {
            Perintah::Tambah(n, reply) => {
                nilai += n;
                let _ = reply.send(nilai);
            }
        }
    }
}
 
#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(16);
    tokio::spawn(actor(rx));
 
    let (reply, terima) = oneshot::channel();
    let _ = tx.send(Perintah::Tambah(5, reply)).await;
    println!("nilai: {}", terima.await.unwrap());
}
EOF
cargo run

The actor task holds nilai as private state. Calls are sent through mpsc, answers come back through oneshot. Because the state is never shared, safety is guaranteed — this is the actor-like pattern widely used in message-passing architectures, and its roots are visible in frameworks such as actix.

Choosing a Concurrency Approach

A concise guideline: for operations waiting on I/O, use tokio async tasks; for CPU-bound work, use threads (std::thread) or rayon; for small shared state, Arc<Mutex>; for large or frequently accessed state, consider the actor pattern; and to route results between tasks, use channels. These choices determine both the performance and the correctness of your program.

Closing

Key takeaways:

  • Async lets a single thread serve thousands of waiting operations.
  • tokio::spawn runs tasks; .await waits without blocking the runtime.
  • mpsc, watch, and broadcast connect tasks with different patterns.
  • Send and Sync are guaranteed by the compiler; data races are rejected at compile time.
  • The actor pattern isolates state in a single task and communicates through channels.

In the next episode 13 we will discuss performance tuning and profiling — profiling a Rust application with perf, tokio-console, and cargo flamegraph, optimizing memory and CPU with zero-cost abstractions and iterators, as well as handling I/O bottlenecks, allocations, and lock contention.

Learning Rust - Concurrency, Async, and Parallelism | Learning Rust