Sichere Nebenläufigkeit mit Ada — Ein praktischer Leitfaden zu Tasks und geschützten Objekten
· Go Komura · Ada, Nebenläufigkeit, Tasking, Geschützte Objekte, Rendezvous, Echtzeit, Parallele Programmierung, Programmiersprache, Hohe Zuverlässigkeit
1. Einleitung — Nebenläufigkeit, die in die Sprache eingebaut ist
Nebenläufigkeit ist in der modernen Softwareentwicklung ein unumgängliches Thema. In den meisten Sprachen ist Nebenläufigkeit jedoch etwas „nachträglich Angeflanschtes“, das von Bibliotheken oder Betriebssystemfunktionen abhängt und tiefes Wissen sowie sorgfältiges Design erfordert, um korrekt eingesetzt zu werden.
Ada hat auf dieses Problem eine eigene Antwort. Nebenläufigkeit ist in die Sprachspezifikation selbst eingebaut.
Adas Nebenläufigkeitsmodell:
- Task (task) ── unabhängig ausgeführte nebenläufige Einheit
- Rendezvous (rendezvous) ── synchrone Kommunikation zwischen Tasks
- Geschütztes Objekt (protected object) ── sprachverwaltete Exklusivzugriffskontrolle
- Echtzeitprioritäten ── Annex-D-Echtzeitfunktionen
Tasks und Rendezvous existieren bereits seit Ada 83 im Jahr 1983, geschützte Objekte und die Annex-D-Echtzeitfunktionen kamen mit Ada 95 hinzu, und die Sprache hat sich seither über Ada 2005 und 2012 weiterentwickelt. Statt auf Low-Level-Synchronisationsprimitive wie Mutexe und Semaphore zu setzen, liegt Adas größter Vorteil bei Nebenläufigkeit darin, dass sich die Entwurfsabsicht direkt im Code ausdrücken lässt.
Dieser Artikel erklärt Adas Nebenläufigkeit Schritt für Schritt anhand von acht praxisnahen Codebeispielen. Jedes Beispiel ist als eigenständiges Snippet tatsächlich kompilier- und ausführbar, sodass Sie es selbst ausprobieren können.
Die in diesem Artikel gezeigten Codefragmente sind zusätzlich als nach Kapiteln geordnete Referenzsammlung auf GitHub veröffentlicht.
ada-task-concurrency - komurasoft-blog-samples (GitHub)
Selbst ausprobieren ── Build und Ausführung
Da ich geschrieben habe, dass die Beispiele „kompilier- und ausführbar“ sind, zeige ich vorab auch, wie das geht.
GNAT besorgen
GNAT ist der Ada-Compiler von GCC. Unter Linux installieren Sie ihn mit apt install gnat-13, unter Windows über das MSYS2-Paket mingw-w64-x86_64-gcc-ada, oder alternativ über Alire (den Paketmanager für Ada/SPARK).
Bauen und ausführen
Jedes Snippet enthält in einer Datei mehrere Kompilationseinheiten (Task-Spezifikation, Task-Rumpf, Hauptprozedur), daher wird es zunächst mit gnatchop aufgeteilt und dann mit gnatmake gebaut. gnatchop ist ein Werkzeug, das Dateien gemäß GNATs Namenskonvention „Unit-Name = Dateiname“ aufteilt.
mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Der Name der Hauptprozedur nach dem Aufteilen wird direkt zum Namen der ausführbaren Datei.
Zuordnung von Kapiteln zu Dateien
Kapitelnummer und Dateinummer sind um eins verschoben (Kapitel 3 entspricht 01_). Die Zuordnung sieht wie folgt aus.
| Kapitel | Datei | Ausführbare Datei | Inhalt |
|---|---|---|---|
| Kap. 3 | 01_hello_task.ada |
hello_task_demo |
Grundform eines Tasks |
| Kap. 4 | 02_rendezvous_intro.ada |
rendezvous_demo |
Bidirektionale Datenübertragung per Rendezvous |
| Kap. 5 | 03_selective_accept.ada |
selective_accept_demo |
Selektiver Accept und Server-Task |
| Kap. 6 | 04_producer_consumer.ada |
producer_consumer_demo |
Erzeuger-Verbraucher |
| Kap. 7 | 05_protected_counter.ada |
protected_counter_demo |
Exklusivzugriff mittels geschütztem Objekt |
| Kap. 8 | 06_bounded_buffer.ada |
bounded_buffer_demo |
Geschützter Entry mit Barriere (begrenzter Puffer) |
| Kap. 9 | 07_timed_entry.ada |
timed_entry_demo |
select-Aufruf mit Timeout |
| Kap. 10 | 08_task_priorities.ada |
task_priorities_demo |
Task-Prioritäten und Echtzeit-Scheduling |
Die Codefragmente im Fließtext sind nur die für die Erklärung nötigen Ausschnitte. Sie laufen so nicht direkt — verwenden Sie zum Ausprobieren die oben genannten Dateien.
2. Ein kurzer Rückblick auf die „Gefahren“ der Nebenläufigkeit
Bevor wir zu Ada kommen, wollen wir kurz klären, warum „sichere“ Nebenläufigkeit überhaupt wichtig ist.
Zu den typischen Fehlern in nebenläufigen Programmen gehören:
- Datenwettlauf (data race): Mehrere Threads greifen gleichzeitig auf dieselbe Speicherstelle zu, wobei mindestens einer davon schreibt. Das Ergebnis ist undefiniert.
- Deadlock (deadlock): Mehrere Tasks warten gegenseitig auf ihre Fertigstellung und kommen nie voran.
- Prioritätsumkehr (priority inversion): Ein Task mit hoher Priorität wartet auf eine Ressource, die ein Task mit niedriger Priorität hält, während ein Task mit mittlerer Priorität den Task mit niedriger Priorität präemptiert (die laufende Ausführung unterbricht und zu einem anderen Task wechselt).
- Verhungern (starvation): Ein Task kann niemals an eine Ressource gelangen, die er braucht.
Adas Nebenläufigkeitsmodell bietet gegen all diese Probleme Abwehrmaßnahmen auf Sprachebene.
Datenwettlauf → Geschützte Objekte garantieren exklusiven Zugriff
Deadlock → Das Rendezvous-Modell bietet strukturierte Synchronisation
Prioritätsumkehr → Priority Ceiling Protocol ist sprachintegriert verfügbar
Verhungern → Kontrolle über Entry-Barrieren und Queuing-Policies
3. Grundlagen von Tasks ── Unabhängige Ausführungseinheiten
Die grundlegende Einheit der Nebenläufigkeit in Ada ist der Task (task). Ein Task ähnelt einem Thread, entspricht aber nicht zwangsläufig eins zu eins einem OS-Thread — die Ada-Laufzeitumgebung übernimmt das Scheduling.
task Greeter is
entry Start;
end Greeter;
task body Greeter is
begin
accept Start;
Put_Line ("Hello from a task!");
end Greeter;
Dieser Code (01_hello_task.ada) enthält mehrere wichtige Punkte.
Ein Task beginnt automatisch mit der Ausführung, sobald er deklariert wird. Der Task Greeter startet zu dem Zeitpunkt, an dem die ihn umgebende Prozedur ihr begin erreicht, und wartet bei accept Start; auf eine Rendezvous-Anfrage vom Aufrufer.
Ein Entry (entry) ist die Schnittstelle, die ein Task nach außen anbietet. Ruft der Aufrufer Greeter.Start; auf, synchronisiert sich das mit accept Start; des Tasks. Das nennt man ein Rendezvous.
Auf die Beendigung eines Tasks wird automatisch gewartet. Wenn die Hauptprozedur endet und noch Tasks laufen, wird implizit auf deren Abschluss gewartet. Das steht im Gegensatz zu den Abstürzen in C++, die durch einen vergessenen Aufruf von std::thread::join entstehen.
Ausführungsbeispiel
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Main: starting task...
Hello from a task!
Main: task has completed.
Hier ist ein Punkt zu beachten. accept Start; besitzt keinen do ... end-Block. Das heißt, in dem Moment, in dem das Rendezvous zustande kommt, werden beide Seiten sofort freigegeben und laufen anschließend nebenläufig weiter. Die Reihenfolge der letzten beiden Zeilen ist deshalb nicht garantiert. Je nach Umgebung erscheint Main: task has completed. zuerst. Wollen Sie auch die Reihenfolge festlegen, platzieren Sie die Verarbeitung, deren Reihenfolge Sie einhalten möchten, innerhalb des do ... end-Blocks von accept Start do ... end Start;. Nur die erste Zeile steht in jedem Fall garantiert am Anfang, denn Greeter bleibt bei accept stehen, bis Greeter.Start; aufgerufen wird.
4. Rendezvous ── Synchrone Kommunikation mit Datenübergabe
Ein Rendezvous ist nicht nur reine Synchronisation, sondern ermöglicht auch die bidirektionale Datenübergabe.
task Worker is
entry Compute (X, Y : Integer; Result : out Integer);
end Worker;
task body Worker is
A, B : Integer;
Output : Integer;
begin
accept Compute (X, Y : Integer; Result : out Integer) do
A := X;
B := Y;
Output := A * A + B * B;
Result := Output;
end Compute;
end Worker;
Der Aufrufer verwendet ihn wie folgt (02_rendezvous_intro.ada).
Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));
Der entscheidende Designpunkt hier ist, dass die Parametermodi explizit angegeben werden.
in-Modus: übergibt einen Wert vom Aufrufer an den Taskout-Modus: liefert ein Ergebnis vom Task an den Aufrufer zurückin out-Modus: bidirektional
Der do ... end-Block im Rumpf von accept bildet die kritische Sektion. Während dieser Zeit ist der Aufrufer blockiert, und der Task nimmt keine anderen Entries an. Sobald die Verarbeitung abgeschlossen ist, setzen beide fort.
Zeitlich betrachtet sieht der Ablauf des Aufeinanderwartens wie folgt aus.
sequenceDiagram
participant Main as Aufrufer
participant W as Worker-Task
Main->>W: Worker.Compute aufrufen
Note over Main: Blockiert, bis accept erreicht ist
W->>W: accept Compute ... do erreicht
Note over Main,W: Rendezvous zustande gekommen / accept-Rumpf wird ausgeführt
W-->>Main: Schreibt das Ergebnis in den out-Parameter Result
Note over Main,W: Bei end Compute setzen beide gleichzeitig fort
Main->>Main: weitere Verarbeitung
W->>W: weitere Verarbeitung
Wer zuerst ankommt, wartet. Kommt der Aufrufer zuerst, hält er an, bis accept erreicht ist; kommt der Task zuerst, hält er an, bis ihn jemand aufruft. In jedem Fall wird der Inhalt von do ... end erst ausgeführt, wenn beide Seiten bereitstehen.
Zusammengefasst zeichnet sich ein Rendezvous durch Folgendes aus:
| Merkmal | Beschreibung |
|---|---|
| Synchron | Aufrufer und Task warten, bis beide gleichzeitig den Rendezvous-Punkt erreichen |
| Datenübertragung | in / out / in out-Parameter übergeben Werte in beide Richtungen |
| Exklusivzugriff | Während der accept-Rumpf ausgeführt wird, sind die anderen Entries des Tasks blockiert |
| Strukturiert | Welcher Entry wann angenommen wird, steht explizit im Task-Rumpf |
Ausführungsbeispiel
gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25
3 * 3 + 4 * 4 = 25 kommt über den out-Parameter zurück. Dass rechts vom = ein Leerzeichen steht, liegt daran, dass 'Image bei Ganzzahltypen vor nicht-negativen Werten immer ein Leerzeichen einfügt. Anders als in Kapitel 3 ist hier die Reihenfolge der beiden Zeilen garantiert, denn der Aufruf von Worker.Compute kehrt erst bei end Compute; zurück.
5. Selektiver Accept ── Mehrere Dienste bedienen
Ein echter Server-Task muss auf mehrere Arten von Anfragen warten können. Ada erreicht das auf Sprachebene mit der select-Anweisung.
task Server is
entry Deposit (Amount : Integer);
entry Withdraw (Amount : Integer; Success : out Boolean);
entry Balance (Value : out Integer);
end Server;
task body Server is
Current : Integer := 0;
begin
loop
select
accept Deposit (Amount : Integer) do
Current := Current + Amount;
end Deposit;
or
accept Withdraw (Amount : Integer; Success : out Boolean) do
if Current >= Amount then
Current := Current - Amount;
Success := True;
else
Success := False;
end if;
end Withdraw;
or
accept Balance (Value : out Integer) do
Value := Current;
end Balance;
or
terminate;
end select;
end loop;
end Server;
Die select-Anweisung in diesem Code (03_selective_accept.ada) besitzt mehrere or-Zweige, von denen einer der Entries mit einem anstehenden Aufruf ausgewählt wird (die Auswahl selbst ist implementierungsdefiniert). Wurde noch kein Entry aufgerufen, wartet der Task, bis einer davon gerufen wird.
or terminate; ist ein Spezialzweig, der den Task sicher beendet, sobald „die Hauptprozedur beendet ist und niemand mehr diesen Task über einen Entry aufrufen kann“. Das ist Adas eigener Mechanismus zur Lösung des Problems des „ewig wartenden Server-Tasks“, das sonst zu einem Deadlock führt.
Die Stärke des selektiven Accept liegt darin, dass sich auch Guard-Bedingungen formulieren lassen.
Das folgende Beispiel ist ein Task mit einem internen Ringpuffer. Würde man nur den select-Teil herausschneiden, wäre nicht mehr erkennbar, woher Count und Head stammen — deshalb zeige ich den vollständigen Deklarationsteil mit. Dieselbe Idee, mit einem geschützten Objekt neu geschrieben, behandelt Kapitel 8.
task Buffer_Task is
entry Put_Item (Item : Integer);
entry Get_Item (Item : out Integer);
end Buffer_Task;
task body Buffer_Task is
Max : constant := 8;
Data : array (0 .. Max - 1) of Integer;
Head : Integer := 0; -- Position der nächsten Entnahme
Tail : Integer := 0; -- Position des nächsten Einfügens
Count : Integer := 0; -- aktuelle Anzahl der Elemente
begin
loop
select
when Count > 0 =>
accept Get_Item (Item : out Integer) do
Item := Data (Head);
Head := (Head + 1) mod Max;
Count := Count - 1;
end Get_Item;
or
when Count < Max =>
accept Put_Item (Item : Integer) do
Data (Tail) := Item;
Tail := (Tail + 1) mod Max;
Count := Count + 1;
end Put_Item;
or
terminate;
end select;
end loop;
end Buffer_Task;
Ein Zweig, dessen Guard-Bedingung falsch ist, fällt in diesem Moment aus der Auswahl heraus. Damit lässt sich Kontrolllogik wie „Get warten lassen, wenn der Puffer leer ist; Put warten lassen, wenn er voll ist“ deklarativ formulieren. Das eigentliche Herzstück des Ringpuffers besteht darin, Head und Tail mit mod Max weiterzuschalten; die Guard-Bedingungen übernehmen zugleich die Aufgabe, sicherzustellen, dass diese Indizes stets innerhalb des gültigen Bereichs bleiben.
6. Erzeuger-Verbraucher ── Synchronisation per Rendezvous
Als typisches Muster mit Rendezvous betrachten wir das Erzeuger-Verbraucher-Muster (Producer-Consumer).
task Consumer is
entry Deliver (Item : Integer);
end Consumer;
task Producer;
task body Consumer is
Sum : Integer := 0;
begin
for I in 1 .. 5 loop
accept Deliver (Item : Integer) do
Sum := Sum + Item;
end Deliver;
end loop;
end Consumer;
task body Producer is
begin
for I in 1 .. 5 loop
Consumer.Deliver (I);
end loop;
end Producer;
Bei diesem Muster (04_producer_consumer.ada) synchronisiert sich der Producer bei jedem Aufruf von Deliver mit dem Consumer. Ist der Producer zu schnell, wird er ausgebremst, bis der Consumer accept ausführt; ist der Consumer zu schnell, wartet er auf den nächsten Aufruf des Producers. Dadurch entsteht ganz natürlich Backpressure (die automatische Drosselung der Sendegeschwindigkeit, wenn die Empfängerseite nicht mithalten kann). Da beim Rendezvous keine Warteschlange dazwischenliegt, funktioniert das ganz ohne die Gefahr eines Pufferüberlaufs.
7. Geschützte Objekte ── Exklusivzugriff ohne Locks
Während ein Task ein „aktiv handelnder Akteur“ ist, sind geschützte Objekte (protected object) ein Mechanismus für „passiv gemeinsam genutzte Daten“.
protected Counter is
procedure Increment;
function Value return Integer;
private
Count : Integer := 0;
end Counter;
protected body Counter is
procedure Increment is
begin
Count := Count + 1;
end Increment;
function Value return Integer is
begin
return Count;
end Value;
end Counter;
Die wichtigsten Regeln für geschützte Objekte lauten:
- Eine Funktion (function) ist nur lesend. Mehrere Tasks können sie gleichzeitig aufrufen.
- Eine Prozedur (procedure) liest und schreibt. Während eine Prozedur läuft, sind alle anderen Prozeduren und Funktionen blockiert.
- Ein Entry (entry) besitzt eine Barriere. Der Aufrufer wartet in einer Warteschlange, bis die Barrierebedingung wahr wird.
In diesem Code (05_protected_counter.ada) rufen drei Worker-Tasks jeweils 1.000-mal Increment auf. Da das geschützte Objekt den Exklusivzugriff garantiert, ist der endgültige Zählerwert stets 3.000. Ein manuelles Sperren und Entsperren eines Mutex ist nicht nötig.
task type Worker (Id : Integer; Rounds : Integer);
task body Worker is
begin
for I in 1 .. Rounds loop
Counter.Increment; -- geschütztes Objekt garantiert den Exklusivzugriff
end loop;
end Worker;
W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);
Ausführungsbeispiel
Hier gibt es ein Problem: Liest die Hauptprozedur Counter.Value einfach direkt aus, bekommt sie möglicherweise einen Wert, während die Worker noch laufen. Die vollständige Fassung (05_protected_counter.ada) fügt deshalb eine Prozedur zum Zählen der Fertigstellungen sowie einen Entry hinzu, der wartet, bis alle fertig sind.
protected Counter is
procedure Increment;
procedure Mark_Done;
entry All_Done;
function Value return Integer;
private
Count : Integer := 0;
Done_Count : Integer := 0;
end Counter;
Dazu wird die Barriere entry All_Done when Done_Count = Num_Workers gesetzt, und jeder Worker ruft nach dem Verlassen seiner Schleife Counter.Mark_Done; auf. Die Hauptprozedur wartet mit Counter.All_Done; auf die Fertigstellung aller Worker, bevor sie den Wert liest. Weder eine separate Flag-Variable zum Warten noch ein Sleep ist dafür nötig.
gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000
Egal wie oft Sie es ausführen, es ist immer 3000. Die drei Tasks rufen insgesamt 3.000-mal Increment auf, und das geschützte Objekt führt jeden einzelnen Aufruf exklusiv aus.
Was ohne geschützte Objekte passiert
Um den Wert geschützter Objekte zu verstehen, betrachten wir gefährlichen Code, der nicht geschützt ist.
-- ⚠ Gefahr: direkte Manipulation einer gemeinsam genutzten Variablen
Shared_Counter : Integer := 0;
task body Bad_Worker is
begin
for I in 1 .. 10_000 loop
Shared_Counter := Shared_Counter + 1; -- Datenwettlauf!
end loop;
end Bad_Worker;
Shared_Counter := Shared_Counter + 1 besteht auf CPU-Ebene aus drei Schritten: Lesen → Addieren → Zurückschreiben. Führen mehrere Tasks das gleichzeitig aus, kann das Additionsergebnis eines Tasks das Lesen eines anderen nicht einholen, und Inkremente gehen verloren. Darüber hinaus fällt dies unter die fehlerhafte Ausführung (erroneous execution) nach Ada RM 9.10. „Fehlerhafte Ausführung“ ist ein Standardbegriff mit stärkerer Bedeutung als „der Wert weicht ab“ — er bezeichnet den Zustand, dass der Standard überhaupt keine Garantien mehr für das Verhalten des Programms gibt. Gleichzeitiges Lesen und Schreiben einer nicht synchronisierten gemeinsam genutzten Variablen führt nicht nur zu einem ungenauen Endwert der Zählung, sondern kann das Verhalten des gesamten Programms beliebig werden lassen. Selbst wenn zwei Tasks jeweils 10.000-mal ausgeführt werden, gibt es keinerlei Garantie, dass der Endwert 20.000 beträgt.
Wenn Sie sich dieses „keine Garantie“ selbst ansehen möchten, bauen Sie die obige Bad_Worker-Variante nach, führen Sie sie wiederholt aus und notieren Sie jedes Mal den Endwert. Dieser Artikel zeigt bewusst keine gemessenen Werte. Das Ergebnis eines Datenwettlaufs hängt von CPU, Optimierungsoptionen und dem Timing zur Laufzeit ab — würde man eine in einer bestimmten Umgebung gemessene Zahl als „so wird es sein“ präsentieren, würde das einen irreführenden Richtwert für „so stark weicht es typischerweise ab“ vermitteln. Worauf es ankommt, ist nicht, dass „ein bestimmter Wert kleiner als 20.000 herauskommt“, sondern dass das Ergebnis bei jeder Ausführung anders ausfällt und es keinerlei Bedeutung hat, wenn auch nur einmal der korrekte Wert erscheint.
Geschützte Objekte verhindern dieses Problem „auf Ebene der Syntax“. Ein einfacher Aufruf Counter.Increment; genügt — Compiler und Laufzeitumgebung garantieren den Exklusivzugriff.
8. Geschützte Entries und Barrieren ── Begrenzter Puffer
Fügt man einem geschützten Objekt Entries hinzu, wird bedingte Synchronisation möglich. Betrachten wir als Beispiel den klassischen begrenzten Puffer (bounded buffer).
type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;
protected Buf is
entry Put (Item : Integer);
entry Get (Item : out Integer);
private
Data : Buffer_Array;
Head : Integer := 0;
Tail : Integer := 0;
Count : Integer := 0;
end Buf;
protected body Buf is
entry Put (Item : Integer) when Count < Buffer_Size is
begin
Data (Tail) := Item;
Tail := (Tail + 1) mod Buffer_Size;
Count := Count + 1;
end Put;
entry Get (Item : out Integer) when Count > 0 is
begin
Item := Data (Head);
Head := (Head + 1) mod Buffer_Size;
Count := Count - 1;
end Get;
end Buf;
when Count < Buffer_Size ist die Barriere (barrier). Eine Barriere wird bei jedem Entry-Aufruf ausgewertet: Ist sie wahr, wird ausgeführt; ist sie falsch, wartet der aufrufende Task in einer Warteschlange. Jedes Mal, wenn sich der Zustand des Puffers ändert (ein anderer Task führt Put oder Get aus), werden die Barrieren der wartenden Tasks neu ausgewertet.
Wann genau die Neubewertung stattfindet, ist in reinem Text schwer nachzuvollziehen. Zeitlich dargestellt sieht der Fall, dass bei einem leeren Puffer zuerst Get eintrifft, wie folgt aus.
sequenceDiagram
participant C as Consumer-Task
participant B as Geschütztes Objekt Buf
participant P as Producer-Task
C->>B: Get aufrufen
B->>B: Barriere „Count größer als 0?“ auswerten → falsch
Note over C: Wartet in der Entry-Warteschlange von Get
P->>B: Put aufrufen
B->>B: Barriere „Count kleiner als Buffer_Size?“ auswerten → wahr
B->>B: Rumpf von Put ausführen / Count wird 1
Note over B: Am Ende der geschützten Operation werden die Barrieren wartender Entries neu ausgewertet
B->>B: Barriere von Get ist jetzt wahr
B-->>C: Rumpf von Get ausführen und Consumer freigeben
Entscheidend ist, dass die Neubewertung der Barrieren gebündelt am Ende der geschützten Operation erfolgt. Zwischen dem Ende des Put-Rumpfs und der Freigabe der Sperre von Buf werden die Barrieren wartender Entries ausgewertet, und wer wahr wird, wird direkt ausgeführt. Ein Fehlerbild wie bei C-Bedingungsvariablen — „vergisst jemand, signal aufzurufen, wacht niemand mehr auf“ — gibt es hier nicht.
Dieses Muster (06_bounded_buffer.ada) ist einer der Momente, in denen Adas geschützte Objekte am meisten glänzen. Vergleichen Sie es mit der Umsetzung in C mit pthread-Mutex und Bedingungsvariable.
// C + pthread (zum Vergleich mit Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) { // entspricht Adas when
pthread_cond_wait(¬_full, &mutex); // Warten auf die Barriere
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(¬_empty); // benachrichtigt wartende Tasks
pthread_mutex_unlock(&mutex);
In Ada verdichtet sich all das zu der einen Zeile when Count < Buffer_Size. Die while-Schleifenbedingung, das Senden des Signals, Timingfehler beim Entsperren — all diese Fehlerquellen verschwinden einfach.
9. Aufrufe mit Timeout ── Niemals ewig warten
In Echtzeitsystemen ist „ewiges Warten“ nicht zulässig. Ada unterstützt Timeouts mit der Konstruktion select ... or delay.
select
Slow_Worker.Do_Work (Result);
Put_Line ("Main: work completed");
or
delay until Ada.Real_Time.Clock + Milliseconds (500);
Put_Line ("Main: timeout after 500ms!");
end select;
In diesem Code (07_timed_entry.ada) befindet sich Slow_Worker gerade in einem delay 2.0 und hat accept noch nicht erreicht, weshalb der in die Warteschlange eingereihte Entry-Aufruf nach 500 ms in ein Timeout läuft. (Der Timeout greift bei der Wartezeit in der Warteschlange, bevor das Rendezvous angenommen wird — er unterbricht nicht die Ausführung des Rendezvous selbst.) delay until gibt einen absoluten Zeitpunkt an und ist die grundlegende Technik der Echtzeitprogrammierung, um kumulative Drift zu verhindern.
Darüber hinaus unterstützt Ada auch den bedingten Aufruf (conditional entry call).
select
Server.Process (Item);
else
Put_Line ("Server is busy, will retry later");
end select;
Mit der else-Klausel geht das Programm sofort zur Ausweichbehandlung über, wenn kein unmittelbares Rendezvous möglich ist. Manuelles Polling müssen Sie dafür nicht schreiben.
Das Design nach dem Timeout nicht vergessen
Timeouts sind praktisch, aber der Kern des Designs liegt darin, „was nach einem gescheiterten Warten geschieht“. Darf der Wert wirklich verworfen werden? Sollte erneut versucht werden? Sollte der Fehler an eine übergeordnete Ebene gemeldet werden? Bleibt das ungeklärt, wird daraus im Produktivbetrieb Datenverlust oder ein Dienstausfall. Wenn Sie einen Timeout schreiben, entwerfen Sie die Verantwortung nach dem Timeout an derselben Stelle mit.
Periodische Tasks und delay until
delay until lässt sich nicht nur für Timeouts, sondern auch für die periodische Ausführung nutzen. Ein einfaches delay 0.1 führt dazu, dass die Periode „Verarbeitungszeit + 0,1 Sekunden“ beträgt, während delay until den nächsten Startzeitpunkt anhand eines absoluten Zeitpunkts festlegt und dadurch eine stabile, von der Verarbeitungsdauer unabhängige Periode einhält.
loop
Next := Next + Period;
Do_Work;
delay until Next;
end loop;
Dieses Muster ist überall dort wirksam, wo eine feste Periodizität gefordert ist — etwa bei Sensorüberwachung oder Regelkreisen.
10. Task-Prioritäten und Echtzeit-Scheduling
Adas Echtzeitfunktionen sind in Annex D (Real-Time Systems) definiert. Unterstützt eine Ada-Implementierung Annex D, lassen sich Task-Prioritäten und Scheduling-Policies angeben.
Prüfen, ob es in der eigenen Umgebung funktioniert
Annex D ist einer der Specialized Needs Annexes (fachspezifischen Anhänge), und der Unterstützungsgrad hängt von Implementierung und Laufzeitumgebung ab. Ob es bei Ihnen funktioniert, lässt sich in drei Schritten klären.
1. Den Prioritätsbereich ansehen
with Ada.Text_IO; use Ada.Text_IO;
with System;
procedure Check_Priority is
begin
Put_Line ("Priority range :"
& Integer'Image (System.Priority'First)
& " .."
& Integer'Image (System.Priority'Last));
Put_Line ("Default_Priority :"
& Integer'Image (System.Default_Priority));
end Check_Priority;
Der Bereich und der Standardwert von System.Priority sind implementierungsabhängig, weshalb hier keine konkreten Zahlen genannt werden. Ist der angezeigte Bereich ausreichend breit, hat eine Angabe wie pragma Priority (System.Default_Priority + 5) in dieser Umgebung eine echte Bedeutung.
2. Prüfen, ob sich die Policy-Angaben kompilieren lassen
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
Lässt sich mit diesen Angaben erfolgreich bauen, werden sie zumindest syntaktisch akzeptiert.
3. Prüfen, ob es tatsächlich gemäß der Priorität läuft
Das ist die größte Falle. Dass der Code kompiliert, ist etwas anderes, als dass der Scheduler des Betriebssystems tatsächlich gemäß der Priorität arbeitet. Auf Allzweckbetriebssystemen wie Linux oder Windows können auf Betriebssystemebene Berechtigungseinstellungen nötig sein, damit Echtzeitprioritäten tatsächlich beim Scheduler ankommen. Auch die README der Beispielsammlung zu diesem Artikel weist darauf hin, dass 08_task_priorities.ada in Umgebungen, in denen Annex D nicht vollständig unterstützt wird, als gewöhnlicher Task läuft.
Das bedeutet: Das Programm läuft weiter, selbst wenn die Priorität keine Wirkung zeigt. Für Anwendungen, die harte Echtzeit erfordern, genügt ein rein am Schreibtisch entworfenes Prioritätskonzept nicht — es ist zwingend nötig, die tatsächliche Reihenfolge in der Zielumgebung zu messen und zu überprüfen.
task High_Task is
pragma Priority (System.Default_Priority + 5);
end High_Task;
task Low_Task is
pragma Priority (System.Default_Priority);
end Low_Task;
Als fortgeschrittenere Konfiguration lassen sich außerdem die Scheduling-Policy und das Priority Ceiling Protocol angeben.
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
Das Priority Ceiling Protocol (Prioritätsobergrenzen-Protokoll) ist ein Protokoll zur Vermeidung von Prioritätsumkehr. Für jedes geschützte Objekt wird über pragma Priority (oder das Priority-Aspect) explizit eine Ceiling-Priorität festgelegt. Übersteigt die aktive Priorität des aufrufenden Tasks diese Obergrenze, wird Program_Error ausgelöst. Während das Objekt gesperrt ist, läuft die Ausführung mit der Ceiling-Priorität, wodurch eine Präemption durch Tasks mittlerer Priorität verhindert wird.
protected Shared_Data is
pragma Priority (15); -- Ceiling-Priorität
procedure Update (Val : Integer);
function Read return Integer;
private
Data : Integer := 0;
end Shared_Data;
Diese Funktionen beruhen auf dem theoretischen Hintergrund von Rate Monotonic Scheduling (RMS) und haben sich in Hard-Real-Time-Systemen wie der Flugsteuerung von Flugzeugen oder medizinischen Geräten bewährt. RMS ist ein Verfahren mit fester Priorität, bei dem Tasks mit kürzerer Periode eine höhere Priorität erhalten; entscheidend für Hard-Real-Time-Anwendungen ist dabei, dass sich vor der Ausführung analysieren lässt, ob eine Menge periodischer Tasks ihre Fristen einhalten kann.
11. Entwurfsrichtlinien für die Praxis
Bisher haben wir die grundlegende Syntax von Tasks und geschützten Objekten betrachtet. Zum Abschluss fassen wir die Entwurfsrichtlinien zusammen, die Sie beim praktischen Einsatz von Adas Nebenläufigkeit im Hinterkopf behalten sollten.
Was in geschützten Objekten tabu ist
Innerhalb eines geschützten Objekts gilt als eiserne Regel: Nur kurze Zustandsaktualisierungen vornehmen, aufwendige Verarbeitung außerhalb ausführen. Da Operationen an geschützten Objekten intern exklusiv ausgeführt werden, blockiert eine lange Blockade darin alle anderen Tasks, die dasselbe geschützte Objekt verwenden.
Konkret zu vermeiden sind:
delayoder zeitaufwendige I/O- komplexe Aufrufe in ein anderes geschütztes Objekt
- schwergewichtige Aufrufe externer Bibliotheken
delay und bestimmte I/O-Operationen innerhalb einer geschützten Operation sind dabei kein reines Performance-Problem, sondern ein begrenzter Fehler (bounded error) nach dem Ada-Standard. Ein begrenzter Fehler ist eine Fehlerart, bei der der Standard zwar den Bereich der möglichen Ergebnisse festlegt, aber nicht, welches davon tatsächlich eintritt. Das ist zwar nicht so unbegrenzt wie eine fehlerhafte Ausführung (erroneous execution), bietet aber ebenfalls keine Garantie für korrektes Verhalten. Tatsächlich kann es je nach Implementierung zu Program_Error oder zu einem Deadlock kommen — deshalb müssen diese Operationen nicht nur „vermieden“, sondern vollständig ausgeschlossen werden.
Ein gutes Design folgt dem Muster: benötigte Werte kurz aus dem geschützten Objekt entnehmen → aufwendige Berechnung oder I/O außerhalb durchführen → nur das Ergebnis kurz in das geschützte Objekt zurückschreiben.
Barrierebedingungen einfach halten
Die Barriere entry ... when <condition> ist mächtig, wird aber bei zu hoher Komplexität schwer lesbar, und es wird schwierig herauszufinden, warum ein Task nicht freigegeben wird.
Ideal ist ein Niveau wie bei when Count < Buffer_Size oder when Used > 0, bei dem die Bedeutung des Zustands auf einen Blick klar ist. Sind mehrere Bedingungen wirklich nötig, sollten Sie erwägen, den Zustand durch einen Aufzählungstyp darzustellen und die Barriere so zu formulieren, dass sie sich wie when State = Running anhand eines Zustandsnamens lesen lässt.
Ausnahmen und Beendigung von Tasks
Für den Fall, dass innerhalb eines Tasks eine Ausnahme auftritt, muss eine Vorgehensweise ausdrücklich festgelegt sein. Mindestens sollten Sie die Ausnahme auf oberster Ebene des Task-Rumpfs abfangen und protokollieren, was passiert ist.
Noch wichtiger ist das Design nach der Ausnahme. Kann das System weiterlaufen, wenn dieser Task stoppt? Darf er neu gestartet werden? Wie werden andere Tasks benachrichtigt? Wie wird gemeinsam genutzter Zustand wieder in einen sicheren Zustand versetzt? Auf diese Fragen müssen Sie Antworten parat haben. Ada bietet den Ausnahmemechanismus als Sprachfeature, aber die Sicherheit nach einer Ausnahme liegt in der Verantwortung des Anwendungsdesigns.
Mini-Checkliste für den Entwurf
| Aspekt | Zu prüfen |
|---|---|
| Gemeinsam genutzter Zustand | Ist er in einem geschützten Objekt eingekapselt? Wird er von außen direkt angefasst? |
| Geschützte Operationen | Sind sie kurz? Blockieren sie intern? |
| Entries | Ist die Barriere einfach? Kann ewig gewartet werden? Gibt es eine Timeout-Strategie? |
| Task-Lebensdauer | Ist die Beendigungsbedingung klar? Gibt es eine Strategie für Ausnahmen? |
| Periodische Verarbeitung | Wurde delay until statt delay erwogen? |
Bei Nebenläufigkeit ist „wird schon gutgehen“ am gefährlichsten. Gemeinsam genutzten Zustand, Wartebedingungen, Beendigungsbedingungen und die Ausnahmestrategie explizit im Code festzuhalten ist der erste Schritt zu sicherer Nebenläufigkeit.
12. Zusammenfassung ── Eine Sprache, die Nebenläufigkeit zur „Grammatik“ gemacht hat
Was Adas Nebenläufigkeitsmodell von anderen Sprachen unterscheidet, ist, dass sichere Nebenläufigkeit nicht als „nachträglich angeflanschte Best Practice“, sondern als Teil der Grammatik selbst eingebaut ist.
| Was Sie erreichen wollen | Ada-Syntax |
|---|---|
| Unabhängige Ausführungseinheit | task / task body |
| Synchrone Kommunikation | entry / accept |
| Warten auf mehrere Anfragen | select / or / else |
| Exklusivzugriff | protected / function / procedure |
| Bedingte Synchronisation | entry ... when <barrier> |
| Timeout | or delay until <time> |
| Prioritätssteuerung | pragma Priority |
Diese Konstrukte werden vom Compiler geprüft. Versucht man beispielsweise, innerhalb einer Funktion eines geschützten Objekts dessen eigene private Komponenten zu verändern, führt das zu einem Kompilierfehler. Ist eine geschützte Operation abgeschlossen, werden die Barrieren wartender Entries automatisch neu ausgewertet — ein manuelles Senden eines Signals ist nicht nötig.
„So wie das Typsystem Speichersicherheit garantiert,
garantiert Adas Nebenläufigkeitssyntax die Sicherheit der Synchronisation.“
Die acht in diesem Artikel behandelten Codebeispiele sind eine praxisnahe Einführung in Tasks, Rendezvous, geschützte Objekte und Echtzeitfunktionen. Probieren Sie sie selbst aus, und wagen Sie sich anschließend an folgende weiterführende Themen.
- Ravenscar-Profil: Ein Tasking-Einschränkungsprofil für hochzuverlässige Echtzeitsysteme. Das eingeschränkte Tasking-Modell ermöglicht statische Deadlock-Analysen.
- Parallele Blöcke in Ada 2022: Datenparallele Verarbeitung mit der Konstruktion
parallel ... do. - Integration mit SPARK: Formale Verifikation des Verhaltens nebenläufiger Programme (unter dem Ravenscar-Profil von GNATprove unterstützt).
Trotzdem: „Ada verwenden“ heißt nicht automatisch sicher
Ein letzter wichtiger Hinweis zum Schluss. Adas Nebenläufigkeitssyntax ist mächtig, aber die Verwendung von Ada macht ein Programm nicht automatisch sicher. Gemeinsam genutzte Daten direkt anzufassen, ohne sie in ein geschütztes Objekt zu legen, lange innerhalb eines geschützten Objekts zu blockieren, oder mehrere geschützte Objekte in komplexer Weise gegenseitig aufzurufen — solche Entwurfsfehler können auch in Ada passieren.
Die Sprachfeatures sind so gestaltet, dass „gefährlicher Code zu schreiben expliziten Aufwand erfordert“, aber sie übernehmen nicht den korrekten Entwurf selbst. Der wahre Wert von Ada liegt darin, die Diskussion über Sicherheit nahe an den Code heranzuholen — Fragen wie „ist dieser Zustand geschützt?“, „wann endet dieser Task?“ oder „unter welcher Bedingung wartet dieser Entry?“ lassen sich als Syntax im Code selbst festhalten.
Adas Philosophie, Entwurf über Typen auszudrücken, zieht sich auch bei Nebenläufigkeit konsequent durch. Sichere Nebenläufigkeit beginnt nicht mit dem sorgfältigen Umgang mit Locks, sondern damit, gefährlichen gemeinsam genutzten Zustand erst gar nicht ungeschützt existieren zu lassen.
Der verbreiteten Ansicht, dass „Nebenläufigkeit schwierig ist“, antwortet Ada: „Wählen Sie die richtige Syntax, und der Compiler garantiert die Sicherheit.“ Diese Entwurfsphilosophie findet sich auch in modernen Sprachen wie Rust oder Pony wieder — doch Ada trägt sie bereits seit 40 Jahren als Teil seiner Sprachspezifikation.
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Echtzeitsystemprogrammierung mit Ada — Priorität, Periodizität und Ausführungszeitsteuerung in der Praxis
Ein praxisnaher Einstieg in Adas Annex D (Echtzeitsysteme) anhand von acht praktischen Codebeispielen: Task-Prioritäten, Ceiling_Locking,...
Generische Programmierung in Ada ── Verträge in Typen schreiben und Wiederverwendung ohne Laufzeitkosten
Eine systematische Einführung in die generische Programmierung in Ada - von generischen Unterprogrammen und Paketen über formale Unterpro...
Einführung in die formale Verifikation mit SPARK ── Von Ada-Verträgen zum mathematischen Beweis
Eine praxisorientierte Einführung in die formale Verifikation mit SPARK, der Ada-Teilsprache. Der Artikel zeigt den Weg von Verträgen (Pr...
Der Reiz der Sprache Ada — Design in Typen ausdrücken und Software, die jahrzehntelang läuft
Eine Einführung in den Reiz der Sprache Ada: starke Typisierung, Bereichsbeschränkungen, die Trennung von Spezifikation und Implementieru...
Fallstricke bei Shared Memory und Best Practices für die Praxis
Wir ordnen die Fallstricke beim praktischen Einsatz von Shared Memory und ein Design ein, das die Unfallrate senkt – von Synchronisation ...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Was ist ein Task in Ada?
- Ein Task ist die grundlegende Einheit der Nebenläufigkeit in Ada. Er ähnelt einem Thread, entspricht aber nicht zwangsläufig eins zu eins einem OS-Thread — die Ada-Laufzeitumgebung übernimmt das Scheduling. Ein Task beginnt automatisch mit der Ausführung, sobald er deklariert wird, und wenn das Hauptprogramm endet, wird implizit auf die Fertigstellung noch laufender Tasks gewartet. Mit der Außenwelt kommuniziert er über ein Rendezvous durch einen Entry (entry) synchron. Tasks und Rendezvous sind seit Ada 83 aus dem Jahr 1983 Teil der Sprachspezifikation.
- Worin unterscheidet sich ein geschütztes Objekt in Ada von einem Mutex?
- Ein geschütztes Objekt ist ein vom Compiler verwalteter Mechanismus zur exklusiven Zugriffskontrolle, bei dem Sie kein manuelles Sperren und Entsperren schreiben müssen. Eine Funktion (function) ist nur lesend und kann von mehreren Tasks gleichzeitig aufgerufen werden, eine Prozedur (procedure) ist lesend und schreibend zugleich und blockiert während ihrer Ausführung alle anderen Aufrufe, und ein Entry (entry) lässt die aufrufende Seite in einer Warteschlange warten, bis eine Barrierebedingung wahr wird. Die Steuerung eines begrenzten Puffers, die man in C mit einer Kombination aus pthread-Mutex und Bedingungsvariable schreibt, verdichtet sich in Ada zu einer einzigen Barrierezeile wie `when Count < Buffer_Size`.
- Wie funktioniert ein Rendezvous in Ada?
- Ein Rendezvous ist der Mechanismus der synchronen Kommunikation zwischen Tasks: Der Entry-Aufruf der aufrufenden Seite und die accept-Anweisung auf Seiten des Tasks warten aufeinander, bis beide gleichzeitig den Rendezvous-Punkt erreichen. Über die Parametermodi in, out und in out lassen sich Daten in beide Richtungen übergeben. Der do…end-Block im Rumpf von accept bildet die kritische Sektion: Während seiner Ausführung ist die aufrufende Seite blockiert, und der Task nimmt keine anderen Entries an. In Kombination mit der select-Anweisung lassen sich das Warten auf mehrere Entries, Timeouts und Guard-Bedingungen ebenfalls deklarativ formulieren.
- Was darf man innerhalb eines geschützten Objekts nicht tun?
- Alles, was lange blockiert: delay-Anweisungen, zeitaufwendige I/O oder schwergewichtige Aufrufe externer Bibliotheken. delay oder bestimmte I/O-Operationen innerhalb einer geschützten Operation gelten nach dem Ada-Standard als begrenzter Fehler (bounded error) und können je nach Implementierung zu Program_Error oder zu einem Deadlock führen — sie müssen deshalb vollständig vermieden werden, nicht nur eingeschränkt. Die eiserne Regel lautet: Nur kurze Zustandsaktualisierungen innerhalb des geschützten Objekts vornehmen, aufwendige Berechnungen oder I/O außerhalb ausführen und nur das Ergebnis kurz zurückschreiben.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.