Here’s why it’s worth using Rust to build networked services
Meet Ferris, the friendly rustacean.
Rust has evolved to the point where it’s now a good choice for building networked applications. Find out why we need it, and what a difference it can make.
I’m going to start by taking you on a whirlwind journey through the imagination. Sitting comfortably? Excellent.
Now imagine you’re a software engineer on a cross-functional team and you’re pairing with literally the world’s best software developer. No expense has been spared in securing this individual. His name is Ferris but, unlike Ferris Bueller, he’s never had a day off.
Our Ferris is extremely knowledgeable and exceedingly patient. He doesn’t hand everything to you on a plate because he wants you to learn how to be an outstanding engineer. He’ll constantly give you tips and suggestions on how to move forward. Most importantly, he’s a faithful friend – he has your back and won’t let you fail. If he spots you doing the wrong thing, he’ll step in and set you right.
You two have loads in common. You’re on the same page. His goal, like yours, is to build brilliant software. Software that isn’t full of security vulnerabilities and bugs that lurk beneath the surface unnoticed – until it’s too late. Software that behaves correctly and efficiently from the start.
Now imagine that every engineer on your team also has a friend like Ferris, all of them working together to help the team succeed. Your team is effectively twice the size it would otherwise be. You’re moving much faster than you were before. And what you’re building is twice as good, twice as efficient, and has half the bugs. The team, the client, and the end customer are all happy.
Our Ferris isn’t the stuff of utopian storytelling. He’s very real. He’s the Rust compiler.
We needed Ferris, the friendly rustacean.
Compiling Rust is a CPU intensive task. That’s because it’s doing so much work analyzing what you’re trying to do. But we would rather rent extra CPU cycles in Google CloudBuild than hire extra developers – human minutes are much more expensive.
The Go language takes a different approach. Its compiler is much faster. That’s often regarded as a positive thing, bringing you feedback sooner.
But unfortunately, it’s not as concerned with safety and correctness. It won’t tell us when we’re not handling errors, or when we’re missing edge cases, or when we haven’t handled all the possible outcomes of an action. It won’t protect us from null reference exceptions, or problems with thread safety. And it defers memory management to a runtime garbage collector.
Over the years, Mozilla has tried and failed, twice to rewrite Firefox’s CSS rendering engine in C++. It’s a highly concurrent and very complicated problem. They wrote Rust in order to help out and then, using Rust, they successfully rewrote the CSS engine from scratch. They analyzed all the bugs ever introduced in the original CSS engine and discovered that almost 3/4 of them wouldn’t even have been possible in Rust.
Why does Rust matter right now?
Rust has been around for over a decade. It’s definitely not new. What is new though, is that it’s recently evolved to the point where it’s become a serious contender for writing higher level programs such as networked services (e.g. web servers and APIs).
In fact, the latest stable version of Rust (v1.39) includes the important async/await technique for treating asynchronous actions (e.g. calling upstream APIs, or opening files on the filesystem) as though they were synchronous. This makes it easy to build highly concurrent applications.
So why would we use Rust over Go, for example?
Go was written at Google to help thousands of Google engineers glue together the many disparate components that make up modern distributed systems. It’s a simple language that’s very easy to learn, with a fast compiler.
Go is fast at run time, massively concurrent and compiles to a single binary. All great features. It’s a higher level language that’s pushing down into the systems level space.
Rust was written at Mozilla, as a modern systems language, but one which is now pushing up into higher-level spaces. It prioritises safety and performance. Crucially, unlike Go and Node.js, it has no garbage collector or other runtime, instead introducing the novel concept of ownership.
Understanding memory management and what it means to “own” and “borrow” makes the language a bit harder to learn than Go, but gives you C++-like power and performance. Like C++, Rust has zero-cost abstractions (e.g. the futures that power the new async/await capability), which means we can use a feature without paying a runtime penalty (over and above that of writing it ourselves).
In fact, often it can be faster than writing it ourselves (because of compiler optimisations etc), meaning that in some situations, Rust can even be faster than C++.
We haven’t talked about types.
Go’s type system is fine. But it’s not as good as Rust’s. Go doesn’t have enums for instance. Rust has very powerful enums where the variants can be tuples or structs (aka tagged unions). It has Algebraic Data Types. And very powerful pattern matching.
Recently, having done some data modelling in both Go and Rust, we’ve found the type system in Rust to be much more expressive and helpful; allowing us to create safer, more accurate and descriptive models. They’re easier to read and understand, not least because there’s a lot less code involved (for example, simulating enums in Go is, quite frankly, ridiculous).
Also, because the Rust compiler is all-knowing, the type inference in Rust is superior to that of Go (and even, arguably, better than that of ReasonML/OCaml). This means Rust code is clean and easy to read, with very little type annotation. A crucial design decision was to enforce that every function’s signature must be explicitly typed. We end up getting the best of both worlds: strong, obvious types that don’t get in the way.
But what about productivity?
Go was never intended to be a perfect language. It was designed to be pragmatic and easy to get moving very quickly. Most people can learn it in half a day and be productive in a week. The fast compiler makes it great for prototyping, and the small binaries are great for command line tools, DevOps tooling, and services running in modern containers.
Rust, on the other hand, is harder to learn, with new concepts that are, at first, tricky to understand. It can take weeks to become productive. Longer compile times can make it slower to build prototypes. And, to add salt to the wound, the compiler’s strictness can test your patience. But we found that, more often than not, when it compiles, it works.
This is important because it means any bugs are often higher-level logic bugs, which tend to be easier to spot, and will have unit tests to guard against errors. In fact, what seems (to me) to happen with Rust, is that you end up needing fewer unit tests because the tests can also be higher-level. This makes refactoring much simpler because there are fewer tests to refactor alongside.
Overall, the additional investment in learning Rust pays back. Over and over again.
I hope this doesn’t read as another Rust vs Go rant. I don’t even think they compete in exactly the same space, and they both undoubtedly have their place. For example, the authors of the all-new Linkerd2 have chosen to rewrite the control plane in Go and the data plane in Rust. This makes perfect sense to me.
Go may well be a better choice when integrating tightly with Kubernetes – its ecosystem is certainly more mature in that space. And Rust is an obvious choice for the sidecar proxies that need raw performance, correctness and stability that are much easier to achieve.
Go will remain a good choice for building networked application services, but it’s becoming evident that Rust is stealthily encroaching on the space Go’s been occupying. Rust’s virtually non-existent runtime, lightweight footprint, and run time safety and performance make it a great choice for containerised microservices.
Ultimately, it’s massively important to choose the right tool for the job. Modern microservice applications should be polyglot – the whole point of a microservice is that its implementation choices are a matter for itself. Modern architectures are composed of components that are built in all sorts of different tech, and where they succeed, it’s largely because they’ve used the most appropriate tooling.
In summary? Ferris is growing on me, and he’s worth getting to know.