Learning Ada 6: tasking

in #programming6 years ago

Ada has tools for concurrency as language primitives, which makes it easy to do things that would be a lot harder in C or C++. An Ada task looks at first like a sort of package, with a public part which defines entries (they seems procedure, but more exactly they are rendezvous points), and a body where these rendezvous points are “implementend” — but this isn't the right term to use, also because the same rendezvous point can appear more than once. Imagine them more like points in the flow where the task is waiting to accept a command.

procedure Steps is

   task Step_By_Step is
      entry Step_One;
      entry Step_Two;
      entry Step_Three;
   end Step_By_Step;
   
   task body Step_By_Step is
      accept Step_One do
         Put_Line ("1");
      end Step_One;
      accept Step_Two do
         Put_Line ("2");
      end Step_One;
      accept Step_Three do
         Put_Line ("3");
      end Step_One;
   end Step_By_Step;
   
begin
   -- ...
end;

The task starts as soon as the begin of the procedure is entered. And it waits on the first accept. It waits to “meet” whoever will “call for” the rendezvous point named Step_One.

-- ...
begin
   Step_By_Step.Step_One;
end;

This is how we say we want to “meet” to the rendezvous point Step_One of the task Step_By_Step. If the block of the procedure Steps is just that, what you will see as output on the console is 1:

1

But the program won't exit when the end is reached, because in fact the task is still running and waiting on the second rendezvous point. Since we don't go to the meeting, the task will wait forever. We need to break it (kill the process, e.g., by hitting Ctrl-C).

Let's go to the second meeting too:

-- ...
begin
   Step_By_Step.Step_One;
   Step_By_Step.Step_Two;
end;

The output will be:

1
2

And again, the “main” reaches the end, but the task is alive waiting on the third point. Instead, this time we go to the first meeting again:

-- ...
begin
   Step_By_Step.Step_One;
   Step_By_Step.Step_Two;
   Step_By_Step.Step_One;
end;

What does it happen? Nothing! The output is still the same, i.e.,

1
2

But this time, our “main” (the procedure Steps) doesn't hit the end. To prove this, let's add few Put_Lines.

begin
   Put_Line ("Steps");
   Step_By_Step.Step_One;
   Step_By_Step.Step_Two;
   Put_Line ("Redo step 1?");
   Step_By_Step.Step_One;
   Put_Line ("Just before the end");
end;

The output will be

Steps
1
2
Redo step 1?

And the program hangs there. In fact the task is waiting at the third accept, the rendezvous point called Step_Three, but we went to step one again, where there's nobody — but we wait for the task to come there!

This program is, of course, wrong and pointless. Everybody's waiting, nobody's going anywhere to meet the other. Everything's still and boring. But you get the use, which is quite simple. Not so powerful nor useful, indeed, if this is all we can do. But of course it isn't!

Take a look at the complete source, steps.adb.

Waiting on more than one rendezvous point

Sequential rendezvous points are waited for sequentially. This means that, in order to make the task proceed, we must “meet” at the correct points in the correct order. If we don't do that, and nothing else can happen, our program will wait forever — until the end of the universe, a blackout, or a Ctrl-C, whatever comes first.

How can we wait for more than a rendezvous point at the same time?

We do it with selectorend select.

      select
         accept Step_One do
            Put_Line ("1");
         end Step_One;
      or
         accept Step_Two do
            Put_Line ("2");
         end Step_Two;
      or
         accept Step_Three do
            Put_Line ("3");
         end Step_Three;
      end select;

Now our task is waiting on one of Step_One, Step_Two, or Step_Three. The first accepted will be executed, and then we are past the end select. If we hit the bottom of our task, the task will end. This is what happens in steps_2.adb: after a delay of one second, we write Byte?, then we “meet” the task at Step_Three. The task accepts the meeting, writes 3 (the do part) and will continue past the end select, where it says goodbye and leaves. In the meantime, the “main” is waiting one second again, and then it says bye too.

Whatever happens in the “main” and in the task is done concurrently, i.e., except, of course, when we meet at a rendezvous point. So, these points work like synchronizing mechanisms.

Our task waits for a “meeting” just once. Then it exits. This can be useful, but more often we want our task to wait again, and again, and again. That is, we want to loop.

      loop
         select
            accept Step_One do
               Put_Line ("1");
            end Step_One;
         or
            accept Step_Two do
               Put_Line ("2");
            end Step_Two;
         or
            accept Step_Three do
               Put_Line ("3");
            end Step_Three;
         end select;
         Put_Line ("Again!");
      end loop;

This is an infinite loop: the task will never end! This also means we can meet it wherever we want (among Step_One, Step_Two, Step_Three), whenever we want.

This is tested in steps_3.adb and steps_4.adb.

Hanging there

We did our game, we want to finish, but the task is forever looping. What should we do, then? Let it live forever in our memory?

Instead of an infinite loop, we can do any kind of loop makes sense for our purpose. We can add an entry which sets a variable so that the loop ends.

   task Step_By_Step is
      -- ...
      entry Finish;
   end Step_By_Step;

   --...
   
      while Keep_Looping loop
         select
            -- ...
         or
            accept Finish do
               Keep_Looping := FALSE;
            end Finish;
         end select;
      end loop;
      -- ...   

We need to remember to meet there just before we hit the end:

begin
   -- ...
   Step_By_Step.Finish;
end;

Terminate together

Ok, we can have a special rendezvous point which makes it possible to exit the conditional loop by changing its condition. Yet, we must “call” this entry for each task when we terminate out program. That could be a long list. Or maybe not. But however it isn't all that nice we needing to explicitly finish our tasks…

We have another way: one of the or of the select can be a terminate.

      loop
         select
            accept Step_One do
               Put_Line ("1");
            end Step_One;
         or
            accept Step_Two do
               Put_Line ("2");
            end Step_Two;
         or
            terminate;
         end select;
         Put_Line ("Again?!");
      end loop;

It is like if the task is waiting on three rendezvous points, where the third one is special and “signals” the end of the task — likely caused by the end of the parent process (currently I don't know if there are other causes).

This way we don't need to have a special entry and to remember to call it: once the parent terminates, the special rendezvous point terminate is hit, and that means the task must terminate, in that “place” and moment.

The source code steps_5.adb shows how it can be done.

Everybody's late!

Instead of or terminate, you can or delay an amount of time. That is, when the delay ends, it's like if the clock arrived at a randezvous point. We can wait for several rendezvous points, and if none of these are met before the end of the delay, the or delay “branch” is taken.

So it can be used like a timeout on the waiting for an accept.

   select
      accept Entry_1 do
         -- ...
      end Entry_1;
   or
      accept Entry_2 do
         -- ...
      end Entry_2;
   or
      -- ...
   or
      delay 3.0;
      -- ...
   end select;

If nobody “calls” any entry in the select within 3 seconds, the statements after the delay are executed.

In steps_6.adb I experiment with this feature.

Entries can take arguments, have guards and requeue

So far I've shown parameterless entries; but an entry looks very much like a procedure: it can take in or out (or both) parameters.

Also, selects can have guards on the form of when conditions. If the condition isn't met, the rendezvous can't be reached.

In steps_7.adb I pretend to have a task playing like a one-account bank where you, the client, can put and withdraw money. Often we need more money than we have. Luckly, the bank can borrow up to 1000. Then,
there's a state sponsored task which each 5 years (which are 5 seconds in the simulation) deposits a bonus on the only account of the bank iff the account is in debt (it holds a negative amount of money). The task is a sort of mechanism to save private debts — and/or banks…

First, we save 120. Then we withdraw 125. The bank gives us 125, as requested, but the balance goes negative (we paid also a fee, so it becomes -7): we can't take money anymore. We need to wait for the magic State to save us. When the bonus arrives, we get our 50.0, and we reach the end — though, Bank and Bonus tasks are still running, hence the program doesn't terminate.

The output of a run of steps_7 is

BANK: Deposit 120.00
BANK: Current deposit: 120.00
BANK: Withdrawing 125.00
BANK: Current deposit: -7.00
We need more money, but we must wait for the bonus
BANK: Deposit 200.00
BANK: Current deposit: 193.00
BANK: Withdrawing 50.00
BANK: Current deposit: 141.00
STATE: We helped the bank^H^H^H^H you with a bonus of 200.00

After the deposit of the bonus has made the balance positive again, the Bonus task (after 5 seconds) call Bank.Give_Bonus (Bonus_Amount) and waits there, because in fact the accept is guarded by the condition that the balance is negative.

The State puts the bonus using the entry Give_Bonus — the Put_Amount entry isn't guarded, of course. (In this model, strangely enough it is the bank which refuses the bonus is the balance isn't negative…)

The entry Give_Bonus must do the same thing of Put_Amount. In order to do so, we use requeue. This requeues the request as another entry. It can be also another entry of another task, but it must have the same parameters.

The term requeue suggests what's behind the scenes of all this accepting thing: a queue. The task has a “mailbox” where it receives the “requests”, which are queued. This picture should remind you of concurrency in Erlang, if you know it a little bit.

What about negative amounts?

This example doesn't use subtypes, even if it would be appropriate. In fact, what does it happen if we get a negative amount of money? This isn't a big deal in this little example, but of course we should block this abusive use of the entries.

Task type

The tasks we've seen so far start as soon as the begin of the enclosing procedure is hit. In Java we must explicitly start the thread, and also in C/C++, a POSIX thread begins when we create it calling the appropriate API (pthread_create). In C, if the parent process dies or just exits, all its children threads die, too. We've seen Ada is different.

But then, how can I have several equal tasks? The task type is what we need.

   task type Task_Name is
      -- ...
   end Task_Name;

The rest is the same, but this time we can say something like this:

    Tasks : array (Positive range 1 .. 10) of Task_Name;

This will start ten identical tasks.

The pointless example task_type.adb shows this feature in a messy way. It prentends there are ten players and a “master” picks randomly one of those and asks it to do something, or maybe not.

The example is really, really messy — don't take it as an example of how things should be done. But anyway it shows several features, also not strictly task related, and problems. First of all, what this section was about: task types. So, we can create a handful of equal tasks.

It shows how to have local variables:

declare
   -- ... variables
begin
   -- ...
end;

It shows that select isn't just for tasks. There's the construct selectthen abort:

select
   delay 1.0;
   -- ... A
then abort
   -- ... B
end select;

This is called asynchronous transfer of control (ATC). If I get it well, it is used to abort locally a computation that can take long, or whatever for whatever reasons takes longer than decided. Since it is in fact an abort, there are some rules: you can't abort everywhere.

I read it this way: start executing the first part of select and the second; both, altogether. If A hits the then abort part before B reaches the end select, the B part is aborted. Otherwise, B is completed before the delay ends and A is executed.

The timeout construct was already shown above.

Then we have how to generate random numbers, and how to check if a task is terminated. Unfortunately, when A_Task'Terminated says false, it doesn't assure it will survive later: it could terminate one tenth of second after you checked for termination. Only death is certain!

What does it happen when you enqueue to a terminated task's mailbox (or queue, if you prefer)? Bad things. Maybe a permanent lock.

If you run task_type, you'll see a lot of noise. If you wait long enough, you'll see that at a certain point the strings “ALICE PLAYERS” and “ANOTHER ROUND” don't appear on the output anymore, but in the same time nowhere you've see the stripe of = signs which mark the end of the loop.

That is, there's some serious deadlock there, despite the dissemination of timeouts and aborts.

When this deadlock happens, the only output comes from the tasks, most of it from the timeout part (“Player … STAMINA …”).

Then, tasks slowly go to the “spontaneous death” part, and when all is over, the program terminates with a Tasking_Error. Not nice. It won't be fixed, because there's nothing to be fixed in a mad playground.

task_type_output.png

Another problem of the task_type example is shown in the previous image: each task prints, and there's nothing serializing this stream. That's why the output of a task can be interspersed in the output of another task.

This is a well known problem. A fix can be this: to have a special task whose only role and aim is to print. It collects “atoms” from other tasks and output them. It is the only “owner” of the output, so it can do it right.

Having a task just for that isn't, however, very good. Tasks need resourches, and what we need here is just a lighter synchronization mechanism.

Ada has protected types: they looks almost like tasks in syntax, but they are synchronization mechanisms to access data/resources concurrently. Protected types give Ada a clean, nice semantic to assure mutual exclusion. It's also very easy to implement a typical many readers - single writer design, and the readings won't lock, as expected.

But this is material for the next lesson.

The sources

You can find the sources of my experiments for this article here.

Final note

This lesson was written mostly months ago, as you can imagine from the time gap between this and number five — eleven months ago, if you check the commit on github. It didn't took me so long to finish it, of course, and I won't explain why I've left it hanging for so long — nothing to do with Steemit, anyway.

I will try to keep a certain pace, but I can't promise anything!

I hope these lessons will inspire you and make you consider learning Ada!