Safe Concurrency with Ada — A Practical Guide to Tasks and Protected Objects

· · Ada, Concurrency, Tasking, Protected Objects, Rendezvous, Real Time, Parallel Programming, Programming Language, High Reliability

1. Introduction — Concurrency Built Into the Language

Concurrency is an unavoidable theme in modern software development. Yet in most languages, concurrency is a “bolt-on” — dependent on libraries or OS facilities, demanding deep knowledge and careful design to get right.

Ada has a unique answer to this problem. Concurrency is part of the language specification itself.

Ada's concurrency model:
- Tasks — independently executing concurrent units
- Rendezvous — synchronous communication between tasks
- Protected objects — language-managed mutual exclusion
- Real-time priorities — Annex D real-time facilities

Tasking and rendezvous have been part of Ada since Ada 83 (1983); protected objects and Annex D real-time facilities were added in Ada 95, with further evolution through Ada 2005 and 2012. Rather than low-level synchronization primitives like mutexes and semaphores, Ada’s defining advantage is that design intent can be expressed directly in code.

This article walks through Ada’s concurrency model step by step with eight practical, compilable, and executable code examples. Each can be tried out on your own machine.

The code fragments in this article are published as an organized reference collection on GitHub.

ada-task-concurrency — komurasoft-blog-samples (GitHub)

2. A Quick Recap of Concurrency Hazards

Before diving into Ada, let’s briefly review why “safe” concurrency matters.

Typical concurrency bugs include:

  • Data races: Multiple threads access the same memory location simultaneously, at least one a write. The result is undefined.
  • Deadlock: Tasks wait on each other indefinitely, and none can proceed.
  • Priority inversion: A high-priority task waits for a resource held by a low-priority task, while a medium-priority task preempts the low-priority one.
  • Starvation: A task can never acquire a resource it needs.

Ada’s concurrency model provides language-level defenses against these problems.

Data races       → Protected objects guarantee exclusive access
Deadlock         → The rendezvous model provides structured synchronization
Priority inversion → Priority Ceiling Protocol available as a language feature
Starvation       → Entry barriers and queuing policies provide control

3. Task Basics — Independent Units of Execution

The fundamental unit of concurrency in Ada is the task. A task resembles a thread, but the Ada runtime manages scheduling — tasks may or may not map one-to-one onto OS threads.

task Greeter is
   entry Start;
end Greeter;

task body Greeter is
begin
   accept Start;
   Put_Line ("Hello from a task!");
end Greeter;

This code (01_hello_task.ada) illustrates several key points.

Tasks begin executing automatically upon declaration. The Greeter task starts when its enclosing procedure reaches begin, and waits at accept Start; for a rendezvous request from the caller.

An entry is the interface a task exposes to the outside world. When the caller invokes Greeter.Start;, it synchronizes with the task’s accept Start;. This is called a rendezvous.

Task completion is waited for automatically. When the main procedure ends, any still-running tasks are implicitly awaited. Contrast this with the crashes caused by forgetting std::thread::join in C++.

4. Rendezvous — Synchronous Communication with Data Transfer

A rendezvous is not just synchronization; it also supports bidirectional data transfer.

task Worker is
   entry Compute (X, Y : Integer; Result : out Integer);
end Worker;

task body Worker is
   A, B   : Integer;
   Output : Integer;
begin
   accept Compute (X, Y : Integer; Result : out Integer) do
      A := X;
      B := Y;
      Output := A * A + B * B;
      Result := Output;
   end Compute;
end Worker;

The caller uses it as follows (02_rendezvous_intro.ada).

Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));

A key design point here is the explicit parameter modes:

  • in mode: passes a value from caller to task
  • out mode: returns a result from task to caller
  • in out mode: bidirectional

The do ... end block inside accept is the critical section. During this block, the caller is blocked and the task will not accept other entries. Once processing completes, both resume.

In summary:

Feature Description
Synchronous Both caller and task wait until both reach the rendezvous point
Data transfer in / out / in out parameters carry values in both directions
Mutual exclusion While an accept body executes, no other entries of that task are accepted
Structured Which entries are accepted when is explicitly written in the task body

5. Selective Accept — Serving Multiple Requests

A real server task must handle multiple kinds of requests. Ada’s select statement achieves this at the language level.

task Server is
   entry Deposit  (Amount : Integer);
   entry Withdraw (Amount : Integer; Success : out Boolean);
   entry Balance  (Value : out Integer);
end Server;

task body Server is
   Current : Integer := 0;
begin
   loop
      select
         accept Deposit (Amount : Integer) do
            Current := Current + Amount;
         end Deposit;
      or
         accept Withdraw (Amount : Integer; Success : out Boolean) do
            if Current >= Amount then
               Current := Current - Amount;
               Success := True;
            else
               Success := False;
            end if;
         end Withdraw;
      or
         accept Balance (Value : out Integer) do
            Value := Current;
         end Balance;
      or
         terminate;
      end select;
   end loop;
end Server;

In the select statement of this code (03_selective_accept.ada), one of the entries with a pending call is selected. If no entry has been called, the task waits until one is.

The or terminate; branch is special — it allows the task to exit cleanly when “the main procedure has finished and no further entry calls can be made to this task.” This is Ada’s unique solution to the “server task that waits forever” deadlock problem.

Selective accept also supports guard conditions:

select
   when Count > 0 =>
      accept Get_Item (Item : out Integer) do
         Item := Data (Head);
         Count := Count - 1;
      end Get_Item;
or
   when Count < Max =>
      accept Put_Item (Item : Integer) do
         Data (Tail) := Item;
         Count := Count + 1;
      end Put_Item;
end select;

Branches whose guard evaluates to false are excluded from selection at that moment. This allows declarative control like “block Get when the buffer is empty, block Put when it’s full.”

6. Producer-Consumer — Synchronizing via Rendezvous

Let’s look at the producer-consumer pattern using rendezvous.

task Consumer is
   entry Deliver (Item : Integer);
end Consumer;

task Producer;

task body Consumer is
   Sum : Integer := 0;
begin
   for I in 1 .. 5 loop
      accept Deliver (Item : Integer) do
         Sum := Sum + Item;
      end Deliver;
   end loop;
end Consumer;

task body Producer is
begin
   for I in 1 .. 5 loop
      Consumer.Deliver (I);
   end loop;
end Producer;

In this pattern (04_producer_consumer.ada), each Deliver call from the producer synchronizes with the consumer. If the producer is too fast, it waits for the consumer’s accept. If the consumer is too fast, it waits for the producer’s next call. This creates natural backpressure.

7. Protected Objects — Mutual Exclusion Without Locks

While tasks are “active agents of computation,” protected objects are the mechanism for “passive shared data.”

protected Counter is
   procedure Increment;
   function Value return Integer;
private
   Count : Integer := 0;
end Counter;

protected body Counter is
   procedure Increment is
   begin
      Count := Count + 1;
   end Increment;

   function Value return Integer is
   begin
      return Count;
   end Value;
end Counter;

The key rules for protected objects:

  • Functions are read-only. Multiple tasks can call functions concurrently.
  • Procedures are read-write. While a procedure executes, all other procedures and functions are blocked.
  • Entries have barriers. The caller queues until the barrier condition becomes true.

In this code (05_protected_counter.ada), three worker tasks each call Increment 1,000 times. The protected object guarantees mutual exclusion, so the final counter value is always exactly 3,000. No manual mutex lock/unlock required.

task type Worker (Id : Integer; Rounds : Integer);

task body Worker is
begin
   for I in 1 .. Rounds loop
      Counter.Increment;  -- protected object guarantees exclusion
   end loop;
end Worker;

W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);

What Happens Without Protected Objects

To appreciate what protected objects give us, let’s look at the unsafe alternative.

-- ⚠ Dangerous: directly manipulating a shared variable
Shared_Counter : Integer := 0;

task body Bad_Worker is
begin
   for I in 1 .. 10_000 loop
      Shared_Counter := Shared_Counter + 1;  -- data race!
   end loop;
end Bad_Worker;

Shared_Counter := Shared_Counter + 1 is three steps at the CPU level: read, add, write back. When multiple tasks execute this concurrently, one task’s addition can overwrite another’s before it’s written back, causing lost increments. Beyond lost updates, this is erroneous execution under Ada RM 9.10 — concurrent reads and writes of a non-synchronized shared variable can have arbitrary effects on the entire program, not just the final count. Even with two tasks each running 10,000 iterations, the final value is in no way guaranteed to be 20,000.

Protected objects prevent this problem at the syntax level. A simple Counter.Increment; call is all it takes — the compiler and runtime guarantee mutual exclusion.

8. Protected Entries and Barriers — Bounded Buffer

Adding entries to a protected object enables conditional synchronization. Let’s see the classic bounded buffer.

type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;

protected Buf is
   entry Put (Item : Integer);
   entry Get (Item : out Integer);
private
   Data    : Buffer_Array;
   Head    : Integer := 0;
   Tail    : Integer := 0;
   Count   : Integer := 0;
end Buf;

protected body Buf is
   entry Put (Item : Integer) when Count < Buffer_Size is
   begin
      Data (Tail) := Item;
      Tail := (Tail + 1) mod Buffer_Size;
      Count := Count + 1;
   end Put;

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

The when Count < Buffer_Size is a barrier. Barriers are evaluated on every entry call; if true, execution proceeds; if false, the calling task queues until the barrier becomes true again. When the buffer state changes (another task executes Put or Get), waiting tasks’ barriers are re-evaluated.

This pattern (06_bounded_buffer.ada) is where Ada’s protected objects truly shine. Compare it with the equivalent in C using pthreads:

// C + pthreads (for comparison)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // Ada's "when" equivalent
    pthread_cond_wait(&not_full, &mutex); // barrier wait
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_empty);         // notify waiting tasks
pthread_mutex_unlock(&mutex);

In Ada, all of this collapses into the single line when Count < Buffer_Size. The while-loop condition, signal emission, and lock-release timing errors — all these opportunities for bugs simply disappear.

9. Timed Entry Calls — Never Wait Forever

In real-time systems, waiting forever is not an option. Ada supports timeouts with the select ... or delay construct.

select
   Slow_Worker.Do_Work (Result);
   Put_Line ("Main: work completed");
or
   delay until Ada.Real_Time.Clock + Milliseconds (500);
   Put_Line ("Main: timeout after 500ms!");
end select;

In this code (07_timed_entry.ada), Slow_Worker is busy with a delay 2.0 and hasn’t reached accept yet — so the caller’s queued entry call times out after 500ms. (The timeout applies to the queue wait before the rendezvous is accepted; it does not interrupt the rendezvous body itself.) delay until specifies an absolute time, which is the fundamental technique for preventing cumulative drift in real-time programming.

Ada also supports conditional entry calls:

select
   Server.Process (Item);
else
   Put_Line ("Server is busy, will retry later");
end select;

The else clause executes immediately if a rendezvous cannot happen right away — no manual polling needed.

Don’t Forget the “After Timeout” Design

Timeouts are convenient, but what really matters is what happens after the timeout. Should the value be discarded? Retried? Reported as an error upstream? Ambiguity here turns into data loss or service outages in production. When you write a timeout, design the post-timeout responsibility in the same place.

Periodic Tasks and delay until

delay until is not just for timeouts — it also enables periodic execution. A simple delay 0.1 creates a period of “processing time + 0.1s,” which drifts as processing time varies. In contrast, delay until anchors to an absolute time, maintaining a stable period regardless of processing duration.

loop
   Next := Next + Period;
   Do_Work;
   delay until Next;
end loop;

This pattern is effective anywhere fixed-interval execution is needed — sensor polling, control loops, and more.

10. Task Priorities and Real-Time Scheduling

Ada’s real-time features are defined in Annex D (Real-Time Systems). When an Ada implementation supports Annex D, task priorities and scheduling policies can be specified.

task High_Task is
   pragma Priority (System.Default_Priority + 5);
end High_Task;

task Low_Task is
   pragma Priority (System.Default_Priority);
end Low_Task;

For more advanced configuration, scheduling policy and the Priority Ceiling Protocol can also be specified:

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

The Priority Ceiling Protocol prevents priority inversion. Each protected object is assigned a ceiling priority explicitly via pragma Priority (or the Priority aspect). A task calling the protected object must have an active priority at or below that ceiling, or Program_Error is raised. While the object is locked, execution runs at the ceiling priority, preventing preemption by medium-priority tasks.

protected Shared_Data is
   pragma Priority (15);  -- ceiling priority
   procedure Update (Val : Integer);
   function Read return Integer;
private
   Data : Integer := 0;
end Shared_Data;

These features are grounded in Rate Monotonic Scheduling (RMS) theory and have a proven track record in hard real-time systems such as flight controls and medical devices.

11. Design Principles for Practice

We’ve covered the basic constructs of tasks and protected objects. Let’s close with practical design principles to keep in mind when using Ada concurrency in real projects.

What Not to Do Inside Protected Objects

The cardinal rule: do only short state updates inside protected objects; do heavy work outside. Protected operations internally acquire exclusive access, so blocking inside one stalls every other task using the same protected object.

Specifically avoid:

  • delay or lengthy I/O
  • Complex calls into other protected objects
  • Heavy external library invocations

Note: delay and certain I/O inside a protected action are not just performance issues — they are potentially blocking operations and constitute a bounded error under the Ada standard. An implementation may raise Program_Error or deadlock, so these must be eliminated entirely, not merely minimized.

A good design follows this pattern: extract needed values from the protected object quickly → perform heavy computation or I/O outside → write only the result back into the protected object quickly.

Keep Barriers Simple

Entry barriers (entry ... when <condition>) are powerful but become unreadable when overcomplicated, making it hard to diagnose why a task isn’t being released.

Aim for barriers whose meaning is immediately obvious: when Count < Buffer_Size, when Used > 0. When multiple conditions are genuinely needed, consider representing state as an enumeration type so the barrier reads as when State = Running — self-documenting and easy to verify.

Task Exceptions and Shutdown

Have an explicit policy for when exceptions occur inside a task. At minimum, catch exceptions at the outermost level of the task body and record what happened.

Beyond that, answer the harder questions: can the system continue if this task stops? Should it be restarted? How do we notify other tasks? How do we return shared state to a consistent condition? Ada provides exceptions as a language feature, but safety after an exception is your design responsibility.

Mini Design Checklist

Aspect Ask yourself
Shared state Is it encapsulated in a protected object? Can external code touch it directly?
Protected operations Are they short? Do they block internally?
Entries Is the barrier simple? Could a task potentially wait forever? Is there a timeout policy?
Task lifecycle Is the termination condition clear? Is there a plan for exceptions?
Periodic work Did you consider delay until instead of delay?

In concurrent programming, “probably fine” is the most dangerous phrase. Making shared state, wait conditions, termination conditions, and exception policies explicit in code is the first step toward safe concurrency.

12. Summary — A Language That Made Concurrency “Grammar”

What sets Ada’s concurrency model apart is that safe concurrency is not a set of “bolt-on best practices” but built into the grammar itself.

What you want Ada syntax
Independent execution unit task / task body
Synchronous communication entry / accept
Serving multiple requests select / or / else
Mutual exclusion protected / function / procedure
Conditional synchronization entry ... when <barrier>
Timeouts or delay until <time>
Priority control pragma Priority

These constructs are subject to compiler verification. For example, attempting to modify the protected object’s own private components inside a protected function results in a compile-time error. When a protected operation completes, waiting entry barriers are automatically re-evaluated — no manual signal or wakeup needed.

"Just as the type system guarantees memory safety,
 Ada's concurrency constructs guarantee synchronization safety."

The eight code examples in this article form a practical introduction to tasks, rendezvous, protected objects, and real-time features. Try them out, and then explore these advanced topics:

  • The Ravenscar profile: A tasking restriction profile for high-integrity real-time systems. The restricted tasking model enables static deadlock analysis.
  • Ada 2022 parallel blocks: Data-parallel processing with the parallel ... do construct.
  • SPARK integration: Formally verify concurrent program behavior (supported by GNATprove under the Ravenscar profile).

Still, “Using Ada” Doesn’t Make You Safe

One final note. Ada’s concurrency constructs are powerful, but using Ada does not automatically make your program safe. You can still put shared data outside protected objects, block inside protected operations, or create complex call chains between multiple protected objects — all in Ada.

The language features are designed so that “writing dangerous code requires explicit effort,” but they do not substitute for correct design. Ada’s real value is that it brings the safety conversation close to the code — questions like “is this state protected?”, “when does this task end?”, “what condition does this entry wait on?” can be answered by reading the syntax itself.

Ada’s philosophy of expressing design through types is consistent in concurrency, too. Safe concurrency begins not with careful lock handling, but with refusing to let shared state exist in the open.

To the conventional wisdom that “concurrency is hard,” Ada answers: “choose the right syntax, and the compiler guarantees safety.” Its design philosophy resonates with modern languages like Rust and Pony — but Ada has held it in its language specification for over 40 years.

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog