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: after notify, notifying thread keeps lock, waking thread goes on the queue waiting for the lock.
  • 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.