Concurrency and Real-Time¶
Understanding the various options¶
Concurrent and real-time programming are standard parts of the Ada language. As such, they have the same semantics, whether executing on a native target with an OS such as Linux, on a real-time operating system (RTOS) such as VxWorks, or on a bare metal target with no OS or RTOS at all.
For resource-constrained systems, two subsets of the Ada concurrency facilities are defined, known as the Ravenscar and Jorvik profiles. Though restricted, these subsets have highly desirable properties, including: efficiency, predictability, analyzability, absence of deadlock, bounded blocking, absence of priority inversion, a real-time scheduler, and a small memory footprint. On bare metal systems, this means in effect that Ada comes with its own real-time kernel.
For further information
A New Ravenscar-Based Profile by P. Rogers, J. Ruiz, T. Gingold and P. Bernardi, in Reliable Software Technologies — Ada Europe 2017, Springer-Verlag Lecture Notes in Computer Science, Number 10300.
Enhanced portability and expressive power are the primary advantages of using the standard concurrency facilities, potentially resulting in considerable cost savings. For example, with little effort, it is possible to migrate from Windows to Linux to a bare machine without requiring any changes to the code. Thread management and synchronization is all done by the implementation, transparently. However, in some situations, it’s critical to be able to access directly the services provided by the platform. In this case, it’s always possible to make direct system calls from Ada code. Several targets of the GNAT compiler provide this sort of API by default, for example win32ada for Windows and Florist for POSIX systems.
On native and RTOS-based platforms GNAT typically provides the full concurrency facilities. In contrast, on bare metal platforms GNAT typically provides the two standard subsets: Ravenscar and Jorvik.
Ada offers a high level construct called a task which is an independent thread of execution. In GNAT, tasks are either mapped to the underlying OS threads, or use a dedicated kernel when not available.
The following example will display the 26 letters of the alphabet twice, using two concurrent tasks. Since there is no synchronization between the two threads of control in any of the examples, the output may be interspersed.
Any number of Ada tasks may be declared in any declarative region. A task declaration is very similar to a procedure or package declaration. They all start automatically when control reaches the begin. A block will not exit until all sequences of statements defined within that scope, including those in tasks, have been completed.
A task type is a generalization of a task object; each object of a task type has the same behavior. A declared object of a task type is started within the scope where it is declared, and control does not leave that scope until the task has terminated.
Task types can be parameterized; the parameter serves the same purpose as an
argument to a constructor in Java. The following example creates 10 tasks, each
of which displays a subset of the alphabet contained between the parameter and
'Z' Character. As with the earlier example, since there is no
synchronization among the tasks, the output may be interspersed depending on
the underlying implementation of the task scheduling algorithm.
In Ada, a task may be dynamically allocated rather than declared statically. The task will then start as soon as it has been allocated, and terminates when its work is completed.
A rendezvous is a synchronization between two tasks, allowing them to exchange data and coordinate execution. Let's consider the following example:
Go entry declared in
After is the client interface to the
task. In the task body, the
accept statement causes the task to wait for
a call on the entry. This particular
simply causes the task to wait until
After.Go. So, even though the two tasks start simultaneously and execute
independently, they can coordinate via
Go. Then, they both continue
execution independently after the rendezvous.
accept pair can take/pass parameters, and the
accept statement can contain a sequence of statements; while these
statements are executed, the caller is blocked.
Let's look at a more ambitious example. The rendezvous below accepts parameters and executes some code:
In the above example, the
Put_Line is placed in the accept statement.
Here's a possible execution trace, assuming a uniprocessor:
At the begin of
Afteris started and the main procedure is suspended.
acceptstatement and is suspended, since there is no pending call on the
The main procedure is awakened and executes the
Put_Lineinvocation, displaying the string
The main procedure calls the
Afteris suspended on its
acceptstatement for this entry, the call succeeds.
The main procedure is suspended, and the task
Afteris awakened to execute the body of the
acceptstatement. The actual parameter
"Main"is passed to the
acceptstatement, and the
Put_Lineinvocation is executed. As a result, the string
"After: Main"is displayed.
acceptstatement is completed, both the
Aftertask and the main procedure are ready to run. Suppose that the
Mainprocedure is given the processor. It reaches its end, but the local task
Afterhas not yet terminated. The main procedure is suspended.
Aftertask continues, and terminates since it is at its end. The main procedure is resumed, and it too can terminate since its dependent task has terminated.
The above description is a conceptual model; in practice the implementation can perform various optimizations to avoid unnecessary context switches.
accept statement by itself can only wait for a single event (call)
at a time. The
select statement allows a task to listen for multiple
events simultaneously, and then to deal with the first event to occur. This
feature is illustrated by the task below, which maintains an integer value that
is modified by other tasks that call
When the task's statement flow reaches the select, it will wait for all four
events — three entries and a delay — in parallel. If the delay of
five seconds is exceeded, the task will execute the statements following the
delay statement (and in this case will exit the loop, in effect
terminating the task). The
accept bodies for the
Get entries will be otherwise executed as they're
called. These four sections of the select statement are mutually exclusive: at
each iteration of the loop, only one will be invoked. This is a critical point;
if the task had been written as a package, with procedures for the various
operations, then a race condition could occur where multiple tasks
simultaneously calling, say,
Increment, cause the value to only get
incremented once. In the tasking version, if multiple tasks simultaneously call
Increment then only one at a time will be accepted, and the value will
be incremented by each of the tasks when it is accepted.
More specifically, each entry has an associated queue of pending callers. If a
task calls one of the entries and Counter is not ready to accept the call
Counter is not suspended at the
select statement) then
the calling task is suspended, and placed in the queue of the entry that it is
calling. From the perspective of the
Counter task, at any iteration of
the loop there are several possibilities:
There is no call pending on any of the entries. In this case
Counteris suspended. It will be awakened by the first of two events: a call on one of its entries (which will then be immediately accepted), or the expiration of the five second delay (whose effect was noted above).
There is a call pending on exactly one of the entries. In this case control passes to the
selectbranch with an
acceptstatement for that entry.
There are calls pending on more than one entry. In this case one of the entries with pending callers is chosen, and then one of the callers is chosen to be de-queued. The choice of which caller to accept depends on the queuing policy, which can be specified via a
pragmadefined in the Real-Time Systems Annex of the Ada standard; the default is First-In First-Out.
Although the rendezvous may be used to implement mutually exclusive access to a shared data object, an alternative (and generally preferable) style is through a protected object, an efficiently implementable mechanism that makes the effect more explicit. A protected object has a public interface (its protected operations) for accessing and manipulating the object's components (its private part). Mutual exclusion is enforced through a conceptual lock on the object, and encapsulation ensures that the only external access to the components are through the protected operations.
Two kinds of operations can be performed on such objects: read-write operations by procedures or entries, and read-only operations by functions. The lock mechanism is implemented so that it's possible to perform concurrent read operations but not concurrent write or read/write operations.
Let's reimplement our earlier tasking example with a protected object called
Having two completely different ways to implement the same paradigm might seem complicated. However, in practice the actual problem to solve usually drives the choice between an active structure (a task) or a passive structure (a protected object).
A protected object can be accessed through prefix notation:
A protected object may look like a package syntactically, since it contains declarations that can be accessed externally using prefix notation. However, the declaration of a protected object is extremely restricted; for example, no public data is allowed, no types can be declared inside, etc. And besides the syntactic differences, there is a critical semantic distinction: a protected object has a conceptual lock that guarantees mutual exclusion; there is no such lock for a package.
Like tasks, it's possible to declare protected types that can be instantiated several times:
declare protected type Counter is -- as above end Counter; protected body Counter is -- as above end Counter; C1 : Counter; C2 : Counter; begin C1.Increment; C2.Decrement; .. . end;
Protected objects and types can declare a procedure-like operation known as an entry. An entry is somewhat similar to a procedure but includes a so-called barrier condition that must be true in order for the entry invocation to succeed. Calling a protected entry is thus a two step process: first, acquire the lock on the object, and then evaluate the barrier condition. If the condition is true then the caller will execute the entry body. If the condition is false, then the caller is placed in the queue for the entry, and relinquishes the lock. Barrier conditions (for entries with non-empty queues) are reevaluated upon completion of protected procedures and protected entries.
Here's an example illustrating protected entries: a protected type that models a binary semaphore / persistent signal.
Ada concurrency features provide much further generality than what's been presented here. For additional information please consult one of the works cited in the References section.
The Ravenscar profile is a subset of the Ada concurrency facilities that supports determinism, schedulability analysis, constrained memory utilization, and certification to the highest integrity levels. Four distinct application domains are intended:
hard real-time applications requiring predictability,
safety-critical systems requiring formal, stringent certification,
high-integrity applications requiring formal static analysis and verification,
embedded applications requiring both a small memory footprint and low execution overhead.
Tasking constructs that preclude analysis, either technically or economically,
are disallowed. You can use the
pragma Profile (Ravenscar) to indicate
that the Ravenscar restrictions must be observed in your program.
Some of the examples we've seen above will be rejected by the compiler when using the Ravenscar profile. For example:
This code violates the No_Task_Hierarchy restriction of the Ravenscar
profile. This is due to the declaration of
Tab in the
procedure. Ravenscar requires task declarations to be done at the library level.
Therefore, a simple solution is to create a separate package and reference it
in the main application:
Also, Ravenscar prohibits entries for tasks. For example, we're not allowed to write this declaration:
task type My_Task (First : Character) is entry Start; end My_Task;
You can use, however, one entry per protected object. As an example, the
declaration of the
Binary_Semaphore type that we've discussed before
compiles fine with Ravenscar:
protected type Binary_Semaphore is entry Wait; procedure Signal; private Signaled : Boolean := False; end Binary_Semaphore;
We could add more procedures and functions to the declaration of
Binary_Semaphore, but we wouldn't be able to add another entry when
Similar to the previous example with the task array declaration, objects of
Binary_Semaphore cannot be declared in the main application:
procedure Main is B : Binary_Semaphore; begin null; end Main;
This violates the No_Local_Protected_Objects restriction. Again, Ravenscar
expects this declaration to be done on a library level, so a solution to make
this code compile is to have this declaration in a separate package and
reference it in the
Ravenscar offers many additional restrictions. Covering those would exceed the scope of this chapter. You can find more examples using the Ravenscar profile on this blog post.