PART I
Fearless Concurrency with Rust
Safety
One of Rust’s selling points is Fearless Concurrency. Concurrent programming is often fraught with peril:
- Data races can occur when multiple threads write to the same data without the protection of either an atomic type or a locking mechanism.
- Lifetime issues can occur when a thread outlives a variable declaration. The variable may be destroyed by the parent, while the child thread still relies on it.
Let’s compare some Rust and C++ code, to see how Rust protects you from these issues.
Finding Prime Numbers
There are many ways to factorize a number and detect if it is prime. Some algorithms are very fast — but for this article we’re going to use a deliberately slow approach. A simple algorithm uses more CPU time, better demonstrating the benefits of multi-threading. We’ll use the following function to determine if a number is prime:
is_prime
iterates from 2 to half the number and ensures that all of the iterations return a remainder (the %
operator) other than zero when the tested number is divided by the iterator.
To be sure that it works, here’s a quick unit test that checks a list of prime numbers under 100:
Here’s an equivalent C++ function:
I kept it simple; range support isn’t in very many shipping C++ compilers yet — using an iota
view from the C++20 standard would enable a similar iterator-based approach. The code is functionally equivalent, however.
Single Threaded Prime Detection
The following Rust program counts how many prime numbers it can find between 2 and 200,000:
This program finds 17,984 prime numbers in the range 2 to 200,000.
An equivalent C++ program looks like this:
This program also finds 17,984 prime numbers. So far, so good. Let’s make a data race!
Racing Data
Looking to speed-up our prime counting, we eagerly fire up some C++ std::thread
objects to divide our workload between two threads:
This program compiles and runs. Visual Studio (my compiler) doesn’t show any warnings. The code has split the workload in two, spawning two threads — each covering half of the prime counting.
Unfortunately, it doesn’t work. The first time I ran it, it counted 17,983 prime numbers. The second time, it counted only 17,981! Threads are concurrently accessing count
. Incrementing a variable is a three-stage process: you read the current value, add one to it, and store the result. There’s no guarantee that all of these steps have been completed independently of other threads — resulting in a data race.
Maybe a Rust version of the same code will work?
The Rust version of the same program won’t compile. It gives two errors:
counter
may not be mutably borrowed more than once.counter
may outlive it’s borrowed value. A lifetime issue.
Rust has detected that your program is unsafe and prevents you from creating code that inadvertently gives the wrong answer.
Safe Multi-Threading
Here’s a safe version in Rust that both compiles and gives the correct answer:
There are three new concepts in this program:
AtomicUSize
is a special “atomic” type. Atomic types provide automatic protection against data-races. They are typically very fast, mapping directly to equivalent CPU instructions. Whenever you access an atomic type, you have to indicate the synchronization guarantee you need — in this caseSeqCst
provides the highest level of protection.Arc
is an “atomic reference counter”. When you wrap a type in anArc
, you can safely clone it and move the clones between threads. Each clone is a pointer to the contained data.Arc
guarantees that the underlying data won’t be deleted until every reference is done using it.- The thread closures (inline functions)
move
their captured data. They are operating on clones of the counterArc
— so they all point to the same place.
Combining these concepts has protected us against both data-race and lifetime issues:
- Rust’s borrow checker is happy because data is not borrowed between threads: the
Arc
is cloned, safely sharing a pointer to the counter data. - Rust’s data-race protection is satisfied that using an
AtomicUsize
cannot result in a data-race.
The result is a fast program that gives the correct answer every time.
Wrap-Up
Rust’s fearless concurrency guarantees saved us from a data-race! C++ didn’t warn about the error, it just gave incorrect results. Rust saved the day, by refusing to compile the incorrect code. In a large program, this could have saved hours of painstaking debugging.
Rust’s Arc
and AtomicUSize
types make for complicated-looking code. In the next part of this series, I’ll show you ways to tame the complexity and create fast, multi-threaded code that remains easy to read.
Are you a Rust fan? You can save 35 percent on the ebook versions of Herbert’s books with promo code rust_2022 now through May 30, 2022. Promo codes are not valid on prior purchases.