Which is a better choice, Rust or Go? Which language should you choose and why? How do Rust and Go differ and how are they similar? And should you combine them in your stack?
By now it’s clear that these two programming languages made it through a wave of new languages in recent years. They’ve become two of the most pragmatic, useful, and most loved languages for systems programming.
At Spectral, we’re a Rust shop. However, we also like to build in Go. In some cases, we use both. In this article, I’ll show you my personal preference. My preference is to use both languages without making compromises to move fast, taking advantage of the best of both worlds. Something I’ve done in the past with Go and Ruby.
We are going to explore the similarities and differences between Rust and Go. Then, I will show you a simple way to combine both languages in a way that lets you have your proverbial cake and eat it, too.
What Is Go?
Go (or GoLang) is a programming language that was designed by Google, mainly to solve pains that grew from using C++ at scale. Or to quote: “The problems introduced by multicore processors, networked systems, massive computation clusters, and the web programming model were being worked around rather than addressed head-on”.
One of the most famous instances of Go implementation at Google, was porting the dl.google.com download service to Go. This resulted in almost alarmingly positive gains and a success story that echoed for years.
What Is Rust?
Rust started as a systems language for writing high-performance applications that are usually written in C or C++, with a focus on uncompromising safety. Over its 10 years of evolution, it became a legitimate general-purpose programming language. Rust comes with the benefits of zero-overhead, high performance, and basically everything that Go targeted as a goal when it was created.
The most famous implementation of Rust was in building Servo, the next-generation Mozilla browser engine. Today, many bits and pieces from Servo made it into Firefox and other related software such as Magic Leap.
Rust vs Go: The Similarities
Go and Rust deliver amazing developer stories. From uncompromising getting started experience to great operability story.
Similarities
Go
Rust
Mature packager
Go mod
Cargo
Easy library creation
Go mod
Cargo
Advanced language constructs
Goroutines (CSP), gradual typing
Generics, FP, inference, Borrow checking
Performance
Close enough to C++
Similar or better than C++
Cross compilation
Via Go
Via the rustup toolchain
Easy ecosystem tooling
go get
cargo install
Self-contained, small binaries
~8mb as a start
~3-4mb as a start (easily less)
Memory safety
Via GC, good enough
Via borrow checking, perfect
Dev tooling
Great, a gold standard
Great, caught up to Go
Rust vs Go: Key Differences
Instead of highlighting individual differences it’s better to connect with the different mindset (or “DNA”) of each language and to understand under what circumstances each was created.
Think of Rust as a precision tool, built to provide performant, stable, and safe software that you can fine-tune, specialize and optimize easily.
More than 8 years ago, Graydon Hoare, creator of Rust, said this about Go:
Go’s a good language. It’s less complex than Rust, but also less ambitious. So you can pick your poison. Go’s memory model, for example, has no concept of isolating memory between co-routines or threads, nor of controlling mutability. Everything can always race on the same global mutable heap. Similarly, it has only one kind of pointer, which can always be null, and all pointers in all co-routines are subject to a single, global garbage collector. Rust statically differentiates all these cases, divides memory and pointers up into different types, which means we can control safety and performance much better, but at the cost of the programmer having to think more. Rust also provides a few extra “modern bits” that Go lacks: generic types, destructors, disjoint unions, that sort of thing. But to its credit, Go gets a lot of mileage out of the stuff it included, and I’m glad it’s making headway.
So, think of Go as a simple tool (in a good way), built for fast software iteration, fast builds in large projects and large teams. All that while trading off almost nothing in runtime and with a bigger ecosystem of libraries to use.
There are some differences worth mentioning. While Rust has zero-cost abstraction, no GC with move & borrow semantics, data race prevention, pattern matching, safety, each of those a monumental achievement. All topped with an even bigger achievement; everything works together. Go prides itself by lacking those (by design) and always providing the simpler to grasp (yet perhaps compromising approach). Lack of generics in Go is one such topic to which one solution is “generating code from a template is good enough”. Alternatively – “just write more code”.
Rust vs Go: How Do I Choose?
When it comes to picking between Rust and Go, I usually follow this initial decision tree, which I’ve also recommended to many colleagues in the past:
If you’re moving out from C++ and want to keep the mindset and values (such as zero-cost), Rust would feel more natural
Your delivery model
Getting things done and failing fast, getting quick feedback – Use Go
Do you value expressiveness, creativity and empowering codebase? Use Rust
Otherwise, if you value verboseness and being explicit as a driving factor for simplicity with some ways to be creative -Use Go
Do you want to move faster by depending on a large ecosystem (think about SDKs for X, clients for Y, database drivers for Z)? Use Go
Mind the specific communities you’re aiming at (as an example: Go has a larger contributor base and many open communities virtually identify with Go, such as CNCF)
This is just an initial list, but like a good 80/20 rule, it covers most of what you’ll need to start. The rest will have to come from your own project requirements and goals.
Using Both Go And Rust
We want to create a way that one is embedded in the other. In our case, we need to choose. We use “binary” as a term to represent: library, executable, service, or any other deliverable software.
Go used in our Rust binary
Rust used in our Go binary
Both are possible, but the shared part should be built as a shared C library.
We’re going to demonstrate Go embedded in our Rust binary.
Orchestrating the build
First we need to build our Go project as a shared C library. We have an example project that deals with Docker APIs that was easier to build in Go, but harder to build in Rust. To build, we will use buildmode=c-archive:
$ go build -a -v -buildmode=c-archive -o libautodocker.a libautodocker.go
We’ll end up with libautodocker.a which is what our Rust project need to interoperate with. This should also create the appropriate header files for linking C libraries that Rust will need.
Since Rust and linking C libraries has a great developer story, using the library should be as easy as:
We need to still figure out how the interface looks like and there are a few tradeoffs to consider, we’ll take a look at that in the following topics.
Aligning Cross-compilation
When Rust cross-compiles, Go needs to cross-compile as well. And the two compilation targets must be the same for the C interop to work.
To avoid quirk, the pragmatic approach would be to use a CI system with matrix build. CI like GitHub Actions, can easily perform a build job on all the targets you’d wish, without forcing a build through LLVM (i.e. building for Windows on a Linux machine). Which Go does fantastically and Rust does well enough (but not perfectly).
It’s best to create a build.rs file, to orchestrate the build through Cargo:
use std::env::var;
use std::path::Path;
fn main() {
let manifest_dir = var("CARGO_MANIFEST_DIR").unwrap();
let lib = "autodocker";
let path = Path::new(manifest_dir.as_str()).join(lib);
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-flags=-l framework=CoreFoundation -l framework=Security");
}
println!("cargo:rustc-link-search=native={}", path.to_str().unwrap());
println!("cargo:rustc-link-lib=static={}", lib);
}
And a GitHub Action workflow snippet to lock down our matrix. As an example:
Other than making sure you’re building for the same target, you need to be aware of the c-lib you’re using in the Rust and Go sides.
It’s best to use a similar choice in both, and, more importantly, to understand that glibc is historically the popular choice and musl would be the less popular one.
If you’re aiming at using Go, chances are you made this choice because you want to enjoy the wealth of packages it has. A good number of these might have a strong dependency on glibc which would force back glibc onto your Rust build as well.
Safety
Since we’re building our bridge between Rust and Go based on packing one of these as a C library, we’re going to have unsafe code in the borders of the bridge.
Handing off from Rust to Go and back means converting strong, safe types to unsafe types. There’s nothing much to do here, other than keeping it in mind. And then incorporate it into your choice of how to do FFI.
The Different Forms Of FFI
We treat the term “FFI” (foreign function invocation) here as the family of ways to communicate between Rust and Go. There are two popular ways to do this:
One is to build a strongly typed interface for every function, capturing the parameter types as well as return types, and handle these safely at both call sites. An advantage here is the clear expectations and strong interfaces, as well as getting a predictable performance profile. Disadvantages would be the work of mapping, translating, and building “drivers” for mapping these back and forth. In addition, there’s the chance to break interfaces by way of breaking upgrades, which are much less visible than what we’re used to in other software practices these days. Consider when a Go library upgrades and changes its C interface, but the Rust side which uses it remains the same — you’ll be getting a segfault and core dump.
The second way, which my preferred one, means that we’re building a single opaque interface that looks like this:
action(name, data)
This abstracts our calls, and turns all communication between Rust and Go to event-based. The single object that travels back and forth is an abstract event. It is upon both sides to build an understanding of how to interpret this event and react to it.
This makes the FFI binding trivial, safety a one-time effort, and the physical interface unbreakable. On the logical side, it’s up to both parties to agree on a protocol that they support and talk in that same protocol. But again, no breakage will happen in the form of core dump. The downside – most likely performance but in very specific cases only.
The way I like to implement this kind of protocol is either use JSON-RPC head-on, because why reinvent the wheel? Or in other cases, acknowledge that the communication is simple and build something very similar but not identical to JSON-RPC myself.
As an example, just create a generic format like so:
We keep a correlation ID because it’s wise to do in event-based systems. You never know when you may need it. But there is a good chance that you’ll need to correlate between action and response, depending on your architecture, now or in the future.
This article shows you how to take an engineer’s approach and pick the best tool for the job. In this case, we compared two different tools (Go and Rust) and showed how to combine them into one coherent solution. Sometimes you’ll just pick one and run with it, though such a decision would strongly depend on the project requirements at hand.
This means that, as a general rule, you should always try to understand what is unique to your project and how these needs are met with Rust and Go. Also, how these would serve your specific team and grow with your organization.
Rapid and constantly-evolving software development cycles have increased the need for reliable and fast infrastructure changes. Thus manually carrying out infrastructure changes has become an unscalable
Imagine slashing the time spent on code reviews while catching more bugs and vulnerabilities than ever before. That’s the promise of AI-driven code review tools. With