Approaches to the Critical Section Problem

Last time, we identified a prototypical way to view synchronization in what is called the critical section problem.

In review: a critical section is comprised of code blocks that we would like to execute atomically, i.e., to be un-interrupted by other processes cooperating on some shared resource.

The motivation: don't let critical sections of cooperating processes overlap lest we encounter a race condition.

Let's take at a motivating example:


Motivating Example


Here is another race condition in a multi-threaded application -- even something as simple as decrementing a counter can be confounded by the need for synchronization.

  #include <pthread.h>
  #include <unistd.h>
  #include <stdio.h>
  
  #define THREAD_COUNT 10000
  
  // Shared total which the threads will update.
  static int shared = THREAD_COUNT;
  void* race();
  
  int main(int argc, char* argv[]) {
      pthread_t *threads = calloc(sizeof(pthread_t), THREAD_COUNT);
      pthread_attr_t attr;
      pthread_attr_init(&attr);
  
      int i;
      for (i = 0; i < THREAD_COUNT; i++) {
          pthread_create(&threads[i], &attr, race, NULL);
      }
      
      for (i = 0; i < THREAD_COUNT; i++) {
          pthread_join(threads[i], NULL);
      }
      
      // Should always be 0 if synchronized; is it?
      printf("Final value: %i\n", shared);
  }
  
  void* race () {
      shared--;
      printf("Decrementing; now at: %i\n", shared);
  }

So, will the threads always countdown from 10000 to 0 successfully even with the eldest sibling thread waiting for all of its siblings to complete (via pthread_join)? Why or why not?

Linux systems' preemptive scheduler creates a race condition on the shared variable during the decrement operation, thus making its final value unpredictable.

Thus, we see that we have a critical section problem above -- we cannot allow any two of our threads to be decrementing the shared variable at any time lest an interrupt compromise synchronization.


Locks


In general, any solution to a critical section problem requires that a process obtains a lock before it may execute its critical section, and then releases it once it has exited its critical section, indicating that other cooperative processes are free to enter theirs.

"Well that sounds easy!" you exclaim, and excitedly rush to create a "lock" variable that is simply a shared integer that is 0 when the lock is open (processes free to enter their critical section) or 1 when closed (processes "blocked" from entering their critical section).

Suppose we create, in sync.c above, a shared static int lock = 0;; how might we (naively) use this to implement the behavior above?

Something that looks like the following, modifying the race function:

  // ...
  static int lock = 0;
  // ...
  
  void* race () {
      while (lock); // Wait for lock to open
      lock++;       // Acquire lock (?)
      // Critical section -------------------------
      shared--;
      printf("Decrementing; now at: %i\n", shared);
      // ------------------------------------------
      lock--;       // Release lock (?)
  }

An interesting approach that seems to work on paper... but does it?

What are the major problems with the "solution" to the critical section problem above?

Two major issues:

  • We made a new critical section problem with our lock!

  • Processes that are waiting for the lock to open are stuck in a spin-lock, sappping CPU resources to simply wait in the while loop.

With these observations in tow, we can start to envision a solution that might need a few more tools than we currently possess...

What sort of support or tools would we need to successfully implement a lock compared to the problematic attempt above?

Get the OS to give you a lock and manage it such that lock requests and releases are guaranteed to be atomic.

This is precisely the strategy we'll investigate next time, and as you might guess, there are some system calls to help us along that path.



  PDF / Print