Nebenläufige Programmierung in Ada

Die Programmiersprache Ada verfügt über spezielle Konstrukte zur nebenläufigen Programmierung. Mit Tasks lassen sich sequenzielle Instruktionen formulieren, die zur Laufzeit nebeneinander, bei mehreren Prozessoren auch gleichzeitig ausgeführt werden.

Lesezeit: 18 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 6 Beiträge
Von
  • Johannes Kanig
  • Jose Ruiz
Inhaltsverzeichnis

Die Programmiersprache Ada verfügt über spezielle Konstrukte zur nebenläufigen Programmierung. Mit Tasks lassen sich sequenzielle Instruktionen formulieren, die zur Laufzeit nebeneinander, bei mehreren Prozessoren auch gleichzeitig ausgeführt werden.

Die meisten Rechner und Smartphones haben heute mehr als einen Prozessorkern. Es ist daher naheliegend, diese durch die Anwendungssoftware zu nutzen. Viele Probleme lassen sich nur schwer in eine Reihe sequentieller Schritte auflösen – auch hier ist es effizienter, sequenzielle Teilaufgaben sowie deren Kommunikation zu beschreiben und die Aufgabe der Zusammenführung dem Compiler zu überlassen.

Hier setzt die "nebenläufige Programmierung" an: Der Programmierer beschreibt nicht eine einzige sequenzielle Abfolge von Arbeitsschritten, sondern mehrere, sowie die Kommunikation, die zwischen diesen Prozessen erfolgen kann. Abhängig vom Problem bietet sich nebenläufige Programmierung auch an, wenn nur ein Prozessor zur Verfügung steht: nämlich immer dann, wenn mehrere unabhängige Aufgaben zu erledigen sind und eine künstlich festgelegte Reihenfolge der Aufgaben unerwünscht ist.

Die nebenläufige Programmierung gilt als deutlich komplexer als die sequenzielle. Neue Arten von Programmierfehlern wie Race Conditions und Deadlocks muss der Entwickler zusätzlich zu den aus der sequenziellen Programmierung bekannten berücksichtigen. Zwei weitere Begriffe sind in diesem Zusammenhang wichtig: Von verteilter Programmierung spricht man, wenn das Programm auf mehreren Maschinen laufen soll und die Kommunikation demzufolge über das Netzwerk erfolgt. Von Echtzeitprogrammierung hingegen, wenn neben dem funktionalen Aspekt – berechnet das Programm das richtige Ergebnis, stürzt es auch nicht ab? – auch Timing-Aspekte – wann gibt das Programm das Ergebnis aus, wie schnell reagiert es auf Eingaben? – eine Rolle spielen. Zu den wenigen Programmiersprachen, die direkt diese Paradigmen unterstützen, gehört neben Erlang und Go auch Ada.

Das grundlegende Konzept der nebenläufigen Programmierung in Ada ist der Task: Er bildet eine Reihe sequenzieller Instruktionen, zum Beispiel Berechnungen oder Funktionsaufrufe. Ein sequenzielles Ada-Programm hat genau einen Task, nämlich den, der das Main-Programm ausführt. Zusätzlich kann der Programmierer weitere, dann nebenläufig ausgeführte Tasks definieren. "Parallel" wäre hier nicht der passende Begriff, denn wirklich gleichzeitig werden diese Tasks nur ausgeführt, wenn auch mehrere Prozessoren zur Verfügung stehen. Ist das nicht der Fall, entscheidet die Laufzeitumgebung beziehungsweise das Betriebssystem, einen Task zu unterbrechen und einen anderen rechnen zu lassen.

In Ada ist jeder Task sowohl zu deklarieren als auch zu definieren. Ein Task lässt sich auf drei verschiedene Arten erstellen: zuerst als ein einfacher Task, der direkt ausgeführt wird, sobald der Programmfluss auf diese Task-Definition trifft. Die Deklaration besteht einfach aus dem Schlüsselwort task und dem Namen des Task. Die Definition ist ähnlich einer Funktionsdefinition, aber mit dem Schlüsselwort task body:

task Paternoster;
task body Paternoster is
... lokale Definitionen des Task: Typen, Variablen, Funktionen, ...
begin
... Der Code des Task
end Paternoster;

Es lässt sich auch ein Task-Typ definieren; das ist immer nützlich, wenn mehrere ähnliche Tasks laufen sollen. Der eigentliche Task wird dann als Variablendeklaration eingeführt:

task type Person;
task body Person is ... wie zuvor ... end Person;
P : Person;

Die beiden ersten Varianten ermöglichen das Erstellen statischer Tasks: Es steht von vornherein fest, wie viele Tasks wann existieren. Die dritte Variante ermöglicht die dynamische Erzeugung von Tasks:

X := New Person;

Das folgende Codebeispiel zeigt zwei Tasks, die mit Variante 1 und 2 definiert wurden und die nebenläufig ausgeführt werden: einen Paternoster und einen Fahrgast.

shared:

Low : constant Float := 0.0;
High : constant Float := 100.0;
Position : Float := Low;

left Side:

task Paternoster;
task body Paternoster is
type State is (Up, Down);
Direction : State := Up;
Step : constant Float := 0.1;
begin
loop
Position :=
(if Direction = Up then Position + 0.1 else Position - 0.1);
Direction :=
(if Position >= High then Down
elsif Position <= Low then Up
else Direction);
end loop;
end Paternoster;

right Side:

task type Person;
task body Person is

Start : constant Float := Ada.Numerics.Float_Random.Random (Float_Gen);

procedure Jump is
begin
if abs (Float(Start) - Position) > 1.0 then
raise Program_Error;
end if;
end Jump;

begin
loop
if abs (Float(Start) - Position) < 0.1 then
Jump;
end if;
end loop;
end Person;

P : Person;

An diesem Beispiel kann man gut sehen, warum die nebenläufige Programmierung angenehm sein kann: Der Code der Fahrstuhlsteuerung kümmert sich nur um den Fahrstuhl und kann andere Aspekte – zum Beispiel die Person – außer Acht lassen. In einer Endlosschleife, die typisch für die nebenläufige Programmierung ist, setzt der Code einfach die Fahrstuhlposition auf einen neuen Wert.

Es ist sinnvoll, den Fahrstuhl als einfachen Task zu definieren: Es gibt in dieser Simulation nur einen Fahrstuhl, und er läuft die ganze Zeit. Es ist ebenfalls sinnvoll, die Person als Task-Typ zu definieren, denn auch wenn es in dem ersten größeren Beispiel nicht der Fall ist, kann man sich natürlich mehrere Fahrgäste vorstellen.

Streng genommen gibt es im Beispiel sogar drei Tasks und nicht nur zwei: den Fahrstuhl, die Person und den "Main"-Task, der das Main-Programm aufruft und in jedem Ada-Programm existiert, auch wenn keine anderen Tasks definiert werden. Im Beispiel führt dieser Task aber nichts anderes aus, als die anderen Tasks zu starten und auf ihre Beendigung zu warten.

Noch ein Aspekt des Fahrgast-Tasks ist wichtig: Die Person möchte auf den Paternoster aufspringen, aber nur, wenn die Position nah genug (10 cm) vom Ausgangspunkt der Person ist. Während des Sprungs wird noch einmal der Abstand überprüft. In einem sequenziellen Programm wäre die Überprüfung völlig unnötig. Hier aber zeigt sie eines der fundamentalen Probleme der Nebenläufigkeit. Der Paternoster-Task kann, während der Person-Task noch entscheidet zu springen, die Position des Fahrstuhls entscheidend verändern und so den Sprung in den Fahrstuhl zu einem Sprung ins Leere umwandeln.

Der typische Programmierfehler im ersten Beispiel ist eine sogenannte Race Condition. Das Problem ist, dass die beiden Tasks die einfache Variable Position verwenden, um miteinander zu kommunizieren. Die Information fließt hier in eine Richtung: Der Paternoster-Task schreibt die jeweils aktuelle Position in die Variable, und der Person-Task liest sie aus, um sich zum Sprung zu entscheiden. Kurz nachdem diese Entscheidung getroffen wurde, kann der Paternoster-Task aber schon wieder die Position ändern. Die Entscheidung basiert somit auf einem veralteten Wert.

Ada bietet zwei Mechanismen der sicheren Kommunikation zwischen Tasks: eine Form der synchronen und eine der asynchronen Kommunikation.