CS110: Principles of Computer Systems

Spring 2021
Instructors Roz Cyrus and Jerry Cain

PDF

Multithreading and Condition Variables

  • The most recent version of our dining philosophers solution removes the deadlock, but it relies on a busy waiting approach that is rarely acceptable in systems programming circles. (Bonus question: when is it acceptable?)
  • If a philosopher doesn't have permission to advance, then that thread should sleep until another thread sees reason to wake it up. In this example, another philosopher thread, after it increments permits within grantPermission, could notify the sleeping thread that a permit just became available.
  • Implementing this idea requires a more sophisticated concurrency directive that supports a different form of thread communication—one akin to the use of signals and sigsuspend to support communication between processes. Fortunately, C++ provides a standard directive called the condition_variable_any to do exactly this.
class condition_variable_any {
public:
   void wait(mutex& m);
   template <typename Pred> void wait(mutex& m, Pred pred);
   void notify_one();
   void notify_all();
};

Multithreading and Condition Variables

  • Here's the main thread routine that introduces a condition_variable_any to support the notification model we'll use in place of busy waiting. (Full program: here)








     
    • All of the variables needed to foster inter-thread communication are defined across the first three lines of main.
    • The philosopher thread routine and the eat thread subroutine accept references to permits, cv, and m, because references to all three need to be passed on to waitForPermission and grantPermission.
    • I go with the shorter name m instead of permitsLock for reasons I'll soon get to. 
int main(int argc, const char *argv[]) {
  size_t permits = 4;
  mutex forks[5], m;
  condition_variable_any cv;
  thread philosophers[5];
  for (size_t i = 0; i < 5; i++) {
    mutex& left = forks[i], & right = forks[(i + 1) % 5];
    philosophers[i] = 
       thread(philosopher, i, ref(left), ref(right), ref(permits), ref(cv), ref(m));
  }
  for (thread& p: philosophers) p.join();
  return 0;
}

Multithreading and Condition Variables

static void waitForPermission(size_t& permits, condition_variable_any& cv, mutex& m) {
  lock_guard<mutex> lg(m);
  while (permits == 0) cv.wait(m);
  permits--;
}

static void grantPermission(size_t& permits, condition_variable_any& cv, mutex& m) {
  lock_guard<mutex> lg(m);
  permits++;
  if (permits == 1) cv.notify_all();
}

  • The new implementations of waitForPermission and grantPermission are below:







     
    • The lock_guard is a convenience class whose constructor calls lock on the supplied mutex and whose destructor calls unlock on the same mutex. It's a convenience class used to ensure the lock on a mutex is released no matter how the function exits (early return, standard return at end, exception thrown, etc.)
    • grantPermission is a straightforward thread-safe increment, save for the fact that if permits just went from 0 to 1, it's possible other threads are waiting for a permit to become available. That's why the conditional call to cv.notify_all() is there.

Multithreading and Condition Variables

  • Implementations of waitForPermission and grantPermission. Still right here:







     
  • The implementation of waitForPermission will eventually grant a permit to the calling thread, though it may need to wait a while for one to become available.
    • Yes, waitForPermission requires a while loop instead an if test.  Why? It's possible the permit that just became available is immediately consumed by the thread that just returned it. Unlikely, but technically possible.
    • When cv is notified within grantPermission, the thread manager wakes the sleeping thread, but mandates it reacquire the lock on m (very much needed to properly re-evaluate permits == 0) before returning from cv.wait(m).
    • If there aren't any permits, the thread is forced to sleep via cv.wait(m). The thread manager releases the lock on m just as it's putting the thread to sleep.
static void waitForPermission(size_t& permits, condition_variable_any& cv, mutex& m) {
  lock_guard<mutex> lg(m);
  while (permits == 0) cv.wait(m);
  permits--;
}

static void grantPermission(size_t& permits, condition_variable_any& cv, mutex& m) {
  lock_guard<mutex> lg(m);
  permits++;
  if (permits == 1) cv.notify_all();
}

Multithreading and Condition Variables

  • The Dining Philosophers Problem, continued
    • while loops around cv.wait(m) calls are so common that the
      condition_variable_any class exports a second, two-argument version of wait whose implementation is a while loop around the first. That second version looks like this:



       
    • It's a template method, because the second argument supplied via pred can be anything capable of standing in for a zero-argument, bool-returning function.
    • The first waitForPermissions can be rewritten to rely on this, as with:
template <Predicate pred>
void condition_variable_any::wait(mutex& m, Pred pred) {
  while (!pred()) wait(m);
}

static void waitForPermission(size_t& permits, condition_variable_any& cv, mutex& m) {
  lock_guard<mutex> lg(m);
  cv.wait(m, [&permits] { return permits > 0; });
  permits--;
}

Multithreading and Condition Variables

  • Fundamentally, the size_t, condition_variable_any, and mutex are collectively working together to track a resource count—in this case, four permission slips.
    • They provide thread-safe increment in grantPermission and thread-safe decrement in waitForPermission.
    • They work to ensure that a thread blocked on zero permission slips goes to sleep until further notice, and that it remains asleep until another thread returns one.
  • In our latest dining-philosopher example, we relied on these three variables to collectively manage a thread-safe accounting of four permission slips. However!
    • There is little about the implementation that requires the original number be four. Had we gone with 20 philosophers and and 19 permission slips, waitForPermission and grantPermission would still work as is.
    • The idea of maintaining a thread-safe, generalized counter is so useful that most programming languages include more generic support for it. That support normally comes under the name of a semaphore.
    • For reason that aren't entirely clear to me, standard C++ omits the semaphore from its standard libraries. My guess as to why? It's easily built in terms of other constructs, so it was deemed unnecessary to provide official support for it. (Update, C++20 plans to include a counting_semaphore, but our version of g++ doesn't support it.  Still, it should have been part of C++11.)

Multithreading and Semaphores

  • The semaphore constructor is so short that it's inlined right in the declaration of the semaphore class, e.g. semaphore::semaphore(int val): value(val) {}
  • semaphore::wait is our generalization of waitForPermission.
void semaphore::wait() {
  lock_guard<mutex> lg(m);
  cv.wait(m, [this] { return value > 0; })
  value--;
}
  • Why does the capture clause include the this keyword?
    • Because the anonymous predicate function passed to cv.wait is just that—a regular function.  Since functions aren't normally entitled to examine the private state of an object, the capture clause includes this to effectively convert the bool-returning function into a bool-returning semaphore method.
  • semaphore::signal is our generalization of grantPermission.
void semaphore::signal() {
  lock_guard<mutex> lg(m);
  value++;
  if (value == 1) cv.notify_all();
}

Multithreading and Semaphores

  • Here's our final version of the dining-philosophers.











     
    • It strips out the exposed size_t, mutex, and condition_variable_any and replaces them with a single semaphore.
    • It updates the thread constructors to accept a single reference to that semaphore.
static void philosopher(size_t id, mutex& left, mutex& right, semaphore& permits) {
  for (size_t i = 0; i < 3; i++) {
    think(id);
    eat(id, left, right, permits);
  }
}

int main(int argc, const char *argv[]) {
  semaphore permits(4);
  mutex forks[5];
  thread philosophers[5];
  for (size_t i = 0; i < 5; i++) {
    mutex& left = forks[i], & right = forks[(i + 1) % 5];
    philosophers[i] = thread(philosopher, i, ref(left), ref(right), ref(permits));
  }
  for (thread& p: philosophers) p.join();
  return 0;
}

Multithreading and Semaphores

  • eat now relies on that semaphore to play the roles previously played by waitForPermission and grantPermission.







  •  
    • We could switch the order of the last two lines, so that right.unlock() precedes
      left.unlock(). Is the switch a good idea? a bad one? or is it really just arbitrary?
    • A former student suggested we use a mutex to bundle the calls to left.lock() and right.lock() into a critical region. Is this a solution to the deadlock problem?
    • We could lift the permits.signal() call up to appear in between right.lock() and the first cout statement. Is that valid? Why or why not?
static void eat(size_t id, mutex& left, mutex& right, semaphore& permits) {
  permits.wait();
  left.lock();
  right.lock();
  cout << oslock << id << " starts eating om nom nom nom." << endl << osunlock;
  sleep_for(getEatTime());
  cout << oslock << id << " all done eating." << endl << osunlock;
  permits.signal();
  left.unlock();
  right.unlock();
}

Lecture 11: Condition Variables and Semaphores

By Jerry Cain

Lecture 11: Condition Variables and Semaphores

  • 1,729