Skip to main content

Select & Join

Race two async operations and take the first result, or wait for all of them to complete. A fundamental pattern in any concurrent system.

Hew

select and await fork (...) are expressions that return values directly. An after clause places the timeout beside the operations it bounds.

select_join.hew
actor Primary {
    receive fn fetch(key: string) -> i64 { 1 }
}
actor Fallback {
    receive fn fetch(key: string) -> i64 { 2 }
}
actor Db {
    receive fn get_users() -> i64 { 10 }
    receive fn get_posts() -> i64 { 20 }
}
 
fn main() {
    let primary  = spawn Primary;
    let fallback = spawn Fallback;
    let db       = spawn Db;
 
    // Race two sources — first reply wins
    let winner = select {
        x from primary.fetch("cache-key")  => x handle e { -1 },
        y from fallback.fetch("cache-key") => y handle e { -1 },
        after 100ms                        => -1,
    };
    println(winner);
 
    // Wait for both to complete — destructure results
    let (users, posts) = await fork (
        db.get_users(),
        db.get_posts(),
    );
    println(users.expect("database actor stopped"));
    println(posts.expect("database actor stopped"));
}

Go

Go's select is a statement that operates on channels. You need to wire up goroutines and channels before the select, and use separate variables for results. The join pattern requires result channels or a sync.WaitGroup with shared variables.

select_join.go
// Race: wire up channels and goroutines first
ch1 := make(chan Result, 1)
ch2 := make(chan Result, 1)
go func() { ch1 <- primary.Fetch(key) }()
go func() { ch2 <- fallback.Fetch(key) }()

var winner Result
select {
case x := <-ch1:
    winner = x
case y := <-ch2:
    winner = y
case <-time.After(100 * time.Millisecond):
    winner = Result{Err: "timeout"}
}

// Join: buffered channels collect results
usersCh := make(chan []User, 1)
postsCh := make(chan []Post, 1)
go func() { usersCh <- db.GetUsers() }()
go func() { postsCh <- db.GetPosts() }()
users := <-usersCh // blocks until ready
posts := <-postsCh

Rust (tokio)

Tokio's select! and join! macros are close to Hew's ergonomics. The main differences: they require a tokio runtime, select! is a macro (not a language construct), and the borrow checker can fight you on shared state in select branches.

select_join.rs
// Race: tokio::select! macro
let winner = tokio::select! {
    x = primary.fetch(key) => x.unwrap_or(-1),
    y = fallback.fetch(key) => y.unwrap_or(-1),
    _ = tokio::time::sleep(
        Duration::from_millis(100)
    ) => Err("timeout"),
};

// Join: tokio::join! macro
let (users, posts) = tokio::join!(
    db.get_users(),
    db.get_posts(),
);

What this shows

Developer experience

Hew's select and await fork (...) are expressions — assign the result directly to let. Go's select is a statement that requires pre-wired channels, separate goroutines, and result variables declared outside the select block. Rust's tokio macros are comparable in ergonomics but require a runtime dependency.

Debugging

In Hew, the timeout is visible in the select expression as an after clause. Older Go timer patterns had resource-lifetime pitfalls in tight loops, and any losing goroutine still needs explicit cancellation via a context if continuing work would be harmful.

Trade-offs

Go's channels are more general-purpose — they work for any goroutine-to-goroutine communication, not just request/response patterns. Rust's tokio macros handle arbitrary futures, not just actor messages. Hew's select and await fork (...) are narrower in scope (actor message awaits) but eliminate the plumbing code entirely.