Introduction
In the previous summary of the CS110L course, we have seen how Rust builds a solid memory safety fortress in a single-threaded environment through its Ownership and Borrowing system. However, such scenarios are rare in modern systems programming. To fully utilize the performance of multi-core processors and handle I/O-intensive tasks such as network requests, we must step into the realm of concurrency programming.
The world of concurrency is fraught with pitfalls. When multiple threads access and modify shared data simultaneously, the “exclusive ownership” model we relied on before seems no longer applicable. This is a breeding ground for countless elusive “ghost bugs” in languages like C/C++. So, how does Rust tackle this challenge? How does it extend its safety philosophy from “exclusive” to “shared”?
This note will summarize the core content of the CS110L course on multithreading programming. We will first confront the horrors of concurrency programming by understanding the power of “race conditions” through a real and painful case study. Then, we will see how Rust’s compiler acts like a vigilant sentinel, preventing those common concurrency errors found in other languages at compile time. Finally, we will delve into the core pattern for safely sharing and modifying data in Rust—Arc<Mutex<T>>, revealing step by step how Rust controls concurrency programming to be as safe as possible at compile time.
The Dangers of Concurrency – Why Multithreading is Difficult
The course first affirms the value of multithreading: it can bring about parallelism (doing multiple things simultaneously on multi-core) and concurrency (tasks can be interleaved to improve the efficiency of I/O-intensive applications). But immediately after, the course raises a heavy topic: concurrency programming is dangerous, and its core threat comes fromrace conditions..
The most notable feature of race conditions isuncertainty. A program with a race condition may run correctly 99% of the time, but under a specific, unpredictable timing, it may crash or produce erroneous results. This makes them extremely difficult to detect in testing and even harder to debug afterward.
To help us deeply understand its dangers, the course analyzes a famous tragic case in computer science history:The Therac-25 Medical Accelerator Incident. In the 1980s, the Therac-25 caused several patients to receive radiation doses far exceeding safe levels due to a race condition in its software, resulting in severe injuries and even death.
The root of the incident was that if an operator entered a specific erroneous command sequence within a very short time (8 seconds) and then quickly corrected it using the cursor keys, a synchronization issue would arise between the two concurrent tasks of the software—one responsible for setting the device mode and the other for responding to the user interface. This would cause the device to emit electron beams in high-energy mode without placing safety shields, resulting in lethal radiation doses.
The lesson from this incident is painful:
-
Race conditions are difficult to reproduce: Initially, the manufacturer AECL could not reproduce this bug and insisted that the device was fine. It was not until an experienced physicist repeatedly tried in the hospital that the rapid key sequence leading to the incident was finally reproduced.
-
“It works here” is not an excuse: Just because a bug was not triggered in testing does not mean it does not exist.
-
Low-probability events are bound to occur in large-scale systems: The course quotes a saying: “At the scale of Twitter, a one in a million chance happens 500 times a day.” In a complex, highly concurrent system, those seemingly impossible timing issues will eventually occur.
The course further illustrates thecompound effect with a modern example: Suppose your social app has 20 steps to display a post, and 5 of them have a 0.01% probability of a race condition. Then, for each post displayed, there is a 0.05% chance of crashing. If a user scrolls through 300 posts a day, the probability of encountering a crash rises to 15%. Coupled with other low-probability bugs in other features, the overall experience of the app will become very poor.
Summary: The danger of concurrency programming stems from its inherent uncertainty. Relying on programmers to be “more careful” or hoping that testing can cover all scenarios is unreliable. We need a more fundamental, systematic solution to eliminate such problems.
Rust’s First Line of Defense – How the Compiler Prevents Concurrency Errors
After experiencing the dangers of concurrency, let’s see how Rust responds. The core idea of Rust shines again: Tools used to ensure memory safety can also ensure thread safety. The ownership and borrowing checker is this solid defense line.
The course demonstrates Rust’s advantages through a classic C++ concurrency trap—capturing references in a loop.
This is problematic C++ code that attempts to let each thread print its own number:
// C++ for(size_t i =0; i <6; i++){ threads.push_back(thread([&i](){// Captured i's reference cout <<"Hello from extrovert "<< kExtroverts[i]<<"!"<< endl; }));}
The behavior of this program is undefined. Because the lambda function [&i] captures areference. The main thread’s for loop will execute quickly, and by the time the child threads actually start running, the value of i is likely already 6 (the value at the end of the loop), causing all threads to attempt to access kExtroverts[6], resulting in an out-of-bounds access.
Now, let’s try to write the same logic in Rust:
// Rust for i in0..6{ threads.push(thread::spawn(||{// Default captures i's reference println!("Hello from extrovert {}!",NAMES[i]); }));}
This code cannot compile! Rust’s borrowing checker will give an error:
error[E0373]: closure may outlive the current function, but it borrows `i`, which is owned by the current function
The compiler here tells us a crucial fact: the thread (and the closure it runs) you created has an uncertain lifetime; it is likely to outlive the current for loop or even the main function. However, this closure borrows a reference to the local variable i. If i has been destroyed in the main thread (leaving the scope), while the child thread is still trying to use it, it will create adangling pointer!!
The compiler not only points out the problem but also provides a solution: use the move keyword.
// Correct Rust Code for i in0..6{ threads.push(thread::spawn(move||{// Use move keyword println!("Hello from extrovert {}!",NAMES[i]); }));}
The move keyword forces the closure to take ownership of the external variables it uses. For integer types like i (which implement the Copy trait), this means each closure will get acopy. Thus, each thread has its own independent and correct value (0, 1, 2, 3, 4, 5), perfectly solving the problem. If i were a type that cannot be Copy (like a String), move would transfer ownership, and the first thread would take ownership of i, while subsequent loop iterations would no longer be able to use i, resulting in a compile-time error, thus avoiding the problem of multiple threads competing for ownership of the same resource.
Summary: Rust’s borrowing checker is not just a memory manager; it is also a powerful concurrency programming tool. It can statically analyze potential lifetime conflicts at compile time, fundamentally eliminating concurrency bugs like “capturing dangling references in a loop” that are very common and dangerous in C/C++.
We have seen that Rust’s ownership system effectively prevents multiple threads from unsafely accessing the same data. But this also brings new problems: what if we need toshare and modify the same data across multiple threads? For example, in the classic “ticket agent” problem, multiple agent threads need to jointly decrement a global remaining ticket counter.
The course guides us step by step to build the standard pattern in Rust for handling such problems.
Attempt 1: Directly Passing Mutable References Our first reaction might be to pass a mutable reference &mut remaining_tickets to each thread. But this will be immediately rejected by the compiler because it violates the borrowing rule: “You cannot have multiple mutable references at the same time.”
Attempt 2: Shared Ownership – Arc<T> To allow multiple threads to “own” access to the same data, we introduce the first tool: Arc<T> (Atomically Reference Counted).
-
How it works: Arc<T> is a smart pointer that allows multiple owners to share data on the same heap memory. It maintains an atomic reference count. Each time you clone an Arc, it does not copy the data but atomically (thread-safely) increments the reference count. When an Arc handle is destroyed, the count decreases. Only when the count reaches zero is the data actually released.
-
Problem: Arc<T> solves the shared ownership problem, but for safety, it only allows you to obtainimmutable references to the data. We still cannot modify the remaining ticket count.
Attempt 3: Interior Mutability – Mutex<T> To allow modification while sharing, we introduce the second tool: Mutex<T> (Mutual Exclusion).
-
How it works: In Rust, the design of Mutex is very clever—the data is wrapped inside the Mutex. You cannot access the data directly; you must first call the .lock() method.
-
Locking and MutexGuard: The .lock() method returns a smart pointer called MutexGuard. This MutexGuard acts like a temporary “key”; as long as it exists, the lock is held. You can safely access and modify the protected data by dereferencing this MutexGuard.
-
Automatic Unlocking (RAII): Most importantly, MutexGuard follows the RAII principle (Resource Acquisition Is Initialization). When the MutexGuard variable leaves its scope, its drop method is automatically called, thus automatically releasing the lock. This means you will never forget to unlock.
Final Solution: Arc<Mutex<T>> Pattern By combining these two tools, we arrive at the golden rule for safely handling shared mutable state in Rust: Arc<Mutex<T>>.
usestd::sync::{Arc,Mutex};usestd::thread;fnmain(){ // 1. Wrap the data in Mutex, then wrap the Mutex in Arc let counter =Arc::new(Mutex::new(0)); let mut handles =vec![]; for _ in0..10{ // 2. Clone Arc to create new ownership handles for new threads let counter_clone =Arc::clone(&counter); let handle =thread::spawn(move||{ // 3. Lock the Mutex in the thread to gain exclusive access let mut num = counter_clone.lock().unwrap(); // 4. Safely modify data through MutexGuard *num +=1; // 5. MutexGuard leaves here, and the lock is automatically released }); handles.push(handle); }// ... Wait for all threads to finish ... }
The workflow of this pattern is:
-
Arc allows multiple threads to safely share ownership of the Mutex.
-
Mutex ensures that at any given time, only one thread can lock it and modify the internal data.
This combination transforms complex concurrency control logic into a clear and safe operation process guaranteed by the type system, effectively eliminating data races.
Summary: Arc<Mutex<T>> is one of the cornerstones of Rust concurrency programming. It is not a single function but a clever design pattern that elegantly resolves the core contradiction of “sharing” and “mutability” in concurrency programming by combining atomic reference counting and mutex mechanisms.
Conclusion
Through these lectures, we have completed a cognitive leap from single-threaded memory safety to multi-threaded concurrency safety.
-
Confronting the Severity of the Problem: We recognize that concurrency bugs like race conditions, with their uncertainty and potential disastrous consequences, require us to adopt a programming paradigm more reliable than “being careful”.
-
The Guardian of the Compiler: Rust integrates the core rules of concurrency safety into its ownership and borrowing system. The compiler is no longer passively checking the code but actively serves as our safety partner, preventing a large number of dangerous operations that could lead to dangling references and data races at compile time.
-
Embracing Safe Patterns: For scenarios that must share and modify data, Rust provides the powerful and safe pattern Arc<Mutex<T>>. By combining different smart pointers, it encapsulates complex synchronization logic within the type system, allowing programmers to focus more on business logic rather than constantly worrying about the underlying synchronization details.
Of course, Rust cannot solve all concurrency problems—logical race conditions (such as check-then-act timing errors) and deadlocks still require programmers to ensure. However, by thoroughly eliminating the most common and dangerous data races at the language level, Rust significantly lowers the barrier to writing correct concurrent code, enabling us to build efficient and robust modern systems with greater confidence. This is the fundamental reason why Rust is creating a new wave in the field of systems programming.