Concurrency Abstractions

Common Abstractions

  • Locks
  • Reader-Writer Locks
  • Semaphores
  • Condition Variables
  • Monitors
  • Messages

Condition Variables in Concurrent Programming

  • Purpose: Enable threads to wait for specific conditions without consuming CPU resources.
  • How do They Work
    1. Waiting: A thread waits on a condition variable if a certain condition is not met, releasing the associated
      mutex atomically to avoid race conditions.
    2. Signaling: Another thread signals the condition variable to wake up waiting threads when the condition
      changes.

Example

A thread pushes items into a Queue while other pops and prints them

let queue = Mutex::new(VecDeque::new());

thread::scope(|s| {
   s.spawn(|| {
            loop { // Busy loop !!
                let mut q = queue.lock().unwrap();
                if let Some(item) = q.pop_front() {
                    println!("Popped: {item}", );
                }
            }
        });

    for i in 0.. {
        queue.lock().unwrap().push_back(i);
        thread::sleep(Duration::from_secs(1));
    }
});

Solve with a Condition Variable

Create a condition variable

let queue = Mutex::new(VecDeque::new());
let not_empty = Condvar::new(); 

If pop_front fails wait on the condition variable

loop {
    let mut q = queue.lock().unwrap();
    if let Some(item) = q.pop_front() {
        println!("Popped: {item}", );
    }
    else {
        not_empty.wait(q); // <--- Wait
    }
}
});

When pushing an item notify the condition variable

for i in 0.. {
    queue.lock().unwrap().push_back(i);
    not_empty.notify_one(); // <-- notify the first thread waiting
    thread::sleep(Duration::from_secs(1));
}

Key Operations

  • wait: Release mutex and enter wait state until condition is signaled.
  • notify_one: Wake up one waiting thread.
  • notify_all: Wake up all waiting threads.

Benefits

  • Efficient waiting mechanism in concurrent programming.
  • Facilitates complex synchronization scenarios.

The Producer-Consumer Problems

Involves two types of threads: Producers and Consumers.

  • Producers generate data and place it into a shared buffer.
  • Consumers retrieve data from the buffer and process it.

Solving it in Rust

The typical (Non-concurrent) CircularBuffer

struct CircularBuffer<T> {
    buffer: Vec<Option<T>>,
    capacity: usize,
    head: usize,
    tail: usize,
    size: usize,
}

Add Method

impl<T> CircularBuffer<T> {

    pub fn add(&mut self, element: T) -> bool {
        if self.size == self.capacity {
            return false
        }
        let i = self.head;
        self.buffer[i] = Some(element);
        self.head = (i + 1) % self.capacity;
        self.size += 1;
        return true;
    }
}    

Remove Method

    pub fn remove(&mut self) -> Option<T> {
        if self.size == 0 {
            return None
        }
        let i = self.tail;
        let result = self.buffer[i].take();
        self.tail = (i + 1) % self.capacity;
        self.size -= 1;
        result
    }

A Concurrent CircularBuffer

Wrap the Data of the buffer in a Mutex.
Create two conditional variables

struct Data<T> {
    buffer: Vec<Option<T>>,
    capacity: usize, head: usize, tail: usize, size: usize,
}

pub struct CircularBuffer<T> {
    data: Mutex<Data<T>>,
    not_empty: Condvar,
    not_full: Condvar
}

The Add Method modified

pub fn add(&self, element: T) {
    let mut data = self.data.lock().unwrap();     // Lock the Mutex
    while data.size == data.capacity {
        data = self.not_full.wait(data).unwrap(); // Wait until not full
    }

    data.buffer[data.head] = Some(element);
    data.head = (data.head + 1) % data.capacity;
    data.size += 1;

    self.not_empty.notify_one();                  // notify that is not empty
}

Remove Method

 pub fn remove(&self) -> T {
    let mut data = self.data.lock().unwrap();      // Lock the mutex
    while data.size == 0 {
        data = self.not_empty.wait(data).unwrap(); // Wait until not empty
    }
    let result = data.buffer[data.tail].take();
    data.tail = (data.tail + 1) % data.capacity;
    data.size -= 1;
    self.not_full.notify_one();                    // Notify that is not full
    result.unwrap()
}

Monitors

A Definition

  • A synchronization construct that allows threads to have:
    1. Mutual exclusion
    2. The ability to wait (block) for a certain condition.
    3. A mechanism for signaling other threads that their condition has been met.
  • A monitor can be thought as a mutex plus a condition variable

A Monitor in Java.

Just mutual exclusion (using a monitor as a Lock)

class Account {
    double balance;
    synchronized public void withdraw(double amount) {
        balance -= amount;
    }
    synchronized public void deposit(double amount) {
        balance += amount;
    }
}
  • The synchronize keyword in a method creates a monitor over
    the current object.
  • Equivalent to synchronized(this) { }
  • synchronize in an static method creates a monitor over the class object.
  • Equivalent to synchronized(X.class)

Using the associated conditional variable

Suppouse that we want to wait that there is enough balance

class Account {
    double balance;
    synchronized public void withdraw(double amount) throws InterruptedException {
        if (amount <= 0) return;

        while (balance < amount) {
            // Wait for enough balance");
            wait();
        }
        balance -= amount;
    }

    synchronized public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            notify(); // Notify that some money have been deposited
        }
    }
}

The producer-Consumer problem in Java

public class CircularBuffer<T> {
    List<T> buffer;
    int capacity, head, tail, size;

    public CircularBuffer(int capacity) {
        buffer = new ArrayList<>(capacity);
        this.capacity = capacity;
    }
  
   
}

Add Method

public synchronized void add(T element) throws InterruptedException {
    while (size == capacity) {
        wait();
    }
    buffer.set(head, element);
    head = (head + 1) % capacity;
    size += 1;
    notifyAll();
}

Remove Method

 public synchronized T remove() throws InterruptedException {
    while (size == 0) {
        wait();
    }
    var result = buffer.get(tail);
    tail = (tail + 1) % capacity;
    size -= 1;
    notifyAll();
    return result;
}

Message Passing Concurrency

Do not communicate by sharing memory;

Instead, share memory by communicating.

Two basic methods for information sharing

  • Original sharing, or shared-data approach
  • Copy sharing, or message-passing approach

Message Passing Concurrency

  • In message-passing approach, the information to be shared is physically copied from the sender process address space to
    the address spaces of all receiver processes
  • This done by transmitting the data in the form of messages
  • A message is a block of information

Synchronous vs Asynchronous messages

Feature Synchronous Asynchronous
Timing Sender waits for receiver to get message Sender proceeds without waiting
Flow Control Automatic through sender blocking Requires explicit management
Complexity Lower, due to direct coordination Higher, due to indirect handling
Use Case Best for tightly coupled tasks Best for independent tasks
Performance Can be slower due to waits Higher, as no waiting is involved
Resource Utilization Lower during waits Higher, as tasks keep running

Rust Channels example

fn channels_example() {
    // Create a channel
    let (sender, receiver) = mpsc::channel();
    // Spawn a new thread
    thread::spawn(move || {
        // Send a message to the channel
        let msg = "Hello from the spawned thread!";
        sender.send(msg).unwrap();
        println!("Sent message: '{}'", msg);
    });

    // Receive the message in the main thread
    let received = receiver.recv().unwrap();
    println!("Received message: '{}'", received);
}

Multiple Producers - One consumer

Create a channel

let (sender, receiver) = mpsc::channel();

Producers

    // Spawn many threads
    for tid in 0..10 {
        let s = sender.clone();   // <--- Clone the sender part
        thread::spawn(move || {
            // Send a message to the channel
            let msg = format!("Hello from thread! {tid}");
            println!("Sent message: '{}'", msg);
            s.send(msg).unwrap();
        });
    }

Consuming

Use a timeout to finish processing...

loop {
    match receiver.recv_timeout(Duration::from_secs(1)) {
        Ok(msg) => println!("Received message: '{}'", msg),
        Err(_) => break
    }
}