Concurrent Programming in Rust: A Detailed Explanation of Core Methods for Data Sharing Between Threads

Concurrent Programming in Rust: A Detailed Explanation of Core Methods for Data Sharing Between Threads

In modern computing, multithreaded programming is key to enhancing application performance and achieving high concurrency. However, data sharing between threads has always been a significant challenge in concurrent programming, fraught with pitfalls such as data races and deadlocks. The Rust language, with its unique ownership system and strict compile-time checks, provides us with the ability to achieve “fearless concurrency”.

Concurrent Programming in Rust: A Detailed Explanation of Core Methods for Data Sharing Between Threads

This article will delve into several core methods for implementing multithreaded data sharing in Rust through specific code practices, including <span>static</span> variables, the <span>Box::leak()</span> technique, and atomic reference counting <span>Arc<T></span>. Whether you are a Rust beginner or an experienced developer, this article will help you gain a deeper understanding and application of Rust’s concurrency capabilities.

Rust: Several Methods for Sharing Data in Multithreading

Rust – Data Sharing Between Threads

Rust Multithreading

Methods:

  • Using <span>move</span> to transfer ownership
  • Using scoped threads to borrow data from a parent thread with a longer lifetime
  • Static
  • <span>Box::leak()</span>
  • <span>Arc<T></span>

Practical Implementation

Create a Project and Enter the Project Directory

cargo new share-data
    Creating binary (application) `share-data` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

cd share-data

Static

  • The value of a static variable is valid for the entire duration of the program
  • Has a <span>'static</span> lifetime
  • Can only be initialized with constant values
  • Represents a memory address and can be referenced
  • Will not call drop at the end of the program
  • Can be either <span>mut</span> or non-<span>mut</span>

Using <span>static</span> to Share Data in a Multithreaded Environment

use std::thread;

static DATA: [i32; 5] = [1, 2, 3, 4, 5];

fn main() {
    let mut handles = Vec::new();

    for _ in 0..6 {
        let h = thread::spawn(|| {
            println!("Data: {DATA:#?}");
        });
        handles.push(h);
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
}

Run

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
   Compiling share-data v0.1.0 (/Users/qiaopengjun/Code/Rust/RustJourney/share-data)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/share-data`
Data: [
    1,
    2,
    3,
    4,
    5,
]
Data: [
    1,
    2,
    3,
    4,
    5,
]
Data: [
    1,
    2,
    3,
    4,
    5,
]
Data: [
    1,
    2,
    3,
    4,
    5,
]
Data: [
    1,
    2,
    3,
    4,
    5,
]
Data: [
    1,
    2,
    3,
    4,
    5,
]

Modifying <span>mut static</span> Variables in a Multithreaded Environment

use std::thread;

static mut COUNTER: u32 = 0;

fn main() {
    let mut handles = Vec::new();

    for _ in 0..10000 {
        let h = thread::spawn(|| unsafe {
            COUNTER += 1;
        });
        handles.push(h);
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
    println!("Counter: {}", unsafe { COUNTER });
}

Run

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
   Compiling share-data v0.1.0 (/Users/qiaopengjun/Code/Rust/RustJourney/share-data)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/share-data`
Counter: 9990

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 took 3.1s 
➜ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/share-data`
Counter: 9987

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/share-data`
Counter: 9987

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/share-data`
Counter: 9990

Why is it not 10000? Because each time the operation <span>COUNTER += 1</span> is not an atomic operation.

This operation roughly consists of three steps:

First step: retrieve the value of <span>COUNTER</span>

Second step: increment <span>COUNTER</span> by 1

Third step: put it back

It is not an atomic operation, at least three steps are involved. Therefore, using mutable static variables shared across threads and modifying them can lead to data races. Hence, it is unsafe, and we should avoid such operations.

The above is an example of immutable Static and mutable Static for sharing data in multithreading.

Box::leak()

Essentially, it is actively leaking memory allocation

  • Releases ownership of the Box and promises never to drop it
  • From the moment of leak, this Box exists indefinitely
  • Since there is no owner, it can be borrowed by any thread as long as the program runs
  • Note: Because it causes memory leaks, do not use too many in one program

Using <span>Box::leak()</span> to Share Data Across Multiple Threads

use std::thread;

fn main() {
    let data: &'static [i32; 5] = Box::leak(Box::new([1, 2, 3, 4, 5]));

    let mut handles = Vec::new();

    for _ in 0..5 {
        let h = thread::spawn(move || {
            println!("Data: {data:?}");
        });
        handles.push(h);
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
}

Although the <span>move</span> keyword is used, it does not transfer ownership.

Run

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
   Compiling share-data v0.1.0 (/Users/qiaopengjun/Code/Rust/RustJourney/share-data)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.50s
     Running `target/debug/share-data`
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]

<span>Arc<T></span>

Atomic Reference Counting

  • Similar to <span>Rc<T></span>, but <span>Arc</span> guarantees that modifications to the reference counter are atomic operations
  • Used in multithreaded environments

Using <span>Arc<T></span> to Share Ownership Across Multiple Threads

use std::{sync::Arc, thread};

fn main() {
    let data = Arc::new([1, 2, 3, 4, 5]);

    let mut handles = Vec::new();

    for _ in 0..4 {
        let local_data = data.clone();
        let h = thread::spawn(move || {
            println!("Data: {local_data:?}");
        });
        handles.push(h);
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
}

Run

RustJourney/share-data on  main [?] is 📦 0.1.0 via 🦀 1.89.0 
➜ cargo run
   Compiling share-data v0.1.0 (/Users/qiaopengjun/Code/Rust/RustJourney/share-data)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/share-data`
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]
Data: [1, 2, 3, 4, 5]

Conclusion

In this article, we explored three main methods for implementing data sharing between threads in Rust:

  1. <span>static</span> variables: Suitable for sharing data that exists for the entire lifecycle of the program and is immutable. Although <span>static mut</span> can achieve mutable data sharing, it bypasses Rust’s borrowing checks, requires <span>unsafe</span> code blocks, and is prone to data races, so it should be used cautiously or in conjunction with other synchronization primitives.
  2. <span>Box::leak()</span>: A technique to obtain a reference with a <span>'static</span> lifetime by actively “leaking” memory. It allows dynamically allocated data to be valid throughout the program’s execution, enabling safe borrowing by multiple threads. However, this method essentially causes memory leaks and should not be overused.
  3. <span>Arc<T></span> (Atomic Reference Counting): This is the most commonly used and flexible way to share ownership safely across threads in Rust. It manages the reference count through atomic operations, ensuring that data is cleaned up only after all threads have finished using it, making it the preferred solution for multithreaded data sharing.

In summary, Rust provides a diverse and powerful set of tools to tackle the challenges of concurrent programming. Understanding and choosing the appropriate data sharing method based on specific scenarios is a key step in writing efficient and safe concurrent Rust programs. I hope this article’s practice helps you gain a deeper understanding of Rust’s “fearless concurrency”.

References

  • https://www.rust-lang.org/en
  • https://course.rs/about-book.html
  • https://lab.cs.tsinghua.edu.cn/rust/

Concurrent Programming in Rust: A Detailed Explanation of Core Methods for Data Sharing Between Threads

Give a “like” to let me know you enjoyed it, and a “recommend” to let more “moon seekers” see it.

Leave a Comment