The purpose of this project is to compare the programming languages Rust and Go regarding their capabilities and characteristics for distributed programming.
First, we will take a look at the general characteristic of Rust and Go. If you are interested in more technical details head over to Distributed Programming in Rust and Go where we will implement a gRPC Server and Client in Rust and Go.
Rust and Go are in many ways quite similar but they do have some striking differences. Regarding syntax, both languages are inspired by C and C++ and simple code snippets can sometimes look almost identical:
fn main(){
println!("This says Hello World in Rust!");
}
func main() {
fmt.Println("This says Hello World in Go!")
}
Both languages have a strong, static type system that supports type inference. When it comes to programming paradigms, both languages follow a mixed paradigm approach that is mostly imperative. Rust and Go are both compiled languages with a compiler that supports cross compilation. The most important technical difference between the two is that Go has a garbage collector whereas Rust does not. This of course means that Rust offers better memory efficiency overall but it also adds additional complexity when coding in Rust as you have deal with Rust’s ownership system.
Language | Rust | Go |
---|---|---|
Garbage collector |
No GC |
Has GC |
Type system |
static |
static |
Cross compilation |
supported |
supported |
One thing that is pretty much inevitable when it comes to distributed systems programming is to have some form of messaging solution so that individual computing nodes can exchange serialized data.
A widely used solution for this is Google’s gRPC so let’s take a look at gRPC implementations for Go and Rust. For demonstration purposes, i built a small noticeboard service with a server that hosts notes which the client can query by title or author. You can find the code in the /grpc-example
directory.
The /grpc-example/proto
directory contains the protobuf definition file for the gRPC service.
Since both implementations of the service use the same protobuf definition, the respective client and server should be able to communicate with each other interchangeably.
That means you can run the Rust client with the Go server and vice versa.
Go of course has an official implementation of gRPC so that’s what i used. For Rust i had to look around a bit to find a suitable implementation. Luckily, Rust has a very convenient package registry with crates.io.
On crates.io you can find multiple crates (Rust’s version of packages/libraries) that offer gRPC implementations. The most promising one at the time seems to be tonic because it has the highest number of all-time and recent downloads. This is the one i decided to use.
What’s nice about working with Protobuf is that the compiler protoc
can generate code for different languages based on your service definition that helps implement the service in the respective language.
This way you don’t have to worry about things like serializing your data structures as Protobuf messages.
Tonic offers the additional package tonic-build
which can handle the code generation automatically.
This way you don’t need to invoke protoc
seperately and can just use Rust’s own build tool Cargo.
To generate Rust code for implementing the service all we need to do is add tonic-build
as a build dependency in the Cargo.toml
configuration file:
[build-dependencies]
tonic-build = "0.3"
and an additional file called build.rs
in the grpc-example/rust-grpc
directory with the following content:
fn main() {
tonic_build::compile_protos("../proto/notes.proto") // make sure to provide the correct path
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
}
Now we can run cargo build
in the /grpc-example/rust-grpc
directory to compile our code.
The generated code will appear inside the /grpc-example/rust-grpc/target/debug/build/
directory, look for a folder called rust-grpc-…
.
The process of generating the code for Go is actually a bit more tedious since you have to use protoc
directly.
That’s why i included the generated code in the repository under grpc-example/go-grpc/build/gen
.
Provided you have installed protoc
and set everything up correctly (find out how to do that here), you should be able to generate the code with this command:
protoc --proto_path=../proto --go_out=plugins=grpc:build/gen ../proto/notes.proto
Now that everything is setup let’s have look at the actual service implementations.
The noticeboard service provides two RPC methods: GetNoteByTitle
and ListNotesByAuthor
.
GetNoteByTitle
is a simple RPC method that takes a message of type Title
(which consists of only a single string) and returns one message of type Note
.
This is the server side implementation for GetNoteByTitle
in Rust:
async fn get_note_by_title(&self, request: Request<Title>) -> Result<Response<Note>, Status> {
for note in &self.notes[..] {
if note.title == request.get_ref().title {
return Ok(Response::new(note.clone()));
}
}
Err(Status::new(Code::NotFound, "Note not found"))
}
The implementation in Go looks like this:
func (s *NoticeboardService) GetNoteByTitle(ctx context.Context, title *noticeboard.Title) (*noticeboard.Note, error) {
for _, note := range s.noticeboard {
if (note.Title == title.Title) {
return ¬e, nil
}
}
return nil, errors.New("note not found")
}
Overall, these implementations are almost identical. What’s worth noting though is that in the Rust implementation the notes are stored in a vector in an Arc: notes: Arc<Vec<Note>>
. An Arc
is a reference counting pointer providing shared ownership of a value and is thread-safe.
The RPC method ListNotesByAuthor
is a bit more complicated since it uses server side streaming.
That means that instead of returning a single message as the response, the server to the client’s request with a stream of multiple messages.
This is the server side implementation of ListNotesByAuthor
in Rust:
async fn list_notes_by_author(
&self,
request: Request<Author>,
) -> Result<Response<Self::ListNotesByAuthorStream>, Status> {
let (mut sender, receiver) = mpsc::channel(100);
let notes = self.notes.clone();
tokio::spawn(async move {
for note in ¬es[..] {
match ¬e.author {
Some(a) => {
if a.mail == request.get_ref().mail {
sender.send(Ok(note.clone())).await.unwrap();
}
}
_ => (),
}
}
});
Ok(Response::new(receiver))
}
And this is the server side implementation of ListNotesByAuthor
in Go:
func (s *NoticeboardService) ListNotesByAuthor(author *noticeboard.Author, srv noticeboard.Noticeboard_ListNotesByAuthorServer) error {
for _, note := range s.noticeboard {
if (note.Author.Mail == author.Mail) {
srv.Send(¬e)
}
}
return nil
}
As you can see, the code is a bit simpler in Go. That’s because the Noticeboard_ListNotesByAuthorServer
uses a ServerStream
. Using the Send
method, it is possible to send the notes to the stream one after another. In the Rust implementation it is necessary to spawn a second thread using the tokio
package. This thread sends the notes to the main thread using a tokio::sync::mpsc
queue. An mpsc
queue offers similar functionality to channels in Go.
This approach for exchanging data between seperate threads is comparable to using a goroutine with a channel in Go. Consider this example from the Go client implementation:
ch := make(chan bool)
go func() {
for {
resp, err := stream.Recv()
if err == io.EOF {
ch <- true
return
}
...
}
}()
<-ch //wait until end of the stream is reached
As we have seen, Rust offers similar capabilities for asynchronous programming although you have to rely on a third party package. In this regard, Go offers better functionality out of the box and using channels and goroutines feels a bit more convenient because the syntax feels very natural.
When it comes to building large distributed systems that are suited for production use in an enterprise environment, most of the time you probably don’t want to build everything from scratch. So it makes sense to choose a framework or platform to build upon. Ideally, this should be one that is already being used by many other people and has turned out to be tried and true.
Below is a small overview of available frameworks, platforms and libraries that can be used to build (large) distributed systems in Rust and Go respectively.
One thing that stood out while researching available frameworks is that the overall amount of frameworks is a lot higher for Go than for Rust. Also, most of these frameworks seem to be generally more mature and better supported (as indicated by their number of contributors, releases as well as the age of the project). While i did find some very promising projects for Rust, only Actix and Tokio seem to be in a state where it would be advisable to use them in an enterprise production system.
The conclusion to be drawn here is that the "community ecosystem" regarding distributed programming is a lot better with Go than with Rust. This is not very surprising considering the fact that Go was made by Google with these kinds of systems in mind from the start. Also the usage of Go in high profile projects like Kubernetes and Docker contributed to the languages popularity in the world of cloud native computing and distributed systems.
Name | Description | Stars on GitHub[1] |
---|---|---|
|
5.9k |
|
|
10.7k |
|
|
971 |
|
|
213 |
|
|
595 |
Name | Description | Stars on GitHub[1] |
---|---|---|
|
15.2k |
|
|
2.7k |
|
|
3.4k |
|
|
2.9k |
|
|
2.7k |
One central aspect of Rust’s philosophy is its focus on performance. Although better low-level performance is in general a good thing, this particular advantage of Rust might not be as useful when it comes to building distributed systems. Since the performance of a distributed system as a whole tends to be constrained more by network latency than by the execution time of individual tasks.
On the other hand, the fact that Rust offers very good memory efficiency means that it could be suited very well for distributed systems that keep a lot of data in memory at a time. For example, the authors of the Ballista framework claim that:
"The combination of Rust and Arrow provides excellent memory efficiency and memory usage can be 5x - 10x lower than Apache Spark in some cases"[2]
As we have seen, Rust and Go offer very similar functionality when it comes to building distributed systems using gRPC. What makes Go stand out is its excellent support for asynchronous programming out of the box. Using goroutines and channels is very convenient. But you can achieve similar results in Rust if you use the third party package tokio.
Regarding gRPC support, tonic is an excellent implementation that is easy to use and can stand up to Google’s official gRPC implementation for go. What’s especially good about using Rust and tonic is that you can handle protobuf code generation directly with Rust’s own build tool Cargo with minimal configuration effort.
The one aspect that seperates Go from Rust for distributed programming is what i call its "community ecosystem". With a plethora of libraries, frameworks and platforms available, Go can hardly be beaten as the go to language for building distributed systems on a solid foundation. From this point of view, Rust is more suited for building low level systems from the ground up because there are just not as many projects to build upon.
In my personal opinion, the bottom line here is that Go is generally a better choice for distributed programming because it offers everything you need and there are a lot of resources out there to make your life easier. But Rust offers exceptional performance and memory efficiency so if you care more about high performance, choose Rust. In the end you can’t really go wrong with either language and you should choose the one that suits your personal preferences.