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 main
to be executed by thetokio
runtime.- In Go, there is only one option: a built-in runtime (even loop), while in Rust, developers have to choose among many options.
async
in front ofmain
is 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::spawn
specifies that the spawner belongs to thetokio
crate.- I prefer the explicitness of the
spawn
invocation via the "crate::spawn
" notation because multiple crates may provide their ownspawn
function. For example,ntex::spawn
can 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 fn
is 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.