Threads and data races: C++ and Rust

A thread is a closure that starts somewhere else, and the interesting part is what it is allowed to touch. C++ lets a thread capture a local by reference, and the programmer is responsible for joining before that local dies. Rust allows the same capture only inside a scope that guarantees the join, and a plain spawn demands ownership instead.

The task in C++

The program starts two threads, each of which writes a sum into a local variable it captured by reference. Both joins happen before the variables are read, which is what makes the program correct.

#include <iostream> #include <thread> int main() { int first = 0; int second = 0; std::thread a([&first] { first = 1 + 2; }); std::thread b([&second] { second = 3 + 4; }); a.join(); b.join(); std::cout << first + second << "\n"; }

g++ -std=c++23 -Wall -Wextra threads.cpp -o threads
./threads

10

The two references are the whole interface between the threads and main. The compiler checks that the variables exist when the lambdas are built and that the destructors run, and it trusts the two joins to keep the references valid until then. A missing join detaches the thread, and the destructor of a joined std::thread terminates the program.

The same task in Rust

A Rust thread returns its value instead of writing through a reference, so the sums come back from the two joins. Nothing is shared, nothing needs a lock, and the two handles are joined before the values are added.

use std::thread; fn main() { let first = thread::spawn(|| 1 + 2); let second = thread::spawn(|| 3 + 4); println!("{}", first.join().unwrap() + second.join().unwrap()); }

rustc --edition 2024 threads.rs
./threads

10

The two programs print the same number, and the shapes are the two ways to get data out of a thread: write into a place the caller owns, or return a value the caller collects. Rust makes the second the default, because a returned value has no lifetime problem.

What differs and why

Who owns the result: the C++ thread writes into a captured reference, so the result exists before the thread starts and the thread fills it. The Rust thread owns its result and hands it over at the join, which means the caller has no uninitialised or half-written variable at any point.

What the compiler knows about sharing: C++ sees a lambda and a reference and has no idea whether another thread touches the same object. Rust sees a closure that borrows a local and rejects the spawn outright, because a thread that may outlive the function cannot hold a reference into it. The same program with a scope, or with an Arc, is accepted and the compiler then checks the sharing rules that go with it.

What happens on a forgotten join: a dropped std::thread that is still joinable calls std::terminate, and a detached one keeps running with a reference that may already be gone. In Rust a dropped JoinHandle detaches the thread and leaks the result, which is a defined outcome: the thread owns its data and cannot touch the caller's stack.

What a data race costs: in C++ it is undefined behaviour that compiles and usually works, which is why ThreadSanitizer exists. In Rust the same race needs an unsafe block or a type that is not Sync, both of which the compiler refuses in safe code.

The habit that breaks

The habit is the plain capture: a thread that reads a local by reference, started with a spawn that does not keep the local alive. In C++ it compiles, and the reference is only wrong if the local dies before the join, which the programmer is trusted to prevent.

#include <iostream> #include <thread> int main() { std::thread worker; { int value = 7; worker = std::thread([&value] { std::cout << value << "\n"; }); } worker.join(); }

g++ -std=c++23 -Wall -Wextra dangling.cpp -o dangling

g++ exits with status 0 and prints no warning

The program is accepted and the reference is dangling by the time the thread reads it, so what it prints is not reproducible and is not shown here as if it were a result. A sanitizer catches it on a good day; the compiler does not catch it at all.

The same capture in Rust is a compile error, and the message names the borrowed variable and the keyword that fixes it.

use std::thread; fn main() { let value = 7; let worker = thread::spawn(|| { println!("{value}"); }); worker.join().unwrap(); }

rustc --edition 2024 borrow.rs

error[E0373]: closure may outlive the current function, but it borrows `value`, which is owned by the current function
--> borrow.rs:5:32
|
5 | let worker = thread::spawn(|| {
| ^^ may outlive borrowed value `value`
6 | println!("{value}");
| ----- `value` is borrowed here
|
note: function requires argument type to outlive `'static`
--> borrow.rs:5:18
|
5 | let worker = thread::spawn(|| {
| __________________^
6 | | println!("{value}");
7 | | });
| |______^
help: to force the closure to take ownership of `value` (and any other referenced variables), use the `move` keyword
|
5 | let worker = thread::spawn(move || {
| ++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0373`.

The fix in this program is move, which copies the integer into the closure and removes the reference entirely. For a value that must be shared and changed, the next step is Arc<Mutex<T>>, and the compiler then checks that the sharing is explicit.

The Rust lesson for this ground is Threads and join handles, and the C++ lesson is Threads. The next page in this set is Build tools.

The C++ programs were built with g++ 15.2.0 and the Rust programs with rustc 1.96.1, and both were tested on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image