Return to the lecture notes index

Lecture 7 (February 7, 2012)

Counting Semaphores

Condition variables are great for modelling events or notificantions. But, what they don't do is allow us to keep track of available resources, waiting if there aren't enough. The sempahore is synchronization primative that is commonly used for this purpose. An instance of a sempahore is initially set to an integer value. After initialization, its value can only be affected by two operations:

P(x) was named from the Dutch word proberen, which means to test.

V(x) was named from the Dutch word verhogen, which means to increment.

The pseudo-code below illustrates the semantics of the two semaphore operations. This time the operations are made to be atomic outside of hardware using the hardware support that we discussed earlier -- but more on that soon.

    /* proberen - test *.
    P(sem)
    {
       while (sem <= 0)
       ;
       sem = sem - 1;
    }


    /* verhogen - to increment */
    V(sem)
    {
       sem = sem + 1;
    }
    

In order to ensure that the critical sections within the semaphores themselves remain protected, we make use of spin-lock mutexes. In this way we again grow a smaller guarantee into a larger one:

    P (csem) {
       while (1)  {
          Acquire_mutex (csem.mutex);
          if (csem.value <= 0) {
             Release_mutex (csem.mutex);
             continue;
          } 
          else {
              csem.value = csem.value  1;
              Release_mutex (csem.mutex);
              break;
          }
       }
    }


    V (csem) 
    {
        Acquire_mutex (csem.mutex);
        csem.value = csem.value + 1;
        Release_mutex (csem.mutex);
    }
    

But let's carefully consider our implementation of P(csem). If contention is high and/or the critical section is large, we could spend a great deal of time spinning. Many processes could occupy the CPU and do nothing -- other than waste cycles waiting for a process in the runnable queue to run and release the critical section. This busy-waiting makes already high resource contention worse.

But all is not lost. With the help of the OS, we can implement semaphores so that the calling process will block instead of spin in the P() operation and wait for the V() operation to "wake it up" making it runnable again.

The pseudo-code below shows the implementation of such a semaphore, called a blocking semaphore:

    P (csem) {
       while (1)  {
          Acquire_mutex (csem.mutex);

          // NOTE: This logic should look a great case for a CV wait.
          if (csem.value <= 0) {
             insert_queue (getpid(), csem.queue);
             Release_mutex_and_block (csem.mutex); /* atomic: lost wake-up */
          } 
          else {
              csem.value = csem.value  1;
              Release_mutex (csem.mutex);
              break;
          }
       }
    }


    V (csem) 
    {
        // NOTE: This logic should look a great case for a CV signal.
        Acquire_mutex (csem.mutex);

        csem.value = csem.value + 1;
        dequeue_and_wakeup (csem.queue)

        Release_mutex (csem.mutex);
    }
    

Please notice that the P()ing process must atomically become unrunnable and release the mutex. This is becuase of the risk of a lost wakeup. Imagine the case where these were two different operations: release_mutex(xsem.mutex) and sleep(). If a context-switch would occur in between the release_mutex() and the sleep(), it would be possible for another process to perform a V() operation and attempt to dequeue_and_wakeup() the first process. Unfortunately, the first process isn't yet asleep, so it missed the wake-up -- instead, when it again runs, it immediately goes to sleep with no one left to wake it up.

Take a look back at the implementation of the sepahore. Notice the use of a mutex to protect the atomicity of the evaluation of the predicate and queuing. Have you seen that before. Actually, step back even farther. Have you seen that logic before?

Yep. You've got it. It is really natural to implement semaphores using condition variables. Give it a try.

Boolean Semaphores

In many cases, it isn't necessary to count resources -- there is only one. A special type of semaphore, called a boolean semaphore may be used for this purpose. Boolean semaphores may only have a value of 0 or 1. In most systems, boolean semaphores are just a special case of counting semaphores, also known as general semaphores.

The Producer-Consumer Problem

One classic concurrency control problem that is readly managued using semaphores is the producer-consumer problem, also known as the bounded buffer problem. In this case we have a producer and a consumer that are cooperating through a shared buffer. The buffer temporarily stores the output of the producer until removed by the consumer. In the event that the buffer is empty, the consumer must pause. In the event that the buffer is full, the producer must pause. Both must cooperating in accessing the shared resource to ensure that it remains consistent.

The example below shows a general solution to the bounded buffer problem using semaphores. Notice the use of counting semaphores to keep track of the state of the buffer. Two semaphores are used -- one to count the available buckets and another to count the full buckets. The producer uses empty buckets (decreasing semaphore value with P()) and increases the number of full buckets (increasing the semaphore value with V()). It blocks on the P() operation if not buckets are available in the buffer. The consumer works in a symmetric fashion.

Binary semaphores are used to protect the critical sections within the code -- those sections where both the producer and the consumer manipulate the same data structure. This is necessary, becuase it is possible for the producer and consumer to operate concurrently, if there are both empty and full buckets within the buffer.

 
    Producer()
    {
        while (1)
        {
            <<< produce item >>>
          P(empty); /* Get an empty buffer (decrease count) , block if unavail */
          P(mutex); /* acquire critical section: shared buffer */

          <<< critical section: Put item into shared buffer >>>

          V(mutex); /* release critical section */
          V(full); /* increase number of full buffers */
        }
    }


    Consumer()
    {
        while (1)
        {
           P(full);
           P(mutex);

           <<< critical section: Remove item from shared buffer */

           V(mutex);
           V(empty);
    }

    

Reader-Writer Problem

The Readers and Writers problem, another classic problem for the demonstration of semaphores, is much like a version of the producer-consumer problem -- with some more restrictions. We now assume two kinds of threads, readers and writers. Readers can inspect items in the buffer, but cannot change their value. Writers can both read the values and change them. The problem allows any number of concurrent reader threads, but the writer thread must have exclusiver access to the buffer.

One note is that we should always be careful to initialize semaphores. Unitialized semaphores cause programs to react unpredictibly in much the same way as uninitalized variables -- execept perhaps even more unpredictably.

In this case, we will use binary semaphores like a mutex. Notice that one is acquired and released inside of the writer to ensure that only one writer thread can be active at the same time. Notice also that another binary mutex is used within the reader to prvent multiple readers from changing the rd_count variable at the same time.

A counting semaphore is used to keep track of the number of readers. Only when the number of readers is available can any writers occur -- otherwise there is an outstanding P() on the writing semaphore. This outstanding P() is matched with a V() operation when the reader thread count is reduced to 0.

It is important to note that the solution we provide below favors readers over writers. If processes are constantly reading, the writer(s) can starve indefinetely. It would certainly be possible to implement a similar solution that would favor writers at the expense of readers.

    Writer()
    {
       while (1)
       {
           P(writing);
           <<< perform write >>>
           V (writing);
       }
    }


    Reader() {
      while (1)   {
           P(mutex);
           rd_count++;
           if (1 == rd_count) /* If we are the first reader -- get write lock */
                 P(writing); /* Once we have it, it keeps writers at bay */
           V(mutex); /* 

           <<< perform read >>>

           P(mutex)
           rd_count--;
           if ( 0 == rd_count) /* If we are the last reader to leave -- */
                 V(writing);   /* Allow writers */
           V(mutex);
       } 
    }