Spectral now part of Check Point’s CloudGuard to provide the industry’s most comprehensive security platform from code to cloud Read now

Rust vs Go – Why not use both?

By Dotan Nahum June 30, 2021

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.

SimilaritiesGoRust
Mature packagerGo modCargo
Easy library creationGo modCargo
Advanced language constructsGoroutines (CSP), gradual typingGenerics, FP, inference, Borrow checking
PerformanceClose enough to C++Similar or better than C++
Cross compilationVia GoVia the rustup toolchain
Easy ecosystem toolinggo getcargo install
Self-contained, small binaries~8mb as a start~3-4mb as a start (easily less)
Memory safetyVia GC, good enoughVia borrow checking, perfect
Dev toolingGreat, a gold standardGreat, 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
    • Shipping mature, use-case-specific software – Consider Rust
  • 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:

extern "C" {
    pub fn images(...);
    pub fn cleanup(...) -> ...;
}

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:

multi_build:
  strategy:
    matrix:
      os: [windows-latest, ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}

Be Aware Of musl and glibc

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:

{
  "cid": "some-unique-correlation-id",
  "action":"clear_images",
  "params":[{..}]
}

And a response object that looks almost the same:

{
  "cid": "some-unique-correlation-id",
  "action":"clear_images",
  "response":[{..}]
}

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.

rust vs go

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.

Related articles

Identity Governance: What Is It And Why Should DevSecOps Care?

Did you know that the household data of 123 million Americans were recently stolen from Alteryx’s Amazon cloud servers in a single cyberattack? But the blame

Parallel Testing Unleashed: 10 Tips to Turbocharge Your DevOps Pipeline

Parallel Testing Unleashed: 10 Tips to Turbocharge Your DevOps Pipeline

Every software team is constantly looking for ways to increase their velocity. DevOps has emerged as a leading methodology that combines software development and IT operations

MongoDB Replica Set: A Developer's Tutorial to MongoDB Replication

MongoDB Replica Set: A Developer’s Tutorial to MongoDB Replication 

There’s one thing every developer should do – prepare for the unknown.  MongoDB is a NoSQL database widely used in web development, designed to handle unstructured

Stop leaks at the source!