[Rust Series 18] Concurrency Basics

한국어 버전

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, and String automatically implement it.
  • Sync: safe for multiple threads to hold &T references at once. References to Sync types are considered Send.

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.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. Fork the starter and open src/main.rs
  2. Paste the lesson code and run cargo check plus cargo run in order
  3. Change types, values, or borrowing flow and compare the compiler feedback with the output

Rust practice here is mainly terminal-driven rather than browser-preview driven. Lessons that need multiple files or extra crates may require a bit more setup inside the starter.

Practice

  1. Clone tx to create two producer threads and tag each message so the consumer can tell them apart.
  2. Add std::thread::sleep to vary producer and consumer speeds, then log how the channel buffers messages.
  3. Write a short snippet that fails with Rc<T> but works with Arc<T> to feel the difference firsthand.

Wrap-Up

Rustian concurrency cares more about protecting data ownership than spawning threads for their own sake. With spawn and join, plus channels and the Send/Sync concepts, you're ready to understand higher-level runtimes. Next up: experiencing asynchronous flow with async/await.

💬 댓글

이 글에 대한 의견을 남겨주세요