Ada-Rapporteur-Group / User-Community-Input

Ada User Community Input Working Group - Github Mirror Prototype
27 stars 1 forks source link

Unexpected (non)termination of tasks created by an allocator. #82

Open Richard-Wai opened 8 months ago

Richard-Wai commented 8 months ago

9.3(1) of the RM says: Each task (other than an environment task — see 10.2) depends on one or more masters (see 7.6.1), as follows:

9.3(6/1) then says: Completion of a task (and the corresponding task_body) can occur when the task is blocked at a select_statement with an open terminate_alternative (see 9.7.1); the open terminate_alternative is selected if and only if the following conditions are satisfied:

Consider a partition with a lone compilation unit thus:

with Ada.Text_IO; use Ada.Text_IO;
with Ada.Unchecked_Deallocation;

procedure Task_Term is
   task type Temp_Task is
      entry Sync;
   end;

   task body Temp_Task is
   begin
      Put_Line ("Delay");
      delay 2.0;
      Put_Line ("Done");

      select
         accept Sync;
      or
         terminate;
      end select;
   end;

   type Task_Access is access Temp_Task;

   procedure Free is new Ada.Unchecked_Deallocation (Temp_Task, Task_Access);

   Some_Task: Task_Access := new Temp_Task;

begin
   Free (Some_Task);
   Put_Line ("Terminated");
end;

The actual output of this program, according to the language rules should be:

Delay
Terminated
(~2 second pause)
Done

And this is indeed what GNAT does.

However I would wager that this is not at all what the causal Ada programmer would expect. Which would be:

Delay
(~2 second pause)
Done
Terminated

I think it is fair to say that most average users would expect that Free will cause the Temp_Task allocated to Some_Task to then terminate. The language rules make it clear, however, that the selection of the terminate alternative is not available, since the dependent master of the task (the elaboration Task_Access) is not "complete" until leaving Task_Term.

This means, therefore, that the task cannot complete on the call to Unchecked_Deallocation, and by extension cannot yet be finalized. In effect all Free does is assign null to Some_Task. I think it would be hard to find someone who would not be surprised by that.

I recognize the provision for exactly this as well in 13.11.2(9/5): "There is one exception: if the object being freed contains tasks, it is unspecified whether the object is deallocated.", but looking at the associated AIs suggests that this exists for a totally different reason.

It seems to reason that if a task created by an allocator is activated and begins executing immediately, that a call to Unchecked_Deallocation should behave in a similar fashion - waiting for the completion of the deallocated task and any dependents, and potentially the selection of any terminate alternatives in that chain.

I can't quite understand why this should not be done. I can understand why tasks created by object declarations should ensure that no other task dependent on the same master will be able to rendezvous with a task that no longer exists, but in the case of a task created through the evaluation of an allocator, the only way for such a thing to happen is through the incorrect use of Unchecked_Deallocation, which is true for any allocated object, not just task objects. Imagine an access type designating a class-wide synchronized interface - why should it be more safe if implemented by a task type rather than a protected type?

I think it really ought to be that an invocation of Unchecked_Deallocation on an access type designating a task type, or an object containing tasks, should wait for completion of all dependent tasks, including the selection of a terminate alternative if available.

Any issues that might arise from that behavior, in my mind, should be included as part of Unchecked_Deallocation's Erroneous Execution section. Curiously there is an awkward proivision hinting at the existing surprising semantics in the descrition of bounded errors at 13.11.2(11), where it is a bounded error to free a discriminated, unterminated task because the discriminants might be deallocated before the task!

I don't see huge risk in such a change, as I'd imagine there would be more code expecting my proposed semantics than relying on the existing semantics.

Richard-Wai commented 8 months ago

Obviously making changes like this is pushing a boulder up a hill, but a thought just occurred to me:

What if instead we introduced the notion of an "allocated master". That is a master that is created during, and associated with, the evaluation of an allocator, and becomes the master of the allocated object. That allocated master itself could easily be considered a natural part of the allocation for the associated access type.

As part of Unchecked_Deallocation for such an access type, the "allocated master" would complete, and that would entail all the usual things, including completion of any tasks, finalization of the allocated object, followed by deallocation of the allocated master.

We could do through through an aspect or keyword

type Task_Access is synchronized access Task_Type;
type Task_Access is access Task_Type with Allocated_Master;