Ada Style Guide/Concurrency

From Wikibooks, open books for an open world
Jump to: navigation, search

Programming Practices · Portability

Contents

Introduction[edit]

Concurrency exists as either apparent concurrency or real concurrency. In a single processor environment, apparent concurrency is the result of interleaved execution of concurrent activities. In a multiprocessor environment, real concurrency is the result of overlapped execution of concurrent activities.

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.

Concurrency Options[edit]

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.

Protected Objects[edit]

guideline[edit]

  • Consider using protected objects to provide mutually exclusive access to data.
  • Consider using protected objects to control or synchronize access to data shared by multiple tasks .
  • Consider using protected objects to implement synchronization, such as a passive resource monitor.
  • Consider encapsulating protected objects in the private part or body of a package.
  • Consider using a protected procedure to implement an interrupt handler.
  • Do not attach a protected procedure handler to a hardware interrupt if that interrupt has a maximum priority greater than the ceiling priority assigned to the handler.
  • Avoid the use of global variables in entry barriers.
  • Avoid the use of barrier expressions with side effects.

example[edit]

generic
   type Item is private;
   Maximum_Buffer_Size : in Positive;
package Bounded_Buffer_Package is
 
   subtype Buffer_Index is Positive range 1..Maximum_Buffer_Size;
   subtype Buffer_Count is Natural  range 0..Maximum_Buffer_Size;
   type    Buffer_Array is array (Buffer_Index) of Item;
 
   protected type Bounded_Buffer is
      entry Get (X : out Item);
      entry Put (X : in Item);
   private
      Get_Index : Buffer_Index := 1;
      Put_Index : Buffer_Index := 1;
      Count     : Buffer_Count := 0;
      Data      : Buffer_Array;
   end Bounded_Buffer;
 
end Bounded_Buffer_Package;
 
------------------------------------------------------------------
package body Bounded_Buffer_Package is
 
   protected body Bounded_Buffer is
 
      entry Get (X : out Item) when Count > 0 is
      begin
         X := Data(Get_Index);
         Get_Index := (Get_Index mod Maximum_Buffer_Size) + 1;
         Count := Count - 1;
      end Get;
 
      entry Put (X : in Item) when Count < Maximum_Buffer_Size is
      begin
         Data(Put_Index) := X;
         Put_Index  := (Put_Index mod Maximum_Buffer_Size) + 1;
         Count := Count + 1;
      end Put;
 
   end Bounded_Buffer;
 
end Bounded_Buffer_Package;

rationale[edit]

Protected objects are intended to provide a "lightweight" mechanism for mutual exclusion and data synchronization. You should use a task only when you need to introduce explicitly a new, concurrent thread of control (see Guideline 6.1.2).

Protected objects offer a low overhead, efficient means to coordinate access to shared data. A protected type declaration is similar to a program unit and consists of both a specification and a body. The data to be protected must be declared in the specification, as well as the operations that can be used to manipulate this data. If some operations are only allowed conditionally, entries must be provided. Ada 95 rules require that entry barriers be evaluated at the end of procedure calls and entry calls on protected objects. Entry barriers should avoid referring to global variables so that the underlying assumptions of the state of the protected object are not violated. Protected procedures and entries should be used to change the state of a protected object.

Most clients of an abstraction do not need to know how it is implemented, whether it is a regular abstraction or a shared abstraction. A protected type is inherently a limited type, and you can use protected types to implement a limited private type exported by a package. As pointed out in Guideline 5.3.3, abstractions are best implemented using private types (possibly derived from controlled types) or limited private types, providing appropriate operations that overcome the restrictiveness imposed by the use of private types.

The Rationale (1995, §9.1) describes the interrupt handling features that make the protected procedure the recommended building block:

A protected procedure is very well suited to act as an interrupt handler for a number of reasons; they both typically have a short bounded execution time, do not arbitrarily block, have a limited context and finally they both have to integrate with the priority model. The nonblocking critical region matches the needs of an interrupt handler, as well as the needs of non-interrupt-level code to synchronize with an interrupt handler. The entry barrier construct allows an interrupt handler to signal a normal task by changing the state of a component of the protected object and thereby making a barrier true.

When using protected procedures for interrupt handling, you must ensure that the ceiling priority of the handler is at least as high as the maximum possible priority of the interrupt to be handled. With priority-ceiling locking, the delivery of an interrupt with a higher priority than the ceiling priority of the handler will result in erroneous execution (Ada Reference Manual 1995, §C.3.1).

A global variable could be changed by another task or even by a call of a protected function. These changes will not be acted upon promptly. Therefore, you should not use a global variable in an entry barrier.

Side effects in barrier expressions can cause undesirable dependencies. Therefore, you should avoid the use of barrier expressions that can cause side effects.

See also Guideline .

exceptions[edit]

If the client of the abstraction containing the protected object must use a select statement with an entry call, you must expose the protected object on the package interface.

Tasks[edit]

guideline[edit]

  • Use tasks to model selected asynchronous threads of control within the problem domain.
  • Consider using tasks to define concurrent algorithms.
  • Consider using rendezvous when your application requires synchronous unbuffered communication.

example[edit]

The naturally concurrent objects within the problem domain can be modeled as Ada tasks.

-- 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.

rationale[edit]

These guidelines reflect the intended uses of tasks. They all revolve around the fact that a task has its own thread of control separate from the main subprogram (or environment task) of a partition. The conceptual model for a task is a separate program with its own virtual processor. This provides the opportunity to model entities from the problem domain in terms more closely resembling those entities and the opportunity to handle physical devices as a separate concern from the main algorithm of the application. Tasks also allow naturally concurrent activities that can be mapped to multiple processors within a partition when available.

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.

Discriminants[edit]

guideline[edit]

  • Consider using discriminants to minimize the need for an explicit initialization operation (Rationale 1995, §9.1).
  • Consider using discriminants to control composite components of the protected objects, including setting the size of an entry family (Rationale 1995, §9.1).
  • Consider using a discriminant to set the priority of a protected object (Rationale 1995, §9.1).
  • Consider using a discriminant to identify an interrupt to a protected object (Rationale 1995, §9.1).
  • Consider declaring a task type with a discriminant to indicate (Rationale 1995, §9.6):
    • Priority, storage size, and size of entry families of individual tasks of a type
    • Data associated with a task (through an access discriminant)

example[edit]

The following code fragment shows how a task type with discriminant can be used to associate data with a task (Rationale 1995, §9.6):

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);
   ...


rationale[edit]

Using discriminants to parameterize protected objects provides a low-overhead way of specializing the protected object. You avoid having to declare and call special subprograms solely for the purpose of passing this information to the protected object.

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).

notes[edit]

Using an access discriminant to initialize a task has a potential danger in that the data being referenced could change after the rendezvous. This possibility and its effects should be considered and, if necessary, appropriate actions taken (e.g., copy the referenced data and not rely on the data pointed to by the discriminant after initialization).

Anonymous Task Types and Protected Types[edit]

guideline[edit]

  • Consider using single task declarations to declare unique instances of concurrent tasks.
  • Consider using single protected declarations to declare unique instances of protected objects.

example[edit]

The following example illustrates the syntactic differences between the kinds of tasks and protected objects discussed here. Buffer is static, but its type is anonymous. No type name is declared to enable you to declare further objects of the same type.

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;

rationale[edit]

The use of anonymous tasks and protected objects of anonymous type avoids a proliferation of task and protected types that are only used once, and the practice communicates to maintainers that there are no other tasks or protected objects of that type. If the need arises later to have additional tasks or protected objects of the same type, then the work required to convert an anonymous task to a task type or an anonymous protected object to a protected type is minimal.

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.

notes[edit]

Though changing the task or protected object from an anonymous type to a declared type is trivial, structural changes to the software architecture might not be trivial. Introduction of multiple tasks or protected objects of the declared type might require the scope of the type to change and might change the behavior of the network of synchronizing tasks and protected objects.

Dynamic Tasks[edit]

guideline[edit]

  • Minimize dynamic creation of tasks because of the potentially high startup overhead; reuse tasks by having them wait for new work on some appropriate entry queue.

example[edit]

The approach used in the following example is not recommended. The example shows why caution is required with dynamically allocated task and protected objects. It illustrates how a dynamic task can be disassociated from its name:

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;

rationale[edit]

Starting up a task has significant overhead in many implementations. If an application has a need for dynamically created tasks, the tasks should be implemented with a top-level loop so that after such a task completes its given job, it can cycle back and wait for a new job.

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.

Priorities[edit]

guideline[edit]

  • Do not rely on pragma Priority unless your compiler supports the Real-Time Annex (Ada Reference Manual 1995, Annex D) and priority scheduling.
  • Minimize risk of priority inversion by use of protected objects and ceiling priority.
  • Do not rely upon task priorities to achieve a particular sequence of task execution.

example[edit]

For example, let the tasks have the following priorities:

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).

rationale[edit]

The pragma Priority allows relative priorities to be placed on tasks to accomplish scheduling. Precision becomes a critical issue with hard-deadline scheduling. However, there are certain problems associated with using priorities that warrant caution.

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.

notes[edit]

Work is being done to minimize these problems, including the introduction of a scheduling algorithm known as the priority ceiling protocol (Goodenough and Sha 1988). The priority ceiling protocol reduces the blocking time that causes priority inversion to only one critical region (defined by the entries in a task). The protocol also eliminates deadlock (unless a task recursively tries to access a critical region) by giving a ceiling priority to each task accessing a resource that is as high as the priority of any task that ever accesses that resource. This protocol is based on priority inheritance and, thus, deviates from the standard Ada tasking paradigm, which supports priority ceiling emulation instead of the priority ceiling blocking that occurs with priority inheritance.

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.

Delay Statements[edit]

guideline[edit]

  • Do not depend on a particular delay being achievable (Nissen and Wallis 1984).
  • Use a delay until not a delay statement to delay until a specific time has been reached.
  • Avoid using a busy waiting loop instead of a delay.

example[edit]

The phase of a periodic task is the fraction of a complete cycle elapsed as measured from a specified reference point. In the following example, an inaccurate delay causes the phase of the periodic task to drift over time (i.e., the task starts later and later in the cycle):

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;

rationale[edit]

There are two forms of delay statement. The delay will cause a delay for at least a specified time interval. The delay until causes a delay until an absolute wake-up time. You should choose the form appropriate to your application.

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 than 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).

notes[edit]

You need to ensure the arithmetic precision of the calculation Poll_Time := Poll_Time + Period; to avoid drift.

Extensibility and Concurrent Structures[edit]

guideline[edit]

  • Carefully consider the placement of components of protected types within a tagged type inheritance hierarchy.
  • Consider using generics to provide extensibility of data types requiring the restrictions provided by protected objects.

rationale[edit]

Once a component of a protected type is added to an inheritance hierarchy of an abstract data type, further extensibility of that data type is impaired. When you constrain the concurrent behavior of a type (i.e., introduce a protected type component), you lose the ability to modify that behavior in subsequent derivations. Therefore, when the need arises for a version of an abstract data type to impose the restrictions provided by protected objects, the opportunity for reuse is maximized by adding the protected objects at the leaves of the inheritance hierarchy.

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.

notes[edit]

You can address synchronization within an inheritance hierarchy in one of three ways:

  • You can declare the root as a limited tagged type with a component that belongs to a protected type and give the tagged type primitive operations that work by invoking the protected operations of that component.
  • Given a tagged type implementing an abstract data type (perhaps resulting from several extensions), you can declare a protected type with a component belonging to the tagged type. The body of each protected operation would then invoke the corresponding operation of the abstract data type. The protected operations provide mutual exclusion.
  • You can use a hybrid approach where you declare a protected type with a component of some tagged type. You then use this protected type to implement a new root tagged type (not a descendant of the original tagged type).

Communication[edit]

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.

Efficient Task Communication[edit]

guideline[edit]

  • Minimize the work performed during a rendezvous.
  • Minimize the work performed in the selective accept loop of a task.
  • Consider using protected objects for data synchronization and communication.

example[edit]

In the following example, the statements in the accept body are performed as part of the execution of both the caller task and the task Server, which contains Operation and Operation2. The statements after the accept body are executed before Server can accept additional calls to Operation or Operation2.

   ...
   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;

rationale[edit]

To minimize the time required to rendezvous, only work that needs to be performed during a rendezvous, such as saving or generating parameters, should be allowed in the accept bodies.

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.

notes[edit]

In some cases, additional functions can be added to a task. For example, a task controlling a communication device might be responsible for a periodic function to ensure that the device is operating correctly. This type of addition should be done with care, realizing that the response time of the task might be impacted (see the above rationale).

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.

Defensive Task Communication[edit]

guideline[edit]

  • Provide a handler for exception Program_Error whenever you cannot avoid a selective accept statement whose alternatives can all be closed (Honeywell 1986).
  • Make systematic use of handlers for Tasking_Error.
  • Be prepared to handle exceptions during a rendezvous .
  • Consider using a when others exception handler.

example[edit]

This block allows recovery from exceptions raised while attempting to communicate a command to another task:

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;
...

rationale[edit]

The exception Program_Error is raised if a selective accept statement (select statement containing accepts) is reached, all of whose alternatives are closed (i.e., the guards evaluate to False and there are no alternatives without guards), unless there is an else part. When all alternatives are closed, the task can never again progress, so there is by definition an error in its programming. You must be prepared to handle this error should it occur.

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.

notes[edit]

There are other ways to prevent Program_Error at a selective accept. These involve leaving at least one alternative unguarded or proving that at least one guard will evaluate True under all circumstances. The point here is that you or your successors will make mistakes in trying to do this, so you should prepare to handle the inevitable exception.

Attributes 'Count, 'Callable, and 'Terminated[edit]

guideline[edit]

  • Do not depend on the values of the task attributes 'Callable or 'Terminated ( Nissen and Wallis 1984).
  • Do not depend on attributes to avoid Tasking_Error on an entry call.
  • For tasks, do not depend on the value of the entry attribute 'Count.
  • Using the 'Count attribute with protected entries is more reliable than using the 'Count attribute with task entries.

example[edit]

In the following examples, Dispatch'Callable is a Boolean expression, indicating whether a call can be made to the task Intercept without raising the exception Tasking_Error. Dispatch'Count indicates the number of callers currently waiting at entry Transmit. Dispatch'Terminated is a Boolean expression, indicating whether the task Dispatch is in a terminated state.

This task is badly programmed because it relies upon the values of the 'Count attributes not changing between evaluating and acting upon them:

---------------------------------------------------------------------
task body Dispatch is
...
   select
      when Transmit'Count > 0 and Receive'Count = 0 =>
         accept Transmit;
         ...
   or
      accept Receive;
      ...
   end select;
...
end Dispatch;
---------------------------------------------------------------------

If the following code is preempted between evaluating the condition and initiating the call, the assumption that the task is still callable might no longer be valid:

...
if Dispatch'Callable then
   Dispatch.Receive;
end if;
...

rationale[edit]

Attributes 'Callable, 'Terminated, and 'Count are all subject to race conditions. Between the time you reference an attribute and the time you take action, the value of the attribute might change. Attributes 'Callable and 'Terminated convey reliable information once they become False and True, respectively. If 'Callable is False, you can expect the callable state to remain constant. If 'Terminated is True, you can expect the task to remain terminated. Otherwise, 'Terminated and 'Callable can change between the time your code tests them and the time it responds to the result.

The Ada Reference Manual (1995, §9.9) itself warns about the asynchronous increase and decrease of the value of 'Count. A task can be removed from an entry queue due to execution of an abort statement as well as expiration of a timed entry call. The use of this attribute in guards of a selective accept statement might result in the opening of alternatives that should not be opened under a changed value of 'Count.

The value of the attribute 'Count is stable for protected units because any change to an entry queue is itself a protected action, which will not occur while any other protected action is already proceeding. Nevertheless, when you use 'Count within an entry barrier of a protected unit, you should remember that the condition of the barrier is evaluated both before and after queueing a given caller.

Unprotected Shared Variables[edit]

guideline[edit]

  • Use calls on protected subprograms or entries to pass data between tasks rather than unprotected shared variables.
  • Do not use unprotected shared variables as a task synchronization device.
  • Do not reference nonlocal variables in a guard .
  • If an unprotected shared variable is necessary, use the pragma Volatile or Atomic.

example[edit]

This code will either print the same line more than once, fail to print some lines, or print garbled lines (part of one line followed by part of another) nondeterministically. This is because there is no synchronization or mutual exclusion between the task that reads a command and the one that acts on it. Without knowledge about their relative scheduling, the actual results cannot be predicted:

-----------------------------------------------------------------------
task body Line_Printer_Driver is
   ...
begin
   loop
      Current_Line := Line_Buffer;
      -- send to device
   end loop;
end Line_Printer_Driver;
-----------------------------------------------------------------------
task body Spool_Server is
   ...
begin
   loop
      Disk_Read (Spool_File, Line_Buffer);
   end loop;
end Spool_Server;
-----------------------------------------------------------------------

The following example shows a vending machine that dispenses the amount requested into an appropriately sized container. The guards reference the global variables Num_Requested and Item_Count, leading to a potential problem in the wrong amount being dispensed into an inappropriately sized container:

Num_Requested : Natural;
Item_Count    : Natural := 1000;
task type Request_Manager (Personal_Limit : Natural := 1) is
   entry Make_Request (Num : Natural);
   entry Get_Container;
   entry Dispense;
end Request_Manager;
 
task body Request_Manager is
begin
   loop
      select
         accept Make_Request (Num : Natural) do
            Num_Requested := Num;
         end Make_Request;
      or
         when Num_Requested < Item_Count =>
            accept Get_Container;
            ...
      or
         when Num_Requested < Item_Count =>
            accept Dispense do
               if Num_Requested <= Personal_Limit then
                  Ada.Text_IO.Put_Line ("Please pick up items.");
               else
                  Ada.Text_IO.Put_Line ("Sorry! Requesting too many items.");
               end if;
            end Dispense;
      end select;
   end loop;
end Request_Manager;
R1 : Request_Manager (Personal_Limit => 10);
R2 : Request_Manager (Personal_Limit => 2);

The interleaving of the execution of R1 and R2 can lead to Num_Requested being changed before the entry call to Dispense is accepted. Thus, R1 might receive fewer items than requested or R2's request might be bounced because the request manager thinks that what R2 is requesting exceeds R2's personal limit. By using the local variable, you will dispense the correct amount. Furthermore, by using the pragma Volatile (Ada Reference Manual 1995, §C.6), you ensure that the Item_Count is reevaluated when the guards are evaluated. Given that the variable Item_Count is not updated in this task body, the compiler might otherwise have optimized the code and not generated code to reevaluate Item_Count every time it is read:

Item_Count : Natural := 1000;
pragma Volatile (Item_Count);
task body Request_Manager is
   Local_Num_Requested : Natural := 0;
begin
   loop
      select
         accept Make_Request (Num : Natural) do
            Local_Num_Requested := Num;
         end Make_Request;
      or
         when Local_Num_Requested <= Personal_Limit =>
            accept Get_Container;
            ...
      or
         when Local_Num_Requested < Item_Count =>
            accept Dispense do
               ... -- output appropriate message if couldn't service request
            end Dispense;
            Item_Count := Item_Count - Local_Num_Requested; 
      end select;
   end loop;
end Request_Manager;

rationale[edit]

There are many techniques for protecting and synchronizing data access. You must program most of them yourself to use them. It is difficult to write a program that shares unprotected data correctly. If it is not done correctly, the reliability of the program suffers.

Ada provides protected objects that encapsulate and provide synchronized access to protected data that is shared between tasks. Protected objects are expected to provide better performance than the rendezvous that usually requires introduction of an additional task to manage the shared data. The use of unprotected shared variables is more error-prone than the protected objects or rendezvous because the programmer must ensure that the unprotected shared variables are independently addressable and that the actions of reading or updating the same unprotected shared variable are sequential (Ada Reference Manual 1995, §9.10; Rationale 1995, §II.9).

The first example above has a race condition requiring perfect interleaving of execution. This code can be made more reliable by introducing a flag that is set by Spool_Server and reset by Line_Printer_Driver. An if (condition flag) then delay ... else would be added to each task loop in order to ensure that the interleaving is satisfied. However, notice that this approach requires a delay and the associated rescheduling. Presumably, this rescheduling overhead is what is being avoided by not using the rendezvous.

You might need to use an object in shared memory to communicate data between (Rationale 1995, §C.5):

  • Ada tasks
  • An Ada program and concurrent non-Ada processes
  • An Ada program and hardware devices

If your environment supports the Systems Programming Annex (Ada Reference Manual 1995, Annex C), you should indicate whether loads and stores to the shared object must be indivisible. If you specify the pragma Atomic, make sure that the object meets the underlying hardware requirements for size and alignment. Multiple tasks sharing the predefined random number generator and certain input/output subprograms can lead to problems with unprotected updates to shared state. The Ada Reference Manual (1995, §A.5.2) points out the need for tasks to synchronize their access to the random number generators (packages Ada.Numerics.Float_Random and Ada.Numerics.Discrete_Random). See Guideline 7.7.5 for the I/O issue.

Selective Accepts and Entry Calls[edit]

guideline[edit]

  • Use caution with conditional entry calls to task entries.
  • Use caution with selective accept with else parts.
  • Do not depend upon a particular delay in timed entry calls to task entries.
  • Do not depend upon a particular delay in selective accepts with delay alternatives.
  • Consider using protected objects instead of the rendezvous for data-oriented synchronization.

example[edit]

The conditional entry call in the following code results in a potential race condition that might degenerate into a busy waiting loop (i.e., perform the same calculation over and over). The task Current_Position containing entry Request_New_Coordinates might never execute if the loop-containing task (shown in the following code fragment) has a higher priority than Current_Position because it does not release the processing resource:

task body Calculate_Flightpath is
begin
   ...
   loop
 
      select
         Current_Position.Request_New_Coordinates (X, Y);
         -- calculate projected location based on new coordinates
         ...
 
      else
         -- calculate projected location based on last locations
         ...
      end select;
 
   end loop;
   ...
end Calculate_Flightpath;

The addition of a delay, as shown, may allow Current_Position to execute until it reaches an accept for Request_New_Coordinates:

task body Calculate_Flightpath is
begin
   ...
   loop
 
      select
         Current_Position.Request_New_Coordinates(X, Y);
         -- calculate projected location based on new coordinates
         ...
 
      else
         -- calculate projected location based on last locations
         ...
 
         delay until Time_To_Execute;
         Time_To_Execute := Time_To_Execute + Period;
      end select;
 
   end loop;
   ...
end Calculate_Flightpath;


The following selective accept with else again does not degenerate into a busy wait loop only because of the addition of a delay statement:

task body Buffer_Messages is
 
   ...
 
begin
 
   ...
 
   loop
      delay until Time_To_Execute;
 
      select
         accept Get_New_Message (Message : in     String) do
            -- copy message to parameters
            ...
         end Get_New_Message;
      else  -- Don't wait for rendezvous
         -- perform built in test Functions
         ...
      end select;
 
      Time_To_Execute := Time_To_Execute + Period;
   end loop;
 
   ...
 
end Buffer_Messages;


The following timed entry call might be considered an unacceptable implementation if lost communications with the reactor for over 25 milliseconds results in a critical situation:

task body Monitor_Reactor is
   ...
begin
   ...
   loop
 
      select
         Reactor.Status(OK);
 
      or
         delay 0.025;
         -- lost communication for more that 25 milliseconds
         Emergency_Shutdown;
      end select;
 
      -- process reactor status
      ...
   end loop;
   ...
end Monitor_Reactor;


In the following "selective accept with delay" example, the accuracy of the coordinate calculation function is bounded by time. For example, the required accuracy cannot be obtained unless Period is within + or - 0.005 seconds. This period cannot be guaranteed because of the inaccuracy of the delay statement:

task body Current_Position is
begin
   ...
   loop
 
      select
         accept Request_New_Coordinates (X :    out Integer;
                                         Y :    out Integer) do
            -- copy coordinates to parameters
            ...
         end Request_New_Coordinates;
 
      or
         delay until Time_To_Execute;
      end select;
 
      Time_To_Execute := Time_To_Execute + Period;
      -- Read Sensors
      -- execute coordinate transformations
   end loop;
   ...
end Current_Position;

rationale[edit]

Use of these constructs always poses a risk of race conditions. Using them in loops, particularly with poorly chosen task priorities , can have the effect of busy waiting.

These constructs are very much implementation dependent. For conditional entry calls and selective accepts with else parts, the Ada Reference Manual (1995, §9.7) does not define "immediately." For timed entry calls and selective accepts with delay alternatives, implementors might have ideas of time that differ from each other and from your own. Like the delay statement, the delay alternative on the select construct might wait longer than the time required (see Guideline 6.1.7).

Protected objects offer an efficient means for providing data-oriented synchronization. Operations on protected objects incur less execution overhead than tasks and are more efficient for data synchronization and communication than the rendezvous. See Guideline 6.1.1 for an example of this use of protected objects.

Communication Complexity[edit]

guideline[edit]

  • Minimize the number of accept and select statements per task .
  • Minimize the number of accept statements per entry.

example[edit]

Use:

accept A;
if Mode_1 then
   -- do one thing
else  -- Mode_2
   -- do something different
end if;

rather than:

if Mode_1 then
   accept A;
   -- do one thing
else  -- Mode_2
   accept A;
   -- do something different
end if;

rationale[edit]

This guideline reduces conceptual complexity. Only entries necessary to understand externally observable task behavior should be introduced. If there are several different accept and select statements that do not modify task behavior in a way important to the user of the task, there is unnecessary complexity introduced by the proliferation of select/accept statements. Externally observable behavior important to the task user includes task timing behavior, task rendezvous initiated by the entry calls, prioritization of entries, or data updates (where data is shared between tasks).

notes[edit]

Sanden (1994) argues that you need to trade off the complexity of the guards associated with the accept statements against the number of select/accept statements. Sanden (1994) shows an example of a queue controller for bank tellers where there are two modes, open and closed. You can implement this scenario with one loop and two select statements, one for the open mode and the other for the closed mode. Although you are using more select/accept statements, Sanden (1994) argues that the resulting program is easier to understand and verify.

Termination[edit]

The ability of tasks to interact with each other using Ada's intertask communication features makes it especially important to manage planned or unplanned (e.g., in response to a catastrophic exception condition) termination in a disciplined way. To do otherwise can lead to a proliferation of undesired and unpredictable side effects as a result of the termination of a single task. The guidelines on termination focus on the termination of tasks. Wherever possible, you should use protected objects (see Guideline 6.1.1), thus avoiding the termination problems associated with tasks.

Avoiding Undesired Termination[edit]

guideline[edit]

  • Consider using an exception handler for a rendezvous within the main loop inside each task.

example[edit]

In the following example, an exception raised using the primary sensor is used to change Mode to Degraded still allowing execution of the system:

...
loop
 
   Recognize_Degraded_Mode:
      begin
 
         case Mode is
            when Primary =>
               select
                  Current_Position_Primary.Request_New_Coordinates (X, Y);
               or
                  delay 0.25;
                  -- Decide whether to switch modes;
               end select;
 
            when Degraded =>
 
               Current_Position_Backup.Request_New_Coordinates (X, Y);
 
         end case;
 
         ...
      exception
         when Tasking_Error | Program_Error =>
            Mode := Degraded;
      end Recognize_Degraded_Mode;
 
end loop;
...

rationale[edit]

Allowing a task to terminate might not support the requirements of the system. Without an exception handler for the rendezvous within the main task loop, the functions of the task might not be performed.

notes[edit]

The use of an exception handler is the only way to guarantee recovery from an entry call to an abnormal task. Use of the 'Terminated attribute to test a task's availability before making the entry call can introduce a race condition where the tested task fails after the test but before the entry call (see Guideline 6.2.3).

Normal Termination[edit]

guideline[edit]

  • Do not create nonterminating tasks unintentionally.
  • Explicitly shut down tasks that depend on library packages.
  • Confirm that a task is terminated before freeing it with Ada.Unchecked_Deallocation.
  • Consider using a select statement with a terminate alternative rather than an accept statement alone.
  • Consider providing a terminate alternative for every selective accept that does not require an else part or a delay .
  • Do not declare or create a task within a user-defined Finalize procedure after the environment task has finished waiting for other tasks.

example[edit]

This task will never terminate:

---------------------------------------------------------------------
task body Message_Buffer is
   ...
begin  -- Message_Buffer
   loop
      select
         when Head /= Tail => -- Circular buffer not empty
            accept Retrieve (Value :    out Element) do
               ...
            end Retrieve;
 
      or
         when not ((Head  = Index'First and then
                    Tail  = Index'Last) or else
                   (Head /= Index'First and then
                    Tail  = Index'Pred(Head))    )
                 => -- Circular buffer not full
            accept Store (Value : in     Element);
      end select;
   end loop;
...
end Message_Buffer;
---------------------------------------------------------------------

rationale[edit]

The implicit environment task does not terminate until all other tasks have terminated. The environment task serves as a master for all other tasks created as part of the execution of the partition; it awaits termination of all such tasks in order to perform finalization of any remaining objects of the partition. Thus, a partition will exist until all library tasks are terminated.

A nonterminating task is a task whose body consists of a nonterminating loop with no selective accept with terminate or a task that depends on a library package. Execution of a subprogram or block containing a task cannot complete until the task terminates. Any task that calls a subprogram containing a nonterminating task will be delayed indefinitely.

A task that depends on a library package cannot be forced to terminate using a selective accept construct with alternative and should be terminated explicitly during program shutdown. One way to explicitly shut down tasks that depend on library packages is to provide them with exit entries and have the main subprogram call the exit entry just before it terminates.

The Ada Reference Manual (1995, §13.11.2) states that a bounded error results from freeing a discriminated, unterminated task object. The danger lies in deallocating the discriminants as a result of freeing the task object. The effect of unterminated tasks containing bounded errors at the end of program execution is undefined.

Execution of an accept statement or of a selective accept statement without an else part, a delay, or a terminate alternative cannot proceed if no task ever calls the entry(s) associated with that statement. This could result in deadlock. Following the guideline to provide a terminate alternative for every selective accept without an else or a delay entails programming multiple termination points in the task body. A reader can easily "know where to look" for the normal termination points in a task body. The termination points are the end of the body's sequence of statements and alternatives to select statements.

When the environment task has been terminated, either normally or abnormally, the language does not specify whether to await a task activated during finalization of the controlled objects in a partition. While the environment task is waiting for all other tasks in the partition to complete, starting up a new task during finalization results in a bounded error (Ada Reference Manual 1995, §10.2). The exception Program_Error can be raised during creation or activation of such a task.

exceptions[edit]

If you are implementing a cyclic executive, you might need a scheduling task that does not terminate. It has been said that no real-time system should be programmed to terminate. This is extreme. Systematic shutdown of many real-time systems is a desirable safety feature.

If you are considering programming a task not to terminate, be certain that it is not a dependent of a block or subprogram from which the task's caller(s) will ever expect to return. Because entire programs can be candidates for reuse (see Chapter 8), note that the task (and whatever it depends upon) will not terminate. Also be certain that for any other task that you do wish to terminate, its termination does not await this task's termination. Reread and fully understand the Ada Reference Manual (1995, §9.3) on "Task Dependence-Termination of Tasks."

The Abort Statement[edit]

guideline[edit]

  • Avoid using the abort statement.
  • Consider using the asynchronous select statement rather than the abort statement.
  • Minimize uses of the asynchronous select statement.
  • Avoid assigning nonatomic global objects from a task or from the abortable part of an asynchronous select statement.

example[edit]

If required in the application, provide a task entry for orderly shutdown.

The following example of asynchronous transfer of control shows a database transaction. The database operation may be cancelled (through a special input key) unless the commit transaction has begun. The code is extracted from the Rationale (1995, §9.4):

with Ada.Finalization;
package Txn_Pkg is
   type Txn_Status is (Incomplete, Failed, Succeeded);
   type Transaction is new Ada.Finalization.Limited_Controlled with private;
   procedure Finalize (Txn : in out transaction);
   procedure Set_Status (Txn    : in out Transaction;
                         Status : in     Txn_Status);
private
   type Transaction is new Ada.Finalization.Limited_Controlled with
      record
         Status : Txn_Status := Incomplete;
         pragma Atomic (Status);
         . . . -- More components
      end record;
end Txn_Pkg;
-----------------------------------------------------------------------------
package body Txn_Pkg is
   procedure Finalize (Txn : in out Transaction) is
   begin
      -- Finalization runs with abort and ATC deferred
      if Txn.Status = Succeeded then
         Commit (Txn);
      else
         Rollback (Txn);
      end if;
   end Finalize;
   . . . -- body of procedure Set_Status
end Txn_Pkg;
----------------------------------------------------------------------------
-- sample code block showing how Txn_Pkg could be used:
declare
   Database_Txn : Transaction;
   -- declare a transaction, will commit or abort during finalization
begin
   select  -- wait for a cancel key from the input device
      Input_Device.Wait_For_Cancel;
      -- the Status remains Incomplete, so that the transaction will not commit
   then abort  -- do the transaction
      begin
         Read (Database_Txn, . . .);
         Write (Database_Txn, . . .);
         . . .
         Set_Status (Database_Txn, Succeeded);
         -- set status to ensure the transaction is committed
      exception
         when others =>
            Ada.Text_IO.Put_Line ("Operation failed with unhandled exception:");
            Set_Status (Database_Txn, Failed);
      end;
   end select;
   -- Finalize on Database_Txn will be called here and, based on the recorded
   -- status, will either commit or abort the transaction.
end;

rationale[edit]

When an abort statement is executed, there is no way to know what the targeted task was doing beforehand. Data for which the target task is responsible might be left in an inconsistent state. The overall effect on the system of aborting a task in such an uncontrolled way requires careful analysis. The system design must ensure that all tasks depending on the aborted task can detect the termination and respond appropriately.

Tasks are not aborted until they reach an abort completion point such as beginning or end of elaboration, a delay statement, an accept statement, an entry call, a select statement, task allocation, or the execution of an exception handler. Consequently, the abort statement might not release processor resources as soon as you might expect. It also might not stop a runaway task because the task might be executing an infinite loop containing no abort completion points. There is no guarantee that a task will not abort until an abort completion point in multiprocessor systems, but the task will almost always stop running right away.

An asynchronous select statement allows an external event to cause a task to begin execution at a new point, without having to abort and restart the task (Rationale 1995, §9.3). Because the triggering statement and the abortable statement execute in parallel until one of them completes and forces the other to be abandoned, you need only one thread of control. The asynchronous select statement improves maintainability because the abortable statements are clearly delimited and the transfer cannot be mistakenly redirected.

In task bodies and in the abortable part of an asynchronous select, you should avoid assigning to nonatomic global objects, primarily because of the risk of an abort occurring before the nonatomic assignment completes. If you have one or more abort statements in your application and the assignment is disrupted, the target object can become abnormal, and subsequent uses of the object lead to erroneous execution (Ada Reference Manual 1995, §9.8). In the case of scalar objects, you can use the attribute 'Valid, but there is no equivalent attribute for nonscalar objects. (See Guideline 5.9.1 for a discussion of the 'Valid attribute.) You also can still safely assign to local objects and call operations of global protected objects.

Abnormal Termination[edit]

guideline[edit]

  • Place an exception handler for others at the end of a task body.
  • Consider having each exception handler at the end of a task body report the task's demise.
  • Do not rely on the task status to determine whether a rendezvous can be made with the task.

example[edit]

This is one of many tasks updating the positions of blips on a radar screen. When started, it is given part of the name by which its parent knows it. Should it terminate due to an exception, it signals the fact in one of its parent's data structures:

task type Track (My_Index : Track_Index) is
   ...
end Track;
---------------------------------------------------------------------
task body Track is
     Neutral : Boolean := True;
begin  -- Track
   select
      accept ...
      ...
   or
      terminate;
   end select;
   ...
exception
   when others =>
      if not Neutral then
         Station(My_Index).Status := Dead;
      end if;
end Track;
---------------------------------------------------------------------

rationale[edit]

A task will terminate if an exception is raised within it for which it has no handler. In such a case, the exception is not propagated outside of the task (unless it occurs during a rendezvous). The task simply dies with no notification to other tasks in the program. Therefore, providing exception handlers within the task, and especially a handler for others, ensures that a task can regain control after an exception occurs. If the task cannot proceed normally after handling an exception, this affords it the opportunity to shut itself down cleanly and to notify tasks responsible for error recovery necessitated by the abnormal termination of the task.

You should not use the task status to determine whether a rendezvous can be made with the task. If Task A depends on Task B and Task A checks the status flag before it rendezvouses with Task B, there is a potential that Task B fails between the status test and the rendezvous. In this case, Task A must provide an exception handler to handle the Tasking_Error exception raised by the call to an entry of an abnormal task (see Guideline 6.3.1).

Circular Task Calls[edit]

guideline[edit]

  • Do not call a task entry that directly or indirectly results in a call to an entry of the original calling task.

rationale[edit]

A software failure known as task deadlock will occur if a task calls one of its own entries directly or indirectly via a circular chain of calls.

Setting Exit Status[edit]

guideline[edit]

  • Avoid race conditions in setting an exit status code from the main program when using the procedure Ada.Command_Line.Set_Exit_Status.
  • In a program with multiple tasks, encapsulate, serialize, and check calls to the procedure Ada.Command_Line.Set_Exit_Status.

rationale[edit]

In accordance with the rules of Ada, tasks in library-level packages may terminate after the main program task. If the program permits multiple tasks to use Set_Exit_Status, then there can be no guarantee that any particular status value is the one actually returned.

Summary[edit]

concurrency options[edit]

  • Consider using protected objects to provide mutually exclusive access to data.
  • Consider using protected objects to control or synchronize access to data shared by multiple tasks .
  • Consider using protected objects to implement synchronization, such as a passive resource monitor.
  • Consider encapsulating protected objects in the private part or body of a package.
  • Consider using a protected procedure to implement an interrupt handler.
  • Do not attach a protected procedure handler to a hardware interrupt if that interrupt has a maximum priority greater than the ceiling priority assigned to the handler.
  • Avoid the use of global variables in entry barriers.
  • Avoid the use of barrier expressions with side effects.
  • Use tasks to model selected asynchronous threads of control within the problem domain.
  • Consider using tasks to define concurrent algorithms.
  • Consider using rendezvous when your application requires synchronous unbuffered communication.
  • Consider using discriminants to minimize the need for an explicit initialization operation (Rationale 1995, §9.1).
  • Consider using discriminants to control composite components of the protected objects, including setting the size of an entry family (Rationale 1995, §9.1).
  • Consider using a discriminant to set the priority of a protected object (Rationale 1995, §9.1).
  • Consider using a discriminant to identify an interrupt to a protected object (Rationale 1995, §9.1).
  • Consider declaring a task type with a discriminant to indicate (Rationale 1995, §9.6):
    • Priority, storage size, and size of entry families of individual tasks of a type
    • Data associated with a task (through an access discriminant)
  • Consider using single task declarations to declare unique instances of concurrent tasks.
  • Consider using single protected declarations to declare unique instances of protected objects.
  • Minimize dynamic creation of tasks because of the potentially high startup overhead; reuse tasks by having them wait for new work on some appropriate entry queue.
  • Do not rely on pragma Priority unless your compiler supports the Real-Time Annex (Ada Reference Manual 1995, Annex D) and priority scheduling.
  • Minimize risk of priority inversion by use of protected objects and ceiling priority.
  • Do not rely upon task priorities to achieve a particular sequence of task execution.
  • Do not depend on a particular delay being achievable (Nissen and Wallis 1984).
  • Use a delay until not a delay statement to delay until a specific time has been reached.
  • Avoid using a busy waiting loop instead of a delay.
  • Carefully consider the placement of components of protected types within a tagged type inheritance hierarchy.
  • Consider using generics to provide extensibility of data types requiring the restrictions provided by protected objects.

communication[edit]

  • Minimize the work performed during a rendezvous .
  • Minimize the work performed in the selective accept loop of a task.
  • Consider using protected objects for data synchronization and communication.
  • Provide a handler for exception Program_Error whenever you cannot avoid a selectiveaccept statement whose alternatives can all be closed (Honeywell 1986).
  • Make systematic use of handlers for Tasking_Error.
  • Be prepared to handle exceptions during a rendezvous .
  • Consider using a when others exception handler.
  • Do not depend on the values of the task attributes 'Callable or 'Terminated (Nissen and Wallis 1984).
  • Do not depend on attributes to avoid Tasking_Error on an entry call.
  • For tasks, do not depend on the value of the entry attribute 'Count.
  • Using the 'Count attribute with protected entries is more reliable than using the 'Count attribute with task entries.
  • Use calls on protected subprograms or entries to pass data between tasks rather than unprotected shared variables.
  • Do not use unprotected shared variables as a task synchronization device.
  • Do not reference nonlocal variables in a guard .
  • If an unprotected shared variable is necessary, use the pragma Volatile or Atomic.
  • Use caution with conditional entry calls to task entries.
  • Use caution with selective accepts with else parts.
  • Do not depend upon a particular delay in timed entry calls to task entries.
  • Do not depend upon a particular delay in selective accepts with delay alternatives.
  • Consider using protected objects instead of the rendezvous for data-oriented synchronization.
  • Minimize the number of accept and select statements per task .
  • Minimize the number of accept statements per entry.

termination[edit]

  • Consider using an exception handler for a rendezvous within the main loop inside each task.
  • Do not create nonterminating tasks unintentionally.
  • Explicitly shut down tasks that depend on library packages.
  • Confirm that a task is terminated before freeing it with Ada.Unchecked_Deallocation.
  • Consider using a select statement with a terminate alternative rather than an accept statement alone.
  • Consider providing a terminate alternative for every selective accept that does not require an else part or a delay.
  • Do not declare or create a task within a user-defined Finalize procedure after the environment task has finished waiting for other tasks.
  • Avoid using the abort statement.
  • Consider using the asynchronous select statement rather than the abort statement.
  • Minimize uses of the asynchronous select statement.
  • Avoid assigning nonatomic global objects from a task or from the abortable part of an asynchronous select statement.
  • Place an exception handler for others at the end of a task body.
  • Consider having each exception handler at the end of a task body report the task's demise.
  • Do not rely on the task status to determine whether a rendezvous can be made with the task.
  • Do not call a task entry that directly or indirectly results in a call to an entry of the original calling task.
  • Avoid race conditions in setting an exit status code from the main program when using the procedure Ada.Command_Line.Set_Exit_Status.
  • In a program with multiple tasks, encapsulate, serialize, and check calls to the procedure Ada.Command_Line.Set_Exit_Status.


Portability