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.

Rust GOes Async
Go helps to figure out the asynchronous Rust

ʕ◔ϖ◔ʔ 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:

  1. The overall Rust complexity. It may complicate thinking and a learning curve.
  2. 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 marks async main to be executed by the tokio 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 of main 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 the tokio crate.
    • I prefer the explicitness of the spawn invocation via the "crate::spawn" notation because multiple crates may provide their own spawn function. For example, ntex::spawn can coexist with tokio::spawn - they are cross-compatible but behave slightly differently.

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:

  1. go f(x) = spawn(f(x))
  2. async fn is just a "Future", and "Future" is just a state machine
  3. Rust channels are as easy as in Go
  4. async block async {} is my friend :)
  5. 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.