Using synchronization to coordinate tasks - C#. NET

In this section, we revisit the topic of synchronization primitives, this time using them to coordinate activity between and amongst groups of Tasks.

We want one group of Tasks (called the supervisors) to exert some direction over another group of Tasks (called the workers). A synchronization primitive is used to mediate between the two groups and allows them to communicate. The communication between the supervisors and the workers is limited to two
messages go and wait.

The synchronization primitive keeps track of a condition. Worker Tasks check with the primitive to see if the condition has been satisfied. If it has, they are told to go and will continue their work. If the condition has not been satisfied, they are made to wait until it is.

The details of the condition vary from one type of primitive to another, but what they all share in common is that they are satisfied by when the supervisors signal the primitive.

In effect, the workers are waiting for signals from the supervisors channeled through the synchronization primitive. Worker Tasks wait for the signals by calling the primitive’s Wait() method (or WaitOne() for classic primitives). the Wait() method blocks (does not return) until the expected signals have been received and the primitive condition has been satisfied. When a primitive tells a worker Task that has been waiting that it may now proceed because the condition has been satisfied, the primitive is said to wake, notify, or release the waiting Task.

You could write your own code to allow supervisors to signal workers, but recommend that you don’t. First, writing synchronization primitives correctly is very hard,and the odds are that you will make mistakes unless you are very experienced in parallel programming. Second, the primitives included in the .NET class library cover the vast majority of situations that parallel programmers encounter and are implemented using a broadly consistent interface. If the type of condition you need your primitive to manage should change, it is a relatively simple thing to switch from one standard primitive to another. Table summarizes the main uses for the most commonly used primitives.

Coordinating Tasks

Coordinating Tasks

For some primitives, both classic and lightweight versions are available. The lightweight versions have names that end with “slim,” such asManualResetEventSlim. The lightweight versions have better performancecharacteristics for most uses when compared with the classic versions, because a call to Wait() on a lightweight primitive is initially handled by spinning, which is ideally suited to short waiting periods. The lightweight versions also support waiting using a CancellationToken. use the lightweight implementations for
preference and recommend that you do the same.

Barrier
When using the System.Threading.Barrier primitive, the supervisors and the workers are the same Tasks, making Barrier useful for coordinating Tasks performing a multiphase parallel algorithm. Multiphase algorithms are broken down into several stages (called phases), where all of the Tasks participating in the work must reach the end of one phase before the next one can begin. This behavior is useful if the results produced in one phase are required as inputs for the next.

When a Task calls the SignalAndWait() method, the primitive is signaled (as though the Task were a supervisor), and the condition is checked (as though the Task were a worker). The condition for the Barrier class is satisfied when all of the Tasks participating in the algorithm have called the SignalAndWait() method. If a Task calls the method before the required number of calls has been made, it is made to wait. The number of calls is specified in the class constructor and can be altered using the AddParticipant() and RemoveParticipant() methods.

The Tasks performing the algorithm call the SignalAndWait() method when they reach the end of a phase. Not only does the Barrier release any waiting Tasks when the current phase ends, but it resets automatically, meaning that subsequent calls to SignalAndWait() will make Tasks wait until the counter reaches 0 again and another phase is complete.

When creating a new instance of Barrier, you can specify a System.Action that will be performed at the end of each phase and before the Tasks are notified that they should start the next one. you can see an example of this in the listing below. The following summarizes key members of the Barrier class.

Selected Members of the System.Threading.Barrier Class

Selected Members of the System.Threading.Barrier Class

Selected Members of the System.Threading.Barrier Class

The above demonstrates how to create and use the Barrier class. When the Barrier instance is created, two constructor arguments are supplied: the number of Tasks that must call SignalAndWait() before the primitive condition is met and a System.Action(Barrier) that will be called each time the condition is met (the listing uses a lambda expression to define System.Action).

In the example, we create an array of BankAccounts and a set of Tasks that perform a simple multiphase algorithm against using the accounts. In the first phase, the Tasks enter a loop to add random amounts to the account they are working with and then signal the Barrier to indicate they have reached the end of the current phase.

The Barrier then executes the constructor Action, which sums the individual balances into the totalBalance variable. The second phase of the algorithm begins, where each Task reduces the balance of its account by 10 percent of the difference between the current balance and the total balance, a procedure that would not have been possible prior to all Tasks completing the first phase. At the end of the phase, the Tasks signal the Barrier again, which marks the end of the second phase and triggers the constructor action again.

Using the Barrier Class

Signaling the Barrier at the end of the final phase is not essential; in Listing, we wanted to calculate the final total balance. Listing shows you how to make use of the Barrier class, but omits one major hazard. There is a deadlock if a Task doesn’t signal the Barrier, because it throws an exception. The current phase will never end, and the waiting Tasks will never be released.

There are two ways to deal with exceptions in this situation. The first is to abandon the Task that has thrown the exception but carry on with the other Tasks. You can do this by creating a selective continuation with the OnlyOnFaulted value and calling the RemoveParticipant() method, which decreases the number of calls to SignalAndWait() that Barrier requires to mark the end of a phase. This approach works as long as you can continue without the result that the abandoned Task would have otherwise provided. This demonstrates this technique.

Dealing with Exceptions by Reducing Participation

The second technique is to use a CancellationToken when creating the Tasks ;and use a version of theBarrier.SignalAndWait() method that takes aCancellationToken as an argument. A selective continuation Task cancels the token, which causes calls to SignalAndWait() to throw an OperationCancelledException, stopping all of the Tasks from continuing. This technique works if you don’t want any of the Tasks to continue if any of them throw an exception. Listing demonstrates this technique. Remember that the exception that was thrown in the first place is unhandled and will have to be dealt with using one of the techniques.

Dealing with Exceptions Using Cancellation

CountDownEvent
The System.Threading.CountDownEvent is similar to Barrier in that it requires a number of calls to a method to satisfy the primitive condition. But unlike Barrier, CountDownEvent separates signaling from waiting.

Calls to the CountDownEvent.Wait() method block until the Signal() method has been called the number of times specified in the constructor; each call to Signal() decrements a counter. Once the counter reaches zero, any waiting Tasks are released. At this point, the event represented by the CountDownEventClass is said to be signaled or set.

Once the event is set, calls to the Wait() method will not cause the Task to wait. CountDownEvent must be manually reset, by calling the Reset() method. Once the event has been reset, we start over again. Calls to Wait() will block until the Signal() method has been called the required number of times. This is in contrast to the Barrier class, which resets automatically.

You can call the AddCount() or TryAddCount() methods to increment the counter but only if the event is not set. If you call AddCount() after the event has set without first calling Reset(),an exception will be thrown. Table details the key members of the CountDownEvent class.

Selected Members of the System.Threading.CountDownEvent Class

Selected Members of the System.Threading.CountDownEvent Class

This demonstrates using CountDownEvent. A set of five supervisor Tasks is created, each of which sleeps for a random amount of time and then calls Signal(). The sixth Task, a worker, calls the CountDownEvent.Wait() method, which blocks until each of the Tasks have signaled the CountDownEvent, setting the event.

Using the CountDownEvent Primitive

The example shows how one group (the five supervisors) directs the behavior of another group (the single worker). The worker is made to wait until the supervisors have all reached a given state and have signaled the primitive. It is important to note that although we created five supervisors, we could have achieved the same effect by having one supervisor call Signal() five times. Synchronization primitives care about which methods are called, not how they are called.

ManualResetEventSlim
The System.Threading.ManualResetEventSlim class provides a simpler approach than CountDownEvent. A single call to Set() signals the event, and any waiting Tasks are released. New calls to Wait() don’t block until the Reset() method is called. Table summarizes the key members of this primitive.

Key Members of the System.Threading.ManualResetEvent Class

Key Members of the System.Threading.ManualResetEvent Class

Note: The ManualResetEventSlim class is the lightweight equivalent to System.Threading.ManualResetEvent.

Listing demonstrates the use of the ManualResetEventSlim class. Two Tasks are created: one worker that repeatedly waits on the event and one supervisor that sets and unsets the event. While the event is set, calls to the Wait() method do not block, and the worker Task proceeds without waiting. When the event is reset, calls to Wait() block until the supervisor sets the event once again.

Tip: The default constructor creates an instance of ManualReset EventSlim with the event initially unset, but you can explicitly specify the initial state of the event by using the overloaded version of the constructor.

Using the ManualResetEventSlim Class

AutoResetEvent
AutoResetEvent is similar to ManualResetEventSlim, but the event is reset automatically after each call to the Set() method, and only one waiting worker Task is released each time the event is set. There is no lightweight alternative to AutoResetEvent, and being a classic primitive, it has no method that allows waiting using a CancellationToken. Table describes the key members of the
AutoResetEvent class.

Key Members of the System.Threading.AutoResetEvent Class

Key Members of the System.Threading.AutoResetEvent Class

This demonstrates the use of the AutoResetEvent class. The constructor requires you to specify whether the event is initially set. We create three worker Tasks, each of which calls the WaitOne() method of the AutoResetEvent. A fourth Task, the supervisor, sets the event every 500 milliseconds. Each time the event is set, one waiting worker Task is released. If you run the program, you will see long sequences where a given worker Task is never released, or seems to be the one constantly being released—the AutoResetEvent class makes no guarantees about which waiting Task will be released when the event is set, and you should be careful not to make assumptions about the order in which workers are released when using this class.

Using the AutoResetEvent Class

SemaphoreSlim
The System.Threading.SemaphoreSlim class allows you to specify how many waiting worker Tasks are released when the event is set, which is useful when you want to restrict the degree of concurrency among a group of Tasks. The supervisor releases workers by calling the Release() method. The default version releases one Task, and you can specify how many Tasks are released by providing an integer argument. The constructor requires that you specify how many calls to the Wait() method can be made before the event is reset for the first time. Specifying 0 resets the event immediately, and any other value sets the event initially and then allows the specified number of calls to the Wait() method to be made without blocking before the event is reset.

Note: The SemaphoreSlim class is the leightweight equivalent to
System.Threading.Semaphore

Below it describes the key members of SemaphoreSlim.

Key Members of the System.Threading.SemaphoreSlim Class

Key Members of the System.Threading.SemaphoreSlim Class

This demonstrates the use of this class. Ten worker Tasks are created and call the SemaphoreSlim.Wait() method. A supervisor Task periodically releases two threads by signaling the primitive by calling SemaphoreSlim.Release(2).

Using the SemaphoreSlim Class

There are no guarantees about which of the waiting Tasks will be released when the Release() method is called. If you compile and run the listing, you will see something similar to the following output, which illustrates that the order in which Tasks call the Wait() method has no relationship to the order in which they are released:

...

Semaphore released

Task 4 released

Task 3 released

Semaphore released

Task 6 released

Task 9 released

Semaphore released

Task 10 released

Task 7 released

Semaphore released

Task 2 released

Task 4 released

...

If there are no Tasks waiting when you call Release(),the event remains set until the Wait() method has been called. If you specified a number to release by providing an integer argument, the event remains set until the Wait() method has been called the number of times you specified.


All rights reserved © 2018 Wisdom IT Services India Pvt. Ltd DMCA.com Protection Status

C#. NET Topics