Skip to main content

Async Rust with Tokio: Futures, Streams, and Channels — and How They Compare to Go

·8 mins
N Acharya
Author
N Acharya
Cloud, Data Engineering, Data Science & AI

Go’s concurrency model is famously approachable: goroutines are lightweight, channels are built into the language, and context handles cancellation without much ceremony. But Rust’s async story — built on stackless futures, the Tokio runtime, and a rich channel ecosystem — is compelling in a different way. It pushes more decisions to compile time, eliminates data races by construction, and delivers higher concurrency density with less memory.

This post works through Rust async from first principles and puts it side-by-side with Go at every step.


The Model: Stackless Futures vs Stackful Goroutines
#

Every Go goroutine gets its own stack — initially small (around 2 KB), but able to grow. The scheduler is preemptive: the runtime can pause a goroutine at any point and run another. You write sequential-looking code and the runtime handles everything.

go func() {
    result := fetchData()   // blocks this goroutine, not the OS thread
    process(result)
}()

Rust futures are stackless. The compiler transforms an async fn into a state machine — a struct that captures all the local variables needed to resume at the next .await. No stack allocation per task. The Tokio runtime polls these state machines: a task runs until it hits an .await that can’t complete yet, then yields back to the scheduler.

tokio::spawn(async {
    let result = fetch_data().await;   // explicit yield point
    process(result).await;
});

What this means in practice:

Rust / TokioGo
Suspension pointsExplicit — every .awaitImplicit — runtime decides
Memory per task~100 bytes (state machine)~2 KB (initial stack)
Data racesImpossible — borrow checkerPossible — go vet / -race helps
SchedulingCooperative, work-stealingPreemptive, work-stealing
Recursion in asyncRequires Box::pinWorks naturally

The tradeoff: Go is easier to write. Rust gives you tighter control and higher concurrency density at the cost of more explicit code.


Spawning Tasks
#

Go
#

go func() {
    fmt.Println("hello from goroutine")
}()

Goroutines fire and forget. Returning a value requires a channel.

Rust
#

let handle = tokio::spawn(async {
    42  // the task's return value
});

let result = handle.await.unwrap();  // JoinHandle<i32>
println!("result: {}", result);

tokio::spawn returns a JoinHandle<T> — a typed, awaitable handle to the task’s result. The task runs concurrently; .await on the handle blocks until it completes.

Key difference: In Go, you must route results through a channel manually. In Rust, the task’s return value is part of the type system from the start.


Channels
#

Rust’s tokio::sync module provides three flavours of channel, each with a distinct contract:

mpsc — Multi-Producer, Single-Consumer
#

The Go default: many senders, one receiver. Tokio makes the buffer bound explicit.

Go

ch := make(chan string, 32)   // buffered

go func() {
    ch <- "ping"
}()

msg := <-ch
fmt.Println(msg)

Rust

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel::<String>(32);
let tx2 = tx.clone();   // clone the sender — receiver stays unique

tokio::spawn(async move {
    tx.send("ping".into()).await.unwrap();
});

tokio::spawn(async move {
    tx2.send("pong".into()).await.unwrap();
});

while let Some(msg) = rx.recv().await {
    println!("{msg}");
}
// loop exits cleanly when all Senders are dropped — no explicit close needed

Go channels need an explicit close(ch) to terminate a range loop. Rust’s mpsc channel closes automatically when the last Sender is dropped — the receiver sees None.


oneshot — Single Value, Request/Response
#

Go has no oneshot primitive. The idiom is a buffered channel of size 1:

Go

reply := make(chan int, 1)

go func() {
    reply <- expensiveWork()
}()

result := <-reply

Rust

use tokio::sync::oneshot;

let (tx, rx) = oneshot::channel::<i64>();

tokio::spawn(async move {
    let result = expensive_work().await;
    tx.send(result).ok();   // send is not async — fires and forgets
});

match rx.await {
    Ok(result) => println!("got {result}"),
    Err(_)     => println!("sender dropped before sending"),
}

The Rust version is explicit about the contract: exactly one value, exactly once. If the sender is dropped before sending, the receiver gets Err — no silent deadlock.


broadcast — Fan-Out to Many Receivers
#

Go channels are single-consumer. Broadcasting to multiple goroutines requires a separate pattern (a sync loop that forwards to per-goroutine channels). Tokio provides this natively:

Go (manual fan-out)

func broadcast(in <-chan string, consumers int) []<-chan string {
    outs := make([]chan string, consumers)
    for i := range outs {
        outs[i] = make(chan string, 16)
    }
    go func() {
        for msg := range in {
            for _, out := range outs {
                out <- msg   // every consumer gets every message
            }
        }
        for _, out := range outs { close(out) }
    }()
    // return as read-only
    result := make([]<-chan string, consumers)
    for i, o := range outs { result[i] = o }
    return result
}

Rust

use tokio::sync::broadcast;

let (tx, _) = broadcast::channel::<String>(16);

for id in 0..3 {
    let mut rx = tx.subscribe();   // each subscriber gets every message
    tokio::spawn(async move {
        while let Ok(msg) = rx.recv().await {
            println!("subscriber {id}: {msg}");
        }
    });
}

tx.send("hello everyone".into()).unwrap();

broadcast::channel is a first-class primitive. Subscribers that fall too far behind get a RecvError::Lagged error rather than silently blocking the sender.


Streams — Async Iterators
#

Go uses channels as lazy sequences: a goroutine generates values, the caller ranges over the channel. Works well, but channels are not composable — there’s no built-in .map, .filter, or .take.

Go (channel as stream)

func naturals(ctx context.Context) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := 0; ; n++ {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

for n := range naturals(ctx) {
    fmt.Println(n)
}

Rust (Stream with combinators)

use tokio_stream::{self as stream, StreamExt};
use std::time::Duration;

let s = stream::iter(0..)
    .filter(|n| n % 2 == 0)        // keep even numbers
    .map(|n| n * n)                 // square them
    .take(5)                        // stop after 5
    .throttle(Duration::from_millis(100));  // rate limit

tokio::pin!(s);
while let Some(val) = s.next().await {
    println!("{val}");   // 0, 4, 16, 36, 64
}

Rust streams compose like iterators. You build a lazy pipeline — nothing runs until you call .next().await. The StreamExt trait brings .map, .filter, .take, .chunks, .throttle, .timeout, and more.


Cancellation: select! vs Context
#

Go’s cancellation model passes a context.Context explicitly through every call. Any function that should be cancellable accepts a ctx argument and checks ctx.Done().

Go

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

select {
case result := <-doWork(ctx):
    fmt.Println("done:", result)
case <-ctx.Done():
    fmt.Println("timed out:", ctx.Err())
}

This is clean and readable. The downside: every function in the chain must accept and thread the context — it’s a convention, not enforced by the compiler.

Rust

use tokio::time::{sleep, Duration};

tokio::select! {
    result = do_work() => {
        println!("done: {result:?}");
    }
    _ = sleep(Duration::from_secs(5)) => {
        println!("timed out");
        // do_work() future is dropped here — cancellation is implicit
    }
}

In Rust, dropping a future cancels it. When select! picks the timeout branch, the do_work() future is immediately dropped — its destructor runs, releasing any held resources. No explicit cancellation token threading required.

For deeper cancellation (propagating into subtasks), Rust uses a CancellationToken from tokio_util:

use tokio_util::sync::CancellationToken;

let token = CancellationToken::new();
let child = token.child_token();

tokio::spawn(async move {
    tokio::select! {
        _ = child.cancelled() => println!("subtask cancelled"),
        _ = do_subtask()      => println!("subtask done"),
    }
});

// elsewhere:
token.cancel();   // propagates to all child tokens

Structured Concurrency: JoinSet vs errgroup
#

Running N tasks and waiting for all of them — with error collection.

Go (errgroup)

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(4)   // at most 4 concurrent

for i := 0; i < 10; i++ {
    i := i
    g.Go(func() error {
        return process(ctx, i)
    })
}

if err := g.Wait(); err != nil {
    log.Fatal(err)
}

Rust (JoinSet)

use tokio::task::JoinSet;

let mut set = JoinSet::new();

for i in 0..10_u32 {
    set.spawn(async move { process(i).await });
}

while let Some(res) = set.join_next().await {
    match res {
        Ok(val) => println!("ok: {val}"),
        Err(e)  => eprintln!("task panicked: {e}"),
    }
}
// all tasks are joined (or aborted) before `set` is dropped

JoinSet aborts all running tasks when it is dropped — structured concurrency is enforced by ownership, not convention.


A Complete Example: Pipeline with Fan-Out
#

Workers pull jobs from a shared queue, process them concurrently, and funnel results to a single collector.

Go

func pipeline(jobs []int) []int {
    in := make(chan int, len(jobs))
    for _, j := range jobs { in <- j }
    close(in)

    out := make(chan int, len(jobs))
    var wg sync.WaitGroup

    for w := 0; w < 4; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range in {
                out <- j * j
            }
        }()
    }

    go func() { wg.Wait(); close(out) }()

    var results []int
    for r := range out { results = append(results, r) }
    return results
}

Rust

use tokio::sync::mpsc;
use tokio::task::JoinSet;

async fn pipeline(jobs: Vec<i64>) -> Vec<i64> {
    let (tx, mut rx) = mpsc::channel::<i64>(64);
    let mut set = JoinSet::new();

    for chunk in jobs.chunks(jobs.len().div_ceil(4)) {
        let tx = tx.clone();
        let chunk = chunk.to_vec();
        set.spawn(async move {
            for j in chunk {
                tx.send(j * j).await.ok();
            }
        });
    }
    drop(tx);   // close the sender side once all workers are spawned

    let collector = tokio::spawn(async move {
        let mut results = Vec::new();
        while let Some(r) = rx.recv().await { results.push(r); }
        results
    });

    set.join_all().await;   // wait for all workers
    collector.await.unwrap()
}

The Rust version makes the ownership of the sender explicit: cloning tx for each worker, then drop(tx) to signal that no more messages are coming — no close(out) in a separate goroutine.


Summary
#

CapabilityGoRust / Tokio
Spawn a taskgo func()tokio::spawn(async { })
Multi-producer channelmake(chan T, n)mpsc::channel(n)
Single-value responsebuffered chan T (size 1)oneshot::channel()
Broadcast to Nmanual forwarding loopbroadcast::channel(n)
Lazy async sequenceschannel + goroutineStream + combinators
Timeout / cancellationselect + contexttokio::select! + drop semantics
Wait for N taskserrgroupJoinSet
Data race prevention-race flag at runtimeborrow checker at compile time

Go wins on simplicity and readability — the mental model is smaller and the language does more for you. Rust wins on correctness guarantees and resource efficiency — data races are compile errors, tasks are cheaper per unit, and the type system prevents misuse of channels.

For network services handling tens of thousands of concurrent connections, Rust async’s lower per-task overhead becomes significant. For most backend services where developer velocity matters more than microseconds, Go remains an excellent choice.