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.