Tasking
Tasks and protected objects allow the implementation of concurrency in Ada. The following sections explain these concepts in more details.
Tasks
A task can be thought as an application that runs concurrently with the main application. In other programming languages, a task can be called a thread, and tasking can be called multithreading.
Tasks may synchronize with the main application but may also process information completely independent from the main application. Here we show how this is accomplished.
Simple task
Tasks are declared using the keyword task
. The task implementation
is specified in a task body
block. For example:
Here, we're declaring and implementing the task T
. As soon as the main
application starts, task T
starts automatically — it's not necessary
to manually start this task. By running the application above, we can see
that both calls to Put_Line
are performed.
Note that:
The main application is itself a task (the main task).
In this example, the subprogram
Show_Simple_Task
is the main task of the application.
Task
T
is a subtask.Each subtask has a master task.
Therefore the main task is also the master task of task
T
.
The number of tasks is not limited to one: we could include a task
T2
in the example above.This task also starts automatically and runs concurrently with both task
T
and the main task. For example:with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Tasks is task T; task T2; task body T is begin Put_Line ("In task T"); end T; task body T2 is begin Put_Line ("In task T2"); end T2; begin Put_Line ("In main"); end Show_Simple_Tasks;
Simple synchronization
As we've just seen, as soon as the main task starts, its subtasks also start automatically. The main task continues its processing until it has nothing more to do. At that point, however, it will not terminate. Instead, the task waits until its subtasks have finished before it allows itself to terminate. In other words, this waiting process provides synchronization between the main task and its subtasks. After this synchronization, the main task will terminate. For example:
The same mechanism is used for other subprograms that contain subtasks: the subprogram's master task will wait for its subtasks to finish. So this mechanism is not limited to the main application and also applies to any subprogram called by the main application or its subprograms.
Synchronization also occurs if we move the task to a separate package. In
the example below, we declare a task T
in the package
Simple_Sync_Pkg
.
This is the corresponding package body:
Because the package is with
'ed by the main procedure, the task T
defined in the package is part of the main task. For example:
Again, as soon as the main task reaches its end, it synchronizes with task
T
from Simple_Sync_Pkg
before terminating.
Delay
We can introduce a delay by using the keyword delay
. This puts the
task to sleep for the length of time (in seconds) specified in the delay
statement. For example:
In this example, we're making the task T
wait one second after each
time it displays the "hello" message. In addition, the main task is waiting
1.5 seconds before displaying its own "hello" message
Synchronization: rendez-vous
The only type of synchronization we've seen so far is the one that happens
automatically at the end of the main task. You can also define custom
synchronization points using the keyword entry
. An entry can be
viewed as a special kind of subprogram, which is called by the master task
using a similar syntax, as we will see later.
In the task definition, you define which part of the task will accept the
entries by using the keyword accept
. A task proceeds until it
reaches an accept
statement and then waits for the master task to
synchronize with it. Specifically,
The subtask waits at that point (in the
accept
statement), ready to accept a call to the corresponding entry from the master task.The master task calls the task entry, in a manner similar to a procedure call, to synchronize with the subtask.
This synchronization between tasks is called rendez-vous. Let's see an example:
In this example, we declare an entry Start
for task T
. In the task
body, we implement this entry using accept Start
. When task T
reaches this point, it waits for the master task. This synchronization
occurs in the T.Start
statement. After the synchronization completes,
the main task and task T
again run concurrently until they synchronize
one final time when the main task finishes.
An entry may be used to perform more than a simple task synchronization: it
also may perform multiple statements during the time both tasks are
synchronized. We do this with a do ... end
block. For the previous
example, we would simply write accept Start do <statements>;
end;
. We use this kind of block in the next example.
Select loop
There's no limit to the number of times an entry can be accepted. We could
even create an infinite loop in the task and accept calls to the same entry
over and over again. An infinite loop, however, prevents the subtask from
finishing, so it blocks the master task when it reaches the end of its
processing. Therefore, a loop containing accept
statements in a task
body is normally used in conjunction with a select ... or terminate
statement. In simple terms, this statement allows the master task to
automatically terminate the subtask when the master task finishes. For
example:
In this example, the task body implements an infinite loop that accepts
calls to the Reset
and Increment
entry. We make the following
observations:
The
accept E do ... end
block is used to increment a counter.As long as task
T
is performing thedo ... end
block, the main task waits for the block to complete.
The main task is calling the
Increment
entry multiple times in the loop from1 .. 4
. It is also calling theReset
entry before and the loop.Because task
T
contains an infinite loop, it always accepts calls to theReset
andIncrement
entries.When the main task finishes, it checks the status of the
T
task. Even though taskT
could accept new calls to theReset
orIncrement
entries, the master task is allowed to terminate taskT
due to theor terminate
part of theselect
statement.
Cycling tasks
In a previous example, we saw how to delay a task a specified time by using
the delay
keyword. However, using delay statements in a loop is not
enough to guarantee regular intervals between those delay statements. For
example, we may have a call to a computationally intensive procedure
between executions of successive delay statements:
while True loop
delay 1.0;
-- ^ Wait 1.0 seconds
Computational_Intensive_App;
end loop;
In this case, we can't guarantee that exactly 10 seconds have elapsed after
10 calls to the delay statement because a time drift may be introduced by
the Computational_Intensive_App
procedure. In many cases, this time
drift is not relevant, so using the delay
keyword is good enough.
However, there are situations where a time drift isn't acceptable. In those
cases, we need to use the delay until
statement, which accepts a
precise time for the end of the delay, allowing us to define a regular
interval. This is useful, for example, in real-time applications.
We will soon see an example of how this time drift may be introduced and
how the delay until
statement circumvents the problem. But before we
do that, we look at a package containing a procedure allowing us to measure
the elapsed time (Show_Elapsed_Time
) and a dummy
Computational_Intensive_App
procedure which is simulated by using a
simple delay. This is the complete package:
Using this auxiliary package, we're now ready to write our time-drifting application:
We can see by running the application that we already have a time
difference of about four seconds after three iterations of the loop due to
the drift introduced by Computational_Intensive_App
. Using the
delay until
statement, however, we're able to avoid this time drift
and have a regular interval of exactly one second:
Now, as we can see by running the application, the delay until
statement ensures that the Computational_Intensive_App
doesn't disturb
the regular interval of one second between iterations.
Protected objects
When multiple tasks are accessing shared data, corruption of that data may occur. For example, data may be inconsistent if one task overwrites parts of the information that's being read by another task at the same time. In order to avoid these kinds of problems and ensure information is accessed in a coordinated way, we use protected objects.
Protected objects encapsulate data and provide access to that data by means of protected operations, which may be subprograms or protected entries. Using protected objects ensures that data is not corrupted by race conditions or other simultaneous access.
Important
Protected objects can be implemented using Ada tasks. In fact, this was the only possible way of implementing them in Ada 83 (the first version of the Ada language). However, the use of protected objects is much simpler than using similar mechanisms implemented using only tasks. Therefore, you should use protected objects when your main goal is only to protect data.
Simple object
You declare a protected object with the protected
keyword. The
syntax is similar to that used for packages: you can declare operations
(e.g., procedures and functions) in the public part and data in the private
part. The corresponding implementation of the operations is included in the
protected body
of the object. For example:
In this example, we define two operations for Obj
: Set
and
Get
. The implementation of these operations is in the Obj
body. The
syntax used for writing these operations is the same as that for normal
procedures and functions. The implementation of protected objects is
straightforward — we simply access and update Local
in these
subprograms. To call these operations in the main application, we use
prefixed notation, e.g., Obj.Get
.
Entries
In addition to protected procedures and functions, you can also define
protected entry points. Do this using the entry
keyword. Protected
entry points allow you to define barriers using the when
keyword. Barriers are conditions that must be fulfilled before the entry
can start performing its actual processing — we speak of releasing the
barrier when the condition is fulfilled.
The previous example used procedures and functions to define operations on
the protected objects. However, doing so permits reading protected
information (via Obj.Get
) before it's set (via Obj.Set
). To allow
that to be a defined operation, we specified a default value (0). Instead,
by rewriting Obj.Get
using an entry instead of a function, we
implement a barrier, ensuring no task can read the information before it's
been set.
The following example implements the barrier for the Obj.Get
operation. It also contains two concurrent subprograms (main task and task
T
) that try to access the protected object.
As we see by running it, the main application waits until the protected
object is set (by the call to Obj.Set
in task T
) before it reads
the information (via Obj.Get
). Because a 4-second delay has been added
in task T
, the main application is also delayed by 4 seconds. Only
after this delay does task T
set the object and release the barrier in
Obj.Get
so that the main application can then resume processing (after
the information is retrieved from the protected object).
Task and protected types
In the previous examples, we defined single tasks and protected objects. We can, however, generalize tasks and protected objects using type definitions. This allows us, for example, to create multiple tasks based on just a single task type.
Task types
A task type is a generalization of a task. The declaration is similar to
simple tasks: you replace task
with task type
. The
difference between simple tasks and task types is that task types don't
create actual tasks that automatically start. Instead, a task declaration
is needed. This is exactly the way normal variables and types work:
objects are only created by variable definitions, not type definitions.
To illustrate this, we repeat our first example:
We now rewrite it by replacing task T
with task type TT
. We
declare a task (A_Task
) based on the task type TT
after its
definition:
We can extend this example and create an array of tasks. Since we're using
the same syntax as for variable declarations, we use a similar syntax for
task types: array (<>) of Task_Type
. Also, we can pass information
to the individual tasks by defining a Start
entry. Here's the updated
example:
In this example, we're declaring five tasks in the array My_Tasks
. We
pass the array index to the individual tasks in the entry point
(Start
). After the synchronization between the individual subtasks and
the main task, each subtask calls Put_Line
concurrently.
Protected types
A protected type is a generalization of a protected object. The
declaration is similar to that for protected objects: you replace
protected
with protected type
. Like task types,
protected types require an object declaration to create actual
objects. Again, this is similar to variable declarations and allows
for creating arrays (or other composite objects) of protected objects.
We can reuse a previous example and rewrite it to use a protected type:
In this example, instead of directly defining the protected object
Obj
, we first define a protected type Obj_Type
and then
declare Obj
as an object of that protected type. Note that the
main application hasn't changed: we still use Obj.Set
and
Obj.Get
to access the protected object, just like in the original
example.