Link

Concurrency and Mutual Exclusion Reading1

Table of contents

  1. Concurrency issues
  2. Mutual-exclusion locks
  3. Reentrant locks
  4. Reentrant Locks in Java

Concurrent programming is about correctly and efficiently controlling access by multiple threads to shared resources. The shared resources will be memory locations used by multiple threads that may try to read/write the data at the same time.

The concurrent programming model comprises multiple threads that are running in a mostly uncoordinated way. The operations of each thread are interleaved (running alongside, before, after, or at the same time) with the operations of other threads. The order of operations depends on how the threads are scheduled by the operating system, i.e. when each thread is chosen to run and for how long. As a result, concurrent programming is often nondeterministic: a concurrency bug may or may not appear depending on the thread interleaving.

Concurrency is different from fork/join parallelism, which emphasized avoiding the situation where multiple threads access shared resources at the same time. For example, in a parallel sum, each thread accesses a disjoint portion of the array so data is not shared.

Why might we want to program concurrently instead of having one thread that does everything we need to do?
Processor utilization
If one thread needs to read data from disk (e.g. a file), this will take a very long time relatively speaking. In a conventional single-threaded program, the program will not do anything for the milliseconds it takes to get the information. But this is enough time for another thread to perform millions of instructions. So by having other threads, the program can do useful work while waiting for input/output.
Failure/performance isolation
Sometimes having multiple threads is simply a more convenient way to structure a program. In particular, when one thread throws an exception or takes too long to compute a result, it affects only what code is executed by that thread. If we have multiple independent pieces of work cause an exception or run for too long, the other threads can still continue executing.

Concurrency issues

Consider a bank account management program. Different threads (e.g. one per bank teller or ATM) deposit or withdraw funds from a single account. Code that works correctly in a sequential programming model might not work when run on multiple threads. Suppose we two threads that both want to withdraw(100) on the same BankAccount instance.

class BankAccount {
    private int balance = 150;

    int getBalance() {
        return balance;
    }

    void setBalance(int amount) {
        balance = amount;
    }

    void withdraw(int amount) {
        int b = getBalance();
        if (amount > b)
            throw new OverdraftException();
        setBalance(b - amount);
    }
}
Describe what happened in the demo above.

Starting with a balance of 150, two calls to withdraw(100) resulted in a remaining balance of 50 and no exception thrown. Both withdrawals succeeded. But we expected one withdrawal to fail with an OverdraftException. The problem is that both threads read the balance as 150.

One way to address the concurrency issues we just observed is by enforcing mutual exclusion: allow only one thread to access any particular account at a time.

The idea is that a thread will hang a do-not-disturb sign on the account before it starts an operation such as withdraw and only remove the sign when it is finished. All threads will check for a do-not-disturb sign before trying to hang a sign and perform an operation. If a sign is present, a thread will wait until there is no sign so that it can be sure it is the only one performing an operation on a bank account.

The work done while the sign is hanging is known as a critical section; it is critical that such operations not be interleaved with other conflicting ones.

Mutual-exclusion locks

To implement the idea of a do-not-disturb sign, we define a mutual-exclusion lock (also known as a lock or a mutex), as an ADT that supports three operations:

new()
Creates a new lock that is initially not held.
acquire()
Takes a lock and attempts to set the lock to held. This is equivalent to putting up a do-not-disturb sign. If the lock is already held, then this thread will wait until it is no longer held.
release()
Takes a lock and sets it to not held. This is equivalent to taking down the do-not-disturb sign.

The acquire operation blocks (does not return) until the caller is the thread that most recently hung the sign. For example, if there are three acquire operations at the same time for a not-held lock, one will win and return immediately while the other two will block. When the winner releases the lock, one other thread will get to hold the lock next.

However, there are two important considerations when using locks. This BankAccount class still has concurrency issues.

class BankAccount {
   private int balance = 150;
   private Lock lk = new Lock();

   void withdraw(int amount) {
      lk.acquire();
      int b = getBalance();
      if (amount > b)
         throw new OverdraftException();
      setBalance(b - amount);
      lk.release();
   }
}
  1. Exiting before releasing the lock.
  2. Nested calls waiting on the same lock.

Reentrant locks

To address the issue of nested calls waiting on the same lock, we can change our definition of the acquire and release operations so that it is okay for a thread to reenter (reacquire) a lock it already holds. Locks that support this are called reentrant locks. Reentrant locks keep track of a count of how many times the current holder has reacquired the lock (as well as the identity of the current holder):

new()
Creates a new lock with no current holder and a count of 0.
acquire()
Blocks if there is a current holder different from the thread calling it. If the current holder is the thread calling it, do not block and increment the counter. If there is no current holder, set the current holder to the calling thread.
release()
Only releases the lock (sets the current holder to none) if the count is 0. Otherwise, it decrements the count.

In other words, a lock is released when the number of release operations by the holding thread equals the number of acquire operations.

Reentrant Locks in Java

To address the issue of exiting before releasing the lock, the synchronized statement in Java automatically releases the lock no matter how a program exits.

synchronized (expression) {
    // statements
}
  1. The expression is evaluated. It must produce (a reference to) an object—not null or a number. This object is treated as a lock. In Java, every object is a lock that any thread can acquire or release.
  2. The synchronized statement acquires the lock, i.e. the object from the previous step. This may block until the lock is available. Locks are reentrant in Java, so the statement will not block if the executing thread already holds it.
  3. After the lock is successfully acquired, the statements are executed.
  4. When control leaves the statements, the lock is released. This happens either when the final } is reached or when the program exits via an exception, a return, a break, or a continue.

Since any object can serve as a lock, a common pattern is to use the instance of BankAccount itself (referenced by keyword this) as the synchronized expression.

class BankAccount {
    private int balance = 0;
    void withdraw(int amount) {
        synchronized (this) {
            int b = getBalance();
            if (amount > b) {
                throw new WithdrawTooLargeException();
            }
            setBalance(b - amount);
        }
    }
}

When an entire method body should be synchronized on this, we can instead use the synchronized keyword in the method declaration before the return type.

class BankAccount {
    private int balance = 0;
    synchronized void withdraw(int amount) {
        int b = getBalance();
        if (amount > b) {
            throw new WithdrawTooLargeException();
        }
        setBalance(b - amount);
    }
}

In practice, it is common to design classes like bank accounts using only synchronized methods. Other methods like setBalance and getBalance would also need to be synchronized. This approach tends to work well provided that critical sections only access the state of a single object.

  1. Dan Grossman. 2016. A Sophomoric Introduction to Shared-Memory Parallelism and Concurrency. https://homes.cs.washington.edu/~djg/teachingMaterials/spac/sophomoricParallelismAndConcurrency.pdf