61
This provides a robust pattern for using Wait and Pulse. Here are the key features to this pattern:
· Blocking conditions are implemented using custom fields (capable of functioning without
Wait and Pulse, albeit with spinning)
· Wait is always called within a while loop that checks its blocking condition (itself within
a lock statement)
· A single synchronization object (in the example above, locker) is used for all Waits and
Pulses, and to protect access to all objects involved in all blocking conditions
· Locks are held only briefly
Most importantly, with this pattern, pulsing does not force a waiter to continue. Rather, it notifies a
waiter that something has changed, advising it to re-check its blocking condition. The waiter then
determines whether or not it should proceed (via another iteration of its while loop) and not the
pulser. The benefit of this approach is that it allows for sophisticated blocking conditions, without
sophisticated synchronization logic.
Another benefit of this pattern is immunity to the effects of a missed pulse. A missed pulse happens
when Pulse is called before Wait perhaps due to a race between the notifier and waiter. But
because in this pattern a pulse means "re-check your blocking condition" (and not "continue"), an
early pulse can safely be ignored since the blocking condition is always checked before calling Wait,
thanks to the while statement.
With this design, one can define multiple blocking fields, and have them partake in multiple
blocking conditions, and yet still use a single synchronization object throughout (in our example,
locker). This is usually better than having separate synchronization objects on which to lock, Pulse
and Wait, in that one avoids the possibility of deadlock. Furthermore, with a single locking object,
all blocking fields are read and written to as a unit, avoiding subtle atomicity errors. It's a good idea,
however, not to use the synchronization object for purposes outside of the necessary scope (this can
be assisted by declaring private the synchronization object, as well as all blocking fields).
Producer/Consumer Queue
A simple Wait/Pulse application is a producer-consumer queue the structure we wrote earlier
using an AutoResetEvent. A producer enqueues tasks (typically on the main thread), while one or
more consumers running on worker threads pick off and execute the tasks one by one.
In this example, we'll use a string to represent a task. Our task queue then looks like this:
Queue<string> taskQ = new Queue<string>();
Because the queue will be used on multiple threads, we must wrap all statements that read or write to
the queue in a lock. Here's how we enqueue a task:
lock (locker) {
taskQ.Enqueue ("my task");
Monitor.PulseAll (locker); // We're altering a blocking condition
}
Because we're modifying a potential blocking condition, we must pulse. We call PulseAll rather than
Pulse because we're going to allow for multiple consumers. More than one thread may be waiting.