3 Pieces of Code I Can Write in Rust (but not Go)

Ludi Rehak
Rustaceans
Published in
5 min readFeb 26, 2024

I recently started learning Rust. I was drawn to the language for two reasons.

  1. The annual Stack Overflow survey that ranks Rust as the most loved / admired programming language for the last consecutive eight years.
  2. Performance.

On both counts, it delivered.

  1. Rust helps you write more reliable code. We are all familiar with compilers that enforce type-safety, preventing errors such as passing arguments to functions with the wrong type. The Rust compiler also enforces memory-safety. Unless you opt in to unsafe Rust, it eliminates entire classes of memory errors, including null pointer dereferences, data races, double frees, and use-after-frees. Less well-known is its requirement for match exhaustiveness. Every match statement must cover all possible cases for the value matched against. The compiler rejects bad code, and instead underlines and explains the offense, catching errors before they reach run-time. The compiler has your back, which is a major reason why Rust is so highly regarded.
  2. Rust is fast. There is no garbage collection, nor any of its unpredictable stop-the-world run-time overhead. Memory management is still automated, though, by determining allocation and deallocation points at compile-time. The Rust community is committed to zero-cost abstractions. That means that high-level constructs (iterators, traits, smart pointers, etc) run as efficiently as any other code you could write to perform the same task. These abstractions do not incur a run-time penalty, allowing you to write expressive, high-level code without sacrificing performance. Rust’s run-time performance is on par with C’s, significantly outperforming other languages across benchmarks.

My expectations for Rust, joy and speed, were fully met. I was satisfied.

And then my expectations were exceeded.

I found that Rust lets me write code that is not possible to write in Go. The examples I’m about to share are not mere hypotheticals; they are taken from real instances at work where Go’s limitations prevented me from implementing the solutions I needed. The difference here isn’t that Rust code was more correct or faster than Go code. It was that I could write it at all!

1. Reading a thread’s ID

Logging the ID of the current thread, or in Go’s case, the goroutine ID, is very useful. It makes it clear which thread is doing what. Without this information, the activity of every thread is interleaved in a single log file, making it difficult to follow a single flow of execution.

In Rust, getting a thread id is as easy as:

let id = thread::current().id();

Go, however, does not expose the goroutine id. Their official explanation is vague, but this comment from Russ Cox is interesting:

Go’s use of threads has broken a surprising number of C libraries that turned out to have hidden thread-local state. Go is not going to make the same mistake.

Go deliberately withholds goroutine IDs to discourage developers from programming thread-local storage. Developers who only want to make sense of their logs must resort to other means to retrieve that information.

2. Prioritize cases with a single select statement

Go chooses one of all the ready cases in a select-case statement (which waits on multiple channel operations) at random.

If multiple cases are ready, and you want to preferentially execute one in a single select statement, you can’t. In the code below, nReady1 is statistically equal to nReady2.

func main() {
ready1 := make(chan struct{})
close(ready1)
ready2 := make(chan struct{})
close(ready2)

nReady1 := 0
nReady2 := 0
N := 10_000
for i := 0; i < N; i++ {
select {
case <-ready1:
nReady1++
case <-ready2:
nReady2++
}
}
fmt.Println("nReady1:", nReady1)
fmt.Println("nReady2:", nReady2)
}
nReady1: 4943
nReady2: 5057

You would have to use nested select statements, with a default, to achieve priority selection.

        select {
case <-ready1:
nReady1++
default:
select {
case <-ready1:
nReady1++
case <-ready2:
nReady2++
}
}

However, Tokio, an asynchronous runtime, lets you set a priority order with the biased keyword in a single select statement. Cases are prioritized in the order they appear in the code.

use tokio::sync::mpsc;
use tokio::select;

#[tokio::main]
async fn main() {
let (tx1, mut rx1) = mpsc::channel::<()>(1);
drop(tx1);
let (tx2, mut rx2) = mpsc::channel::<()>(1);
drop(tx2);

let mut n_ready1 = 0;
let mut n_ready2 = 0;
let n = 10_000;
for _ in 0..n {
select! {
biased; // prioritize ready cases in the order they appear
_ = rx1.recv() => {
n_ready1 += 1;
},
_ = rx2.recv() => {
n_ready2 += 1;
},
}
}

println!("n_ready1: {}", n_ready1);
println!("n_ready2: {}", n_ready2);
}
n_ready1: 10000
n_ready2: 0

Rust expresses in a single select statement what takes Go N select statements, where N is the number of cases to prioritize.

3. Generic Type with Pointer and Value Receivers

Let’s say, in Go, you want to define a type constraint for a type parameter T that implements two methods:

  1. M() on the bare value T
  2. P() on the pointer *T

For example, S implements both methods.

type S struct{}

func (s S) M() {}
func (s *S) P() {}

Unfortunately, it’s not possible to specify both methods with a single type constraint. You must use two separate type parameters, each with their own constraint, and then chain them in a function f. First, define T to be constrained by the Mer interface type. Then, define PT to be constrained by the Per[T] interface type, and refer to the first T. It’s not intuitive.

type Per[T any] interface {
P()
*T // non-interface type constraint element
}

type Mer interface {
M()
}

func f[T Mer, PT Per[T]](t T) {
PT(&t).P()
t.M()
}

In Rust, the solution is straightforward. Define a single trait, MyTrait, then use it as a trait bound in f.

trait MyTrait {
fn M(&self);
fn P(&mut self);
}

struct S;

impl MyTrait for S {
fn M(&self) {}
fn P(&mut self) {}
}

fn f<T: MyTrait>(t: &mut T) {
t.M();
t.P();
}

Rust’s slogan is:

A language empowering everyone to build reliable and efficient software.

I wouldn’t say Rust is for “everyone”, but I’ve found the rest of the slogan to be true. Rust ensures reliability by detecting errors at compile-time rather than run-time, offers performance, and has a degree of expressiveness that lets me write code other languages don’t.

--

--