Locks & Cond. Vars.

Locks and Condition Variables

Optional readings for this topic from Operating Systems: Principles and Practice: Sections 5.2-5.4.

Needed: higher-level synchronization mechanism that provides

  • Mutual exclusion: easy to create critical sections
  • Blocking: delay a thread until some desired event occurs

Locks

Lock: an object that can only be owned by a single thread at any given time (C++ class std::mutex). Operations on a lock:

  • lock: mark the lock as owned by the current thread; if some other thread already owns the lock then first wait until the lock is free. Lock typically includes a queue to keep track of waiting threads.
  • unlock: mark the lock as free (it must currently be owned by the calling thread).

Too much milk solution with locks (using C++ library APIs):

std::mutex mutex;
...
mutex.lock();
if (milk == 0) {
  buy_milk();
}
mutex.unlock();

A more complex example: producer/consumer.

  • Producers add characters to a buffer
  • Consumers remove characters from the buffer
  • Characters will be removed in the same order added
  • Version 1:
    class Pipe {
        Pipe() {}
        void put(char c);
        char get();
    
        std::mutex mutex;
        char buffer[SIZE];
        int count = 0;
        int nextPut = 0;
        int nextGet = 0;
    };
      
    void Pipe::put(char c) {
        mutex.lock();
        count++;
        buffer[nextPut] = c;
        nextPut++;
        if (nextPut == SIZE) {
            nextPut = 0;
        }
        mutex.unlock();
    }
    
    char Pipe::get() {
        char c;
        mutex.lock();
        count--;
        c = buffer[nextGet];
        nextGet++;
        if (nextGet == SIZE) {
            nextGet = 0;
        }
        mutex.unlock();
        return c;
    }
    
  • Version 2: handle empty and full situations
    class Pipe {
        Pipe() {}
        void put(char c);
        char get();
    
        std::mutex mutex;
        char buffer[SIZE];
        int count = 0;
        int nextPut = 0;
        int nextGet = 0;
    };
    
    void Pipe::put(char c) {
        mutex.lock();
        while (count == SIZE) {
            mutex.unlock();
            mutex.lock();
        }
        count++;
        buffer[nextPut] = c;
        nextPut++;
        if (nextPut == SIZE) {
            nextPut = 0;
        }
        mutex.unlock();
    }
    
    char Pipe::get() {
        char c;
        mutex.lock();
        while (count == 0) {
            mutex.unlock();
            mutex.lock();
        }
        count--;
        c = buffer[nextGet];
        nextGet++;
        if (nextGet == SIZE) {
            nextGet = 0;
        }
        mutex.unlock();
        return c;
    }
    

Condition Variables

Synchronization mechanisms need more than just mutual exclusion; also need a way to wait for another thread to do something (e.g., wait for a character to be added to the buffer)

Condition variables: used to wait for a particular state to be reached (e.g. characters in buffer).

  • C++ class std::condition_variable.
  • wait(lock): atomically release lock, put thread to sleep until condition is notified; when thread wakes up again, re-acquire lock before returning.
  • notify_one(): if any threads are waiting on condition, wake up one of them.
  • notify_all(): same as notify, except wake up all waiting threads.
  • Note: when thread wakes up after notify, it may not be able to acquire the lock right away (the notifying thread may own it, or some other thread may have acquired it).
  • Warning: when a thread wakes up after wait there is no guarantee that the desired condition still exists: another thread might have snuck in.

Producer/Consumer, version 3 (with condition variables):

  class Pipe {
      Pipe() {}
      void put(char c);
      char get();

      std::mutex mutex;
      std::condition_variable charAdded, charRemoved;
      char buffer[SIZE];
      int count = 0;
      int nextPut = 0;
      int nextGet = 0;
  };

  void Pipe::put(char c) {
      mutex.lock();
      while (count == SIZE) {
          charRemoved.wait(mutex);
      }
      count++;
      buffer[nextPut] = c;
      nextPut++;
      if (nextPut == SIZE) {
          nextPut = 0;
      }
      charAdded.notify_one();
      mutex.unlock();
  }

  char Pipe::get() {
      char c;
      mutex.lock();
      while (count == 0) {
          charAdded.wait(mutex);
      }
      count--;
      c = buffer[nextGet];
      nextGet++;
      if (nextGet == SIZE) {
          nextGet = 0;
      }
      charRemoved.notify_one();
      mutex.unlock();
      return c;
  }

Monitors

How many locks should you use?

  • More locks may permit more concurrency (less lock contention)
  • But, more locks lead to complexity, potential for deadlock
  • Lock acquisition is relatively expensive, so fewer locks may result in less overhead
  • Best approach: as few locks as possible, while providing an acceptable level of lock contention.

Good general approach: associate a lock with a collection of related variables.

Monitor:

  • A shared data structure
  • A collection of procedures
  • One lock that must be held whenever accessing the shared data (typically each procedure acquires the lock at the very beginning and releases the lock before returning).
  • One or more condition variables used for waiting.
  • The Pipe class is an example of a monitor.