Ada Concurrent Programming

Ada was pretty far ahead of its time. Structured concurrency, the rendezvous, protected objects, and more were all designed and implemented decades ago.

About

Ada is a block-structured, object-oriented, concurrent language. The concurrency model is designed to best suit client-server and embedded applications. The notes here are intended to give an overview with examples but are not intended to be complete.

I have brief notes on Ada here. There is also an Ada Programming Wikibook that is pretty good, and there’s a good section in there on tasking.

The Basic Ideas

The Ada concurrency philosophy can be summed up with these three ideas:

That’s it! The rest is just details.

Task Declarations

The task specification defines the type and says which entries are available for clients to call, if any:

task type Mailbox is
   entry Store (L: Letter);
   entry Retrieve (L: out Letter);
end Mailbox;

Now, you can declare objects of that type:

M1, M2: Mailbox;
A: array (1 .. 200) of Mailbox;
type Mailbox_Pointer is access Mailbox;

The task body, or implementation, is always written separately from the specification. Always.

task body Mailbox is
   The_Letter: Letter;
begin
   loop
      accept Store (L: Letter) do
         The_Letter := L;
      end Store;

      accept Retrieve (L: out Letter) do
         L := The_Letter;
      end Retrieve;
   end loop;
end Mailbox;

By the way:

Life of a Task

Here’s how it all works. It looks complicated, but really it’s quite sensible:

procedure P is
 ⋮
X: Integer := F(5);
 ⋮
task T;
 ⋮
task body T is
 ⋮
begin
 ⋮
end T;
 ⋮
Y: Boolean;
 ⋮
begin
 ⋮
end P;
  • T is nonexistent.
  • P gets called.
  • P starts by elaborating its declarative part.
  • The elaboration of task T; (by P) makes T created.
  • P finishes the elaboration of its declarative part (i.e., reaches the begin).
  • T starts activating, that is, elaborating the declarative part of its body. It does this while P is blocked.
  • T finishes activation then starts executing concurrently with P.
  • T finishes executing (i.e., reaches the end) and is thus completed.
  • When T’s dependent tasks have terminated, then T starts finalizing.
  • After finalization, T is terminated.
  • P can complete at any time, but can only finalize and terminate after T terminates.
  • When P terminates, it returns, and thus T is out of scope and hence nonexistent.

Activation

Activation is the elaboration of the declarative part of a body. That is, the part before the construct’s begin. (Running the part between begin and end is called execution.)

Basic Principles of Activation

The general idea is that a parent creates a task during its activation, then waits for the dependent tasks to finish their activations before executing.

Here’s an example picture that may help. For the block:

M: declare
      ⋮
      task T1;
      task T2;
      task T3;
      ⋮
   begin
      ⋮
   end M;

a possible timeline is:

ada_task_activation.png

Note that:

Again, while it looks complicated, it makes sense!

Activation of Dynamically Allocated Tasks

Now you might ask, what if a task is created dynamically? In this case, the parent is the unit that evaluates the new expression. For example:

procedure P is
 ⋮
task type Controller;
type Device_Handle is access Controller;
 X: Device_Handle;
 ⋮
begin
 ⋮
 X := new Controller;
 ⋮
end P;
  1. At the assignment, a new controller object is allocated.
  2. The new controller object activates while P is blocked.
  3. After activation of the controller, the parent P resumes execution and starts executing concurrently with the controller.

Activation of Tasks in Packages

Because package bodies can have executable code, there’s nothing too special here!

package P is
   task T;
end P;

package body P is
   task body T is
   begin
      -- ...
   end T;
begin
   -- At the begin, T begins activating. P is blocked until
   -- T finishes activating. After T finishes activating,
   -- P resumes execution and runs concurrently with T.
end P;

Communication

Tasks communicate in one of two ways:

  1. By reading and writing shared variables, defined in “outer” scopes that both tasks can see.
  2. Via the rendezvous.

Typically, shared variables will hold protected objects to prevent race conditions, but you are free to share unprotected variables if you like. We’ll cover the rendezvous first.

Rendezvous

The rendezvous is the Ada mechanism for two tasks to communicate with each other. One task makes an entry call to another task. The caller blocks until the callee accepts the call, which happens at an accept statement in the callee. If the callee reaches an accept statement and there are no outstanding calls to that entry, the callee blocks waiting for a caller. Technically, the rendezvous is the part inside the callee’s accept statement, which is executed while the caller is blocked. When the accept statement finishes, both tasks proceed concurrently.

The rendezvous is fully synchronous: both sides wait for each other. (Later we’ll see ways to cancel the wait if desired.)

Here’s a classic example. First the caller (client) task:

task type Customer;

task body Customer is
   P: Phone_Number;
begin
   -- ...
   Operator.Inquire("Alice", P);
   -- ...
end Customer;

And the callee (server):

task type Operator is
   entry Inquire(Name: String; Number: out Phone_Number);
end Operator;

task body Operator is
begin
   -- ...
   accept Inquire(Name: String; Number: out Phone_Number) do
      -- ...
      Number := Get_Phone_Number(Name);
   end Inquire;
   -- ...
end Operator;

Servers often sit in loops. More about this later.

But first, time for the deep and important details:

There are several ways to control the rendezvous. The client can do a conditional entry call or a timed entry call. The server can do a selective wait.

Conditional Entry Call

It’s possible for the caller to not rendezvous if the callee is not already blocked waiting to accept the call:

-- I forget where this example came from
select
    Maid.Clean_Up(Spill);
    Read_Good_Book;
else
    Clean_Up(Spill);
    Take_Shower;
end select;

Timed Entry Call

It’s also possible to wait for a certain amount of time before giving up on the rendezvous:

-- Another example stolen from someone else, I forgot who
select
    Date.Meet_At_Cafe;
    Eat;
    Date.Take_Home;
or
    delay 2.0 * Hours;
    Go_Home_Alone;
end select;

Selective Wait

Conditional entry calls and timed entry calls are for the caller. But the callee (the server) can also do conditional accepts and timeouts, too. The general form is a select statement with multiple guarded alternatives and an optional else part.

select
   when G1 => alternative1
or
   when G2 => alternative2
or
   when G3 => alternative3
oror
   when Gn => alternativen
else
   optional else part
end select;

Execution begins with the evaluation of the guards ($G_1 \ldots G_n$ above). This determines which of the alternatives are open. After guard evaluation, execution continues as if the closed guards don’t even exist. If there are no open alternatives, then: (1) the else part is done if present, or (2) if no else part is present, then Program_Error is raised.

Again, there are a lot of rules, but hopefully you will see that they make sense.

Example time. Assume the types Item (anything) and Index (a modular type) have been defined already. Here then is safe bounded buffer, implemented as a server task. (In practice, a protected object would be better, but this example is classic, and the idea of this actually appears in other languages, so it is good to know.)

task type Buffer is
   entry Put(I: Item);
   entry Get(I: out Item);
   entry Get_Size(S: out Natural);
end Buffer;

task body Buffer is
   Data: array (Index) of Item;
   Head, Tail: Index := 0;
   Count: Natural := 0;
begin
   loop
      select
         accept Put(I: Item) do
            Data(Tail) := I;
         end Put;
        Count := Count + 1;
        Tail := Tail + 1;
      or
         accept Get(I: out Item) do
            I := Data(Head);
         end Get;
        Count := Count - 1;
        Head := Head + 1;
      or
         accept Get_Size(S: out Natural) do
            S := Count;
         end Get_Size;
      or
         terminate;
      end select;
   end loop;
end Buffer;

The terminate alternative is selected if and only if: (1) the task depends on a completed master AND (2) all other dependents of this master have terminated or are blocked on a select call with an open terminate alternative.

If the terminate alternative is taken, the task becomes completed.

Here is the selective wait semantics as a picture:

ada_selective_accept.png

Termination

A task is completed when it reaches the end of its body, or when it raises an exception that is not handled in the body (which is also a way to end up at the end of the body). Once completed, it begins its finalization process. A task is terminated (1) it has finalized and (2) all of its dependents have terminated.

A task depends on one or more masters. A master is not allowed to begin its finalization until after all of its dependents have terminated.

Here’s an illustration of how to find the masters of various tasks:

package P is
   task type Resource;
end P;


package body P is
   task body Resource is
      -- ...
   end Resource;
end P;
with P; use P;
package Q is
   task type Customer;
   type R_Pointer is access Resource;
   task Clerk is
      -- ...
   end Clerk;
end Q;
package body Q is
   task body Customer is
      -- ...
   end Customer;
   task body Clerk is
      -- ...
   end Clerk;
end Q;
with P, Q; use P, Q;
procedure R is
   task T1;
   task body T1 ... end;
   X: Resource;
   package Q is
      Y: Customer;
   end Q;
   procedure S is
      Z: Customer;
   begin ... end S;
   T2: R_Pointer;
begin
   B: declare
      task T3 is ... end T3;
      task body T3 is ... end T3;
   begin
      T2 := new Resource;
   end;
end R;
TaskMasters
ClerkP, R
T1R
XR
YR
ZS, R
T2.allP, Q, R
T3B, R

It’s possible for a task to terminate even if it never activates.

Exercise: How so?

A task may terminate an still be in scope. If you try to call an entry on a terminated task, the exception Tasking_Error is raised. You can always query T'Terminated, which is true iff T has terminated.

Exercise: You should ONLY check T'Terminated for true. Why?

There’s also the attribute T'Callable, which is false iff T is completed, terminated, or abnormal. (We’ll talk about abnormal later.)

Exercise: You should ONLY check T'Callable for false. Why?

Task States

I made this diagram 25 years ago or so. I hope it is correct.

ada_task_states.png

Protected Objects

Protected objects are the way Ada puts the resources in control of synchronization. They are like monitors but a bit more sophisticated — most everything you normally do in these cases is taken care of declaratively! There is that wonderful structured concurrency again. The bounded buffer example (shown in the context in which Index is defined as a modular type and Item is the type of objects to be stored in a buffer):

protected type Buffer is              -- SPECIFICATION
   entry Put(I: Item);               --
   entry Get(I: out Item);           --   Public part specifies:
   function Size return Natural;     --   entries, procedures, functions
private
   Data: array (Index) of Item;      --   Private part holds the shared
   Head, Tail: Index := 0;           --   data (this is in the spec so
   Count: Natural := 0;              --   the compiler knows how much space
end Buffer;                           --   to allocate for an object)

protected body Buffer is              -- BODY (operation bodies ONLY; no data)

    entry Put(I: Item) when Count < Index'Modulus is
    begin
        Data(Tail) := I;
        Count := Count + 1;
        Tail := Tail + 1;
    end Put;

    entry Get(I: out Item) when Count > 0 is
    begin
        I := Data(Head);
        Count := Count - 1;
        Head := Head + 1;
    end Get;

    function Size return Natural is
        return Count;
    end Size;

end Buffer;

How it works:

It's the programmer's responsibility to see that all barriers execute quickly and don't call a potentially blocking operation!

Advantages of protected objects (please read the Ada 95 Rationale section on protected types):

More Concurrency Features

There are a few more concurrency features that are worth mentioning, but we won’t go into too much detail here.

Atomics

Like many languages, Ada provides for atomic operations through procedures and functions that map directly to hardware support for these operations. These are found in the packages described in Section C.6 of the Reference Manual. The packages are:

Synchronized Queues

The standard library has three packages for sychronized queues, found starting at Section A.18.27 of the Reference Manual. Here’s the main package:


generic
   type Element_Type is private;
package Ada.Containers.Synchronized_Queue_Interfaces
   with Pure, Nonblocking, Global => null is

   type Queue is synchronized interface;

   -- Blocks if the queue has a maximum capacity and is full
   procedure Enqueue (Container: in out Queue; New_Item: in Element_Type) is abstract
      with
         Synchronization => By_Entry,
         Nonblocking => False,
         Global'Class => in out synchronized;

   -- Blocks if empty
   procedure Dequeue (Container: in out Queue; Element: out Element_Type) is abstract
      with
         Synchronization => By_Entry,
         Nonblocking => False,
         Global'Class => in out synchronized;

   -- Current number of items in the queue
   function Current_Use (Container: Queue) return Count_Type is abstract
      with
         Nonblocking,
         Global'Class => null,
         Use_Formal => null;

   -- Maximum number of items that have been in the queue at any one time
   function Peak_Use (Container: Queue) return Count_Type is abstract
      with
         Nonblocking,
         Global'Class => null,
         Use_Formal => null,
         Post'Class => Peak_Use'Result >= Current_Use (Container);
end Ada.Containers.Synchronized_Queue_Interfaces;

Requeue

Ada has a requeue statement to help with condition synchronization. The rendezvous or protected call can’t do everything! In particular, it cannot make the choice on blocking based on the parameters of the entry call. Nor can it handle a service that needs to be done in two steps.

This statement is well described in Section 9.2 of the Rationale, which includes examples. You can find the precise details in Section 9.5.4 of the Reference Manual.

ATC

Ada’s asynchronous transfer of control mechanism is described in Section 9.4 of the Rationale and defined officially in Section 9.7.4 of the Reference Manual.

ATC is a way to transfer control from one task to another, without waiting for the first task to finish. It is used for things like interrupt handling, error recovery, unexpected mode changes, deadline overrun detection, or convergent computation with approximations. It needs to be used carefully.

Abort

Ada has a controversial abort statement. It marks a task, and all of its dependents abnormal, which means a lot of things. Most people don’t use it, as it is dangerous and has so many nasty ramifications, including race conditions and other cases in which the program is highly unpredictable. ATC is often a better, safer option.

Official details are in Section 9.8 of the Reference Manual. You might find the paper Ada’s Abort Statement: License to Kill a good read.

Pro Tip

Just place

pragma Restrictions (No_Abort_Statements);

at the top of your compilation unit, then any use of this statement becomes a compiler error.

Exceptions

Okay, this is tricky. Exceptions are always tricky in concurrent programming.

Exceptions During Activation

The interesting cases for exceptions raised during activation are described in this example:

procedure P is

   task T1;
   task T2;

   -- If an exception occurs here, during the elaboration
   -- of the declarative part of P, both T1 and T2 become
   -- TERMINATED, without ever having a chance to activate.

   task body T3 is
      -- If an exception occurs here, during the elaboration
      -- of the declarative part of T3, T3 becomes COMPLETED
      -- (allowing it to finalize). There is no effect on T1
      -- or T2. However, as P has to wait for T3 to finish
      -- activating, and T3 never finishes activating, Ada
      -- causes a Tasking_Error exception to be raised in P,
      -- right at its begin.
   begin
      -- ...
   end T3;

   -- ...
begin
   -- ...
end P;

The rule that Tasking_Error is raised in the parent whenever a tasks raises an exception during its activation is a general one. It also applies to tasks created dynamically.

Exceptions During Execution

There are a few interesting cases to note during execution:

Examples

There are some useful and runnable examples over at my Programming Languages Explorations site. In the Ada folder you’ll find examples for:

What’s Next?

We’ve only covered the basic model here.

The next things to study would be:

Summary

We’ve covered:

  • Core ideas of the Ada concurrency model
  • Tasks declarations
  • Task lifecycle: activation, execution, termination
  • Communication
  • Task states
  • Protected objects
  • A handful of features
  • Exceptions in Ada concurrency