Ada Concurrency
Sequential Programming
Traditional programs are sequential, performing actions in a
specified sequence. The classical pattern for sequential program execution is
described as Input, Process, Output. This pattern may be performed once or it
may be performed many times using either iteration or recursion. This pattern
is very effective at doing one thing at a time for one user. The pattern
exhibits serious limitations when dealing with multiple events overlapping in
time, or with multiple users.
For instance, the following sequential program calculates
the sum of the digits in an integer. The program supports one user who enters a
single number.
1  Simple Sequential Program
| 
----------------------------------------------------------------------- 
-- calculate
  the sum of the digits of a positive integer 
----------------------------------------------------------------------- 
with
  Ada.Text_IO; use Ada.Text_Io; 
with
  Ada.Integer_Text_IO; use Ada.Integer_Text_IO; 
procedure Sum_Digits
  is 
   Num : Natural; 
   Sum : Integer := 0; 
begin 
   Put("Enter a positive integer:
  "); 
   Get(Num); 
   Put_Line("Given number ="&
  Num'Image); 
   while Num > 0 loop 
      Sum := Sum + (Num mod 10); 
      Num := Num / 10; 
   end loop; 
   Put_Line("Sum of the digits ="
  & Sum'Image); 
end
  Sum_Digits; | 
Concurrent Programming
Computer designers soon realized that some problems required
more than one program to run at a time and some business needs required more
than one person at a time to use computing resources. The first solution for
this problem was called time sharing. Operating systems were developed that
allowed several programs to take turns interleaving their sequential processing
so that each program could make some progress in its sequential actions during
a given time period. This provided the illusion of concurrent behavior and also
more efficiently used computer resources. Using the Input, Process, Output
model each program would have periods of low activity while waiting for input
or output data or resources. Other programs could be allowed to execute during
these “waiting” periods.
These early solutions still only used a single central
processor since computers of the time only had a single central processor. The
time sharing approach allowed multiple users to run many different programs or
many instances of the same program simultaneously, but performance was
noticeably impacted as the number of simultaneous users increased. Some systems
could support a dozen users while others could support 50 to 100 users before
performance became bothersome.
Operating systems such as Unix implemented concepts of pipes
and filters allowing many programs to chain input and output to perform complex
processing. Unix pipes were I/O channels within the computer providing
guaranteed delivery of data from one program to another. 
For example, if a user wanted to count the number of files
in a directory the user could combine two Unix filters or programs. The “ls”
program lists the files in a directory. The “wc” program counts words or lines
read from its input and outputs the count. The user would simply write
ls | wc –l
The “|” symbol was used to implement a pipe between ls and
wc. The output of the ls command is written to the pipe and that output is sent
to the input of the wc command. The concept was thought of as plumbing data
from one program to another through a “pipe”. The command wc –l caused the wc program to count
only lines, not words or characters. The ls command output the list of files in
the directory with one file name per line of output. The pipe concept was very
sophisticated for its time. The pipe actually caused the two programs to
synchronize their processing. If the pipe was empty the reading program would
be suspended until data arrived. If the pipe was full the writing program would
be suspended until data was consumed by the reading program.
The first attempts to increase performance through hardware
involved locating two or more computer motherboards in the same computer
chassis. Sequential programs could then be distributed across the two or more
central processors to provide increased processing capability to programs and
users. One of the problems of this early approach is that pipes did not
initially work across computer boundaries. A program executing on one
motherboard could not pipe data to a program executing on the other
motherboard. Networking was needed to communicate data between motherboards.
Eventually multi-core processors were developed along with
multi-tasking cores, allowing parallel tasks to be executed on the same central
processor. Those tasks can communicate directly or through shared memory
without the complexities and delays of networking.
Tasks
The commonly used term for separately executing
sub-processes is threads, while the academic term for has long been tasks. The
Ada language has supported the creation of tasks since its first language
standard in 1983, long before the term “threads” became popular for
sub-processes.
Every Ada program executes at least one task, which is
commonly called the main task. Additional tasks can be created. The example
below shows a main task which creates a child task. Both the child task and the
main task run at the same time. 
2 Simple Task
| 
with
  Ada.Text_IO; use Ada.Text_IO; 
procedure
  Simple_Task_Main is 
   task Hello_Task; 
   task body Hello_Task is 
   begin 
      for I in 1..10 loop 
         Put_Line("=== Hello from the
  child task.==="); 
      end loop; 
   end Hello_Task; 
begin 
   for I in 1..11 loop 
      Put_Line("Hello from the main
  task."); 
   end loop; 
end
  Simple_Task_Main; | 
The interleaving of the outputs from the two tasks will vary
from one execution to another. Following is an output of this program.
3 Simple Task Output
| 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from the
  main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
=== Hello
  from the child task.=== 
Hello from
  the main task. 
Hello from
  the main task. | 
The child task automatically starts when execution of the
main task reaches the “begin” statement in the main task.
Task Interactions
The two tasks shown above do not share any data. Most useful
concurrent programs need to share data among the tasks. Ada provides two
approaches for sharing data. The Ada Rendezvous mechanism allows two tasks to
pass data from one to another in a synchronous manner. Ada Protected Objects
allow data to be shared asynchronously through a shared buffer.
Rendezvous
A task may provide entries. Entries may be called by other
tasks to pass data between the called task and the calling task. The two tasks
synchronize at the calling/called points. If the caller gets to the entry
interface first the caller waits for the called task. If the called task gets
to the entry interface point first the called task waits for the calling task.
The following example demonstrates the use of tasks to
perform parallel addition of the elements of an array. The calling task passes
the first half of an array to one task and the second half of the same array to
another task. Each task totals the elements in its portion of
the array and passes back the sum. The calling task then retrieves the two
sums, and calculates the final sum of array elements. This example also
calculates the time required to perform the parallel sum and displays that
timing as well as the result.
The parallel sum routine is provided in a separate Ada
package. The package specification identifies the data types and function
interface required to call the parallel addition routine. The package body
defines the executable code for the parallel addition routine. Finally, a main
procedure is defined to test the parallel addition and record execution times.
4 Parallel Addition Specification
| 
package
  Parallel_Addition is 
   type Data_Array is array(Integer range
  <>) of Integer; 
   type Data_Access is access all Data_Array; 
   function Sum(Item : in not null
  Data_Access) return Integer; 
end
  Parallel_Addition; | 
The package specification defines two data types. Data_Array
is an unconstrained array of Integer. Data_Access is an access type referencing
Data_Array. Access types are roughly equivalent to references in C++.
The function Sum takes a non-null instance of Data_Access as
an input parameter and returns an Integer; Use of an access type as the
parameter allows the program to handle very large arrays of integer very
efficiently.
5 Parallel Addition Body
| 
package body
  Parallel_Addition is 
   --------- 
   -- Sum -- 
   --------- 
   function Sum (Item : in not null
  Data_Access) return Integer is 
      task type Adder is 
         entry Set (Min : Integer; Max :
  Integer); 
         entry Report (Value : out Integer); 
      end Adder; 
      task body Adder is 
         Total : Integer := 0; 
         First : Integer; 
         Last 
  : Integer; 
      begin 
         accept Set (Min : Integer; Max :
  Integer) do 
            First := Min; 
            Last  := Max; 
         end Set; 
         for I in First .. Last loop 
            Total := Total + Item (I); 
         end loop; 
         accept Report (Value : out Integer)
  do 
            Value := Total; 
         end Report; 
      end Adder; 
      A1 
  : Adder; 
      A2 
  : Adder; 
      R1 
  : Integer; 
      R2 
  : Integer; 
      Mid : constant Integer := (Item'Length
  / 2) + Item'First; 
   begin 
      A1.Set (Min => Item'First, Max =>
  Mid); 
      A2.Set (Min => Mid + 1, Max =>
  Item'Last); 
      A1.Report (R1); 
      A2.Report (R2); 
      return R1 + R2; 
   end Sum; 
end
  Parallel_Addition; | 
The package body for Parallel_Addition contains the
implementation of the function Sum. Inside the function Sum a task type named
Adder is defined. First the interface for Adder declares that it has two
entries. The entry named Set reads in two parameters Min and Max, which are the
array indices an instance of Adder will use to access elements of the array
pointed to by the parameter Item passed into function Sum. The entry named
Report passes a single integer out to the calling task of the Adder instance.
The task body of the Adder task type accepts the Set entry,
reading the Min and Max values and assigning them to the local variables First
and Last. The task then sums all the values from index First to index Last.
Finally, the Adder task accepts the Report entry and passes its total to the calling
task.
The calling task in this case is the task that calls the Sum
function.
Two instances of the Adder task, named A1 and A2, are
created. Both instances start executing when the Sum function reaches its
“begin” statement. Both tasks suspend at the accept statement for the Set
entry, waiting for some task to call them. The executable part of the Sum
function then calls the Set entry for each task instance, passing in the
appropriate Min and Max values. The Sum function then calls the Report entries
of each task. The Sum function waits for the tasks to execute the Accept call
for the Report entry, then adds the two reported values and returns that final
total.
The main procedure for this example is:
6 Parallel Addition Test Main Procedure
| 
with
  Parallel_Addition; use Parallel_Addition; 
with
  Ada.Text_IO;       use Ada.Text_IO; 
with
  Ada.Calendar;      use Ada.Calendar; 
procedure
  Parallel_Addition_Test is 
   The_Data : Data_Access := new Data_Array
  (1 .. Integer'Last / 5); 
   Start   
  : Time; 
   Stop    
  : Time; 
   The_Sum 
  : Integer; 
begin 
   The_Data.all := (others => 1); 
   Start        := Clock; 
   The_Sum      := Sum (The_Data); 
   Stop         := Clock; 
   Put_Line ("The sum is: " &
  Integer'Image (The_Sum)); 
   Put_Line 
     ("Addition elapsed time is "
  & 
      Duration'Image (Stop - Start) & 
        " seconds."); 
   Put_Line 
     ("Time per addition operation is
  " & 
        Float'Image(Float(Stop - Start) /
  Float(The_Data'Length)) & 
        " seconds."); 
end
  Parallel_Addition_Test; | 
The test procedure dynamically allocates an instance of
Data_Array containing 429496729 integers and assigns the value 1 to each
element. A start time of the Sum function is assigned to the variable Start,
the Sum function is called, and a stop time of the Sum function is assigned to
the variable Stop. Finally the sum, the total execution time of the function,
and the average time per addition are displayed.
7 Parallel Addition Test Output
| 
The sum
  is:  429496729 
Addition
  elapsed time is  1.147519700 seconds. 
Time per
  addition operation is  2.67178E-09
  seconds. | 
A common programming pattern for concurrency is the
Producer-Consumer pattern. This is the pattern used for Unix pipes and filters,
and variations on this pattern continue to be used in many programs. The
simplest producer-consumer pattern employs a single producing task and a single
consuming task. This simple producer-consumer is easily implemented using the
Ada Rendezvous mechanism.
8 Producer Consumer Rendezvous
| 
----------------------------------------------------------------------- 
-- Producer
  Consumer implemented using the Ada Rendezvous 
----------------------------------------------------------------------- 
with
  Ada.Text_IO; use Ada.Text_Io; 
procedure
  rendezvous_pc is 
   task producer; 
   task consumer is 
      entry Put(Item : in Integer); 
   end consumer; 
   task body producer is 
   begin 
      for I in 0..15 loop 
         Consumer.Put(I); 
      end loop; 
   end Producer; 
   task body consumer is 
      Num : Integer; 
   begin 
      loop 
         select 
            accept Put(Item : in Integer) do 
               Num := Item; 
            end Put; 
            Put_Line("Consumed"
  & Num'Image); 
         or 
            terminate; 
         end select; 
      end loop; 
   end consumer; 
begin 
   null; 
end rendezvous_pc; | 
Program 9 Producer Consumer Rendezvous
Output
| 
Consumed 0 
Consumed 1 
Consumed 2 
Consumed 3 
Consumed 4 
Consumed 5 
Consumed 6 
Consumed 7 
Consumed 8 
Consumed 9 
Consumed 10 
Consumed 11 
Consumed 12 
Consumed 13 
Consumed 14 
Consumed 15 | 
In this example the main task only starts up the producer
and consumer and then waits for them to complete. The producer generates 16
integer values from 0 through 15 then stops. The consumer reads each value
produced by the producer and quits after the producer quits.
Protected Objects
The Rendezvous can be very useful in synchronous
communication between tasks, but is very clumsy when developing asynchronous
task communication solutions. Ada’s solution is the Protected Object.
Ada protected objects are shared data buffers protected from
race conditions between tasks. Protected objects have three kinds of interfaces
to tasks. 
| 
Protected Operation | 
Description | 
| 
Function | 
Protected functions provide shared read-only access to the protected
  object. Multiple tasks can simultaneously call protected functions of a
  protected object. Functions implement a read lock on the protected object
  preventing any task from changing the object while it is being read. | 
| 
Procedure | 
Protected procedures provide unconditional read/write access to a
  protected object. Protected procedures employ an exclusive read/write lock
  ensuring only one task at a time has access to the object during execution of
  the procedure.  | 
| 
Entries | 
Protected entries provide conditional read/write access to a
  protected object. Protected entries employ an exclusive read/write lock
  ensuring only one task at a time has access to the object during execution of
  the entry. Tasks calling a protected entry while the controlling condition is
  false are automatically suspended in an entry queue. Tasks in an entry queue
  are activated and released from the queue when the controlling condition
  evaluates to TRUE. The order in which tasks are released from the entry queue
  depends upon the chosen queuing policy. The default queuing policy is First
  In First Out. | 
Following is a simple producer-consumer example using a
protected object. 
10 Generic Protected Object Specification
| 
generic 
   type Element_Type is private; 
package
  Protected_Buffer is 
   Capacity : constant := 10; 
   type Index_T is mod Capacity; 
   type Internal_Buffer is array(Index_T) of
  Element_Type; 
   protected type Buffer_T is 
      entry Add(Item : in Element_Type); 
      entry Get(Item : out Element_Type); 
   private 
      Buf : Internal_Buffer; 
      Add_Idx : Index_T := Index_T'First; 
      Get_Idx : Index_T := Index_T'First; 
      Count  
  : Natural := 0; 
   end Buffer_T; 
end
  Protected_Buffer; | 
This protected object provides two operations. The Add entry
and the Get entry allow a task to write to the protected object and to read
from the protected object. 
The Internal_Buffer type is an array indexed by a modular
type. The valid index values for this array are 0 through 9. Modular types
provide modular arithmetic. In this case 9 + 1 results in 0. Modular types are
very useful for implementing circular buffers.
The implementation of the protected type is:
11 Protected Object
Implementation
| 
package body
  Protected_Buffer is 
   -------------- 
   -- Buffer_T -- 
   -------------- 
   protected body Buffer_T is 
      --------- 
      -- Add -- 
      --------- 
      entry Add (Item : in Element_Type) when
  Count < Capacity is 
      begin 
         Buf(Add_Idx) := Item; 
         Add_Idx := Add_Idx + 1; 
         Count := Count + 1; 
      end Add; 
      --------- 
      -- Get -- 
      --------- 
      entry Get (Item : out Element_Type)
  when Count > 0 is 
      begin 
         Item := Buf(Get_Idx); 
         Get_Idx := Get_Idx + 1; 
         Count := Count - 1; 
      end Get; 
   end Buffer_T; 
end
  Protected_Buffer; | 
The protected body reveals the conditions associated with
each protected entry. The Add entry is only open to execution when Count is
less than Capacity. The Get entry is only open to execution when Count is
greater than 0.
This implementation of a protected object is designed to
ensure that every value written to the protected object can be read from the
protected object. As we will see later, other behaviors are possible.
12 Protected Producer Consumer
Main
| 
with
  Ada.Text_IO; use Ada.Text_IO; 
with
  Protected_Buffer; 
procedure
  Main is 
   package Int_Buf is new Protected_Buffer(Integer); 
   use Int_Buf; 
   Shared_Buf : Buffer_T; 
   task producer; 
   task body producer is 
   begin 
      for I in 0..15 loop 
         Shared_Buf.Add(I); 
      end loop; 
   end producer; 
   task consumer; 
   task body consumer is 
      Num : Integer; 
   begin 
      loop 
         select 
            Shared_Buf.Get(Num); 
            Put_Line("Consumed"
  & Num'Image); 
         or 
            delay 0.001; 
            exit; 
         end select; 
      end loop; 
   end consumer; 
begin 
   null; 
end Main; | 
13 Protected Main Output
| 
Consumed 0 
Consumed 1 
Consumed 2 
Consumed 3 
Consumed 4 
Consumed 5 
Consumed 6 
Consumed 7 
Consumed 8 
Consumed 9 
Consumed 10 
Consumed 11 
Consumed 12 
Consumed 13 
Consumed 14 
Consumed 15 | 
In this example the producer and consumer tasks never
directly communicate with each other. Instead, the two tasks only communicate
with the protected object. The producer task adds values to the protected
object and the consumer task gets values from the protected object.
Notice that all the lock manipulation is handled
automatically. 
There are many variations on the producer-consumer model.
For instance, it is possible to have a producer that produces data faster than
the consumer can consume data. If one uses the protected object implementation
shown above the producer will be slowed down to the rate of the consumer
because the buffer will eventually fill. While this behavior may be desirable
under some circumstances, it may be unacceptable under other circumstances.
Some problem domains may require that the producer work at
full speed, completely uninhibited by the consumer. The result is that the
consumer must under-sample the data produced by the producer.
14 Undersampling Example
| 
with
  Ada.Text_IO; use Ada.Text_IO; 
procedure
  Undersampling is 
   protected Buffer is 
      procedure Add(Item : Integer); 
      entry Get(Item : out Integer); 
   private 
      Value : Integer; 
      Is_New : Boolean := False; 
   end Buffer; 
   protected body Buffer is 
      procedure Add(Item : Integer) is 
      begin 
         Value := Item; 
         Is_New := True; 
      end Add; 
      entry Get(Item : out Integer) when
  Is_New is 
      begin 
         Item := Value; 
         Is_New := False; 
      end Get; 
   end Buffer; 
   task Producer is 
      Entry Stop; 
   end Producer; 
   task Consumer is 
      Entry Stop; 
   end Consumer; 
   task body Producer is 
      Num : Natural := 0; 
   begin 
      loop 
         select 
            Accept Stop; 
            Exit; 
         else 
            Buffer.Add(Num); 
            Num := Num + 1; 
         end select; 
      end loop; 
   end Producer; 
   task body Consumer is 
      Num : Natural; 
   begin 
      loop 
         select 
            accept Stop; 
            exit; 
         else 
            select 
               Buffer.Get(Num); 
               Put_Line("Consumed:"
  & Num'Image); 
            else 
               null; 
            end select; 
         end select; 
      end loop; 
   end Consumer; 
begin 
   delay 0.0001; -- wait 0.0001 second 
   Producer.Stop; 
   Consumer.Stop; 
end
  Undersampling; | 
 15 Undersampling output
| 
Consumed: 173 
Consumed: 174 
Consumed: 195 
Consumed: 209 
Consumed: 224 
Consumed: 238 
Consumed: 253 
Consumed: 271 
Consumed: 286 
Consumed: 300 
Consumed: 314 
Consumed: 328 
Consumed: 343 
Consumed: 356 
Consumed: 372 
Consumed: 386 
Consumed: 402 
Consumed: 416 
Consumed: 430 
Consumed: 444 
Consumed: 459 
Consumed: 475 
Consumed: 489 
Consumed: 503 
Consumed: 517 
Consumed: 530 
Consumed: 545 | 
The main task calls the Stop entries for the producer and
consumer to coordinate the shutdown of the program. Failure to do this will
result in an eventual integer overflow and the corresponding run time
exception.
It is also possible that the consumer may consume data
faster than the data is produced. In this case either the consumer can be
slowed down to wait for the producer, or the consumer can oversample the values.
The following example demonstrates oversampling by the consumer.
16 Oversampling Example
| 
with
  Ada.Text_IO; use Ada.Text_IO; 
procedure
  Oversampling is 
   protected Buffer is 
      procedure Add(Item : Integer); 
      function Get return Integer; 
   private 
      Value : Integer := Integer'First; 
   end Buffer; 
   protected body Buffer is 
      procedure Add(Item : Integer) is 
      begin 
         Value := Item; 
      end Add; 
      function Get return Integer is 
      begin 
         return Value; 
      end Get; 
   end Buffer; 
   task Producer is 
      entry Stop; 
   end Producer; 
   task Consumer is 
      entry Stop; 
   end Consumer; 
   task body Producer is 
      Num : Natural := 0; 
   begin 
      Put_Line("Starting
  Producer"); 
      loop 
         select 
            accept Stop; 
            exit; 
         else 
            Buffer.Add(Num); 
            Num := Num + 1; 
            delay 0.005; 
         end select; 
      end loop; 
   end Producer; 
   task body Consumer is 
      Num : Natural; 
   begin 
      Put_Line("Starting
  Consumer"); 
      loop 
         select 
            accept Stop; 
            exit; 
         else 
            Num := Buffer.Get; 
            Put_Line("Consumed:"
  & Num'Image); 
            Delay 0.001; 
         end select; 
      end loop; 
   end consumer; 
begin 
   delay 0.1; 
   Producer.Stop; 
   Consumer.Stop; 
end
  Oversampling; | 
17 Oversampling Output
| 
Starting
  Producer 
Starting
  Consumer 
Consumed: 0 
Consumed: 0 
Consumed: 0 
Consumed: 0 
Consumed: 1 
Consumed: 1 
Consumed: 1 
Consumed: 1 
Consumed: 2 
Consumed: 2 
Consumed: 2 
Consumed: 2 
Consumed: 2 
Consumed: 2 
Consumed: 3 
Consumed: 3 
Consumed: 4 
Consumed: 4 
Consumed: 4 
Consumed: 4 
Consumed: 5 
Consumed: 5 
Consumed: 5 
Consumed: 5 
Consumed: 5 
Consumed: 6 
Consumed: 6 
Consumed: 6 
Consumed: 6 
Consumed: 7 
Consumed: 7 
Consumed: 7 
Consumed: 7 
Consumed: 8 
Consumed: 8 
Consumed: 8 
Consumed: 9 
Consumed: 9 
Consumed: 9 
Consumed: 9 
Consumed: 10 
Consumed: 10 
Consumed: 10 
Consumed: 10 
Consumed: 11 
Consumed: 11 
Consumed: 11 
Consumed: 11 
Consumed: 12 
Consumed: 12 
Consumed: 12 
Consumed: 12 
Consumed: 13 
Consumed: 13 
Consumed: 13 
Consumed: 13 
Consumed: 14 
Consumed: 14 
Consumed: 14 
Consumed: 14 
Consumed: 15 
Consumed: 15 
Consumed: 15 
Consumed: 16 
Consumed: 16 
Consumed: 16 
Consumed: 16 
Consumed: 17 
Consumed: 17 
Consumed: 17 | 
 
I do enjoy reading your post. Indeed, your posts have widen my knowledge.
ReplyDeleteRegarding protected buffer implementation, here is another variation of it.
generic
type Element_Type is private;
package Protected_Buffer is
type Internal_Buffer is array (Positive Range <>) of Element_Type;
protected type Buffer_T (Capacity : Natural := 10) is
entry Add (Item : in Element_Type);
entry Get (Item : out Element_Type);
private
Buf : Internal_Buffer (1 .. Capacity);
Add_Idx : Positive := Positive'First;
Get_Idx : Positive := Positive'First;
Count : Natural := 0;
end Buffer_T;
end Protected_Buffer;
package body Protected_Buffer is
protected body Buffer_T is
entry Add (Item : in Element_Type) when Count < Capacity is
begin
Buf (Add_Idx) := Item;
Add_Idx := Add_Idx mod Capacity + 1;
Count := Count + 1;
entry Get (Item : out Element_Type) when Count > 0 is
begin
Item := Buf (Get_Idx);
Get_Idx := Get_Idx mod Capacity + 1;
Count := Count - 1;
end Get;
end Buffer_T;
end Protected_Buffer;
Sorry for missing end Add; and not telling who I am.
DeleteAnh Vo