Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, everything we've written in this course has been sequential. One line follows another, and the CPU spends a lot of its time just waiting—waiting for a file to read, waiting for a network response, or waiting for a heavy calculation to finish. In the real world, that's a waste of resources. Today, we're going to look at how Rust handles concurrency using threads.

To make this concrete, let's build a simple "Site Health Checker." We have a list of URLs, and we want to check if they are reachable. Doing this one by one is painfully slow because the program hangs while waiting for the server to respond. We want to fire off a request for every site simultaneously.

Doing things one by one is too slow

First, let's look at the naive approach. I'll use a dummy function to simulate a network request so we don't have to pull in external crates like reqwest just for this lesson.

fn check_site(url: &str) {
    println!("Checking {}...", url);
    std::thread::sleep(std::time::Duration::from_secs(1)); // Simulate network lag
    println!("{} is UP!", url);
}

fn main() {
    let sites = vec!["google.com", "rust-lang.org", "github.com", "stackoverflow.com"];
    
    for site in &sites {
        check_site(site);
    }
}

If you run this, it takes 4 seconds. That's unacceptable. We have four cores (or more) on our machine, and we're using exactly one of them while the others just sit there. Let's throw some threads at this.

Spawning our first workers

Rust makes it easy to spawn a thread using std::thread::spawn. It takes a closure containing the code you want to run in parallel. I'll wrap the check_site call in a thread for each URL.

use std::thread;

fn main() {
    let sites = vec!["google.com", "rust-lang.org", "github.com", "stackoverflow.com"];
    
    for site in &sites {
        thread::spawn(|| {
            check_site(site);
        });
    }
}

I just ran this, and it didn't work. In fact, it probably didn't print anything at all. Why? Because thread::spawn starts a thread in the background, but the main thread doesn't wait for them. The program hits the end of main and exits immediately, killing all the background threads before they even finish their first sleep cycle. We need to "join" these threads back to the main thread.

The borrow checker stops me in my tracks

Here is where I usually trip up when I'm rushing. I'll create a vector to hold the JoinHandles returned by spawn, and then I'll loop through them to call .join().

use std::thread;

fn main() {
    let sites = vec!["google.com", "rust-lang.org", "github.com", "stackoverflow.com"];
    let mut handles = vec![];

    for site in &sites {
        let handle = thread::spawn(|| {
            check_site(site);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

If you try to compile this, Rust will scream at you. You'll see an error saying site does not live long enough. The compiler is worried that the main function might finish and drop the sites vector while a background thread is still trying to read a string from it. Rust's ownership rules are just as strict in threads as they are anywhere else: you cannot have a reference to data that might be destroyed.

Fixing it with ownership and Arc

There are two ways to fix this. We could clone() the string for each thread, but if we had a massive piece of data, cloning would be expensive. Instead, I'll use Arc (Atomic Reference Counted). Arc allows multiple threads to own a piece of data. When the last Arc is dropped, the data is finally cleaned up.

I'll wrap the sites list in an Arc and move a clone of that Arc into each thread. Note the move keyword before the closure—this tells Rust to move the ownership of the Arc clone into the thread's scope.

use std::thread;
use std::sync::Arc;

fn check_site(url: &str) {
    println!("Checking {}...", url);
    thread::sleep(std::time::Duration::from_secs(1));
    println!("{} is UP!", url);
}

fn main() {
    // Wrap our data in an Arc
    let sites = Arc::new(vec!["google.com", "rust-lang.org", "github.com", "stackoverflow.com"]);
    let mut handles = vec![];

    for i in 0..sites.len() {
        // Clone the Arc (not the data inside it)
        let sites_ref = Arc::clone(&sites);
        
        let handle = thread::spawn(move || {
            // Use the index to get the site from the shared Arc
            let site = &sites_ref[i];
            check_site(site);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

Now it works. The total execution time drops from 4 seconds to roughly 1 second. We've effectively parallelized the I/O wait. I used a loop with an index here to keep it simple, but the core takeaway is the Arc: it's your primary tool for sharing read-only data across threads without fighting the borrow checker over lifetimes.




📋 Practical Task

Exercise: Building a Multi-threaded CPU-Intensive Calculator

In the previous example, we dealt with I/O wait. Now, let's deal with computation. Your task is to create a program that calculates the sum of squares for a large range of numbers, but splits the work across four different threads.

Requirements:

  • Create a large range of numbers (e.g., 0 to 1,000,000).
  • Divide this range into four equal chunks (e.g., 0-250k, 250k-500k, etc.).
  • Spawn four threads, each calculating the sum of squares for its assigned chunk.
  • Each thread should return its partial sum back to the main thread via the JoinHandle.
  • The main thread must join all four threads and print the final combined total.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.