CS 332 s20 — Condition Variables
Table of Contents
1 Video Lecture
Please watch the video lecture on the material outlined below: https://carleton.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=a27b4edf-1126-4d96-b4db-abac011d60b4
2 Waiting for a Change
- locks let us protect a critical section, but present a question:
- if a thread needs to wait for lock to become available, what should it do?
- spinlock says "burn the CPU"
- there's probably a better way
- if a thread needs to wait for lock to become available, what should it do?
- many other situations arise where we need to wait for another thread to take some action
- thread-safe queue, might want to wait for an element to be inserted if we try and remove from an empty queue
- web server waits for a request, word processor waits for a keypress, a maps app waits for a user command or new data before updating the view
2.1 Polling
- unsatisfying solution: waiting thread repeatedly checks shared state to see if it has changed
int TSQueue::remove() { int item; bool success; do { success = tryRemove(&item); } until(success); return item; }
- this busy-waiting is very inefficient
3 Condition Variable
- a synchronization primitive that facilitates efficient waiting
- three methods:
CV::wait(Lock *lock)
- atomically releases the lock and suspends the calling thread
- places the calling thread on the condition variable's waiting list
- when the calling thread is later re-scheduled, it re-acquires the lock before returning from
wait
CV::signal()
- takes one thread off the condition variable's waiting list and makes it eligible to run (i.e., adds it to the scheduler's ready list)
- no effect if no threads are waiting
CV::broadcast()
- takes all threads off the condition variable's waiting list and makes them eligible to run
- no effect if no threads are waiting
- note that Unix has unrelated system calls that interact with processes also called
wait
andsignal
- condition variable has no state apart from its waiting list
- a condition variable is used to wait for a change to shared state, and a lock must always protect updates to shared state
- all three methods should only be called when holding the associated lock
3.1 Standard Design Pattern
SharedObject::someMethodThatWaits() { lock.acquire(); // Read and/or write shared state here. while (!testOnSharedState()) { cv.wait(&lock); } assert(testOnSharedState()); // Read and/or write shared state here. lock.release(); } SharedObject::someMethodThatSignals() { lock.acquire(); // Read and/or write shared state here. // If state has changed in a way that // could allow another thread to make // progress, signal (or broadcast). cv.signal(); lock.release(); }
3.2 Atomic wait
3.3 wait
in a loop
- Why did the design pattern above have the thread waiting on the condition variable inside a loop?
- when a thread is re-enabled via
signal
orbroadcast
, it may not run immediately- it's just added to the ready list with no special priority
- when it finally does run, other threads may have acquired the lock and changed shared state in the meantime
- whatever was true when
signal
orbroadcast
was called may no longer hold
- whatever was true when
- thus,
wait
must always be called from within a loop- there is no guarantee of atomicity between
signal
orbroadcast
and the return from the call towait
- there is no guarantee of atomicity between
3.3.1 simplifies the implementation
- this lack of atomicty actually helps simplify the implmentation of condition variables (without making them more complex to use)
- no special scheduling code:
signal
puts a waiting thread onto the ready list and lets the scheduler choose when to run it - no special code needed to re-acquire the lock at the end of
wait
, thread callsacquire
normally - some implementations even warn that
wait
can return even if nosignal
orbroadcast
occurred- from Java documentation:
When waiting upon a Condition, a "spurious wakeup" is permitted to occur, in general, as a concession to the underlying platform semantics. This has little practical impact on most application programs as a Condition should always be waited upon in a loop, testing the state predicate that is being waited for. An implementation is free to remove the possibility of spurious wakeups but it is recommended that applications programmers always assume that they can occur and so always wait in a loop.
3.3.2 improves modularity
- waiting in a loop makes it easier to reason about when a thread will continue
- we only have to look at the loop condition
- we don't need to examine the code to understand where and why
signal
orbroadcast
are called
- this also means that signaling at the wrong time will never cause a waiting thread to proceed when it should not
signal
andbroadcast
can be seen as hints that it might be a good time to proceed- may matter for performance, but extra calls won't harm correctness
4 Blocking Bounded Queue
#ifndef _BBQ_H_ #define _BBQ_H_ #include "Lock.h" #include "CV.h" #include "thread.h" // BBQ.h // Thread-safe blocking queue. const int MAX = 10; class BBQ{ // Synchronization variables Lock lock; CV itemAdded; CV itemRemoved; // State variables int items[MAX]; int front; int nextEmpty; public: BBQ(); ~BBQ() {}; void insert(int item); int remove(); }; #endif
#include <assert.h> #include <pthread.h> #include "BBQ.h" // BBQ.cc // thread-safe blocking queue // Wait until there is room and // then insert an item. void BBQ::insert(int item) { lock.acquire(); while ((nextEmpty - front) == MAX) { itemRemoved.wait(&lock); } items[nextEmpty % MAX] = item; nextEmpty++; itemAdded.signal(); lock.release(); } // Wait until there is an item and // then remove an item. int BBQ::remove() { int item; lock.acquire(); while (front == nextEmpty) { itemAdded.wait(&lock); } item = items[front % MAX]; front++; itemRemoved.signal(); lock.release(); return item; } // Initialize the queue to empty, // the lock to free, and the // condition variables to empty. BBQ::BBQ() { front = nextEmpty = 0; }
5 Sleeplock
osv uses a spinlock and a condition variable to implement an efficient lock for general use.
void sleeplock_acquire(struct sleeplock* lock) { if (!synch_enabled) { return; } kassert(lock); spinlock_acquire(&lock->lk); while (lock->holder != NULL) { condvar_wait(&lock->waiters, &lock->lk); } lock->holder = thread_current(); spinlock_release(&lock->lk); } void sleeplock_release(struct sleeplock* lock) { if (!synch_enabled) { return; } kassert(lock && lock->holder == thread_current()); spinlock_acquire(&lock->lk); lock->holder = NULL; condvar_signal(&lock->waiters); spinlock_release(&lock->lk); }
6 Reading: The Producer/Consumer Problem
Read Section 30.2 (p. 374–382) of the OSTEP book. It shows how locks and condition variables can be used to address a class operating systems scenario: the producer/consumer problem. By walking through flawed solutions, it should help you build your intuition for how to design thread-safe code using the synchronization primitives we've discussed so far.
7 Homework
- Week 3 quiz due tonight 9pm (April 29)
- Keep working on lab 2. Make sure to read through the posts on the lab 2 check-in forum and the #labs Slack channel.
Footnotes:
After it releases the lock, but before it waits, another thread preempts it, acquires the lock, makes the changes the original thread is waiting for, and calls signal (which has no effect on an empty waiting list. Eventually the original thread is scheduled again, whereupon it puts itself on the waiting list and suspends, forever waiting for a signal that already came.
The thread could be suspended while still holding the loc, which would prevent any other thread from making the state change it is waiting for