Concurrent Programming in Rust
Safe and efficient handling of concurrency was one of the primary goals behind Rust's creation, mainly to solve server high-load capacity issues.
The concept of concurrency refers to different parts of a program executing independently. This is often confused with parallelism, which emphasizes "simultaneous execution."
Concurrency often leads to parallelism.
This chapter covers programming concepts and details related to concurrency.
Threads
A thread is an independently running part of a program.
Threads differ from processes in that threads are a concept within a program, while programs typically execute within a process.
In environments with operating systems, processes are typically scheduled alternately for execution, while threads are scheduled by the program within the process.
Since thread concurrency can likely result in parallelism, common parallel computing issues like deadlocks and race conditions frequently occur in programs with concurrent mechanisms.
To solve these problems, many other languages (like Java and C#) use special runtime software to coordinate resources, but this significantly reduces program execution efficiency.
C/C++ languages support multi-threading at the lowest level of the operating system, but neither the language itself nor its compiler has the ability to detect and avoid parallel errors, which puts great pressure on developers who need to spend considerable effort avoiding errors.
Rust doesn't rely on a runtime environment, similar to C/C++.
However, Rust is designed with mechanisms including ownership to eliminate the most common errors at compile time, which other languages don't have.
But this doesn't mean we can be careless when programming. To date, problems caused by concurrency haven't been completely solved in the public domain, and errors can still occur. Be extremely careful when writing concurrent programs!
In Rust, new threads are created using the std::thread::spawn
function:
use std::thread;
use std::time::Duration;
fn spawn_function() {
for i in 0..5 {
println!("spawned thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
}
fn main() {
thread::spawn(spawn_function);
for i in 0..3 {
println!("main thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
}
Output:
main thread print 0
spawned thread print 0
main thread print 1
spawned thread print 1
main thread print 2
spawned thread print 2
The order of this output might vary in some cases, but it generally prints like this.
This program has one child thread that's meant to print 5 lines of text, while the main thread prints three lines. However, it's clear that as the main thread ends, the spawned thread also ends without completing all its prints.
The std::thread::spawn
function takes a function without parameters as its argument, but the above approach isn't recommended. We can use closures to pass functions as parameters:
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 0..5 {
println!("spawned thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 0..3 {
println!("main thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
}
Closures are anonymous functions that can be saved into variables or passed as arguments to other functions. Closures are equivalent to Lambda expressions in Rust, with the following format:
|parameter1, parameter2, ...| -> return_type {
// function body
}
For example:
fn main() {
let inc = |num: i32| -> i32 {
num + 1
};
println!("inc(5) = {}", inc(5));
}
Output:
inc(5) = 6
Closures can omit type declarations and use Rust's automatic type inference:
fn main() {
let inc = |num| {
num + 1
};
println!("inc(5) = {}", inc(5));
}
The result remains the same.
The join Method
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 0..5 {
println!("spawned thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 0..3 {
println!("main thread print {}", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
Output:
main thread print 0
spawned thread print 0
spawned thread print 1
main thread print 1
spawned thread print 2
main thread print 2
spawned thread print 3
spawned thread print 4
The join
method allows the program to wait for the child thread to finish before stopping.
move Keyword for Forced Ownership Transfer
This is a common situation:
use std::thread;
fn main() {
let s = "hello";
let handle = thread::spawn(|| {
println!("{}", s);
});
handle.join().unwrap();
}
Attempting to use the current function's resources in a child thread is definitely wrong! The ownership mechanism prohibits such dangerous situations as they would violate the deterministic nature of resource destruction under the ownership system. We can handle this using the move
keyword with closures:
use std::thread;
fn main() {
let s = "hello";
let handle = thread::spawn(move || {
println!("{}", s);
});
handle.join().unwrap();
}
Message Passing
The main tool for implementing message-passing concurrency in Rust is channels. A channel consists of two parts: a transmitter and a receiver.
The std::sync::mpsc
module contains methods for message passing:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
Output:
Got: hi
The child thread receives the transmitter tx
from the main thread and calls its send
method to send a string, which the main thread then receives through the corresponding receiver rx
.