Rust’s data model is built around the idea of safe concurrency. Some things and limitations may not even make sense to you as long as you think in terms of a single-threaded application. Therefore a Rust course wouldn’t be complete without discussing concurrency and parallelism. The good news is that writing threaded applications in Rust is so much easier.
Let us look at the definition of a data race. A data race occurs when multiple threads of execution attempt to address a value an at least one of them is modifying it. That means you can share immutable values without limitation but mutable access must be exclusive. No other thread can safely read or write the data.
Does it remind you of something? Yes, that’s exactly the shared immutable borrows versus exclusive mutable borrows dichotomy. It was somewhat useful in a single-threaded scenario but maybe not useful enough to care.
Multi-threaded data model
Global variables are discouraged in Rust. Unlike C++, it only supports
compile-time initialization. It can do some magic via lazy initialization
that is deferred to the first use of the global object. Passing localy
created things by value or using a smart pointer like Box
or Arc
is
the preferred way.
There are multiple ways to add concurrency to your Rust application. One is
to create threads using the standard library. Another is using a scheduler
library like tokio
that uses a thread pool to distribute tasks. Tasks are
different from threads. They use coroutines instead of plain functions
and are extremely cheap to create and schedule.
You can pass things to newly created threads or tasks by value or by a
shared reference using Arc
. You can share mutable data using Mutex
and
pass that using Arc
. You can get access to the data by locking the mutex.
You cannot access it without locking. Rust guarantees race-free use of
shared mutable data in both cases.
A more modern approach is to share a message queue. You can use channels
from std::sync::mpsc
or other solutions. That way you can pass things
between threads. You can send things either by value or by a Box
owning
smart pointer. You can share things via Arc
smart pointer.
When you use tokio
you typically want to use its own implementation of
queues and also its I/O subsystem.
Simple threads with moved values
Threads and closures can be used together to from simple threaded functions
with input and output values. Once the function is called, the thread is
started and computation (or just waiting in our example) begins. The caller
can wait and fetch the resulting value using .join()
.
use std::{thread, time::Duration, time::SystemTime};
fn delayed_value(sleep: u64, value: i32) -> thread::JoinHandle<i32> {
thread::spawn(move || {
thread::sleep(Duration::from_secs(sleep));
value
})
}
fn main() {
println!("{:?}", SystemTime::now());
let result = delayed_value(2, 42).join().unwrap();
println!("{:?}", SystemTime::now());
println!("{}", result)
}
It is easy to run multiple threads in parallel and then wait for the results of all of them at once.
fn main() {
println!("{:?}", SystemTime::now());
let thread1 = delayed_value(3, 42);
let thread2 = delayed_value(2, 43);
let result1 = thread1.join().unwrap();
let result2 = thread2.join().unwrap();
println!("{:?}", SystemTime::now());
println!("{} {}", result1, result2)
}
What if you want to pass a common input value? In the following snippet you
can only pass the value
to two different functions because i32
is a
Copy
type and therefore its value gets copied to each function’s value
argument.
fn main() {
let value = 42;
println!("{:?}", SystemTime::now());
let thread1 = delayed_value(3, value);
let thread2 = delayed_value(2, value);
let result1 = thread1.join().unwrap();
let result2 = thread2.join().unwrap();
println!("{:?}", SystemTime::now());
println!("{} {}", result1, result2)
}
You cannot do the same with non-Copy
types like String
. But you can
still copy a string using .clone()
which performs a deep copy of the data
structure. Trivial types use memory-based Copy
, more complex type provide
a Clone
implementation.
use std::{thread, time::Duration, time::SystemTime};
fn delayed_value(sleep: u64, value: String) -> thread::JoinHandle<String> {
thread::spawn(move || {
thread::sleep(Duration::from_secs(sleep));
format!("Answer is {}.", value)
})
}
fn main() {
let value = "42".to_string();
println!("{:?}", SystemTime::now());
let thread1 = delayed_value(3, value.clone());
let thread2 = delayed_value(2, value.clone());
let result1 = thread1.join().unwrap();
let result2 = thread2.join().unwrap();
println!("{:?}", SystemTime::now());
println!("{} {}", result1, result2)
}
Shared immutable data
What if you need to share a data structure rather than clone it? Maybe
it’s large, maybe you’ll need to implement shared mutation later. If
instead of cloning a String
you clone an Arc<String>
, the string itself
is created only once and each .clone()
just increments its reference
count.
use std::{thread, time::Duration, time::SystemTime};
use std::sync::Arc;
fn delayed_value(sleep: u64, value: Arc<String>) -> thread::JoinHandle<String> {
thread::spawn(move || {
thread::sleep(Duration::from_secs(sleep));
format!("Answer is {}.", value)
})
}
fn main() {
let value = Arc::new("42".to_string());
println!("{:?}", SystemTime::now());
let thread1 = delayed_value(3, value.clone());
let thread2 = delayed_value(2, value.clone());
let result1 = thread1.join().unwrap();
let result2 = thread2.join().unwrap();
println!("{:?}", SystemTime::now());
println!("{} {}", result1, result2)
}
Why don’t we just use reference? Because it’s not all that easy. Rust
doesn’t currently support scoped threads and thus thread::spawn()
requres
a 'static
closure. That means we could only pass references with a
'static
lifetime which may not always be what we want to do.
Shared mutable data
Technically, the borrow checker prevents you from sharing mutable data. But in the real world you’d like to distinguish sharable versus non-sharable rather than mutable versus immutable.
In other languages you often share data together with a mutex to resolve
concurrent access. Therefore you want a Mutex<T>
that is sharable
(immutable in Rust) but that still provides access to a &mut T
. This is
called interior mutability. For the borrow checker the Mutex<T>
looks
immutable but a locked mutex enables mutable access to its inner value.
use std::{thread, time::Duration, time::SystemTime};
use std::sync::{Arc, Mutex};
fn delayed_value(sleep: u64, value: i32, results: Arc<Mutex<Vec<String>>>) -> thread::JoinHandle<()> {
thread::spawn(move || {
thread::sleep(Duration::from_secs(sleep));
results.lock().unwrap().push(format!("Answer is {}.", value));
})
}
fn main() {
let results = Arc::new(Mutex::new(Vec::new()));
println!("{:?}", SystemTime::now());
let thread1 = delayed_value(3, 42, results.clone());
let thread2 = delayed_value(2, 43, results.clone());
thread1.join().unwrap();
thread2.join().unwrap();
println!("{:?}", SystemTime::now());
println!("{:?}", results.lock().unwrap())
}
You might wonder why we need to use such a complex type as
Arc<Mutex<Vec<String>>>
. Well, the core value is a Vec<String>
so that you
can .push()
new results to it. It is wrapped by a Mutex<_>
so that you can .lock().unwrap()
to get
exclusive access to it. This .lock()
method only fails when another
thread dies while holding the mutex.
Why don’t we pass use the Mutex<_>
directly? We could use &Mutex<_>
to
share the mutex across threads but then the threads aren’t scope and would
require a &'static Mutex<_>
reference. We already solved the same problem
using Arc<_>
for immutable data. As Mutex<_>
is considered immutable,
let’s just do the same for the mutex and pass data around in
Arc<Mutex<_>>
.
Rust’s mutex wrapper feature
Most programming languages treat mutex as a cooperative tool to synchronize critical sections that are then used to synchronize access to data. Rust is different. It wraps the data by the mutex so that it is only accessible in the critical section. This allows the borrow checker to statically check the correctness of the code.
As long as you wrap your data in Mutex<_>
and Arc<_>
, it is passed
around via .clone()
and data access guarded using .lock()
. Only in a
critical section the data is available.
// This block is your critical section
{
let data = mutex.lock().unwrap();
data.do_whatever_you_want();
data.do_whatever_you_want();
data.do_whatever_you_want();
}
Channels
Sharing data using a mutex isn’t the final answer to all your questions.
Locking may have additional performance impact and you need to use
additional tools like CondVar
to introduce events.
If you just need to feed a thread with events or messages, a queue (or channel) based approach may serve you better.
use std::{thread, time::Duration, time::SystemTime};
use std::sync::mpsc::{SyncSender, sync_channel};
fn delayed_value(sleep: u64, value: i32, sender: SyncSender<String>) -> thread::JoinHandle<()> {
thread::spawn(move || {
thread::sleep(Duration::from_secs(sleep));
sender.send(format!("Answer is {}.", value)).unwrap();
})
}
fn main() {
let (sender, receiver) = sync_channel(32);
let thread1 = delayed_value(3, 42, sender.clone());
let thread2 = delayed_value(2, 43, sender.clone());
// Here in the main thread...
println!("{:?}", SystemTime::now());
println!("{}", receiver.recv().unwrap());
println!("{:?}", SystemTime::now());
println!("{}", receiver.recv().unwrap());
println!("{:?}", SystemTime::now());
thread1.join().unwrap();
thread2.join().unwrap();
}