A Rusty Tale of Shared Memories and Passed Messages Part 2: Pointers to Shared Memory

Justin Wernick <>
Curly Tail, Curly Braces
2017-07-11

Abstract

Writing multithreaded code can be challenging. Luckily, the Rust programming language aims to make it easier by baking information about multithreading directly into the language's type system. This post explores the second way that you can have multiple threads communicate with each other in Rust: pointers to shared memory, with concurrent access protected with a lock.

Multithreading

(This post is part 2 in a series. Click here to read part 1)

Multithreading is an inherently difficult problem. The art of writing programs that do multiple things at exactly the same time is full of pitfalls, things that have to happen in a certain order, threads all waiting for each other, and amusing anecdotes about philosophers trying to eat spaghetti.

Rust is a systems programming language. As such, it gives you the tools that you need in order to write multithreaded code. After all, you can't make full use of a modern CPU without using multiple threads. Rust is also built around an idea of having a smart compiler that can steer you around some of the pitfalls in systems programming. This makes Rust a good fit for writing multithreaded code, since it can point out several situations that are unsafe before you've even written a unit test.

You can find a longer introduction to what multithreading is, why it's difficult, and why it's important in part 1 of this blog post series. I also talked about a clever approach to writing multithreaded Rust code in part 1, which was passing messages using channels.

In this post, I'm going to be talking about the other main approach to communicating between different threads offered by Rust's standard library. This is sharing memory between threads.

How to Spawn a Thread in Rust

Spawning a new thread in Rust is fairly straightforward. Call thread::spawn with a function that you'd like to run on a new thread.

Usually, you'll see the move keyword used if the function is declared inline. move means that the ownership of any values referenced in the inline function's body will be taken over by that function. This is especially important to do when you're spawning threads because of Rust's lifetime rules.

The new thread may outlive the function call that spawned it. It follows that the lifetime of variables used inside the new thread must match those of the new thread, not the function that it was spawned from.

use std::thread;

let outside_value = String::from("Hello world");

let child_thread = thread::spawn(move || {
    // this new thread may outlive the context that spawned it
    println!("{} from a different thread", outside_value);
});

// outside_value can no longer be used here. Ownership has been
// transferred to within the closure, and thus to the child thread

Getting Around Rust's Ownership Rules with Pointers

Rust has a few rules about variables and ownership:

  1. A variable will have exactly one owner.

  2. There can be many immutable borrows of a variable at the same time.

  3. There can only be one mutable reference to a variable at a time.

Point 1, a variable needing to have one and only one owner, is a problem for our desire to have access to the same memory from two independent threads. The trick to work around this limitation is pointers.

If you're not familiar with pointers, they are a special data type that, rather than holding the data that we're interested in, rather hold the address in memory where we can find the data. In other words, a pointer is a reference to data. We can have two threads that each have individual ownership of their own pointer to the data.

In Rust's standard library, we'll do this using Arc, an Atomic Reference-Counting pointer. The "reference-counting" aspect means that the pointer will keep track of how many references there are to the data it's pointing at. When the last reference is gone, the memory used by the actual data is also freed.

The 'atomic' part of Arc's name refers to the operations on it being correctly synchronized between threads. It's safe to share an Arc between two threads, even though it would not be safe to share the closely related Rc. If you make a mistake and try to pass an Rc to another thread, the Rust compiler will refuse to continue.

In summary, Arc is a pointer to a piece of data somewhere in memory, which is safe to share between threads, is safe to make as many copies of it as you need, and will automatically clean up after itself when the last copy goes out of scope.

Passing Pointers Between Threads

The simplest version of sharing memory is with immutable data. You can have multiple copies of the pointer, one in each thread for example, and access the data as you need.

This would be particularly useful to avoid unnecessary copying if, for example, you had a large amount of data that you were processing in parallel.

use std::thread;
use std::time::Duration;
use std::sync::Arc;

let data_pointer = Arc::new(
    vec!("some", "useful", "data")
);

let child_data_pointer = data_pointer.clone();
let child_thread = thread::spawn(move || {
    for &line in child_data_pointer.iter() {
        println!("Child thread: {}", line);
        thread::sleep(Duration::from_millis(100));
    }
});

for &line in data_pointer.iter() {
    println!("Parent thread: {}", line);
    thread::sleep(Duration::from_millis(100));
}

child_thread.join();

The only restriction on passing around a pointer like this and using it directly is that nobody can change the data.

Safely Making Changes with Locks

Let's say that you did actually need to change the data. Perhaps the data isn't just an immutable source of information, but also somewhere that you need to publish results to.

There are two ways to accomplish this. Both uphold the restriction that there can only be one mutable reference to data at a time.

The first is the mutex. A mutex can be thought of as putting the data into a public bathroom stall. In order to use the data, as it is to use the bathroom stall, you need to lock the door. While the door is locked, nobody else can use the data. At most, they can wait for the previous occupant to finish using the data and release their lock. At which point they can lock the data themselves and take their turn to access it.

use std::thread;
use std::time::Duration;
use std::sync::Arc;
use std::sync::Mutex;

let data_pointer = Arc::new(
    Mutex::new(
        vec!("some", "useful", "data")
    )
);

let child_data_pointer = data_pointer.clone();
let child_thread = thread::spawn(move || {
    // when this thread calls .lock(), it asks for the lock
    match child_data_pointer.lock() {
        Ok(mut data) => {
            // here we have exclusive access to the data
            data.push("Hello world from child thread");
        },
        Err(e) => {
            println!("Failed to get a lock: {}", e);
        }
    };
    // thanks to Rust's lifetime rules, the lock is cleared up
    // automatically
});

// for the example's sake, let the child get the lock first
thread::sleep(Duration::from_millis(100));

// the .lock() will wait if the child thread still has its lock
match data_pointer.lock() {
    Ok(data) => {
        // here we have exclusive access to the data
        for &line in data.iter() {
            println!("Parent thread: {}", line);
        }
    },
    Err(e) => {
        println!("Failed to get a lock: {}", e);
    }
};

child_thread.join();

It's a good idea to do as little as possible while you're inside the lock. After all, while you have a lock, nobody else can access the data at all.

Mutexes can be a bit heavy handed. Either you have unrestricted access to data, or none at all. If you are in a situation where your threads will just be reading the data most of the time, and could have simultaneous access without worry, but occasionally need to make changes, then there is a different type of lock you can use. The RwLock, or Reader-Writer lock. Simply put, there's a difference between asking for a lock in order to read data, and in order to write data.

If thread A wants to read data, and thread B is already reading it, there is no reason to make thread A wait.

use std::thread;
use std::time::Duration;
use std::sync::Arc;
use std::sync::RwLock;

let data_pointer = Arc::new(
    RwLock::new(
        vec!("some", "useful", "data")
    )
);

let child_data_pointer = data_pointer.clone();
let child_thread = thread::spawn(move || {
    // when this thread calls .write(), it asks for a write lock
    match child_data_pointer.write() {
        Ok(mut data) => {
            // here we have exclusive access to the data
            data.push("Hello world from child thread");
        },
        Err(e) => {
            println!("Failed to get a lock: {}", e);
        }
    };
    // thanks to Rust's lifetime rules, the write lock is cleared up
    // automatically

    // .read() asks for a read-only lock
    match child_data_pointer.read() {
        Ok(data) => {
            // other readers may also have access to the data here,
            // but no writers
            for &line in data.iter() {
                println!("Child thread: {}", line);
                thread::sleep(Duration::from_millis(100));
            }
        },
        Err(e) => {
            println!("Failed to get a lock: {}", e);
        }
    };
});

// for the example's sake, let the child get the write lock first
thread::sleep(Duration::from_millis(100));

// the .read() will wait if the child thread still has its write lock,
// but not if the child is in its read lock
match data_pointer.read() {
    Ok(data) => {
        // other readers may also have access to the data here,
        // but no writers
        for &line in data.iter() {
            println!("Parent thread: {}", line);
            thread::sleep(Duration::from_millis(100));
        }
    },
    Err(e) => {
        println!("Failed to get a lock: {}", e);
    }
};

child_thread.join();

As with the mutexes, it's a good idea to do as little as possible while you're inside the lock. An extra complication with a RwLock is that, since a writer can't get a lock while there are any readers currently reading, but new readers can get a lock, a new reader might 'jump the queue' and get a lock before a writer. If you don't ever have a point where there are no readers, the writer may never get a chance to get its own lock. This can typically be avoided by doing as little as possible while the lock is active, but it's a pitfall to be aware of when debugging your own code.

Why is this great in Rust?

One of the best parts of the lock implementations in Rust, in my opinion, is that the library design ensures you're using the correct lock method to get access to the data. The immutability rules mean that you can avoid mutexes when you don't need them, while ensuring that you don't forget to use locks when you need them.

You can't accidentally forget to lock the data because you need to call .lock() to get access to the data. You also are not likely to forget to unlock the data after processing, since releasing the lock is also handled by Rust's lifetime rules automatically.

Poisoned Locks

All of the lock functions I mentioned above return a Result, with an error case. If a thread panics while it has a Mutex or RwLock locked, then it will be considered 'poisoned'. All future attempts to access the data will return an error.

The moral of the story? Try to avoid panicking while holding a lock. As the Hitchhiker's Guide to the Galaxy says: "Don't Panic".

Channels vs Shared Memory

In my previous post, I talked about channels for sending messages between threads. A natural question would be which should you use, channels or shared memory?

Like many engineering problems, the answer is that it depends on what you need to do. That's why we have both, neither are inherently 'better' than the other.

In my opinion, a good rule of thumb would be to look at how your data needs to move.

If you have many threads that are publishing data, and only one thread receiving that data, then channels are a natural fit. This pattern may come up if, for example, your threads are each processing a data set and sending the results back to a central thread to be aggregated.

If you have many threads that need to read data, but only one thread writing that data, then shared memory is a good solution. Shared memory is an even better fit if you can do all of the writing before hand, and avoid needing to use locks at all by sharing immutable data.

It's also possible to have both approaches in the same application, so you can match your method of communication to the specific part of your problem.

Final Thoughts

Whichever option you choose, you will appreciate Rust's compiler telling you when you're using data structures that are not meant to be thread safe, or using them incorrectly. Just try not to get too comfortable when writing multithreaded code, it's still possible to have errors in your logic.


If you'd like to share this article on social media, please use this link: https://www.worthe-it.co.za/blog/2017-07-11-a-rusty-tale-of-shared-memories-and-passed-messages-2.html

Copy link to clipboard

Tags: blog, rust


Support

If you get value from these blog articles, consider supporting me on Patreon. Support via Patreon helps to cover hosting, buying computer stuff, and will allow me to spend more time writing articles and open source software.


Related Articles

A Rusty Tale of Shared Memories and Passed Messages Part 1: Channels

Writing multithreaded code can be challenging. Luckily, the Rust programming language aims to make it easier by baking information about multithreading directly into the language's type system. This post explores the first way that you can have multiple threads communicate with each other in Rust: message passing using channels.

Going Four Times Faster using Multi-Threading

This is part two in a three part series where I discuss how I did performance optimization on a Rust applilcation I developed: a submission to a programming competition called the Entelect Challenge. In this article, I show how I used Rayon to make an embarrasingly parallel program I'd written in Rust multithreaded.

Subscribe to my RSS feed.