Anyone who's developed multithreaded applications in Rust has likely encountered the need for thread-safe access to data. Rust's ownership model can make multithreading safe and efficient, and in this article, we'll explore how Rust handles this with its Sync
and Send
traits.
Understanding Send
and Sync
At the core of Rust's concurrency model are the Send
and Sync
traits, both of which are marker traits. Unlike traits that define methods, these provide information about the types that implement them.
Send
Trait
The Send
trait allows for transferring ownership of types across thread boundaries. If a type implements the Send
trait, it means its ownership can be safely sent from one thread to another. Most types in Rust are Send
by default. For example, primitive types like integers and floats are Send
, as are compound types like tuples, structs, and even references, provided the things they reference are also Send
.
fn is_send() {}
fn main() {
is_send::(); // OK, because i32 is Send
is_send::<&str>(); // OK, because &str (static reference) is Send
}
Sync
Trait
The Sync
trait signifies that a type is safe to share between threads. If a type is Sync
, it means that it is safe for multiple threads to have references to it and access it concurrently. As a rule of thumb, for a type to be Sync
, it must either be immutable or manage its internal mutability in a thread-safe manner using mechanisms like Mutex
or RwLock
.
fn is_sync() {}
fn main() {
is_sync::(); // OK, because i32 is Sync
is_sync::<&str>(); // OK, because immutable references are Sync
// Note: Types that contain non-Sync components (like raw pointers) would not pass here
}
Creating Thread-Safe Types
To create types that are safe to share and send across threads, you often need to combine Send
and Sync
. Let's design a simple counter that's safe to be shared across threads using std::sync
constructs.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final result: {}", *counter.lock().unwrap());
}
In this example, Arc
is used to safely share the counter across threads, while Mutex
ensures only one thread can update the counter at a time. Both Arc
and Mutex
implement Send
and Sync
.
The Role of the Compiler
Rust's compiler plays a key role by enforcing the rules of these traits. If you try to send a non-Send
type to a new thread, the Rust compiler will throw a compilation error. Similarly, sharing non-Sync
references across threads will lead to compilation errors, stopping you from making dangerous multithreading errors.
Custom Types and Unsafe Code
In rare cases, you may need to define custom types where
the automatic implementation doesn't apply, particularly when dealing with raw pointers or complex memory manipulations. In such cases, you can opt into Send
or Sync
by implementing them manually but be cautious. Incorrect implementation may lead to race conditions or undefined behavior.
unsafe impl Send for MyType {}
unsafe impl Sync for MyType {}
Even though this is possible, try to use Rust's concurrency abstractions whenever possible, and only delve into unsafe territory if absolutely necessary.
Conclusion
The Send
and Sync
traits are integral to writing safe concurrent applications in Rust. They leverage Rust's powerful type system to ensure that only data meant for multithreaded access is allowed across threads. By understanding and correctly implementing these traits, developers can build robust multithreaded applications without sacrificing safety for speed.