Justin Worrell works at Shyft Solutions

Rust

Written by Justin Worrell on March 29, 2022. This should take about 8 minutes to read.

Rust is Awesome: A Case Study

Intro

The Rust programming language is taking the software development world by storm. Not only has Rust been a developer darling, voted the most loved language on Stack Overflow for multiple years running; it is gaining widespread adoption within large software companies as well.

After watching a really great by talk, Rust: A Language for the Next 40 Years, by Carol Nichols, I decided it was past time that I learn Rust.

I started with reading The Rust Programming Language. However, coming from a mutable object-oriented programming background (thanks Java…), many of Rust’s concepts were quite new to me. It quickly became obvious that if I wanted to learn the language well – I would need to learn by doing.

For a long time, I’ve been interested in making near real-time weather data available via API, and exposing it with as much concurrency and as little latency as possible. A great opportunity presented itself when the National Weather Service made satellite derived lightning data available on AWS. Rust seemed like a perfect fit for this task – so I set off learning by doing.

The end result was a prototype lightning API where a user can supply a geographic bounding box and almost instantly get back a json containing all recent lightning strikes within that bounding box. Below are some of the ways Rust helped me achieve this prototype.

Rust Language Features

Of course, Rust is a massive topic. I've highlighted a few features below, along with links to resources to learn more.

Ownership and Borrowing

In Rust, any given value has exactly one owner (a variable). Ownership can be moved to a different variable.

fn ownership() {
    // x "owns" the Box
    let mut x = Box::new(10);  // In Java this would be Integer(10).

    *x = 11;  // set the value in the Box to 11

    let mut y = x;  // y now owns the Box
    *y = 12;  // set the value in the Box to 12

    *x = 13;  // error[E0382]: use of moved value: `x`
}

In addition to ownership, a value can be borrowed:

  • One mutable borrow or
  • One or more immutable borrows but never both

If a value is lent out, the owner can't mutate it. Much like if you lend someone a cake, you can't eat it. The cake analogy breaks down with immutable borrows: a value can be borrowed immutably multiple times.

Ownership and borrowing is the main way Rust achieves the benefits of garbage collection without a garbage collector. When a value has one owner, and that owner goes out of scope, it now has 0 owners. The compiler knows it is safe to deallocate those resources.

A very thorough discussion of ownership and borrowing can be found here: What Is Ownership?

Traits

Traits in Rust fill much of the same role as interfaces in Java. Here is the classic undergrad polymorphism example, implemented in Rust:

trait Vehicle {
    fn start_engine(&self);
    fn stop_engine(&self);

    fn get_wheel_count(self) -> u64;  // unsigned int
    fn get_max_speed(self) -> f64;  // 64 bit float
}

An important quality of Rust traits is that since the type of a variable is (generally) known at compile time, methods can usually be called directly (static dispatch, like with C++ templates, but unlike Java objects.)

Rust does support dynamic dispatch when required, such as an array of Vehicles whose types are not known at compile time.

You can read more about what makes Rust traits awesome here: Abstraction without overhead: traits in Rust.

Functional Features

Rust supports anonymous functions, which are convenient for things like sorting arrays of objects.

fn anonymous_function() {
    // Vec (array) of Vehicle objects
    let mut vehicles = Vec::<Vehicle>::new();

    // Sort the array of vehicles by their max speed
    vehicles.sort_by(|v1, v2| {   // |v1, v2| is an anonymous function 
        let v1_max_speed = v1.get_max_speed();
        let v2_max_speed = v2.get_max_speed();
        
        if v1_max_speed < v2_max_speed {
            return Ordering::Less
        } else if v2_max_speed < v1_max_speed {
            return Ordering::Greater
        }
        
        return Ordering::Equal
    })
}

Rust also has robust support for closures and iterators, which can often make it feel like programming in a high level language.

You can learn all about Rust's functional features here: Functional Language Features: Iterators and Closures.

Enum

Rust Enum(erations) can contain values. Here is the source of the Rust Result enum, with some comments added:

enum Result<T, E> {
   Ok(T),   
   // Ok _contains_ an object of type T, which can be anything

   Err(E),  
   // Err _contains_ an object of type E, which can be anything but should be an error object
}

In this case, a Result enum can contain either "Ok", with a return value, or "Err" with an error object.

Match (Option, Result)

Enums really shine when used with pattern matching. Because match statements must have a branch for every possibility, the combination of match and enum accomplishes both error handling and preventing Null reference errors:

while app_state.is_running() {
    let lightning_messages =
        match get_lightning_messages() {
            Ok(lm) => {
                lm
            }

            Err(e) => {
                println!("get_lightning_messages failed with {}", e);
                continue;
            }
        };

    all_messages.append(lightning_messages)

In the above example, the type system forces us to check if get_lightning_messages returned Ok (with a value) or Err (with an error). In this case we simply print an error message and continue. Note, in the Ok branch, lm becomes the "return" value of the match expression and thus lightning_messages is set to lm.

Security

Security is a massive challenge even for the largest organizations who have entire teams devoted to it. As a one-person team developing a side project which I intend to expose on the public internet; I needed a way to achieve security without devoting a disproportionate portion of effort it.

The Rust type system makes many security vulnerabilities impossible – the application simply refuses to compile. According to Microsoft, “what separates Rust from C and C++ is its strong safety guarantees. Unless explicitly opted-out of through usage of the ‘unsafe’ keyword, Rust is completely memory safe.” Mozilla goes even further, “Over the course of its lifetime, there have been 69 security bugs in Firefox’s style component. If we’d had a time machine and could have written this component in Rust from the start, 51 (73.9%) of these bugs would not have been possible. Given the resource constraints of a one-person project, the above are protections I really wanted for my API!

The following entire classes of errors are prevented by the Rust type system:

  • Null pointer issues: safe Rust does not have null pointers. Instead, functions return Optional objects. The type system forces the developer to handle these in a safe way.
  • Use after free, double free: Rust’s concept of ownership (any given object only has a single owner) makes both of these impossible.
  • Buffer overflows: due to a safe standard library, these errors are impossible within safe Rust code.
  • Data races: again, because every piece of data has a single owner, and no more than one mutable reference at any given time – data races are also impossible.

Performance

In decades past, performance was important to software because hardware resources were scarce. Common thinking today is that hardware is cheap but that software developers are expensive. This thinking propelled PHP and Python to prominence for dynamic websites. However, for a one-person side project, this thinking is inverted:

  • This is a side project that I work on nights and weekends for learning and enjoyment – developer time is “free.”
  • When this API is up and running it will be hosted on AWS. AWS can be pretty expensive for a side project. The more efficient my software is – the less I will need to spend on AWS to make it available to the world.

Rust focuses on “zero cost abstractions”. In many languages developer friendly and security focused abstractions come with a runtime penalty. A major design goal of Rust is achieving the same level of abstraction without a runtime performance penalty. For example, Java offers many of the same memory safety guarantees as Rust (use after free, double free) – but does so with a run time cost: the garbage collector constantly running in the background. Rust is able to achieve these same things (and more) at compile time through its type system (ownership and borrowing.)

When Discord transitioned one of their critical services from Go to Rust, they achieved a massive performance win (Go in purple, Rust in blue):

Discord Performance Win

image credit: Discord

In the above picture, one can see a dramatic performance improvement resulting from moving from a garbage collected language to one that primarily uses ownership and reference counting.

Another good example is Rust’s take on polymorphism. In mainstream object-oriented languages, polymorphism is primarily achieved with indirection (dynamic dispatch): when a method is called, a look up takes place, and (based on the concrete type of the object), the correct method is called. While this type of polymorphism also exists in Rust, much of the same functionality can be achieved in Rust at compile time: the compiler knows at build time which method to call and as such proper code to do so is generated during compilation (static dispatch).

Concurrency

In order to keep costs down with an internet exposed API, efficient concurrency is critical. The more requests that can be served with a given quantity of CPU and memory – the lower AWS bills will be every month. For the most part, software systems have moved from thread-based concurrency to asynchronous concurrency. The C10K Problem has been solved for two decades now.

While asynchronous concurrency leads to much more efficient systems, it can place additional burdens on developers. Callbacks can lead to difficult to follow code. Many systems solve this by making asynchronous programming look like synchronous programming. This brings its own set of hurdles: if a developer accidentally makes a blocking call – they will hang all connections while it services a single client. More concerning, this code will pass unit tests and will likely not be caught by static analysis either.

In Rust’s concurrency model, asynchronous functions return Future objects. This enables the type system to enforce some aspects of concurrency. Additionally, it can make blocking calls easier to spot (as async calls must be awaited and blocking calls are not.) This model compiles asynchronous functions to state machines, which as implemented ends up being another zero-cost abstraction.

For a prototype capability, initial performance numbers were very encouraging, easily capable of saturating a 1 GB ethernet link on a 5-year-old laptop.

Performance
Concurrency: 100 Requests/Sec: 9770.07
Time taken: 0.512s Per Request: 10.24ms
Completed Requests: 5000 Transfer Rate: 5.81 Gb/s

Performance achieved on a 2017 Macbook Pro laptop, local link

Conclusion

Now that I've given you a taste of the Rust programming language, I hope that you want to learn more!