Ada 95 Quality and Style Guide Chapter 6
Concurrent programming is more difficult and error prone than sequential programming. The concurrent programming features of Ada are designed to make it easier to write and maintain concurrent programs that behave consistently and predictably and avoid such problems as deadlock and starvation. The language features themselves cannot guarantee that programs have these desirable properties. They must be used with discipline and care, a process supported by the guidelines in this chapter.
The correct usage of Ada concurrency features results in reliable, reusable, and portable software. Protected objects (added in Ada 95) encapsulate and provide synchronized access to their private data (Rationale 1995, §II.9). Protected objects help you manage shared data without incurring a performance penalty. Tasks model concurrent activities and use the rendezvous to synchronize between cooperating concurrent tasks. Much of the synchronization required between tasks involves data synchronization, which can be accomplished most efficiently, in general, using protected objects. Misuse of language features results in software that is unverifiable and difficult to reuse or port. For example, using task priorities or delays to manage synchronization is not portable. It is also important that a reusable component not make assumptions about the order or speed of task execution (i.e., about the compiler's tasking implementation).
Although concurrent features such as tasks and protected objects
are supported by the core Ada language, care should be taken when
using these features with implementations that do not specifically
support Annex D
(Real-Time Systems). If Annex D is not specifically supported,
features required for real-time applications might not be implemented.
Guidelines in this chapter are frequently worded "consider . . ." because hard and fast rules cannot apply in all situations. The specific choice you make in a given situation involves design tradeoffs. The rationale for these guidelines is intended to give you insight into some of these tradeoffs.
Many problems map naturally to a concurrent programming solution. By understanding and correctly using the Ada language concurrency features, you can produce solutions that are largely independent of target implementation. Tasks provide a means, within the Ada language, of expressing concurrent, asynchronous threads of control and relieving programmers from the problem of explicitly controlling multiple concurrent activities. Protected objects serve as a building block to support other synchronization paradigms.
Tasks cooperate to perform the required activities of the software. Synchronization and mutual exclusion are required between individual tasks. The Ada rendezvous and protected objects provide powerful mechanisms for both synchronization and mutual exclusion.
guideline
rationale
exceptions
guideline
-- The following example of a stock exchange simulation shows how naturally -- concurrent objects within the problem domain can be modeled as Ada tasks. ------------------------------------------------------------------------- -- Protected objects are used for the Display and for the Transaction_Queue -- because they only need a mutual exclusion mechanism. protected Display is entry Shift_Tape_Left; entry Put_Character_On_Tape (C : in Character); end Display; protected Transaction_Queue is entry Put (T : in Transaction); entry Get (T : out Transaction); function Is_Empty return Boolean; end Transaction_Queue; ------------------------------------------------------------------------- -- A task is needed for the Ticker_Tape because it has independent cyclic -- activity. The Specialist and the Investor are best modeled with tasks -- since they perform different actions simultaneously, and should be -- asynchronous threads of control. task Ticker_Tape; task Specialist is entry Buy (Order : in Order_Type); entry Sell (Order : in Order_Type); end Specialist; task Investor; ------------------------------------------------------------------------- task body Ticker_Tape is ... begin loop Display.Shift_Tape_Left; if not More_To_Send (Current_Tape_String) and then not Transaction_Queue.Is_Empty then Transaction_Queue.Get (Current_Tape_Transaction); ... -- convert Transaction to string end if; if More_To_Send (Current_Tape_String) then Display.Put_Character_On_Tape (Next_Char); end if; delay until Time_To_Shift_Tape; Time_To_Shift_Tape := Time_To_Shift_Tape + Shift_Interval; end loop; end Ticker_Tape; task body Specialist is ... loop select accept Buy (Order : in Order_Type) do ... end Buy; ... or accept Sell (Order : in Order_Type) do ... end Sell; ... else -- match orders ... Transaction_Queue.Put (New_Transaction); ... end select; end loop; end Specialist; task body Investor is ... begin loop -- some algorithm that determines whether the investor -- buys or sells, quantity, price, etc ... if ... then Specialist.Buy (Order); end if; if ... then Specialist.Sell (Order); end if; end loop; end Investor;
Multiple tasks that implement the decomposition of a large, matrix multiplication algorithm are an example of an opportunity for real concurrency in a multiprocessor target environment. In a single processor target environment, this approach may not be justified due to the overhead incurred from context switching and the sharing of system resources.
A task that updates a radar display every 30 milliseconds is an example of a cyclic activity supported by a task.
A task that detects an over-temperature condition in a nuclear reactor and performs an emergency shutdown of the systems is an example of a task to support a high-priority activity.
You should use tasks for separate threads of control. When you synchronize tasks, you should use the rendezvous mechanism only when you are trying to synchronize actual processes (e.g., specify a time-sensitive ordering relationship or tightly coupled interprocess communication). For most synchronization needs, however, you should use protected objects (see Guideline 6.1.1), which are more flexible and can minimize unnecessary bottlenecks. Additionally, passive tasks are probably better modeled through protected objects than active tasks.
Resources shared between multiple tasks, such as devices, require control and synchronization because their operations are not atomic. Drawing a circle on a display might require that many low-level operations be performed without interruption by another task. A display manager would ensure that no other task accesses the display until all these operations are complete.
guideline
type Task_Data is record ... -- data for task to work on end record; task type Worker (D : access Task_Data) is ... end; -- When you declare a task object of type Worker, you explicitly associate this task with -- its data through the discriminant D Data_for_Worker_X : aliased Task_Data := ...; X : Worker (Data_for_Worker_X'Access);
The following example shows how to use discriminants to associate data with tasks, thus allowing the tasks to be parameterized when they are declared and eliminating the need for an initial rendezvous with the task:
task type Producer (Channel : Channel_Number; ID : ID_Number); task body Producer is begin loop ... -- generate an item Buffer.Put (New_Item); end loop; end Producer; ... Keyboard : Producer (Channel => Keyboard_Channel, ID => 1); Mouse : Producer (Channel => Mouse_Channel, ID => 2);
The next example shows how an initial rendezvous can be used to associate data with tasks. This is more complicated and more error prone than the previous example. This method is no longer needed in Ada 95 due to the availability of discriminants with task types and protected types:
task type Producer is entry Initialize (Channel : in Channel_Number; ID : in ID_Number); end Producer; task body Producer is IO_Channel : Channel_Number; Producer_ID : ID_Number; begin accept Initialize (Channel : in Channel_Number; ID : in ID_Number) do IO_Channel := Channel; Producer_ID := ID; end; loop ... -- generate an item Buffer.Put (New_Item); end loop; end Producer; ... Keyboard : Producer; Mouse : Producer; ... begin ... Keyboard.Initialize (Channel => Keyboard_Channel, ID => 1); Mouse.Initialize (Channel => Mouse_Channel, ID => 2); ...
Task discriminants provide a way for you to identify or parameterize a task without the overhead of an initial rendezvous. For example, you can use this discriminant to initialize a task or tell it who it is (from among an array of tasks) (Rationale 1995, §II.9). More importantly, you can associate the discriminant with specific data. When you use an access discriminant, you can bind the data securely to the task because the access discriminant is constant and cannot be detached from the task (Rationale 1995, §9.6). This reduces and might eliminate bottlenecks in the parallel activation of tasks (Rationale 1995, §9.6).
guideline
task Buffer;
Because it is declared explicitly, the task type Buffer_Manager is not anonymous. Channel is static and has a name, and its type is not anonymous.
task type Buffer_Manager; Channel : Buffer_Manager;
The consistent and logical use of task and protected types, when necessary, contributes to understandability. Identical tasks can be declared using a common task type. Identical protected objects can be declared using a common protected type. Dynamically allocated task or protected structures are necessary when you must create and destroy tasks or protected objects dynamically or when you must reference them by different names.
guideline
task type Radar_Track; type Radar_Track_Pointer is access Radar_Track; Current_Track : Radar_Track_Pointer; --------------------------------------------------------------------- task body Radar_Track is begin loop -- update tracking information ... -- exit when out of range delay 1.0; end loop; ... end Radar_Track; --------------------------------------------------------------------- ... loop ... -- Radar_Track tasks created in previous passes through the loop -- cannot be accessed from Current_Track after it is updated. -- Unless some code deals with non-null values of Current_Track, -- (such as an array of existing tasks) -- this assignment leaves the existing Radar_Track task running with -- no way to signal it to abort or to instruct the system to -- reclaim its resources. Current_Track := new Radar_Track; ... end loop;
You can use dynamically allocated tasks and protected objects when you need to allow the number of tasks and protected objects to vary during execution. When you must ensure that tasks are activated in a particular order, you should use dynamically allocated tasks because the Ada language does not define an activation order for statically allocated task objects. In using dynamically allocated tasks and protected objects, you face the same issues as with any use of the heap.
guideline
task T1 is pragma Priority (High); end T1; task T2 is pragma Priority (Medium); end T2; task Server is entry Operation (...); end Server; ---------------------------- task body T1 is begin ... Server.Operation (...); ... end T1; task body T2 is begin ... Server.Operation (...); ... end T2; task body Server is begin ... accept Operation (...); ... end Server;
At some point in its execution, T1 is blocked. Otherwise, T2 and Server might never execute. If T1 is blocked, it is possible for T2 to reach its call to Server's entry (Operation) before T1. Suppose this has happened and that T1 now makes its entry call before Server has a chance to accept T2's call.
This is the timeline of events so far:
T1 blocks T2 calls Server.Operation T1 unblocks T1 calls Server.Operation -- Does Server accept the call from T1 or from T2?
You might expect that, due to its higher priority, T1's call would be accepted by Server before that of T2. However, entry calls are queued in first-in-first-out (FIFO) order and not queued in order of priority (unless pragma Queueing_Policy is used). Therefore, the synchronization between T1 and Server is not affected by T1's priority. As a result, the call from T2 is accepted first. This is a form of priority inversion. (Annex D can change the default policy of FIFO queues.)
A solution might be to provide an entry for a High priority user and an entry for a Medium priority user.
--------------------------------------------------------------------- task Server is entry Operation_High_Priority; entry Operation_Medium_Priority; ... end Server; --------------------------------------------------------------------- task body Server is begin loop select accept Operation_High_Priority do Operation; end Operation_High_Priority; else -- accept any priority select accept Operation_High_Priority do Operation; end Operation_High_Priority; or accept Operation_Medium_Priority do Operation; end Operation_Medium_Priority; or terminate; end select; end select; end loop; ... end Server; ---------------------------------------------------------------------
However, in this approach, T1 still waits for one execution of Operation when T2 has already gained control of the task Server. In addition, the approach increases the communication complexity (see Guideline 6.2.6).
Priority inversion occurs when lower priority tasks are given service while higher priority tasks remain blocked. In the first example, this occurred because entry queues are serviced in FIFO order, not by priority. There is another situation referred to as a race condition. A program like the one in the first example might often behave as expected as long as T1 calls Server.Operation only when T2 is not already using Server.Operation or waiting. You cannot rely on T1 always winning the race because that behavior would be due more to fate than to the programmed priorities. Race conditions change when either adding code to an unrelated task or porting this code to a new target.
You should not rely upon task priorities to achieve an exact sequence of execution or rely upon them to achieve mutual exclusion. Although the underlying dispatching model is common to all Ada 95 implementations, there might be differences in dispatching, queuing, and locking policies for tasks and protected objects. All of these factors might lead to different sequences of execution. If you need to ensure a sequence of execution, you should make use of Ada's synchronization mechanisms, i.e., protected objects or rendezvous.
Priorities are used to control when tasks run relative to one another. When both tasks are not blocked waiting at an entry, the highest priority task is given precedence. However, the most critical tasks in an application do not always have the highest priority. For example, support tasks or tasks with small periods might have higher priorities because they need to run frequently.
All production-quality validated Ada 95 compilers will probably support pragma Priority. However, you should use caution unless Annex D is specifically supported.
There is currently no universal consensus on how to apply the basic principles of rate monotonic scheduling (RMS) to the Ada 95 concurrency model. One basic principle of RMS is to arrange all periodic tasks so that tasks with shorter periods have higher priorities than tasks with longer periods. However, with Ada 95, it might be faster to raise the priorities of tasks whose jobs suddenly become critical than to wait for an executive task to reschedule them. In this case, priority inversion can be minimized using a protected object with pragma Locking_Policy(Ceiling_Locking) as the server instead of a task.
guideline
Periodic: loop delay Interval; ... end loop Periodic;
To avoid an inaccurate delay drift, you should use the delay until statement. The following example (Rationale 1995, §9.3) shows how to satisfy a periodic requirement with an average period:
task body Poll_Device is use type Ada.Real_Time.Time; use type Ada.Real_Time.Time_Span; Poll_Time : Ada.Real_Time.Time := ...; -- time to start polling Period : constant Ada.Real_Time.Time_Span := Ada.Real_Time.Milliseconds (10); begin loop delay until Poll_Time; ... -- Poll the device Poll_Time := Poll_Time + Period; end loop; end Poll_Device;
The Ada language definition only guarantees that the delay time is a minimum. The meaning of a delay or delay until statement is that the task is not scheduled for execution before the interval has expired. In other words, a task becomes eligible to resume execution as soon as the amount of time has passed. However, there is no guarantee of when (or if) it is scheduled after that time because the required resources for that task might not be available at the expiration of the delay.
A busy wait can interfere with processing by other tasks. It can consume the very processor resource necessary for completion of the activity for which it is waiting. Even a loop with a delay can have the impact of busy waiting if the planned wait is significantly longer then the delay interval. If a task has nothing to do, it should be blocked at an accept or select statement, an entry call, or an appropriate delay.
The expiration time for a relative delay is rounded up to the nearest clock tick. If you use the real-time clock features provided by Annex D, however, clock ticks are guaranteed to be no greater than one millisecond (Ada Reference Manual 1995, §D.8).
guideline
The reusability of common protected operations (e.g., mutually exclusive read/write operations) can be maximized by using generic implementations of abstract data types. These generic implementations then provide templates that can be instantiated with data types specific to individual applications.
The need for tasks to communicate gives rise to most of the problems that make concurrent programming so difficult. Used properly, Ada's intertask communication features can improve the reliability of concurrent programs; used thoughtlessly, they can introduce subtle errors that can be difficult to detect and correct.
6.2.1 Efficient Task Communication
guideline
... loop select accept Operation do -- These statements are executed during rendezvous. -- Both caller and server are blocked during this time. ... end Operation; ... -- These statements are not executed during rendezvous. -- The execution of these statements increases the time required -- to get back to the accept and might be a candidate for another task. or accept Operation_2 do -- These statements are executed during rendezvous. -- Both caller and server are blocked during this time. ... end Operation_2; end select; -- These statements are also not executed during rendezvous, -- The execution of these statements increases the time required -- to get back to the accept and might be a candidate for another task. end loop;
When work is removed from the accept body and placed later in the selective accept loop, the additional work might still suspend the caller task. If the caller task calls entry Operation again before the server task completes its additional work, the caller is delayed until the server completes the additional work. If the potential delay is unacceptable and the additional work does not need to be completed before the next service of the caller task, the additional work can form the basis of a new task that will not block the caller task.
Operations on protected objects incur less execution overhead than tasks and are more efficient for data synchronization and communication than the rendezvous. You must design protected operations to be bounded, short, and not potentially blocking.
Minimizing the work performed during a rendezvous or selective accept loop of a task can increase the rate of execution only when it results in additional overlaps in processing between the caller and callee or when other tasks can be scheduled due to the shorter period of execution. Therefore, the largest increases in execution rates will be seen in multiprocessor environments. In single-processor environments, the increased execution rate will not be as significant and there might even be a small net loss. The guideline is still applicable, however, if the application could ever be ported to a multiprocessor environment.
guideline
Accelerate: begin Throttle.Increase(Step); exception when Tasking_Error => ... when Constraint_Error => ... when Throttle_Too_Wide => ... ... end Accelerate;
In this select statement, if all the guards happen to be closed, the program can continue by executing the else part. There is no need for a handler for Program_Error. Other exceptions can still be raised while evaluating the guards or attempting to communicate. You will also need to include an exception handler in the task Throttle so that it can continue to execute after an exception is raised during the rendezvous:
... Guarded: begin select when Condition_1 => accept Entry_1; or when Condition_2 => accept Entry_2; else -- all alternatives closed ... end select; exception when Constraint_Error => ... end Guarded;
In this select statement, if all the guards happen to be closed, exception Program_Error will be raised. Other exceptions can still be raised while evaluating the guards or attempting to communicate:
Guarded: begin select when Condition_1 => accept Entry_1; or when Condition_2 => delay Fraction_Of_A_Second; end select; exception when Program_Error => ... when Constraint_Error => ... end Guarded; ...
Because an else part cannot have a guard, it can never be closed off as an alternative action; thus, its presence prevents Program_Error. However, an else part, a delay alternative, and a terminate alternative are all mutually exclusive, so you will not always be able to provide an else part. In these cases, you must be prepared to handle Program_Error.
The exception Tasking_Error can be raised in the calling task whenever it attempts to communicate. There are many situations permitting this. Few of them are preventable by the calling task.
If an exception is raised during a rendezvous and not handled in the accept statement, it is propagated to both tasks and must be handled in two places (see Guideline 5.8).
The handling of the others exception can be used to avoid propagating unexpected exceptions to callers (when this is the desired effect) and to localize the logic for dealing with unexpected exceptions in the rendezvous. After handling, an unknown exception should normally be raised again because the final decision of how to deal with it might need to be made at the outermost scope of the task body.