Concurrency Abstractions

Common Abstractions

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

Locks

A lock (or mutex from mutual exclusion) is a synchronization primitive
that prevents state from being modified or accessed by multiple threads of execution at once.

  • When a thread wants to access a shared resource, it first tries to acquire the lock associated with that resource.
  • If the lock is available (i.e., not held by another thread), the thread acquires the lock, accesses the resource, and
    then releases the lock when it's done.
  • If the lock is not available (i.e., currently held by another thread), the requesting thread is blocked from
    proceeding until the lock becomes available.

Lock Types

  • Mutex (Mutual Exclusion): A basic lock that allows only one thread to access a resource at a time.
  • Reentrant Locks: It can be acquired multiple times by the same thread without causing a deadlock.
  • Read/Write Locks: It allow multiple readers to access the resource simultaneously but require exclusive access for
    writing.

Java Synchronized

class BankAccountSync {
    private double balance;
    private final Object lock = new Object();

    public BankAccountSync(double initialBalance) {
        this.balance = initialBalance;
    }

    // Synchronized method to deposit money
    public void deposit(double amount) {
        synchronized (lock) {
            if (amount > 0) {
                balance += amount;
                System.out.println("Deposited: " + amount);
            }
        }
    }

    // Synchronized method to withdraw money
    public void withdraw(double amount) {
        synchronized (lock) {
            if (amount > 0 && balance >= amount) {
                balance -= amount;
                System.out.println("Withdrawn: " + amount);
            } else {
                System.out.println("Insufficient balance for withdrawal");
            }
        }
    }

    public double getBalance() {
        synchronized (lock) {
            return balance;
        }
    }
}

Using the Method modifier

class BankAccountSync {
    private double balance;

    public BankAccountSync(double initialBalance) {
        this.balance = initialBalance;
    }

    // Synchronized method to deposit money
    public synchronized void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            System.out.println("Deposited: " + amount);
        }
    }

    // Synchronized method to withdraw money
    public synchronized void withdraw(double amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
            System.out.println("Withdrawn: " + amount);
        } else {
            System.out.println("Insufficient balance for withdrawal");
        }
    }

    public double getBalance() {
        synchronized (this) {
            return balance;
        }
    }
}

Using Lock (from java.util.concurrent.locks)

class BankAccountWithLock implements BankAccount {
    private double balance;
    private final Lock lock = new ReentrantLock();

    public BankAccountWithLock(double initialBalance) {
        this.balance = initialBalance;
    }

    // Method to deposit money using Lock
    public void deposit(double amount) {
        lock.lock();
        try {
            if (amount > 0) {
                balance += amount;
                System.out.println("Deposited: " + amount);
            }
        } finally {
            lock.unlock();
        }
    }

    // ... etc
}

Comparison

Feature Lock synchronized
Locking Mechanism Explicit locking and unlocking. Implicit by block or method.
Acquisition Contro Option to return immediately or with a timeout. Always wait for lock.
Flexibility Allows locking and unlocking to occur in different scopes. Limited to the current block.
Reentrancy Explicit Implementation By default
ReadWrite Locks Yes No
Performance & Scalability Potentially better. --
Use Case Complex synchronization tasks. Simpler ones.

Rust Mutex

  • A mutex in Rust is a piece of data with a lock to protect it.
  • To access the data in a mutex, a thread must first signal that it wants access by asking to acquire the mutex’s lock.
  • lock returns a smart pointer to the value inside the Mutex.
  • Mutex allows only one thread to access some data at any given time

Rust Mutex

use std::sync::Mutex;

pub struct BankAccount {
    balance: Mutex<f64>
}
impl BankAccount {
    pub fn new(initial_balance: f64) -> BankAccount {
        BankAccount {
            balance: Mutex::new(initial_balance)
        }
    }
}

Rust Mutex

impl BankAccount {
    pub fn deposit(&self, amount: f64) {
        let mut balance = self.balance.lock().unwrap();
        *balance += amount;
        println!("Deposited: {}", amount);
    }

    pub fn withdraw(&self, amount: f64) {
        if let Ok(mut balance) = self.balance.lock() {
            if *balance >= amount {
                *balance -= amount;
                println!("Withdrawn: {}", amount);
            } else {
                println!("Insufficient balance for withdrawal");
            }
        }
    }

    pub fn get_balance(&self) -> f64 {
        *self.balance.lock().unwrap()
    }
}

The Dinning Philosophers

The Scenario

  • Example problem originally formulated in 1965 by E. Dijkstra
  • Five philosophers are sitting at the same table
  • Each one has his own plate of spaghetti
  • There is a fork between each plate

The Dinning Philosophers

The Rules

  • Each philosopher alternately thinks and eats
  • A philosopher can only eat when he has both a left and a right fork.
  • Each fork can be held by only 1 philosopher
  • After finishing eating, he will put down both forks.
  • No philosopher can know when others may want to eat or think

The Dinning Philosophers

The Problem

Design a concurrent algorithm,
such that each philosopher will not starve.

(He can forever alternate between eating and thinking)

First try

public class FirstSolution {
    List<Object> forks = List.of(
            new Object(), new Object(), new Object(), new Object(), new Object()
    );
    List<Philosopher> philosophers = List.of(
            new Philosopher("Plato", 1, 0),
            new Philosopher("Aristotle", 2, 1),
            new Philosopher("Aquinas", 3, 2),
            new Philosopher("Descartes", 4, 3),
            new Philosopher("Locke", 0, 4)
    );

    void run() throws InterruptedException {
        for (var p : philosophers) {
            var t = new Thread(() -> {
                for (int i = 0; i < 100; i++)
                    p.run(forks);
            });
            t.start();
        }
    }
}

First Try - Philosopher class

void run(List<Object> forks) {
    think();
    synchronized (forks.get(fork1)) {
        synchronized (forks.get(fork2)) {
            eat();
        }
    }
}


Deadlock is possible

For example, if I have a sequence, where all philosophers get the right fork, and then try to get the left one

Possible solution

Reverse the way one philosopher get the forks.
  List<Philosopher> philosophers = List.of(
        new Philosopher("Plato", 1, 0),
        new Philosopher("Aristotle", 2, 1),
        new Philosopher("Aquinas", 3, 2),
        new Philosopher("Marx", 3, 4), // <-- Left handed
        new Philosopher("Locke", 0, 4)
);
  • It does NOT guarantees fairness.
  • Good solution for this case, can be difficult to implement with many participants

Another potential solution. Using locks

  • Do not block when trying to acquire the left fork
  • Release the right fork and retry
void run(List<Lock> forks) {
    think();

    forks.get(fork1).lock();
    while (!forks.get(fork2).tryLock())
        forks.get(fork1).unlock();

    eat();

    forks.get(fork2).unlock();
    forks.get(fork1).unlock();
}

Some problems with this solution

  • Kind of busy loop to do the checking
  • In an extreme case it can lead to a livelock
  • Livelock is a condition that takes place when two or more threads change their state continuously, with neither
    making progress

Reader-Writer lock

Also known as a shared-exclusive lock or a multiple readers/single writer lock.

It is a synchronization mechanism to handle situations where a resource can be accessed by multiple threads
simultaneously.
This type of lock allows concurrent read-only access to the shared resource, while write operations require exclusive
access.

They are useful in high concurrency scenarios where reads are frequent and writes are infrequent.

Reader-Writer lock

  1. Multiple threads can hold the read lock simultaneously, as long as no thread holds the write lock
  2. Only one thread can hold the write lock at any given time. When a thread holds the write lock, no other thread can
    hold either the read or write lock.
  3. They can be implemented with different priority policies, such as giving preference to readers, writers, or neither.
  4. The choice of policy can affect the lock's behavior in terms of fairness and potential for starvation.

Reader-Writer lock in Rust

pub fn new(initial_balance: f64) -> BankAccountRW {
    BankAccountRW {
        balance: RwLock::new(initial_balance)
    }
}

pub fn deposit(&self, amount: f64) {
    if let Ok(mut balance) = self.balance.write() {
        *balance += amount;
        println!("Deposited: {}", amount);
    }
}

pub fn get_balance(&self) -> f64 {
    *self.balance.read().unwrap()
}
    

Reader-Writer lock in Java

public class BankAccount {
    private double balance;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public void deposit(double amount) {
        lock.writeLock().lock();
        try {
            balance += amount;
        } finally {
            lock.writeLock().unlock();
        }
    }

    public double getBalance() {
        lock.readLock().lock();
        try {
            return balance;
        } finally {
            lock.readLock().unlock();
        }
    }
    // .. etc
}

Semaphores

Introduction to Semaphores

  • Semaphores are a synchronization primitive used in concurrent programming.
  • Introduced by E. Dijkstra in 1965.
  • They provide a mechanism for controlling access to a shared resource by multiple processes or threads.

Semaphore Operations

Two fundamental operations:

  1. down (P) or acquire: Decrements the semaphore count. If the count becomes negative, the process/thread is blocked
    until it becomes non-negative.
  2. up (V) or release: Increments the semaphore count. If there are processes/threads blocked, it unblocks one of
    them.

Types of Semaphores

  1. Binary Semaphore: Can only take the values 0 and 1. Equivalent to a Lock
  2. Counting Semaphore: Can take any non-negative integer value. Useful for managing resources with limited capacity.

Using a semaphore as a Lock (A binary one)

public class Counter {
    int value = 0;
    Semaphore semaphore = new Semaphore(1, true);

    void increment() {
        semaphore.acquire();  // wait or down or P

        int localCounter = value;
        localCounter = localCounter + 1;

        value = localCounter;
        semaphore.release();  // signal or up or V
    }
}

Counting semaphore

Useful for managing resources with limited capacity.

Elevator simulation: We have people waiting for an elevator, and the elevator has a limited capacity.

public class Elevator {
    Semaphore semaphore;

    public Elevator(int capacity) {
        semaphore = new Semaphore(capacity);
    }

    public void enter(String name) {
        semaphore.acquire();
        System.out.println(name + " entered the elevator.");
        sleep(1_000); // Simulate activity
        System.out.println(name + " exited the elevator.");
        semaphore.release();
    }
}

Example Semaphore Implementation

Pseudo code

class Semaphore {
    private boolean lock;
    private int count;
    private Queue<Thread> q;

    public Semaphore(int init) {
        lock = false;
        count = init;
        q = new Queue();
    }

    public void down() {
    }

    public void up() {
    }

}

Implementation


public void down() {
    while (lock.testAndSet()) { /* just spin */ }
    if (count > 0) {
        count--;
        lock = false;
    }
    else {
        q.add(currrentThread);
        lock = false;
        suspend();
    }
}

public up() {
    while (lock.testAndSet()) { /* just spin */ }
    if (q == empty) 
        count ++;
    else
        q.pop().wakeUp();
    lock = false;
}