What You'll Learn
Rust's concurrency model aims to eliminate data races at compile time. We'll tour the standard library's thread and channel APIs and build an intuitive grasp of what the [[send-sync|Send and Sync]] traits represent.
Spawning and Joining Threads
The std::thread module creates OS threads directly. spawn takes a closure to run in the new thread, and join waits until it finishes.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("worker {i}");
thread::sleep(Duration::from_millis(20));
}
});
println!("main thread continues");
handle.join().expect("thread panicked");
}
If a thread panics, join returns an Err, so handle it with expect or match.
move Closures and Ownership
Use move closures to transfer ownership explicitly when passing data to a thread.
fn main() {
let numbers = vec![1, 2, 3];
let handle = std::thread::spawn(move || {
println!("sum = {}", numbers.iter().sum::<i32>());
});
handle.join().unwrap();
}
Without move, the compiler prevents you from accidentally using numbers before the thread completes. That's how Rust lowers the chance of data races. Think of move on thread closures as declaring “this thread owns the data it uses.”
Passing Messages with Channels
Instead of sharing data across threads, send ownership through messages. std::sync::mpsc channels split the sender and receiver.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let msgs = vec!["prepare", "compile", "ship"];
for msg in msgs {
tx.send(msg.to_string()).unwrap();
}
});
for received in rx {
println!("got: {received}");
}
}
The receiver acts like an iterator and pulls values until the sender closes. Clone tx if you need multiple producers.
Intuiting Send and Sync
- Send: safe to move the value to another thread. Primitive types,
Vec, andStringautomatically implement it. Sync: safe for multiple threads to hold&Treferences at once. References toSynctypes are consideredSend.
The compiler checks whether a type is Send before spawning threads or sending through channels. [[rc|Rc<T>]] fails because its reference count isn't atomic; use [[arc|Arc<T>]] instead. Sharing interior mutability across threads also demands synchronization primitives such as [[mutex|Mutex<T>]].
The most common combo looks like this:
use std::sync::{Arc, Mutex};
fn main() {
let count = Arc::new(Mutex::new(0));
let worker = Arc::clone(&count);
std::thread::spawn(move || {
*worker.lock().unwrap() += 1;
}).join().unwrap();
println!("count = {}", *count.lock().unwrap());
}
[[arc|Arc]] shares ownership across threads, and [[mutex|Mutex]] guarantees only one thread mutates the data at a time.
Thread + Channel Example: Parallel Text Processing
Let's split a tiny log-processing job across threads.
use std::sync::mpsc;
use std::thread;
fn main() {
let lines = vec![
"INFO - start", "WARN - retry", "INFO - done",
];
let (tx, rx) = mpsc::channel();
let producer = thread::spawn(move || {
for line in lines {
tx.send(line.to_string()).unwrap();
}
});
let consumer = thread::spawn(move || {
let mut warn_count = 0;
for msg in rx {
if msg.contains("WARN") {
warn_count += 1;
}
}
println!("warn count = {warn_count}");
});
producer.join().unwrap();
consumer.join().unwrap();
}
By transferring ownership through messages, you can avoid shared mutexes while still dividing work safely.
Practice in CodeSandbox
The sandbox below uses CodeSandbox's Rust starter. Move the main code into src/main.rs, then compare cargo check and cargo run so you can read the compiler feedback beside the final output.
💬 댓글
이 글에 대한 의견을 남겨주세요