Rust’s Type System In-Depth: Part 3

Exploring Rust’s Type System: Generic Container Types, Interior Mutability, and Concurrency Rust’s Way
Code
Concurrency
Container
Author

Sanjeevi

Published

August 16, 2023

Container types

Interior Mutability

Concurrency

Can deadlocks occur even if threads are not involved?

Part 1

Part 2

Containers

I call them Container Types, not Collection Types, because these types contain other types and provide specific capabilities to the types they encapsulate. Each container type has distinct capabilities, so we can combine them in ways that offer the desired functionality while considering the trade-offs involved. These wrapper types are implemented using unsafe Rust but expose high-level APIs, all while adhering to the safety guarantees of Rust. Without using unsafe, these types cannot be implemented, so we wouldn’t be able to employ these patterns.

Box - You might recall that references denoted by & are used for str because the compiler isn’t aware of the size of a str—it can vary in length. However, the & reference itself is always of a fixed length, which the compiler can utilize. Box type places any data it contains in the Heap memory. The Box type is an owned type, meaning it owns the type T and thus involves ownership. Box implements both the Deref and DerefMut traits. As a result, we can treat Box as an inner type. It’s equivalent to the unique_ptr in C++, though it’s less prone to misuse. The advantage of Box is that, regardless of the size of the data it contains, it always occupies 8 bytes. Box is particularly useful for handling potentially infinite-sized recursive types, storing larger data compared to the stack, and passing it between different parts of the code to access. Additionally, it’s used for creating trait objects. However, there’s a potential for data leakage when used with the Box::leak associated function. Moreover, it can be employed as Any type similar to dynamic programming languages like Python, where types aren’t explicitly specified at compile time, and it can be downcast to the concrete type at runtime.

    use std::{any::Any};
    let box_string = Box::new(String::from("String inside Box"));
    //Not possible if the type is Any
    println!("{}", box_string.len());
    //no matter inner type it's always 8 bytes on 64bit arch
    println!("{}", std::mem::size_of::<Box<String>>());
    //The type only known at runtime
    //The type annotation conveys a certain behavior that is expected.
    //In other words, even though it lacks a specific type, it still
    //offers greater type safety compared to Duck typing.
    let box_any: Box<dyn Any + Send + Sync> = Box::new(vec![1, 2, 3, 4]);
    let static_vec: &'static Vec<i32> = Box::leak(box_any.downcast::<Vec<i32>>().unwrap());

Box APIs are constrained in other words, we can’t call arbitrary methods on a Box of any type. For example, calling the clone method on a Box is only possible if the inner type itself is cloneable. Additionally, only a type of Any can be downcast from a Box.

Single ownership is overly restrictive. For instance, if we want to share data with different parts of the program, we have to send a clone of that data. This leads to heap allocation and increases memory usage when the heap becomes too large. However, for certain types, we can’t even clone the data, such as Mutex. Nevertheless, we still want to share ownership with different parts, or in other words, the scope of ownership isn’t limited to where it was created. Creating a clone of them will extend the lifetime of those types.

There are two different types that provide shared ownership: Rc and Arc. These stand for Reference Counted pointer and Atomic Reference Counted pointer, respectively. Rc is suitable for single-threaded code, while Arc is suitable for multi-threaded code. Both types implement the Deref trait, allowing shared ownership to be aliased. These types provide only immutable access to the contained data, enabling us to access it in different scopes without synchronization overhead.

In single-threaded code, we can use Rc to share ownership without cloning the underlying data. Instead of cloning the data, we clone the Rc itself. Cloning the Rc will increment the reference count, meaning each clone increments the count. This essentially creates an immutable alias. Memory is managed automatically. When a clone of the Rc goes out of scope or is explicitly dropped, the reference count decreases. There’s no need to call drop explicitly since cloning the Rc doesn’t block other parts of the code from reading it, as it’s immutable. When the reference count reaches zero, the memory is deallocated.

Rc is a heap-allocated owned Reference Counted pointer. It allows us to share data immutably within threads of different scopes. Rc doesn’t implement DerefMut, so we can invoke as many immutable methods on the inner type in different scopes as needed for reading.

Weak<T> - A non-owning and also not borrowed type, i.e., not restricted in lifetime like &T. This is equivalent to shared_ptr in C++.

use std::rc::Rc;
fn main(){
// Now the string can be accessed in different
//parts of the code without needing to be cloned.
   let rc_string = Rc::new(String::from("This is Rc"));
   let clone1 = rc_string.clone();
   //ref count is 2
   println!("{}",Rc::strong_count(&rc_string));
   function1(clone1);
   let clone2 = rc_string.clone();
   //just because it's a clone doesn't mean we can use it whenever
   //we want. Each clone has a single owner, but the data is 
   //only deallocated when the reference count is zero."
   //function2(clone1);
   println!("{}",Rc::strong_count(&rc_string));
}
//The scope ends here, and the reference count (Rc ref) is decremented only 
//if the function receives a clone of the Rc. Otherwise, 
//the Rc is destroyed at this point, and no further 
//clones can be created after this.
fn function1(x:Rc<String>){}
fn function2(x:Rc<String>){}

Arc - An Atomic Reference Counted Pointer, allocated on the heap, is used to share ownership immutably across threads. Arc implements Deref but not DerefMut, similar to Rc. However, it has the capability to be shared among different threads, enabling us to read data concurrently without cloning the actual underlying data. This extends the lifetime of the contained data. When cloning an Arc instance using Arc::clone(), it increments the atomic reference count rather than copying the data itself. It’s important to clone the Arc instance, either to store a clone in a temporary variable before moving it into a closure, async block, or channels, or to shadow the clone inside of those blocks before moving. Otherwise, if the Arc instance is moved to another thread, it cannot be cloned from outside that thread.

The compiler helps to avoid using Rc in different threads but can’t complain about using Arc within threads. The choice of performance is left to us. Arc can be used either in single-threaded or multi-threaded code. However, using Arc in single-threaded scenarios is overkill and imposes a cost that is unnecessary if only one thread is used. The compiler won’t warn about using Mutex if we use it for sharing purpose but block the other thread from accessing data.

Both Rc and Arc own the data; we can’t move the data out of them. Both types have a to_mut method to safely mutate the data inside them. This is analogous to “Clone on Write,” except it only clones when the reference count of Rc or Arc is not zero, meaning only when there are no clones of them that still exist. In other words, it returns a new Arc or Rc only when necessary.

We can’t move the data out of Rc or Arc directly since other code might still be referencing that data. However, we can achieve this using an Option or by cloning the inner data in a way that doesn’t cause issues.

Interior Mutability

Interior mutability - What is it? Before delving into interior mutability, let’s understand what inherent mutability is in the first place. We can modify data if the type is annotated with mut in front of it. This explicit declaration signifies that we intend to mutate the data after it’s assigned, and only a mutable reference can alter the referent at a time. Inherent mutability is something that the borrow checker can comprehend. However, interior mutability pertains to a type’s intrinsic capability, exemplified by types like Cell and RefCell. But why would we even require interior mutability? To grasp its necessity, let’s first comprehend what inherent mutability entails.

fn main(){
    // It's as if mutability is associated with a type during definition.
    let mut a = 10;
    // Inherent Mutability
    two_mut_ref(&mut a,&mut a);
    
    //no problem
    two_immut_ref(&a,&a);
}
// This code compiles as long as both x and y point to different i32 values
fn two_mut_ref(x:&mut i32,y:&mut i32){}
// This code compiles whether it's pointing to different data or the same piece of data.
// Immutable references can be aliased since no one can mutate the data. Therefore,
// this code doesn't cause memory violations either way.
fn two_immut_ref(x:&i32,y:&i32){}

Interior mutability refers to the capabilities of types like RefCell, Mutex, and others. When defining these types, you don’t need to use mut in front of them. In fact, Rust will give a warning stating that the “variable does not need to be mutable,” even though it mutates the data behind those types. The UnsafeCell is the core type that other types wrapped.

We can modify the value in a static variable without using mut by employing interior mutability types like AtomicUsize. This approach eliminates the need for an unsafe block to perform mutations while maintaining Rust’s memory safety guarantees.

The first interior mutability type we’re going to explore is Cell<T>. This type is used with Copy types. I’m not suggesting that this type should be used with Copy types, as Rust won’t allow us to create a Cell of Clone types. This is essentially how most APIs in Rust are designed: they provide guarantees internally and express those guarantees through types to prevent misuse externally.

This type has the advantage of allowing code to compile that would typically be disallowed by the borrow checker, with no more overhead than what references would introduce if allowed to perform the same actions. As I first mentioned in part 1 of this series, the RWLock pattern in single-threaded code for Copy types is too restrictive than necessary. Instead of struggling with the borrow checker to prove our intent, we can use types like this to use safely without affecting productivity.

Cell doesn’t implement dereference traits; instead, we have to use methods on the type to read or mutate the data. The get method returns the value of type T: Copy, which means we are not immutably borrowing, or in other words, the Cell type can’t be borrowed like &T, but it does allow in-place mutation through the set method.

use std::cell::Cell;
use std::ops::Deref;
fn main(){
  let mut_type = Cell::new(10);
  let first_mut =&mut_type;
  first_mut.set(10);
  let second_mut = &mut_type;
  second_mut.set(25);
  println!("{:?}",first_mut);
  two_immut_ref(&mut_type,&mut_type);
  println!("{mut_type:?}");
}
// Still not permitted by the borrow checker if `x` and `y` point 
//to the same data, even though we don't need mutable references to mutate the data.
fn two_mut_ref(x:&mut Cell<i32>,y:&mut Cell<i32>){}
// Allowed by the borrow checker, but we can mutate the data eventually.
fn two_immut_ref(x:&Cell<i32>,y:&Cell<i32>){
     x.set(11);
}

The borrow checker saves us from problems related to inherent mutability but not interior mutability. The rule of the borrow checker is that we can have aliased &T, but not &mut T. This restriction makes writing concurrent code difficult in Rust because we want different parts of the code to mutate the data in different threads. Due to the fact that mutable references can’t be aliased, we can’t convince the borrow checker that the two mutable references are synchronized. Rust’s solution is to have aliased immutable references of the container types and to obtain mutability from them so that we can mutate the data in different places. With these container types and the type system along with the borrow checker, we can employ various concurrency models in Rust without compromising the safety that Rust provides.

RefCell - Unlike Cell, the RwLock/XOR mutability is checked at runtime instead of compile time. Mutate the data inside the RefCell only within a single thread. Like Cell, RefCell provides methods to read or write the data. RefCell supports both Copy and Clone types, but again, this is unnecessary overhead for Copy types for which the Cell type is preferred. QCell - can be used to reduce the overhead of RefCell by refusing to compile. However, working with those types is less ergonomic than RefCell types. RefCell is only capable of mutating the data within the scope, i.e., it should be used with Rc to mutate data in different scopes.

It’s possible to accidentally cause a memory leak when using Rc and RefCell. These are the side effects of bending single ownership and unique mutable references. However, if the language is too restrictive, we can’t have the flexibility of expressing other patterns.

use std::{cell::RefCell, rc::Rc};
fn main() {
    let rc_ref_cell = Rc::new(RefCell::new(vec![1u8, 2, 4, 5]));
    let ref1 = &rc_ref_cell;
    let ref2 = &rc_ref_cell;

    //don't use it like this
    //let ref1 = ref_cell.borrow();
    //let ref2 = ref_cell.borrow_mut();

    accept(ref1);

// We aren't storing a reference in a temporary variable.
// In other words, it's valid up to the line of usage.
    (*ref1.borrow_mut()).push(23);
    (*ref2.borrow_mut()).push(67);
    println!("{:?}", rc_ref_cell);
}

fn accept(x: &Rc<RefCell<Vec<u8>>>) {
    (*x.borrow_mut()).push(89);
}

Mutex and RwLock are intended for use in multi-threaded code to synchronize shared data using locking mechanisms. The compiler won’t raise any concerns about using them in single-threaded code because they don’t violate memory safety, but they come with overhead that we don’t need to incur. Mutex implements both Deref and DerefMut, while RWLock’s read method only implements Deref, as it shouldn’t allow writing when the intention is to read. This behavior could be problematic in many programming languages, as illustrated in here.

These containers or types are generic and safe to use. It’s important to note that their generic nature doesn’t imply that they can hold any type and be used indiscriminately. These types have invariants, and these invariants are captured through the types.

Each generic type has certain capabilities. Even though Mutex and RwLock permit synchronized access to data, they can’t be used alone in a multi-threaded context. This is because mutexes lack the capability to exist across different scopes; they behave like single-threaded types. However, using them in single-threaded code is excessive. Locks act like lifetimes for data, preventing access to the data beyond the scope and restricting access once the lock is released, which occurs when the scope ends.

The choice of types to wrap within another type depends on the task at hand. For instance, if you need to share data across thread boundaries while being able to mutate it, wrap the data in thread-safe interior mutability types first (RwLock, Mutex, Atomics, etc.). Then, wrap these types into an Arc (atomic reference counting) so that the data can exist in different threads, with synchronization types ensuring that if mutability is held, other parts of the code won’t simultaneously access the data.

All types in Rust have single ownership, including Rc and Arc. Even though Rc and Arc provide shared ownership, they still operate under single ownership rules when not cloned. For example, Rc or Arc is moved if a clone of that data is not created by calling clone on them. If we need to mutate the data across different scopes, we must place a RefCell inside an Rc. After that, we create a clone of the type in a temporary variable and then pass it to the function where we need access.

use std::sync::{Arc,Mutex};
use std::thread::spawn;
fn main(){
    let data = String::from("This is the actual data");
    //we can't create mutex without data
    //and the variable data is moved to Mutex
    let mutex_with_data = Mutex::new(data);
    //Mutex moved to Arc
    let arc_mutex = Arc::new(mutex_with_data);
    // The order of cloning and where it's used matters.
    let clone1 = arc_mutex.clone();
    let clone2 = clone1.clone();
    
    send(clone1);
    spawn(move||{
        (*clone2.lock().unwrap()).push_str("..Other");
    }).join().unwrap();
    
    println!("{:?}",arc_mutex);
}
fn send(x:Arc<Mutex<String>>){
    spawn(move||{
        let lock = x.lock();
        lock.map(|mut string|string.push_str(" From Spawned.."));
    }).join().unwrap();
}

Types and those expression

When we wrap the types, the signature can become quite long and confusing. However, once we understand the capabilities of these types, just by looking at the type signature, we can tell a story. For instance, we can determine whether it’s single-threaded, whether it involves mutability, or if it’s without mutability.

    use std::cell::RefCell;
    use std::rc::Rc;
    let box_rc_refcell_i32: Box<Rc<RefCell<String>>> = Box::new(Rc::new(RefCell::new(String::new())));
    box_rc_refcell_i32.borrow_mut().push_str(" Wow..");;

First, the Box is dereferenced to the inner type Rc, which also implements the Deref trait. Then, it is dereferenced again to the inner of Rc, and finally, we are able to call the methods of RefCell. Even though the initialization process might be lengthy, at least accessing them is less cumbersome and the type annotation is optional here, but it becomes explicit when defining function parameters.

“Container types” are not limited to just smart pointers; they encompass a broader range. Both Option and Result types are examples of generic containers, with Option representing “None” values and Result handling errors.

CONCURRENCY

Problems arise when more than one thread is involved and how Rust solves those.

The compiler can perform a variety of optimizations to enhance code speed without altering the final outcome. Assumptions that work well in single-threaded code can become problematic in multi-threaded scenarios. The sequence of operations can yield distinct outcomes in each run when multiple threads are executed concurrently due to race conditions. This means that we cannot determine which thread will run first. If the compiler optimizes the code as it did before, there might be incorrect results without proper synchronization. When the compiler is aware that a computation involves multiple threads, it should refrain from applying the usual optimizations. Instead, it should ensure atomic execution of instructions, treating them as single units of operation. This synchronization is crucial for maintaining consistent results when accessing shared resources across threads.

Every primitive type and data structure in Rust is single-threaded by default. This design allows the compiler to optimize as usual. Data structures and types become multi-threaded only when they are used inside thread-safe containers. There is no separate data structure in the standard library for single-threaded or multi-threaded contexts. Generic thread container APIs eliminate the need for specific data structures for different scenarios. Instead, it’s up to the user’s choice to use them in either a single-threaded or concurrently multi-threaded manner. This approach differs from APIs in other languages. For instance, Java has different data structures of the same type for single and multi-threaded use. While the cost of this separation is transparent to the programmer, it doesn’t prevent the misuse of single-threaded data structures in multi-threaded code, leading to data races and other problems.

Aliasing and Mutation

Aliasing and Mutation cause problems in both single-threaded and multi-threaded code. Aliasing can lead to serious troubles. If the data is meant to be used across threads or passed between threads, it shouldn’t be aliased. When one piece of data is used in multiple threads, aliasing of that data can result in a data race. In languages like Java, C/C++, and even Go, there is no built-in notion of reading and writing data safely, which means any thread can read or write data without proper synchronization, potentially corrupting the data. Restricted aliasing greatly enhances the experience of working with concurrency code. Functional programming languages like Haskell use immutable data structures to completely avoid aliasing problems.

Apart from aliasing causing data races, it also leads to memory errors in the case of C/C++. If another thread uses a variable from outside and it’s aliased, unpredictable behavior can occur.

  1. The variable that the thread uses might become invalidated if the data is not alive by the time the thread reads or writes it. This could lead to use-after-free errors or writing to memory that is not as intended.

  2. Even if the data is still valid, the thread might write to the data (assuming it’s a growable data structure), causing reallocation. Therefore, using an aliased variable can lead to a memory error.

In C++, you must use the unique_pointer, but in Rust, we have guidance that restricts sending any type across thread boundaries. If a value is sent to a channel, it is possible to use it afterward. This happens in Go, where data races can occur, and in C++, using it with unique_ptr leads to segmentation faults. However, in Rust, it results in a compile error.

Doing concurrency in the presence of unrestricted aliasing is really difficult to do correctly. With Rust’s combination of restricted aliasing and the expressive type system, we can achieve concurrency without the many difficulties seen in other programming languages.

If data is meant to be sent to another thread, such as in channels, it should not be used again. Using it again in this context can lead to a data race if the data is aliased. This situation is what happens in Go if we’re not careful about avoiding usage after sending to a channel.

However, in Rust, plain copies of data, which aren’t wrapped in a struct or enum, can be used afterward without causing a data race. This is because the data is either cloned or exists as an independent copy. For other types, when data is moved to a channel, it implies that we can’t use it afterward due to single ownership.

use std::sync::mpsc;
    let to_send = Box::new(10);
    let to_send = 10; 
    let (send,receiver) = mpsc::channel();
    send.send(to_send);
    //This doesn't cause data race because
    //plain copy types are cloned implicitly
    //so variable to_sent is not aliased
    println!("{to_send}");
    //Comment out the second to_send variable
    //And see what happen

Global variables

Static or Global variables pose a problem either in single threaded or multi threaded code. In single threded code Static variables introduce global state(Multi threaded code too), which can make the code more difficult to understand and reason about. Changes to a static variable can have unintended consequences in different parts of the program, leading to bugs that are hard to trace and also they can complicate testing, as their values can change across different test cases or even between test runs. This can make it harder to reproduce and isolate bugs. But some problems are prevented in Rust. Because of the visibility and scoping rules accessing static is restricted.

fn main(){
    println!("{}",new::PRIVATE);
}
mod new{
    static PRIVATE:u32 = 10223;
    fn fn_in_mod(){
         {
        static PRIVATE2:&str ="This can't be accessed outside this scope";
         }
         println!("{}",PRIVATE2);
    }
}

Module visibilities are private by default. The main function can’t access the PRIVATE static in another module without declaring it with pub, and only submodules can access that. Due to scope restrictions, we can’t access beyond the scope once it ends. We don’t have permission, and the borrow checker won’t allow referring beyond where it was declared (the region), even if it’s static but defined in a local scope. Rust is a language that goes beyond providing memory safety.

In multi-threaded code, the consequences are even more serious because static variables are shared among all threads in a multi-threaded program. If not properly synchronized, concurrent access to static variables can lead to data races, where multiple threads try to read or modify the variable simultaneously, causing unpredictable behavior, incorrect memory access, and crashes.

However, static variables are immutable by default in Rust. Not just static, all variables are immutable by default regardless of the scope, except interior mutability types. This is why mutating a static variable is only done through an unsafe block, which is explicit and opt-in, rather than opt-out.

Ordering of Computation

Apart from race conditions, reordering computations might change the result when we run concurrently to speed up the processing time of operations. For most operations in the real world, the order is irrelevant,independent of other. That is, tasks that take longer time in single-threaded code take less time in multi-threaded code and should return the same result as the single-threaded result.

    use rayon::prelude::*;
    let seq_sum = (0..1000).into_iter().sum::<i32>();
    let par_sum = (0..1000).into_par_iter().rev().sum::<i32>();
    assert!(seq_sum == par_sum);

For example , A interger are Commutative and Associative - where changing order/precedence doesn’t change the end results. Why this properties are useful in concurrency programming. We can speed up the compuation by splitting the chunks then perform operation on each chunks in different threads and finally accumulating the results thus reduce the time to perform operation and still getting same result. Not all operations are satisfy commutative and associative properties. Matrix operations not commutative except in some special cases i.e A x B not equal to B x A so we need different way of achieving parallelism.

    use faer_core::{mat, Mat};
    let matrix1: Mat<f64> = mat! {
        [5.6,9.4,25.7],
        [1.2,5.6,4.6],
        [6.7,34.3,24.7],
    };
    let matrix2: Mat<f64> = mat! {
        [1.2,5.6,4.6],
        [6.7,34.3,24.7],
        [5.6,9.4,25.7]
    };
    let axb = &matrix1 * &matrix2;
    let bxa = &matrix2 * &matrix1;
    //This assertion is true as long as the result is not equal.
    assert!(axb != bxa);

Concurrency in the Presence of Borrow Checker

Why do two mutable references to the same data not compile, either in single-threaded or multi-threaded code?The borrow checker treats both cases similarly because it lacks awareness of threads. Having two mutable references simultaneously is disallowed due to the ‘exclusive or mutability’ (XOR mutability) principle. Thus, sharing a mutable reference, even across different threads, is disallowed. Instead, we pass shared immutable references (&T) to different threads and perform modifications there. Multiple immutable references are allowed by the borrow checker, and exclusive reference is ensured through synchronized interior mutability types.

However, we can have aliasable &T and then mutate in different threads. How is this not undefined behavior? This practice doesn’t violate Rust’s safety guarantees. Rust ensures that when we have mutable access to data, only the specific thread can access that data, and other threads must wait for reading/writing. This is how Rust’s concurrency primitives work, with some container types being synchronized and others not. But how does the compiler differentiate between synchronized and non-synchronized types to prevent programmers from using non-thread-safe types in multi-threaded code? Rust’s solution is its type system, using marker types like Send and Sync. These marker types, which lack runtime representation, label types as safe to Send or Sync or not.

Whether it’s two closures in a single thread or two spawned closures, we can’t use &mut T due to borrow checker restriction. Multiple ownership ensures that the data cloning will persist as long as the creator exists, possibly the main thread. This is because the borrow checker doesn’t know about threads and their scopes. For example, even though spawning is a thread, for the borrow checker, it’s just a scope in single-threaded code, and the closure is simply borrowing the data from its environment. If there is no 'static bound on the spawned thread, the borrow checker allows the code to be safe. However, once the main thread terminates, the child references data that is invalid. The concurrency APIs are designed in a way that reflects properties that must hold in threads because there is no garbage collector to ensure that the references are valid. This design is inherent to concurrency APIs. Rust’s type system, including the combination of other features, such as representing empty traits for static analysis, coherence rules to prevent reimplementation, auto traits, and negative implementations on traits, plays a crucial role. APIs differentiate multi-threaded from single-threaded paradigms, and then the borrow checker enforces these requirements once the API prerequisites are satisfied.

The static bound on the spawned thread ensures that the data must have a static lifetime. That’s why we can’t pass references that depend on the outside of the closure, but we’re able to pass Owned Types like String, Vector, Arc, and Box which are moved into that thread. There are also other thread APIs that permit lifetimes other than static lifetimes, such as scoped threads where they return a JoinGuard, similar to a MutexGuard, meaning they are implicitly joined by default rather than explicitly like with a JoinHandle. JoinGuard doesn’t need a static lifetime but a lifetime at least as long as the lifetime of the JoinGuard. The Code is here.

In single-threaded code, we can obtain both &T and &mut T through inherent mutability. However, in multi-threaded code, we can’t obtain &mut T through inherent mutability; it’s only possible via interior mutability types. Not all types offer mutable references to their underlying data— only containers like Mutex, Cell, RefCell, and others can provide mutable references through shared references &T. Not all interior mutability types are safe to use in threads. In the previous section, we mentioned that RefCell is for single-threaded use, while Mutex, RWLock, and Once are suitable for multi-threaded use. But how does the compiler differentiate between types for single-threaded and multi-threaded use to prevent mixing them, which could lead to various errors associated with concurrent code?

Rust’s solution is to use the type system to distinguish between thread-safe and non-thread-safe types at compile time. For example, atomic operations are thread-safe, but plain Cell and UnsafeCell are not. Traits like Send and Sync are automatically implemented by the compiler. Thread APIs such as spawn and scoped threads only accept types that implement Send and Sync, due to bounds on those APIs. Manually implementing Send or Sync is considered unsafe, meaning the guarantees are held by the implementor.

The Send trait guarantees that it’s safe to transfer ownership of data of type T to another thread. For instance, Arc is Send because it’s safe to send a clone of Arc across thread boundaries, where the reference count is atomic. However, Rc is not Send, as it’s meant for single-threaded use and prevents us from using it in multi-threaded code. On the other hand, the Sync trait ensures that it’s safe to share a reference &T among different threads, avoiding synchronization-induced data races. These two marker traits allow us to write concurrent programs without data races, as they provide invariants we must adhere to when using concurrency primitives.

In C/C++, if a type isn’t safe to send/share between threads, there’s no way to enforce this apart from reading documentation or comments above the code. In contrast, Rust enforces this through it’s type system. Types that are neither Send nor Sync can be verified through the negative implementation of these traits. The rules for thread safety are embedded in Rust’s type system.

impl !Send for Rc
impl !Sync for Rc

This means that Rc is neither Send nor Sync. Consequently, if type bounds are specified as T: Send + Sync, passing an Rc would result in a compile error. This, in turn, guarantees no data races at compile time. This is how we can have both threaded and non-threaded APIs in the same language and also prevent ourselves from using one type in place of another.

How all the previous concepts live in different thread boundaries, via Arc, mutable synchronization capabilities of Mutex, RwLock & others, marker traits like Send and Sync, were essential ingredients to avoid classical mistakes when dealing with concurrency in Rust. We can’t accidentally pass references or free variables to the Closure thread, because Rust detects that at compile time. This has already reduced the likelihood of hard-to-debug data races and considerably eased the code review process, as observed in Uber Engineering’s experience here. The blog post points out that creating data races is trivial and explains how they used a dynamic approach to detect data race issues, consuming more than 246 (analyzed) engineers’ time.

In Go, when using coroutines, each instance gets an independent copy of the Mutex instance, thus the data is not synchronized at all. In Rust, this doesn’t happen due to the ownership rule, but there are still incorrect usages for Copy types. In Java, having a ReadLock doesn’t imply that we don’t have permission to write.

The coding idioms and best practices recommended to avoid data races in other languages are, in fact, type errors in Rust.”

Can deadlocks occur even if threads are not involved?

But the above tricks of sharing multiple immutable references to different threads and then modifying them offer flexibility, but they can also cause deadlocks if we are not cautious. For example, if we pass two immutable references of synchronization types as arguments, it can lead to a deadlock. The borrow checker does not prevent this because the state is immutably aliased, so the borrow checker assumes that no mutation occurs. Therefore, it’s up to us to use it correctly. In the case of inherent mutability, we are protected by the borrow checker, whether in a single thread or multiple threads. However, in the case of interior mutability, we have to be careful even in single-threaded code. This is because the limitations on thread APIs prevent us from using non-thread-safe types in thread-safe APIs, but they cannot prevent or issue warnings about using thread-safe types in single-threaded code. Synchronization types like Mutex and RwLock will block other threads from accessing data until the lock is released.

use std::sync::Mutex;
fn main() {
    
    let mutex = Mutex::new(10);
// Actual rules still apply.
// This code won't compile
// because the borrow checker doesn't allow &mut T and &mut T to access the same data at the same time.
// two_mut(&mut mutex, &mut mutex);
   two_mut(&mutex,&mutex);
   //We never reach here
   println!("{:?}",mutex);
}
// What if x and y point to the same data?
// The borrow checker won't help here since
// both are &T, i.e., shared immutable references, but
// under the hood, we're getting &mut T.
fn two_mut(x:&Mutex<i32>,y:&Mutex<i32>){
//first interior mutable reference
    let mut x = x.lock().unwrap();
    *x+=11;
//second interior mutable reference
    *y.lock().unwrap()+=1;
}

The same applies to the RwLock as well.

use std::sync::RwLock;
fn main() {
    let read_write = RwLock::new(10);
    two_rwlock(&read_write, &read_write);
    println!("{:?}", read_write);
}
// What if x and y point to the same data?
fn two_rwlock(x: &RwLock<i32>, y: &RwLock<i32>) {
    //Getting read lock
    let x = x.read().unwrap();
    //Getting write lock
    let mut y = y.write().unwrap();
    *y += 11;
    println!("{x}");
}

The above code will run indefinitely if both x and y point to the same data. The borrow checker doesn’t assist in this situation. There are three possible solutions to avoid this:

  1. Drop the lock as soon as you are done with the data.
  2. Do not use a temporary variable to store the lock result; instead, perform mutable/immutable operations using the dereference operator where the data is dropped automatically after that line.
  3. Use different scopes to isolate the variables.