An thread is a concurrent execution thread that can advance in parallel with other threads.
In the modern world, CPUs aren’t getting much faster, but they do have more cores. To take advantage of the hardware, we need to run tasks in parallel.
Rust uses a 1:1 relationship for threads. This means that when you create a thread in Rust, it maps directly to a native Operating System thread.
Advantage: It directly uses the system’s thread mechanisms and doesn’t need an async runtime.
Disadvantage: Creating thousands of threads can consume a lot of memory; for workloads with a huge number of concurrent tasks, there are asynchronous models like async/await.
Creating a Thread with spawn
The basic function is std::thread::spawn. It takes a Closure containing the code to execute.
use std::thread;
use std::time::Duration;
fn main() {
// Create a secondary thread
thread::spawn(|| {
for i in 1..10 {
println!("Secondary thread: number {}", i);
thread::sleep(Duration::from_millis(1));
}
});
// Main thread code
for i in 1..5 {
println!("MAIN thread: number {}", i);
thread::sleep(Duration::from_millis(1));
}
}If you run this, you’ll notice something curious: The secondary thread will likely be cut off halfway through. When the main thread (main) finishes, the entire program shuts down, instantly killing all secondary threads, whether they have finished or not.
Waiting with join
To prevent the program from closing prematurely, we need to save the “handler” (JoinHandle) that spawn returns and tell the main thread to wait.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Working in the thread...");
});
// ... do other things in main ...
// Block main until the thread finishes
handle.join().unwrap();
}The .join() method blocks the current thread until the thread associated with the handle finishes. It returns a Result, so we use unwrap (if the thread had panicked, join would return an error).
Using Data in Threads with move
In this case, the Borrow Checker proves its worth.
Imagine you want to use a vector created in main inside a thread.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
// ❌ Error: closure may outlive the current function,
// but it borrows `v`, which is owned by the current function
println!("Here is the vector: {:?}", v);
});
handle.join().unwrap();
}Why does it fail?
Rust reasons like this: “You are spawning a thread. That thread could run for an hour. But the main function (or whatever function you’re in) could finish in 1 millisecond, freeing the memory of v. If I allow this, the thread would try to read freed memory (Use After Free).”
Rust doesn’t know how long the thread will live, so it assumes it can live longer than the reference.
The Solution: move
To fix this, we must force the Closure to take ownership of the values it uses. We use the move keyword before the vertical bars.
let handle = thread::spawn(move || {
// Now the vector 'v' belongs to this thread.
// No one else can use it in 'main' after this line.
println!("Here is the vector: {:?}", v);
});By moving the data, we guarantee that the data lives exactly as long as the thread, because the thread is now its owner.
The Send Trait
Have you ever wondered why we can send a Vec<i32> to another thread but not other things?
The signature of thread::spawn has a very important bound:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,Notice the Send.
Send is a Marker Trait. It has no methods. It only serves to mark types that are safe to transfer (move) from one thread to another.
- Almost all Rust types are
Send(i32,String,Vec,Box). - If a struct is composed entirely of
Sendtypes, it is automaticallySend.
What is NOT Send?
The classic example is Rc<T>.
As we saw in the previous category, Rc uses a simple counter to know when to free memory. That counter is NOT atomic.
If we sent an Rc to another thread:
Thread A clones the Rc (attempts to add 1).
Thread B clones the Rc (attempts to add 1).
Without atomic synchronization, they could overwrite each other.
Result: The counter becomes incorrect, and memory will be freed prematurely (total chaos).
That’s why Rc does not implement Send. If you try to move an Rc inside a thread::spawn, the compiler will give you an error.
// ❌ Error: `Rc<i32>` cannot be sent between threads safely
thread::spawn(move || {
println!("{:?}", mi_rc);
});To share ownership between threads, we normally use Arc<T> (Atomic Reference Counting). Arc<T> can implement Send and Sync when the contained type also meets the necessary bounds.