next up previous contents
Next: Monitors in Java Up: Concurrent Programming Previous: Programming primitives for mutual   Contents

Monitors

Since critical regions arise from the need to permit consistent updates to shared data, it seems logical that information about conflicting operations on data should be defined along with the data, using a class-like approach. This is the philosophy underlying monitors, a higher-level approach to providing programming support for mutual exclusion. Monitors were proposed by Per Brinch Hansen and Tony Hoare.

Roughly speaking, a monitor is like a class description in an object-oriented language. At its heart, a monitor consists of a data definition, to which access should be restricted, along with a list of functions to update this data. The functions in this list are assumed to all be mutually exclusive--that is, at most one of them can be active at any time. The monitor guarantees this mutual exclusion by making other function calls wait if one of the functions is active.

For instance, here is a monitor that handles the bank account example from the previous lecture:

    monitor bank_account{

      double accounts[100];

      // transfer "amount" from accounts[source] to accounts[target]
      boolean transfer (double amount, int source, int target){ 
        if (accounts[source] < amount){
          return false;
        }
        accounts[source] -= amount;
        accounts[target] += amount;
        return true;
      }

      // compute the total balance across all accounts
      double audit(){
        double balance = 0.0;
        for (int i = 0; i < 100; i++){
          balance += accounts[i];
        }
        return balance;
      }
  }

By definition, the functions transfer(...) and audit() are assumed to require mutually exclusive access to the array accounts[]. Thus, if one process is engaged in a transfer and a second process wants to perform an audit, the second process is suspended until the first process finishes.

Thus, there is an implicit ``external'' queue associated with the monitor, where processes that are waiting for access are held. We use the word queue but, in practice, this is just a set. No assumption is made about the order in which waiting processes will get access to the monitor.

The capabilities we have described so far for monitors are not as general as, say, semaphores. Consider for instance the following scenario.

We have three bank accounts, i, j and k and we want to perform the following two operations concurrently.

   transfer(500.00,i,j);
   transfer(400.00,j,k);

If the balance in i is greater than 500.00, this pair of transfers should always succeed. However, if accounts[j] is not at least 400.00 initially, there is a possibility that the second transfer will be scheduled before the first and will fail.

We could try to get around this by reprogramming the transfer function as follows:

      // transfer "amount" from accounts[source] to accounts[target]
      boolean transfer (double amount, int source, int target){ 
        if (accounts[source] < amount){
          // wait for another transaction to transfer money
          // into accounts[source]
        }
        accounts[source] -= amount;
        accounts[target] += amount;
        return true;
      }

The problem is that all other processes are blocked out by the monitor while this process waits. We thus need to augment the monitor definition with the ability to suspend a process and relinquish the monitor.

A process that suspends itself is waiting for the data in the monitor to change its state. It does not make sense to place such a process in the same queue as one that is waiting (unconditionally) to enter the monitor for the first time. Instead, it makes sense to a second queue with a monitor where suspended processes wait, which we shall call an ``internal'' queue, to distinguish it from the ``external'' queue where blocked processes wait.

We then need a dual operation to wake up a suspended process when the state changes in a way that may be of benefit to the suspended process--in this example, when another transfer succeeds.

Using these ideas, we can now rewrite the transfer function as follows, where wait() suspends a process and notify() wakes up a suspended process.

      // transfer "amount" from accounts[source] to accounts[target]
      boolean transfer (double amount, int source, int target){ 
        if (accounts[source] < amount){
          wait();
        }
        accounts[source] -= amount;
        accounts[target] += amount;
        notify();
        return true;
      }

We now have to deal with another tricky problem: what happens when a notify() is issued? The notifying process is itself still in the monitor. Thus, the process that is woken up cannot directly execute.

At least three different types of notification mechanisms have been identified in the literature.

The simplest is one called signal and exit. In such a setup, a notifying process must immediately exit the monitor after notification and hand over control to the process that has been woken up. This means that notify() must be the last instruction that it executes. We shall see later that we might use multiple internal queues in a monitor, each of which is notified independently. With signal and exit, it is not possible to notify multiple queues because the first call to notify() must exit the monitor.

Another mechanism is called signal and wait. In this setup, the notifying process and the waiting process swap roles, so the notifying process hands over control of the monitor and suspends itself. This is not very commonly used.

The last mechanism is signal and continue. Here, the notifying process continues in the monitor until it completes its normal execution. The woken up process(es) shift from the internal to the external queues and are in contention to be scheduled when access to the monitor is relinquished by the notifying process. However, no guarantee is given that one of the newly awakened processes will be the next to enter the monitor--they may all be blocked out by another process that was already waiting in the external queue.

When a suspended process resumes execution, there is no guarantee that the condition it has been waiting for has been achieved. For instance, in the example above, transfer is waiting for an amount of at least 400 to be transferred into accounts[j]. Any subsequent successful transfer will potentially notify() this waiting process, including transfers to accounts other than j. Even if the transfer is into accounts[j], the amount might be less than the 400 threshold required by the waiting copy.

The moral of the story is that a waiting condition should check the state of the data before proceeding because the data may still be in an undesirable state when it wakes up. We should rewrite the earlier code as:

      // transfer "amount" from accounts[source] to accounts[target]
      boolean transfer (double amount, int source, int target){ 
        while (accounts[source] < amount){
          wait();
        }
        accounts[source] -= amount;
        accounts[target] += amount;
        notify();
        return true;
      }

In other words, the if is replaced by a while so that the first thing done after waking up from wait() is to check the condition again, before proceeding.

In this example, clearly not every successful transfer is relevant to a waiting process. The only relevant transfers are those that feed into the account whose balance is low. Thus, instead of a single internal queue, it makes sense to have multiple queues--in this case, one queue for each account (in other words, an array of queues). Then, a successful transfer will notify only the queue pertaining to the relevant account.

This means that we have to extend the monitor definition to include a declaration of the internal queues (sometimes called condition variables) associated with the monitor. In the example below, we assume we can declare an array of queues. In practice, this may not be possible--we may have to explicitly assign a separate name for each queue.

    monitor bank_account{

      double accounts[100];

      queue q[100];  // one internal queue for each account

      // transfer "amount" from accounts[source] to accounts[target]
      boolean transfer (double amount, int source, int target){ 
        while (accounts[source] < amount){
          q[source].wait();  // wait in the queue associated with source
        }
        accounts[source] -= amount;
        accounts[target] += amount;
        q[target].notify();  // notify the queue associated with target
        return true;
      }

      // compute the total balance across all accounts
      double audit(){ ...}
       
  }

If you notify() a queue that is empty, it has no effect. This is more robust than the V(S) operation of a semaphore that increments S when nobody is waiting on S.


next up previous contents
Next: Monitors in Java Up: Concurrent Programming Previous: Programming primitives for mutual   Contents
Madhavan Mukund 2004-04-29