Nebenläufige Programmierung in Ada

Seite 2: Protected Objects

Inhaltsverzeichnis

Das Problem des Paternoster-Beispiels war, dass sich die Position des Fahrstuhls ändern lässt, während ein Task sie kritisch benutzt. Die Lösung des Problems besteht darin, den Zugriff auf solche kritische Ressourcen zu beschränken. Dafür gibt es in verschiedenen Systemen unterschiedliche Lösungen wie Mutexes oder Semaphoren. Ada verwendet dafür das "geschützte Objekt" (protected object). Man kann es sich als ein Objekt im Sinne der Objektorientierung vorstellen, also als private Daten zusammen mit Prozeduren, die diese manipulieren. Ein Prozeduraufruf eines geschützten Objekts läuft prinzipiell wie ein normaler Aufruf ab, mit einem Unterschied: Zu jedem Zeitpunkt kann nur eine Prozedur des Objekts laufen. Läuft bei einem Aufruf schon eine Prozedur des gleichen Objekts in einem anderen Task, wird der aufrufende Task geblockt und wartet, bis der aktuelle Aufruf fertig ist.

Der Sinn geschützter Objekte ist die sichere Kommunikation zwischen Tasks. Diese nennt man asynchron, denn im Prinzip entscheidet jeder Task selbst, wann er auf das Objekt zugreifen möchte – auch wenn manchmal zu warten ist. Wie für Tasks ist zwischen Deklaration und Definition eines geschützten Objekts zu unterscheiden. Die Deklaration sieht wie folgt aus:

protected Elevator is
<Operationsdeklarationen>
private
<Variablendeklarationen>
end Elevator;

Wie für Tasks kann man auch einen protected type definieren und dann Objekte dieses Typs deklarieren. Die Definition eines geschützten Objekts gibt im Wesentlichen die Implementierungen der Operationen an:

protected body Elevator is
...
end Elevator;

Ein geschütztes Objekt kann drei Arten von Operationen definieren. Diese können potenziell den aufrufenden Task so lange blockieren, bis sich die Operation wirklich ausführen lässt:

  • Funktionen dürfen private Daten lesen, aber nicht schreiben, und lassen sich gleichzeitig mit anderen Funktionen des gleichen Objekts ausführen;
  • Prozeduren dürfen private Daten lesen und schreiben, aber nicht gleichzeitig mit anderen Operationen ausgeführt werden.
  • Entries verhalten sich so wie Prozeduren. Zusätzlich haben sie eine Eingangsbedingung, die mit dem Schlüsselwort when angegeben wird. Ist diese zum Zeitpunkt des Aufrufs falsch, wird der Entry zusätzlich so lange geblockt, bis die Bedingung wahr wird.

Im folgenden längeren Codebeispiel wurde ein Fahrstuhl als geschütztes Objekt implementiert. Zu jedem Zeitpunkt kann nur eine Person fahren, den Fahrstuhl nutzen, um von Etage A nach Etage B zu fahren, und dann aussteigen. Diese Exklusivität wird hier erreicht, indem die Funktion Step_In ein Flag setzt, das weitere Aufrufe von Step_In so lange blockiert, bis Step_Out aufgerufen wird.

left side:

task body Person is

From : Floor := ...;
To : Floor;
begin
Elevator.Step_In (From, Id);
delay 2.0;
Elevator.Step_Out (To, Id);
end Person;

right side:
protected Elevator is
entry Step_In (F : Floor; Id : Integer);
procedure Step_Out (F : Floor; Id : Integer);
private
Running : Boolean := False;
From : Floor;
end Elevator;

protected body Elevator is
entry Step_In (F : Floor ; Id : Integer) when not Running is
begin
Running := True;
end Step_In;

procedure Step_Out (F : Floor ; Id : Integer) is
begin
Running := False;
end Step_Out;
end Elevator;

Die andere wichtige Form der Kommunikation zwischen Ada-Tasks ist die synchrone Kommunikation mit einem Rendezvous. Tasks können so wie geschützte Objekte Entries bereitstellen, die außerhalb der Tasks so ähnlich wie Prozeduren aussehen und aufgerufen werden. Anders als bei einem Prozeduraufruf ist ein Rendezvous aber von beiden Seiten zu veranlassen: Ein Task muss mit dem Schlüsselwort accept die Bereitschaft eines Rendezvous für einen bestimmten Entry signalisieren, und ein anderer Task muss diesen Entry auch aufrufen. Tritt zunächst nur eines der beiden Ereignisse ein, wird dieser Task so lange blockiert, bis das andere Ereignis geschieht. Das erinnert an ein Client-Server-Modell: Der Client, der aufrufende Task, wird so lange blockiert, bis der Server, der anbietende Task, auch bereit ist, diese Art von Entry anzunehmen. Andersherum wird der Server so lange warten, bis auch tatsächlich ein Client den Service in Anspruch nimmt. Wenn dann endlich beide bereit sind, passiert das sogenannte Rendezvous.

Für den Client eines Rendezvous gibt es nur wenige Möglichkeiten, das Blockieren zu vermeiden: So kann man einen Timeout für die Wartezeit angeben oder testen, ob der Server für ein bestimmtes Rendezvous empfänglich ist. In den anderen Fällen blockiert der Client so lange, bis der Server bereit ist. Dagegen gibt es für den Server eine Reihe von Möglichkeiten, das Blockieren zu vermeiden. So kann der Server mit einem select-Statement auf mehrere Entries gleichzeitig warten; sind mehrere Clients für ein Rendezvous verfügbar, wird einer von ihnen ausgewählt. Das select-Statement erlaubt es auch, eine bestimmte Aktion auszuführen, wenn gerade kein Client da ist, auf bestimmte Entries nur zu warten, wenn eine Bedingung erfüllt ist, oder aufzuhören zu warten, wenn der Task nicht mehr gebraucht wird.

In folgendem Codebeispiel ist vor allem der Elevator-Task interessant: Er bietet, wie man es von einem Fahrstuhl erwarten kann, drei Entries an: den Ruf des Fahrstuhls mit Richtungsangabe, das Betreten an einer bestimmten Etage mit Angabe der Zieletage und das Verlassen des Fahrstuhls. Zum Betreten und Verlassen des Fahrstuhls ist es klar, dass beide Parteien – die Person und der Fahrstuhl – dazu bereit sein müssen, es ist daher natürlich, das als Rendezvous zu programmieren. Bei dem Ruf ist das weniger klar, aber auch hier wurde der Einfachheit halber ebenfalls ein Rendezvous verwendet.

left side
task Elevator is
...
begin
loop
select
accept Call (Floor : Floor_Type; Dir : Dir_Type) do
Calls (Floor) :=
(if Floor = Floor_Type'First then Up
elsif Floor = Floor_Type'Last then Down
else Merge (Calls (Floor), Dir));
end Call;
or when Door_Is_Open and then Num_People /= Capacity =>
accept Step_In (Current_Floor) (To : Floor_Type) do
Num_People := Num_People + 1;
Calls (To) := Both;
end Step_In;
or when Door_Is_Open and then Num_People /= 0 =>
accept Step_Out (Current_Floor) do
Num_People := Num_People - 1;
end Step_Out;
else
Update_State;
delay 0.1;
end select;
end loop;
end Elevator;

right side:
task Person is
begin
Elevator.Call (From, Dir);
Elevator.Step_In (From) (To);
Elevator.Step_Out (To);
end Person;

Der Code des Fahrstuhl-Tasks wartet nun auf eines der drei möglichen Ereignisse: Ruf beziehungsweise Betreten und Verlassen auf der aktuellen Etage unter bestimmten Bedingungen – die Tür ist offen, es ist noch Platz im Fahrstuhl. Das wird mit einem select-Statement erreicht. Als Rendezvous-Aktion werden dabei jeweils die Anzahl der Fahrgäste aktualisiert und der Wunsch eines neuen Fahrgasts registriert. Nur wenn keines dieser Ereignisse auftritt, aktiviert der Fahrstuhl seine Zustandsmaschine, die zum Beispiel die Tür schließt oder die Etage wechselt.

Der Person-Task sieht im Wesentlichen so aus, wie man es erwartet (siehe das obige Beispiel): Die Person ruft den Fahrstuhl, steigt ein und wieder aus. Alle drei Aufrufe sind Rendezvous-Aufrufe und daher potenziell – wie im wirklichen Leben – mit Warten verbunden.

Die Beispiele zeigen, dass nebenläufige Programmierung Vorteile bietet, wenn mehrere gleichzeitig ablaufende Prozesse (in Ada: Tasks) zu beschreiben sind; in den Beispielen waren das der Fahrstuhl und die Fahrgäste. Potenziell würden die Programme auf Multiprozessorsystemen schneller laufen, auch wenn das nicht das Ziel der Beispiele war. Gleichzeitig ist aber in der nebenläufigen Programmierung auf die Kommunikation zwischen Tasks zu achten. Ada bietet hier Mechanismen der asynchronen Kommunikation (geschützte Objekte) und synchronen Kommunikation (mit Rendezvous) an.