Rust GOes Async
I found a quite helpful way to get a better feeling of async Rust by looking at it from the Go perspective.
ʕ◔ϖ◔ʔ go f(x) = spawn(f(x)) 🦀
I had a backend project of a microservice that needed to be implemented with asynchronous Rust. At that time, there was a similar Go project, so I kinda got the chance to look at async Rust through the lens of Go's approach.
There is something cozy about Go's goroutines: you just use the go keyword, and it magically runs a function concurrently on a lightweight "green thread".
Meanwhile, with asynchronous Rust, everything seems somehow god damn complicated.
However, my point is that it only seems complicated in Rust.
There are 2 reasons:
- The overall Rust complexity. It may complicate thinking and a learning curve.
- The way how the asynchronous Rust is studied.
I mean, a typical Rust guide, such as the async book, quickly dives you into an in-depth exploration of async types and event loop implementation. It's the case when you can't see the forest behind the trees. In contrast, a typical Go guide skips the intricate details.
So, I think it can be really helpful - seeing something complex through the lens of simplicity.
Let’s get straight to the point. I suggest the following mind map:
| Go | Rust |
|---|---|
go f(x) |
spawn(f(x)) |
ch := make(chan int) |
let (tx, rx) = mpsc::channel::<i32>(0) |
ch <- v |
tx.send(v).await |
v := <-ch |
let v = rx.recv().await |
select |
tokio::select! |
The Rust's spawn method may appear from different runtime crates, e.g. tokio::spawn, or async_std::task::spawn, however, the core concept remains the same. The mental model of running the "green threads" in Go ("go the func!") maps closely to Rust’s async tasks spawning ("spawn the task!").
ʕ◔ϖ◔ʔ go the func = spawn the task 🦀
Also, there are many similar instruments in both languages like synchronization channels and the select notion.
go -> spawn
Let’s look at the Go's concurrency example and compare it to its Rust equivalent.
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
}The program creates a lightweight green thread (goroutine) to execute say("world") concurrently while the main function continues with say("hello").
What is the Rust equivalent?
use tokio::time::{sleep, Duration};
async fn say(s: &str) {
for _ in 0..5 {
sleep(Duration::from_millis(100)).await;
println!("{s}");
}
}
#[tokio::main]
async fn main() {
tokio::spawn(say("world"));
say("hello").await;
}
say("world") runs concurrently using tokio::spawn while the main function continues with say("hello"). This causes "hello" to print five times while "world" prints concurrently, resulting in interleaved outputs.
Let's compare them closely.

Go
go say("world")Rust
tokio::spawn(say("world"))A similar async-std approach would look like this:
async_std::task::spawn(say("world"));
💡 spawn(f(x)) starts a new asynchronous task managed by an asynchronous runtime.
🚩 The example is simplified: the spawned task should be awaited in the main() function; otherwise, the program will exit as soon as say("hello") in main() finishes its job. The example works because main() gives the spawned task enough time by printing 5 lines.
Rust syntax is more explicit.
Also, with Rust, a lot of "meta-thinking" is involved: what runtime to choose, and are there any compatibility issues? That's why mental simplification helps to get through all these complications.
The Rust syntax looks a little bit more scary. But it only seems scary.
#[tokio::main]annotation marksasync mainto be executed by thetokioruntime.- In Go, there is only one option: a built-in runtime (even loop), while in Rust, developers have to choose among many options.
asyncin front ofmainis needed to mark an async context of execution:- In Go this detail is hidden and managed by the language itself, but in Rust, there is a clear distinction between a synchronous execution and execution in an asynchronous context.
tokio::spawnspecifies that the spawner belongs to thetokiocrate.- I prefer the explicitness of the
spawninvocation via the "crate::spawn" notation because multiple crates may provide their ownspawnfunction. For example,ntex::spawncan coexist withtokio::spawn- they are cross-compatible but behave slightly differently.
- I prefer the explicitness of the
select -> tokio::select!
Let's look at another asynchronous technique: select.
ʕ◔ϖ◔ʔ select = tokio::select! 🦀
select allows to wait on multiple concurrent branches. The basic pattern in Go and Rust is the following:
loop { // infinitely
select { // only one of
case #1: sending a message
case #2: waiting for a message
case #3: doing something async
}
}Let's take a concurrent Go's Fibonacci example.
func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}A Rust equivalent looks as follows (pay attention to the comment lines!):
async fn fibonacci(c: mpsc::Sender<i32>, mut quit: mpsc::Receiver<()>) {
let (mut x, mut y) = (0, 1);
loop { // for
tokio::select! { // select
_ = c.send(x) => { // case c <- x
(x, y) = (y, x + y);
}
Some(_) = quit.recv() => { // case <-quit
println!("quit");
return;
}
}
}Rust code is more verbose and explicit, but functionally identical. Here's the visual recap:

Simplify the Mental Model
A straightforward mental model can ease the learning curve, making a Rust developer competitive in the backend development domain. For instance:
go f(x)=spawn(f(x))async fnis just a "Future", and "Future" is just a state machine- Rust channels are as easy as in Go
- async block
async {}is my friend :) - etc.
Async programming in Rust doesn’t have to be any more intimidating. With the right framing, it can be just as accessible and intuitive.