Dealing with Silent Task Termination

Motivation

A task completes abnormally when an exception is raised in its sequence of statements and is not handled. Even if the task body has a matching exception handler and it executes, the task still completes after the handler executes, although this time it completes normally. Similarly, if a task is aborted the task completes, again abnormally.

Whatever the cause, once completed a task will (eventually) terminate, and it does this silently — there is no notification or logging of the termination to the external environment. A vendor could support notification by their run-time library [1], but the language standard does not require it and most vendors — if not all — do not.

Nevertheless, applications may require some sort of notification of the event that caused the termination. Assuming the developer is responsible for implementing it, how can the requirement best be met?

Implementation

For unhandled exceptions, the simplest solution to silent termination is to define the announcement or logging response as an exception handler located in the task body exception handler part:

with Ada.Exceptions; use Ada.Exceptions;
with Ada.Text_IO;    use Ada.Text_IO;
-- ...
   task body Worker is
   begin
      --  ...
   exception
      when Error : others => -- last wishes
         Put_Line ("Task T terminated due to " & Exception_Name (Error));
   end Worker;

A handler at this level expresses the task's last wishes prior to completion, in this case printing the names of the task and the active exception to Standard_Output. The others choice covers all exceptions not previously covered, so in the above it covers all exceptions. Specific exceptions also could be covered, but the others choice should be included (at the end) to ensure no exception occurrence can be missed.

You'll probably want this sort of handler for every application task if you want it for any of them. That's somewhat inconvenient if there are many tasks in the application, but feasible. Possible mitigation includes the use of a task type. In that case you need only define the handler once, in the task type's body. You could even declare such a task type inside a generic package, with generic formal subprograms for the normal processing and the exception handler's processing. That would make the task type reusable. But that's a bit heavy, and it can be awkward. See the Notes section below for details.

Alternatively, we could prevent unhandled exceptions from causing termination in the first place. We can do that by preventing task completion, via some additional constructs that prevent reaching the end of the task's sequence of statements. We will show these constructs incrementally for the sake of clarity.

Before we do, note that many tasks are intended to run until power is removed, so they have an infinite loop at the outer level as illustrated in the code below. For the sake of clarity and realism, we name the loop Normal and call some procedures to show the typical structure, along with the last wishes handler:

task body Worker is
begin
   Initialize_State;
   Normal : loop
      Do_Actual_Work;
   end loop Normal;
exception
   when Error : others => -- last wishes
      Put_Line ("Task T terminated due to " &
                Exception_Name (Error));
end Worker;

In the above, the procedures' names indicate what is done at that point in the code. The steps performed may or may not be done by actual procedure calls.

Strictly speaking, the optional exception handler part of the task body is the very end of the task's sequence of statements (the handled sequence of statements). We want to prevent the thread of control reaching that end — which would happen if any handler there ever executed — because the task would then complete.

Therefore, for the first additional construct, we first wrap the existing code inside a block statement. The task body's exception handler section becomes part of the block statement rather than at the top level of the task:

task body Worker is
begin
   begin
      Initialize_State;
      Normal : loop
         Do_Actual_Work;
      end loop Normal;
   exception
      when Error : others =>  -- last wishes
         Put_Line ("Task T terminated due to " &
                   Exception_Name (Error));
   end;
end Worker;

Now any exception raised within Initialize_State and Do_Actual_Work will be caught in the block statement's handler, not the final part of the task's sequence of statements. Nothing else changes, semantically. The task will still complete because the block statement exits after the handler executes, and so far there's nothing after that block statement. We need to make one more addition.

The second (final) addition prevents reaching the end of the sequence of statements after a handler executes, and hence the task from completing. This is accomplished by wrapping the new block statement inside a new loop statement. We name this outermost loop Recovery:

task body Worker is
begin
   Recovery : loop
      begin
         Initialize_State;
         Normal : loop
            Do_Actual_Work;
         end loop Normal;
      exception
         when Error : others =>
            Put_Line ("Task T terminated due to " &
                      Exception_Name (Error));
      end;
   end loop Recovery;
end Worker;

When the block exits after the exception handler prints the announcement, normal execution resumes and the end of the Recovery loop is reached. The thread of control then continues at the top of the loop. Of course, absent an unhandled exception reaching this level, the Normal loop is never exited in the first place.

These two additions ensure that Worker never terminates due to an unhandled exception raised during execution of the task's sequence of statements. Note that an exception raised during elaboration of the task body's declarative part is not handled by the approach, or any other approach at this level, because the exception is propagated immediately to the master of this task. Such a task never reaches the handled sequence of statements in the first place.

That works, but the state initialization requires some thought. As shown above, full initialization is performed again when the Recovery loop circles back around to the top of the loop. As a result, the normal processing in Do_Actual_Work must be prepared for suddenly encountering completely different state, i.e., a restart to the initial state. If that is not feasible the call to Initialize_State could be moved outside, prior to the start of the Recovery loop, so that it only executes once. Perhaps a different initialization procedure could be called after the exception handler to do partial initialization. Whether or not that will suffice depends on the application.

However, these solutions do not address task termination due to task abort statements.

Aborting tasks is both messy and expensive at run-time. If a task is updating some object and is aborted before it finishes the update, that object is potentially corrupted. That's the messiness. If an aborted task has dependent tasks, all the dependents are aborted too, transitively. A task in a rendezvous with the aborted task is affected, as are those queued waiting to rendezvous with it, and so on. That's part of the expensiveness when aborts are actually used. Worse, even if never used, abort statements impose an expense at run-time. The language semantics requires checks for an aborted task at certain places within the run-time library. Those checks are executed even if no task abort statement is ever used in the application. To avoid that distributed cost, you would need to apply a tasking profile disallowing abort statements and build the executable with a correspondingly reduced run-time library implementation.

As a consequence, aborting a task should be very rarely done. Regardless, the task abort statement exists. How can we express a last wishes response for that cause too?

Fortunately, Ada provides a facility that addresses all possible causes: normal termination, termination due to task abort, and termination due to unhandled exceptions.

With this facility developers specify procedures that are invoked automatically by the run-time library during task finalization. These procedures express the last wishes for the task, but do not require any source code within the task, unlike the exception handler in each task body described earlier. These response procedures are known as handlers.

During execution, handlers can be applied to an individual task or to groups of related tasks. Handlers can also be removed from those tasks or replaced with other handlers. Because procedures are not first-class entities in Ada, handlers are assigned and removed by passing access values designating them.

The facility is defined by package Ada.Task_Termination. The package declaration for this language-defined facility follows, with slight changes for easier comprehension.

with Ada.Task_Identification;  use Ada.Task_Identification;
with Ada.Exceptions;           use Ada.Exceptions;

package Ada.Task_Termination
   --  ...
is
   type Cause_Of_Termination is (Normal, Abnormal, Unhandled_Exception);

   type Termination_Handler is access protected procedure
     (Cause : in Cause_Of_Termination;
      T     : in Task_Id;
      X     : in Exception_Occurrence);

   procedure Set_Dependents_Fallback_Handler
     (Handler : in Termination_Handler);

   function Current_Task_Fallback_Handler
     return Termination_Handler;

   procedure Set_Specific_Handler
     (T       : in Task_Id;
      Handler : in Termination_Handler);

   function Specific_Handler (T : Task_Id) return Termination_Handler;
end Ada.Task_Termination;

As shown, termination handlers are actually protected procedures, with a specific parameter profile. Therefore, the type Termination_Handler is an access-to-protected-procedure with that signature. The compiler ensures that any designated protected procedure matches the parameter profile.

Termination handlers apply either to a specific task or to a group of related tasks, including potentially all tasks in the partition. Each task has one, both, or neither kind of handler. By default none apply.

Clients call procedure Set_Specific_Handler to apply the protected procedure designated by Handler to the task with the specific Task_Id value T. These are known as specific handlers. The use of a Task_Id to specify the task, rather than the task name, means that we can set or remove a handler without having direct visibility to the task in question.

Clients call procedure Set_Dependents_Fallback_Handler to apply the protected procedure designated by Handler to the task making the call, i.e., the current task, and to all tasks that are dependents of that task. These handlers are known as fall-back handlers.

Handlers are invoked automatically, with the following semantics:

  1. If a specific handler is set for the terminating task, it is called and then the response finishes.

  2. If no specific handler is set for the terminating task, the run-time library searches for a fall-back handler. The search is recursive, up the hierarchy of task masters, including, ultimately, the environment task. If no fall-back handler is found no handler calls are made whatsoever. If a fall-back handler is found it is called and then the response finishes; no further searching or handler calls occur.

As a result, at most one handler is called in response to any given task termination.

The following client package illustrates the approach. Package Obituary declares protected object Obituary.Writer, which declares two protected procedures. Both match the profile specified by type Termination_Handler. One such procedure would suffice, we just provide two for the sake of illustrating the flexibility of the dynamic approach.

    
    
    
        
with Ada.Exceptions; use Ada.Exceptions; with Ada.Task_Termination; use Ada.Task_Termination; with Ada.Task_Identification; use Ada.Task_Identification; package Obituary is protected Writer is procedure Note_Passing (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence); -- Written by someone who's read too much English lit procedure Dissemble (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence); -- Written by someone who may know more than they're saying end Writer; end Obituary;

Clients can choose among these protected procedures to set a handler for one or more tasks.

The two protected procedures display messages corresponding to the cause of the termination. One procedure prints respectful messages, in the style of someone who's read too much Old English literature. The other prints rather dissembling messages, as if written by someone who knows more than they are willing to say. The point of the difference is that more than one handler can be available to clients, and their choice is made dynamically at run-time.

The package body is structured as follows:

with Ada.Text_IO;  use Ada.Text_IO;

package body Obituary is

   protected body Writer is
      procedure Note_Passing () is ...
      procedure Dissemble () is ...
   end Writer;

begin -- optional package executable part
   Set_Dependents_Fallback_Handler (Writer.Note_Passing'Access);
end Obituary;

In addition to defining the bodies of the protected procedures, the package body has an executable part. That part is optional, but in this case it is convenient. This executable part calls procedure Set_Dependents_Fallback_Handler to apply one of the two handlers. Because this call happens during library unit elaboration, it sets the fall-back handler for all the tasks in the partition (the program). The effect is global to the partition because library unit elaboration is invoked by the environment task, and the environment task is the master of all application tasks in the partition. Therefore, the fall-back handler is applied to the top of the task dependents hierarchy, and thus to all tasks. The application tasks need not do anything in their source code for the handler to apply to them.

The call to Set_Dependents_Fallback_Handler need not occur in this particular package body, or even in a package body at all. But because we want it to apply to all tasks in this specific example, including library tasks, placement in a library package's elaboration achieves that effect.

The observant reader will note the with-clause for Ada.Text_IO, included for the sake of references to Put_Line. We'll address the ramifications momentarily. Here are the bodies for the two handlers:

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; package body Obituary is protected body Writer is procedure Note_Passing (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence) is begin case Cause is when Normal => Put_Line (Image (Departed) & " went gently into that good night"); when Abnormal => Put_Line (Image (Departed) & " was most fouly murdered!"); when Unhandled_Exception => Put_Line (Image (Departed) & " was unknit by the much unexpected " & Exception_Name (Event)); end case; end Note_Passing; procedure Dissemble (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence) is begin case Cause is when Normal => Put_Line (Image (Departed) & " died, naturally."); Put_Line ("We had nothing to do with it."); when Abnormal => Put_Line (Image (Departed) & " had a tragic accident."); Put_Line ("We're sorry it had to come to that."); when Unhandled_Exception => Put_Line (Image (Departed) & " was apparently fatally allergic to " & Exception_Name (Event)); end case; end Dissemble; end Writer; begin -- optional package executable part Set_Dependents_Fallback_Handler (Writer.Note_Passing'Access); end Obituary;

Now, about those calls to Ada.Text_IO.Put_Line. Because of those calls, the bodies of procedures Note_Passing and Dissemble are not portable. The Put_Line calls are useful for illustration and will likely work as expected on a native OS. However, their execution is a bounded error and may do something else on other targets, including raising Program_Error if detected.

For a portable approach, we move these two blocking calls to a new dedicated task and revise the protected object accordingly. That's a portable approach because a task can make blocking calls.

First, we change Obituary.Writer to have a single protected procedure and a new entry. The protected procedure will be used as a termination handler, as before, but does not print the messages. Instead, when invoked by task finalization, the handler enters the parameter values into an internal data structure and then enables the entry barrier on the protected entry. The dedicated task waits on that entry barrier and, when enabled, retrieves the stored values describing a termination. The task can then call Put_Line to print the announcement with those values.

Here's the updated Obituary package declaration:

    
    
    
        
with Ada.Exceptions; use Ada.Exceptions; with Ada.Task_Termination; use Ada.Task_Termination; with Ada.Task_Identification; use Ada.Task_Identification; with Ada.Containers.Vectors; package Obituary is pragma Elaborate_Body; Comment_On_Normal_Passing : Boolean := True; -- Do we say anything if the task completed normally? type Termination_Event is record Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Id; end record; package Termination_Events is new Ada.Containers.Vectors (Positive, Termination_Event); protected Writer is procedure Note_Passing (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence); entry Get_Event (Next : out Termination_Event); private Stored_Events : Termination_Events.Vector; end Writer; end Obituary;

As a minor refinement we add the option to not print announcements for normal completions, for those applications that allow task completion.

We must declare the generic container instantiation outside the protected object, an unfortunate limitation of protected objects. We would prefer that clients have no compile-time visibility to it, since it is an implementation artifact.

The updated package body is straightforward:

    
    
    
        
package body Obituary is protected body Writer is ------------------ -- Note_Passing -- ------------------ procedure Note_Passing (Cause : Cause_Of_Termination; Departed : Task_Id; Event : Exception_Occurrence) is begin if Cause = Normal and then not Comment_On_Normal_Passing then return; else -- store all three causes and their info Stored_Events.Append (Termination_Event'(Cause, Departed, Exception_Identity (Event))); end if; end Note_Passing; --------------- -- Get_Event -- --------------- entry Get_Event (Next : out Termination_Event) when not Stored_Events.Is_Empty is begin Next := Stored_Events.First_Element; Stored_Events.Delete_First; end Get_Event; end Writer; begin -- optional package executable part Set_Dependents_Fallback_Handler (Writer.Note_Passing'Access); end Obituary;

A new child package declares the task that prints the termination information:

    
    
    
        
package Obituary.Output is pragma Elaborate_Body; task Printer; end Obituary.Output;

In the package body, the task body iteratively suspends on the call to Writer.Get_Event, waiting for a termination handler to make the termination data available. Once it returns from the call, if ever, it simply prints the information and awaits further events:

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; package body Obituary.Output is ------------- -- Printer -- ------------- task body Printer is Next : Termination_Event; begin loop Writer. Get_Event (Next); case Next.Cause is when Normal => Put_Line (Image (Next.Departed) & " died, naturally."); -- What a difference that comma makes! Put_Line ("We had nothing to do with it."); when Abnormal => Put_Line (Image (Next.Departed) & " had a terrible accident."); Put_Line ("We're sorry it had to come to that."); when Unhandled_Exception => Put_Line (Image (Next.Departed) & " reacted badly to " & Exception_Name (Next.Event)); Put_Line ("Really, really badly."); end case; end loop; end Printer; end Obituary.Output;

We declared this task in a child package because one can view the Printer and the Writer as parts of a single subsystem, but that structure isn't necessary. An unrelated application task could just as easily retrieve the information stored by the protected Writer object.

Here is a sample demonstration main procedure, a simple test to ensure that termination due to task abort is captured and displayed:

    
    
    
        
with Obituary.Output; pragma Unreferenced (Obituary.Output); -- otherwise neither package is in the executable procedure Demo_Fallback_Handler_Abort is task Worker; task body Worker is begin loop -- ensure not already terminated when aborted delay 0.0; -- yield the processor end loop; end Worker; begin abort Worker; end Demo_Fallback_Handler_Abort;

Note that the nested task would not be accepted under the Ravenscar or Jorvik profiles because those profiles require tasks to be declared at the library level, but that can easily be addressed.

When this demo main is run, the output looks like this:

worker_00000174BC68A570 had a terrible accident.
We're sorry it had to come to that.

The actual string representing the task identifier will vary with the implementation.

You'll have to use control-c (or whatever is required on your host) to end the program because the Printer task in Obituary.Output runs forever. Many applications run forever so that isn't necessarily a problem. That could be addressed if need be.

Pros

The facility provided by package Ada.Task_Termination allows developers to respond in any way required to task termination. The three causes, normal completion, unhandled exceptions, and task abort are all supported. Significantly, no source code in application tasks is required for the termination support to be applied, other than the isolated calls to set the handlers.

Cons

On a bare metal target there may be restrictions that limit the usefulness of the facility. For example, on targets that apply the Ravenscar or Jorvik profiles, task abort is not included in the profile and tasks are never supposed to terminate for any reason, including normally. Independent of the profiles, some run-time libraries may not support exception propagation, or even any exception semantics at all.

Relationship With Other Idioms

None.

Notes

If you did want to use a generic package to define a task type that is resilient to unhandled exceptions, you could do it like this:

    
    
    
        
with System; with Ada.Exceptions; use Ada.Exceptions; generic type Task_Local_State is limited private; with procedure Initialize (This : out Task_Local_State); with procedure Update (This : in out Task_Local_State); with procedure Respond_To_Exception (Current_State : in out Task_Local_State; Error : Exception_Occurrence); package Resilient_Workers is task type Worker (Urgency : System.Priority := System.Default_Priority) with Priority => Urgency; end Resilient_Workers;
package body Resilient_Workers is task body Worker is State : Task_Local_State; begin Recovery : loop begin Initialize (State); Normal : loop Update (State); -- The call above is expected to return, ie -- this loop is meant to iterate end loop Normal; exception when Error : others => Respond_To_Exception (State, Error); end; end loop Recovery; end Worker; end Resilient_Workers;

Although this code looks useful, in practice it has issues.

First, in procedure Initialize, the formal parameter mode may be a problem. You might need to change the parameter mode from mode out to mode in-out instead, because recovery from unhandled exceptions will result in another call to Initialize. Mode out makes sense for the first time Initialize is called, but does it make sense for all calls after that? It depends on the application's procedures. The behavior of Update may be such that local state should only partially be reset in subsequent calls to Initialize.

Furthermore, if Initialize must only perform a partial initialization on subsequent calls, the procedure must keep track of the number of calls. That requires a variable declared external to the body of Initialize. The additional complexity is unfortunate. We could perhaps mitigate this problem by having two initialization routines passed to the instantiation: one for full initialization, called only once with mode out for the state, and one for partial initialization, called on each iteration of the Recovery loop with mode in-out for the state:

    
    
    
        
package body Resilient_Workers is task body Worker is State : Task_Local_State; begin Fully_Initialize (State); Recovery : loop begin Normal : loop Update (State); -- The call above is expected to return, i.e. -- this loop is meant to iterate end loop Normal; exception when Error : others => Respond_To_Exception (State, Error); end; Partially_Initialize (State); end loop Recovery; end Worker; end Resilient_Workers;

If both application initialization routines happen to do the same thing, we'd like the developer to be able to pass the same application procedure to both generic formal procedures Fully_Initialize and Partially_Initialize in the instantiation. But that wouldn't compile because the parameter modes don't match.

Then there's the question of the nature of the task. Is it periodic, or sporadic, or free running? If it is periodic, we need a delay statement in the Normal loop to suspend the task for the required period. The generic's task body doesn't do that. The actual procedure passed to Update could do the delay, but now, like a single version of Initialize required to do both partial and full initialization, it needs additional state declared external to the procedure body (for the Time variable used by the absolute delay statement).

Finally, the single generic formal type used to represent the task's local state can be awkward. Having one type for a task's total state is unusual, and aggregating otherwise unrelated types into one isn't good software engineering and doesn't reflect the application domain. Furthermore, that awkwardness extends to the procedures that use that single object, in that every procedure except for Initialize will likely ignore parts of it.

In summary, the problems are likely more problematic than this generic is worth.

Footnotes