How does async/concurrency work in ADA compared to other languages?

Hello again!
Something I’ve been really interested in is concurrency in different languages. A lot of my experience has been in Rust with Tokio and async/await, but I have a decent amount of experience with go as well.

Additionally, Zig recently came out with their new Async feature which is quite unique: Zig's New Async I/O | Loris Cro's Blog

Which leads me to wonder about Ada. From what I can tell, Ada has tasks. Do they work like Tokio or C# tasks? Is structured concurrency a thing?

Like, to come up with a contrived example, how would having a server that listening to oncoming connections work? In Tokio, I could see spawning a task for each oncoming connection, and then making a channel for each task to communicate with it.

Are green threads/virtual threads a thing/possible in ADA? Are stackless coroutines?

1 Like

I should also probably share how coroutines work in C++ Coroutines (C++20) - cppreference.com

I’m not super huge into the async world, so I can’t really give details on that, but I will say that Ada has a lot of options for how you want to manage your concurrency.

If you want to block on a task entry, you simply call it and the calling program will block until that entry is ready to run:

Some_Task.Action;

If you still want it to block, but not indefinitely, you can do a timed entry call:

select
   Some_Task.Action;
   -- You can do additional things here after successful action
or
   delay 5.0; -- Wait up to 5sec or give up
   -- You can do other things here if it times out
end select;
-- Stuff here happens regardless of time out or not

If you don’t want to block at all but want to try and then leave, you can use a conditional entry call:

select
   Some_Task.Action;
   -- You can do additional things here after successful action
else
   -- You can do other things here if the action wasn't ready
end select;
-- Stuff here happens regardless of success or not

These also work with entry calls to protected objects in addition to task entries. I think the conditional entry call is probably the means one would implement an async call, but again, I’m not really into the async world, so grain of salt.

Ada also has “rendezvous” where two different tasks meet, some shared code is run, then the tasks each go about their business. It’s a pretty neat model that can be leveraged for some interesting solutions to tasking/threading problems.

task body Some_Task is
begin
   loop
      select
         accept Action(Input_Data : in Input_Type; Output_Data : out Output_Type) do
            -- Code in here is "task safe" relative to this task vs the calling task
            -- since both the calling task and this task meet here and wait until 
            -- the "end" is reached before continuing.
         end Action;
         -- Other statements not guarded by the rendezvous
      or
         accept Some_Other_Action;
         -- Other statements not guarded by the rendezvous
      end select;
   end loop;
end Some_Task;

Sorry I don’t have any async specific stuff, but you seem knowledgeable on it, so I was hoping it can it least give you some things to think about.

There’s also a lot of others features like entry barriers that let you set conditions to actually do the task’s entry and lots of other things.

EDIT: I don’t know how into books you are, but I really liked this one:

It’s based on the 2012 version of Ada, but it’s still really relevant.

6 Likes

Differently to other languages Ada has integrated concurrency primitives. For decades “newer” languages simply ignored concurrency. Now they try to add it, but still at a very low level abstraction. Ada’s concurrency is high level. It has protected objects and tasks. The model is safer and more effective. In particular you can implement all low level primitives like mutexes, events (level and pulse), semaphores on top of Ada’s ones.

Ada tasks allow many implementations. The most frequent implementation is tasks backed by system threads.

Yes, as one of the tasking models you would not like to use because doing blocking I/O from such a tread would block all other threads. It sometimes deployed on bare-bone embedded systems.

Ada does not have co-routines. It is worth to note that stackless co-routines are totally useless for practical purposes.

Yes, you can trivially do that in Ada: accept connection, create or reuse a task, pass the socket to it. Task is just an object in Ada.

But in general it does not scale because having a task per connection is too heavy for small targets or massive number of connections. So a better solution is to use a single task asynchronously managing several sockets and handling connections. Additionally one can have a pool of tasks to handle connection requests. You can find an implementation of this approach here.

3 Likes

The best resource might be the AdaCore tutorial on tasking

Tasking is a really deep subject, I’ve used it only a little bit, but I will do my best to highlight the big points.

High level

Ada uses the task keyword for concurrency. It defines a declaration area and a block of code to run. IME using GNAT, that means “task = thread”.

A task runs concurrently in the scope it’s declared in, and that scope won’t exit until it finishes. This is similar to “jthreads” from C++. If you want a task to live longer than that, you need to dynamically allocate it.

task is defined by the language and obeys lexical scoping rules. Anything visible at the scope of a task can be used by that task. You can also pass data into a task by declaring it with a discriminant.

Ada provides a monitor like element, called a “protected object.” A function in a protected object can only read, but a procedure can read-or-write that protected object’s data.

Sometimes you want a guard before operating on some data, or you want to provide a way for multiple threads to coordinate or pass data. This is what “entries” do, they look like procedures or functions, but like procedures, they have no return type.

There isn’t a fire-and-forget async/await system like in Rust or C#, though I think you probably might be able to build one using the language primitives.

Simple task example

Trendy_Test runs a bunch of test executors in parallel. The task is actually declared inside the Run subprogram:

function Run return Test_Report_Vectors.Vector is
    Results   : Test_Results;       --  thread-safe type to combine results
    Tests     : Test_Queues.Queue;  --   thread-safe queue of parallel tests

    task type Parallel_Test_Task is end Parallel_Test_Task;
    task body Parallel_Test_Task is
        Next_Test : Test_Procedure;
    begin
        loop
            select
                Tests.Dequeue (Next_Test);
            or
                delay 0.1;
                exit;
            end select;

            Run_Test (Next_Test, Results);
        end loop;
    end Parallel_Test_Task;

Starting all parallel threads for running parallelizable tests within Run looks like this:

declare
    Num_CPUs : constant System.Multiprocessors.CPU := System.Multiprocessors.Number_Of_CPUs;
    Runners : array (1 .. Num_CPUs) of Parallel_Test_Task;
    pragma Unreferenced (Runners);
begin
    -- Tests have started when declared above, wait for runners to complete.
    null;
end;

In this example Tests and Results are both protected objects which act to prevent the tasks from accessing shared state at the same time.

Test_Results is super straightforward, and requires no manual locking (if you’re familiar with using std::mutex or RAII read or write lock objects). By making Add a procedure it can read/write the Results vector. If I try to write in Get_Results which is a function which only has read access, it’s a compile-time error.

protected type Test_Results is
    procedure Add(T : Test_Report);
    function Get_Results return Test_Report_Vectors.Vector;
private
    Results : Test_Report_Vectors.Vector;
end Test_Results;

Entries

Entries are like procedures, but can have guards forcing callers to wait and have associated queues of who is waiting for them. The Jorvik and Ravenscar profiles are subsets of Ada tasking that describe agreed upon limits of tasking features like max queue length.

An entry defined on a task or protected object looks just like a procedure call. However, it doesn’t proceed until the guard is true on a protected object, or the task calls accept on that entry, which is what @jere shows above.

Overall

My experience with using tasks/protected objects on multiple projects is that they seem to work well. I’ve run into some issues because it’s baked into the language about wanting more introspection into what’s actually going on. Concurrency in general is also hard and I’m also not the sharpest crayon in the box, so maybe my code has all sorts of concurrency bugs I don’t know about /shrug.

3 Likes

I remember gnat having a green threads lib you could select in 1995, not any more though.

2 Likes

Thanks for all the answers!
I heard that Ada 2022 has a new “parallel” keyword that hasn’t been implemented yet.

How does this interact with all of the currently existing task stuff

It will not. Parallel loops are symmetric fine-grained parallelism, tasks and protected objects are for asymmetric bulky one.

In my understanding “parallel” would naturally have a quite limited use, but may potentially provide a huge performance gain when backed by FPGA hardware or similar.

1 Like

FWIW, one difference between Ada and one other language like Java is that Ada follows the model of avoidance-synchronisation whereas Java that of condition-synchronisation. Effectively, these are different strategies applied when trying to decide whether the right conditions exist for a task/thread to execute whatever logic it’s meant to execute.

For example, suppose a very simple buffer which in Ada could be implemented as a protected object (PO) (other ways exist). Normally, something like the following would be declared:

protected type Buffer is
   entry Has_New_Data;
   procedure Set_New_Data (D : Interfaces.Unsigned_64);
private
   New_Data : Boolean := False;
   Data : Interfaces.Unsigned_64;
end Buffer;

protected body Buffer is
   entry Has_New_Data when New_Data is
   begin
      New_Data := False;
   end Has_New_Data;

   procedure Set_New_Data (D : Interfaces.Unsigned_64) is
   begin
      Data := D;
      New_Data := True;
   end Set_New_Data;
end Buffer;

What happens here is that when the when New_Data barriers evaluates to True (and the monitor has been acquired) the runtime guarantees that the condition is unequivocally true so one of the blocked tasks is allowed to continue and execute the PO code (which ones depends on the specific annexes that are active in the runtime). The big advantage with that is that the PO code doesn’t need to check itself whether the condition is actually true indeed, so the block inside the entry only contains logic related to the happy-path scenario. This, imo, is great cause it allows a reader to easily follow through the normal flow without having to navigate through a number of lines which are totally unrelated to what should happen when some condition is true.

Java, on the other hand, takes a different approach, that of condition-synchronisation, whereby the code itself needs to check whether a condition is true in order to determine if it can proceed or not. What’s worse is that the JVM has something called “spurious-wakeups” where the runtime can basically make a blocked thread runnable for totally unrelated reasons. So, in addition to the main condition-checking, the code needs to also manually check whether the data related to the condition are correct indeed, every time a thread is made runnable.
So, the equivalent of the above example would be something like this in Java (showing only the first part):

private Lock lock = new ReentrantLock();
private Condition newDataCond = lock.newCondition();
private boolean newData = false;

public void hasNewData() {
    lock.lock();
    try {
    // Check conditions are right in order to proceed, here
    while (!newData) {
        try {
            newDataCond.await();
        } catch (InterruptedException e) {
            // maybe also log
            throw new RuntimeException(e);
        }
    }
    // Happy-path logic, here
    } finally {
        lock.unlock();
    }
}

where the while loop has been setup to guard against the spurious-wakeups mentioned above.

The price to pay for Ada’s simplicity, is that entry arguments aren’t allowed to be used in the barrier condition, which in some cases can be limiting. But this issue can be solved (perhaps more elegantly) using the requeue feature.

3 Likes

Hi again!

Sorry to necro this thread, but I’m curious, how would you translate this code into ADA?

(Taken from here: A Tour of Go)

package main

import (
“fmt”
)

func fibonacci(n int, c chan int) {
x, y := 0, 1
for i := 0; i < n; i++ {
c ← x
x, y = y, x+y
}
close(c)
}

func main() {
c := make(chan int, 10)
go fibonacci(cap(c), c)
for i := range c {
fmt.Println(i)
}
}

look at parallel and gcc-16.

I can’t seem to find a lot of information on that.

I found this reddit post (https://www.reddit.com/r/ada/comments/1otyt0s/ada_2022_parallel_implementation_beta_for_fsf/) and this page (Parallel constructs) from what looks like the standard, but that’s about it

Edit: I also found this library which seems like the main implementation for the parallel keyword? (parasail/lwt at main · parasail-lang/parasail · GitHub)

Here is an implementation from Simple Components that uses the square splitting algorithm:

   function Fibonacci (N : Natural) return Unbounded_Unsigned is
      --
      -- F(2k)   = F(k)(2F(k+1)) - F(k))
      -- F(2k+1) = F(k+1)*F(k+1) + F(k)*F(k)
      --
      A, B, D, E : Unbounded_Unsigned;
      M          : Natural := N;
      Bit        : Natural := 1;
   begin
      while M > 0 loop
         Bit := Bit * 2;
         M   := M / 2;
      end loop;
      Set (B, 1);
      while Bit > 1 loop
         Square (A, E);    -- A = Fm, B = Fm+1
         Square (B, D);
         Add (E, D);
         Set (D, B);
         Mul_By_Power_Of_Two (D, 1);
         Sub (D, A);
         Mul (D, A);
         Swap (A, D);
         Swap (B, E);
         M := M * 2;       -- A = Fm, B = Fm+1
         Bit := Bit / 2;
         if (N / Bit) mod 2 /= 0 then
            Set (D, B);
            Add (D, A);
            Swap (A, B);
            Swap (D, B);
            M := M + 1;    -- A = Fm, B = Fm+1
        end if;
      end loop;
      return A;
   end Fibonacci;

Note unbounded integers, Fibonacci numbers grow very fast. If you need just bounded values simply tabulate them. See this page. In practice Fibonacci numbers are computed by some modulo in Montgomery domain..

See here for info on gcc 16.

Right so the right person to ask would be @sttaft