Running example: reader/writer lock (see next lecture for detailed implementation)
Semaphores are great for certain kinds of problems, but for more complex problems, it can be very difficult to figure out the P and V protocol. Leads to hard-to-read, hard-to implement, and often incorrect code.
Example: a shared variable that is read often but updated rarely. Want to allow many threads to read at the same time (but not while threads are writing), or allow a single thread to write (but not while threads are reading).
We might solve this by writing functions ready_to_read and ready_to_write which only return when it is safe to read or write, and done_reading and done_writing which update the synchronization state to allow other threads to proceed:
Shared state:
x : variable ... other synchronization state ... | |
Code to read:
ready_to_read() # at this point we are # sure no writers are running y = x done_reading() |
Code to write:
ready_to_write() # at this point we are # sure no other writers # or readers are running x++ done_writing() |
It is possible but difficult to implement the ready_ and done_ functions using semaphores, and the solution is complicated and can be hard to understand (I encourage you to try this yourself). Monitors are an alternative design pattern that make the implementations of these complex interactions clearer.
A monitor is an object (in the object-oriented programming sense): it has some internal state (fields) and some methods. Monitors are responsible for managing the interaction of threads: threads that need to interact will call methods on the monitor, and these methods will only return when it is safe for the threads to proceed.
class ReaderWriterMonitor(object): def __init__(self): # initialize the monitor def ready_to_read(self): # block until it is safe to read # # postcondition: it is safe to read # i.e. no threads are writing def done_reading(self): # update the monitor to indicate # that a thread is no longer reading # # precondition: current thread has # called ready_to_read def ready_to_write(self): # block until it is safe to write # # postcondition: it is safe to write # i.e. no threads are reading or writing def done_writing(self): # update the monitor to indicate # that a thread is no longer writing # # precondition: current thread has # called ready_to_write |
in monitor initialization, create a single lock; every monitor method must acquire the lock before running and release it before returning
while not [predicate]: [condition].wait()
monitor methods may read and update state freely, but if an update would cause a predicate to become true, it must call notifyAll on the corresponding condition variable.
This rule means that we can freely read and update fields of the monitor without worrying about race conditions. Monitor library implementations typically ensure this in one of two ways:
Java's synchronized methods are an example of this approach: the Java runtime uses locks to ensure that two synchronized methods of the same object cannot run simultaneously
library support and programming idioms: languages without baked-in support for monitors require the programmer to explicitly acquire and release a monitor lock; the programmer must acquire the lock at the beginning of every method and release it before the method returns.
There is only one lock per monitor; by convention this lock is always called "lock".
Python uses this latter approach, and provides Lock objects for this purpose:
class MyMonitor(object): def __init__(self): self.lock = Lock() def monitor_method_2(self): self.lock.acquire() # ... monitor method implementation ... self.lock.release() def monitor_method_1(self): self.lock.acquire() # ... monitor method implementation ... self.lock.release() |
Python provides special "with" syntax that can be used in place of explicit calls to acquire and release. Code within a "with lock:" block will acquire the lock before it begins and release it before it ends.
class MyMonitor(object): def __init__(self): self.lock = Lock() def monitor_method_2(self): with self.lock: # ... monitor method implementation ... def monitor_method_1(self): with self.lock: # ... monitor method implementation ... |
With clauses are preferred to explicit acquire and release calls, because they guarantee that the lock is freed no matter how the code exits; it can be easy to forget to release the lock if, for example, the monitor code throws an exception or returns in multiple places.
In the reader writer example, the ready_to_read function must wait until there are no writers (it isn't safe for a reader to proceed if there are writers). This invariant would need a corresponding condition variable in the monitor, as well as enough state to determine whether the invariant is true or false:
class ReaderWriterMonitor(object): def __init__(self): self.lock = Lock() self.num_writers = 0 # predicate: self.num_writers == 0 self.no_writers = Condition(self.lock) |
We may also want a condition indicating that there are no readers. Perhaps we'll find we also want to block until there are no threads whatsoever. Each new predicate should have a corresponding condition variable:
class AnotherMonitor(object): def __init__(self): self.lock = Lock() self.num_writers = 0 self.num_readers = 0 # predicate: self.num_writers == 0 self.no_writers = Condition(self.lock) # predicate: self.num_readers == 0 self.no_readers = Condition(self.lock) # predicate: self.num_writers == 0 and self.num_readers == 0 self.no_threads = Condition(self.lock) |
Language specific notes: python condition variables require a reference to the monitor lock. Java uses objects as both monitors and condition variables, which forces each monitor to have only one condition variable.
Important: Writing down the predicate is critical.
Monitor methods should only do a few things: - read and update internal state (fields) of the monitor - wait for a predicate to become true
It is not necessary to worry about race conditions or lost updates inside of a monitor method, because of rule 1.
However, any update that may cause one of the monitor's predicates to become true must call notifyAll on the corresponding condition variables.
class AnotherMonitor(object): def __init__(self): self.lock = Lock() self.a = 0 self.b = 0 self.c = 0 # predicate: a > b + c self.enough_as = Condition(self.lock) # predicate: a >= 0 self.a_nonneg = Condition(self.lock) def increment_a(self): with self.lock: self.a += 1 if self.a == self.b + self.c + 1: # enough_as predicate has just become true # we should tell the world self.enough_as.notifyAll() if self.a == 1: # a_nonneg predicate has just become true # we should tell the world self.a_nonneg.notifyAll() |
If a monitor method wants to ensure that some predicate holds, it must wait.
In the original design of monitors, (Hoare-style monitors), one might write code like the following:
class AnotherMonitor(object): def __init__(self): self.lock = Lock() # predicate: all_clear = True self.safe = Condition(self.lock) def check_safe(self): if not self.all_clear: self.safe.wait() # idea: can assume self.all_clear is true at this point |
The intent is that if all is not clear, then the check_safe method should wait until it is safe, and then return.
However, it is very difficult to implement condition variables that ensure that the predicate actually holds when wait returns; we must prevent other threads from running between when the predicate becomes true and when wait returns.
For this reason, almost all monitor support libraries use MESA-style monitors. With MESA-style condition variables, the only guarantee that wait gives upon returning is that the corresponding predicate was true at some point. Users are responsible for rechecking the predicate after wait returns and waiting again if necessary.
In practice, this means that to wait for something to become true, you wait within a while loop:
class AnotherMonitor(object): def __init__(self): self.lock = Lock() # predicate: all_clear = True self.safe = Condition(self.lock) def check_safe(self): while not self.all_clear: self.safe.wait() # at this point we are guaranteed that self.all_clear is true |
The monitor implementation is very similar to the semaphore implementation, so we did not spend too much time on it. Here is the summary:
each monitor has a spin lock and a queue of waiting threads (in python, this is stored in the Lock object). Acquiring the lock while it is being held causes the thread to be descheduled and placed on the monitor lock's queue
each condition variable also has a queue of waiting threads. When a thread calls wait, the monitor lock is released, and the thread is descheduled and placed on the condition variable's waiting queue.
when notifyAll is called on a condition variable, all of the threads that were waiting on the condition variable are placed in the waiting queue for the lock (they must re-acquire the lock before they become runnable again).
For the curious, here is a pseudocode implementation of Python's Lock and Condition classes.
I find it useful to think of a monitor as a state machine (like finite automata). The fields of the monitor tell you which state the machine is currently in. Predicates (condition variables) are the collection of states from which a method is allowed to make progress (much like accepting states). Methods cause the machine to transition. NotifyAll is called when the machine enters a state the satisfies the predicates.