Start page   

Multitasking mit Protothreads und Protosockets

Einleitung

In diesem Aufsatz erkläre ich, wie man mit Hilfe von Protothreads und Protosockets eigene Multitasking fähige Programme für kleine Mikrocontroller schreibt. Viele Projekte benutzen diese Technologie, so auch mein AVR Webserver.

Multitasking ist beinahe unumgänglich, wenn man Mikrocomputer miteinander vernetzt. Denn neben der Kommunikation sollen sie gleichzeitig noch andere Aufgaben wahrnehmen, z.B. eine Maschine steuern. Zu diesem Zweck entwickelte Adam Dunkels am schwedischen Institut für Computerwissenschaften die Protothreads.

Präemptives Multitasking

Die großen Betriebssysteme (Windows, Linux, etc) können präemptives Multitasking. Dabei unterbricht ein Timer in regelmäßigen Intervallen den gerade laufenden Task, um andere Tasks auszuführen. Die Tasks werden also nicht wirklich gleichzeitig ausgeführt, sondern immer abwechsend ein Stück von jedem Task.

Damit das Betriebssytem seine Tasks aktiv unterbrechen kann, braucht es einen Timer und einen Prozessor, der dazu spezielle Funktionen hat. Mikrocontroller sind für präemtives Multitasking in der Regel nicht geeignet.

Kooperatives Multitasking

Kooperatives Multitasking beruht darauf, dass jeder Task sich selbst unterbricht, wenn er dazu bereit ist. Man sagt, dass die Tasks kooperieren, weil sie sich gegenseitig Rechenzeit abgeben. Der Haken ist: Wenn ein Task aus irgend einem Grund überhaupt keine Rechenzeit abgibt, dann bleiben alle anderen Tasks hängen. Wer noch das alte Windows 3 kennt, kann sich sicher an solche Situationen erinnern.

Da kooperatives Multitasking ohne besondere Hardware auskommt, findet man in Programmen für Mikrocontroller diverse implementierungen dieser Variante. Eine klassische implementierung ist der Zustandsautomat, auf dessen Idee die Protothreads direkt aufsetzen.

Zustandsautomat

Zuerst möchte ich ein einfaches Beispielprogramm zeigen, dass den Inhalt einer Textdatei auf den Bildschirm aufgibt. Dieses Programm wird in den nächsten Schritten so verändert, dass es Multi-Tasking fähig wird.
int main() {
    File* file=fopen("datei.txt");              // Datei öffnen

    while (!feof(file)) {                       // Solange das Dateieende nicht erreicht ist ...
        int bytesRead=fread(buffer,10,1,file);  // bis zu 10*1 Bytes lesen
        fwrite(buffer,bytesRead,1,stdout);      // die gelesenen Bytes auf dem Bildschirm ausgeben
    }

    fclose(file);                               // Datei schließen
    return 0;
}
Um dieses Programm Multitasking fähig zu machen, muss die while Schleife in regelmäßigen Zeitabständen irgendwie unterbrochen werden, um andere Tasks auszuführen. Eine mögliche Umsetzungsmethode für kooperatives Multitasking ist der Zustandsautomat.

Der Zustandsautomat besteht aus einer (meist) endlosen Programmschleife, in der die einzelnen Tasks immer wieder nacheinander aufgerufen werden.

int main() {
    while(1) {   // Endlos-Schleife
        mache ein bisschen von Task 1;
        mache ein bisschen von Task 2;
    }
}

Damit auch wirklich jeder Task oft genug an die Reihe kommt, darf keiner der Tasks stehen bleiben. Jeder Tasks darf pro Schleifendurchlauf nur eine kleine kurze Aktion durchführen. Aufgaben, die länger dauern (z.B. das Lesen aus einer Datei, oder das Warten auf eine Benutzereingabe) müssen über mehrere Schleifendurchläufe verteilt werden.

Die Task-Funktionen des Zustandsautomaten sind so geschrieben, dass sie bei jedem Schleifendurchlauf Bescheid wissen, welchen kleinen Arbeitsschritt sie als nächstes zu tun haben. Diese Information merkt sich der Task in seiner Zustands-Variable.

Das folgende Programmbeispiel zeigt, wie so ein Task programmiert werden kann:

int step=0;
int step2=0;
File* file;
char buffer[10];
int bytesRead;

// Task 1: Gib den Inhalt einer Datei aus
void print_file(char* filename) {
    switch (step) {
        case 0: 
            file=fopen(filename);  // Datei öffnen
            step=1;
            break;
        case 1:
            bytesRead=fread(buffer,10,1,file);  // lesen
            step=2;
            break;
        case 2:
            fwrite(buffer,bytesRead,1,stdout);  // ausgeben
            if (feof(file)) {  // Entscheiden, was als nächstes kommt
                step=3;
            }
            else {
                step=1;
            }
            break;    
        case 3:
            fclose(file);  // Datei schließen
            step=999;  
            break;
        case 999:
           // Nichts mehr tun
           break;
    }   
}

// Task 2: Gib xXxXxX und so weiter aus
void print_x() {
    switch (step2) {
        case 0: 
            putchar('x');
            step2=1;
            break;
        case 1:
            putchar('X');
            step2=0;
            break;
    }   
}

int main() {
   while (1) {
       print_file("datei_mit_50_u.txt");  // Task 1 aufrufen
       print_x();                         // Task 2 aufrufen
       // Hier könnten noch weitere Tasks stehen
   }      
}
Das Hauptprogramm besteht hier aus einer simplen Endlosschleife, in der alle Tasks wiederholt aufgerufen werden. Der print_file() Task führt bei jedem Aufruf nur eine kleine Aktion aus, nämlich:

Wenn die Datei komplett ausgegeben wurde, macht der Task bei allen weiteren Schleifendurchläufen einfach gar nichts mehr. Oder anders formuliert: Er gibt die Rechenzeit sofort an andere Tasks ab. Der zweite Task gibt immer abwechselnd die Buchstaben x und X aus, und zwar unendlich oft. Er endet niemals.

Angenommen, die Datei enthält genau 50 "u", dann sieht die Bildschirmausgabe so aus:

xXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXxXxXxXxXxXxXxXxXxXxXxX...
Diese Bildschirmausgabe zeigt deutlich, dass beide Tasks quasi zeitgleich ablaufen. Zwischen den Text-Blöcken erscheinen immer zwei "x", weil der print_file Task nur bei jedem zweiten Aufruf etwas auf dem Bildschirm ausgibt.

Die Variablen "step" und "step2" sind die Zustandsvariablen der Zustandautomaten. Sie geben stets darüber Ausfkunft, in welchem Zustand sich die Tasks befinden, bzw. welche Aktion sie Task als nächstes zu tun haben.

Tasks, Threads und Prozesse

Aufgaben, die der Computer zeitgleich ausführt, nennt man allgemein Tasks. Die Begriffe Thread und Prozess beschreiben einen Task noch etwas präziser:

Bei Mikrocontrollern arbeitet man in der Regel mit Threads, da dazu keine besondere Speicherverwaltung notwendig ist. Für Pozesse müsste man jeweils eigene Speicherbereiche reservieren (sowohl für Variablen, als auch für den Stack) und bei jedem Prozess-Wechsel zwischen diesen Speicherbereichen umschalten. Mit den Mitteln der Programmiersprache C geht das gar nicht, denn es gibt keinen Befehl, der zur direkten Manipulation des Stack-Pointers geeignet wäre. Möglich wäre so etwas nur mit eingebettetem Assembler Code.

Der eigentliche Grund, warum man bei Mikrocontrollern nur selten Prozesse verwendet ist aber, dass man dazu viel mehr RAM Speicher benötigt.

Protothreads

Die Protothreads von Adam Dunkels sind eine Variante von Zustandsautomaten. Sie beruhen wie das obige Programmbeispiel auf dem switch Befehl. Das obige Programmbeispiel würde mit Protothreads (pt.h) so aussehen:
#include "pt.h"

File* file;
char buffer[10];
int bytesRead;

struct pt thread1;
struct pt thread2;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);
    file=fopen(filename);
    PT_YIELD(&thread1);
    while (!eof(file)) {
        bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);
        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }
    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}

PT_THREAD(print_x()) {
   PT_BEGIN(&thread2);
   while(1) {
       putchar('x');
       PT_YIELD(&thread2);
       putchar('X');
       PT_YIELD(&thread2);
   }
   PT_END(&thread2);
}

int main() {
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    while (1) {
        print_file("datei.txt");
        print_x();
    }
}
Hinter der Fassade entsteht durch die Protothread-Makros letztendlich ein Programm mit switch und case Befehlen. Die Makros ermöglichen aber eine übersichtlichere Gestaltung des Quelltextes. Man kann den Quelltext leichter von oben nach unten lesen.

Im obigen Beispiel sind thread1 und thread2 die Zustands-Variablen. Hier merkt sich der Thread, an welcher Stelle er unterbrochen wurde, und wo er beim nächsten Aufruf weiter arbeiten muss.

PT_THREAD benutzt man, um eine Funktion zu deklarieren, die als Thread ausführbar ist. Das Makro legt bloss fest, welchen Datentyp der Rückgabewert der Funktion hat, nämlich ein char. Der Rückgabewert zeigt an, ob der Thread beendet ist, oder warum er sich selbst unterbrochen hat. Folgende Rückgabewerte sind in der Datei pt.h definiert:

#define PT_WAITING 0
#define PT_YIELDED 1
#define PT_EXITED  2
#define PT_ENDED   3
Die Zahlenwerte sind je nach Version unterschiedlich, deswegen sollte man in Programmen immer mit den Namen arbeiten.

PT_BEGIN benutzt man ganz am Anfang im Rumpf der Thread-Funktion. An dieser Stelle fügt das Makro den switch Befehl ein.

PT_YIELD gibt Rechenzeit an andere Threads ab, indem es eine Sprungmarke mittels case Befehl setzt und die Ausführung des Threads mit "return PT_YIELDED" unterbricht. Beim nächsten Schleifendurchlauf sorgt der "switch" Befehl dafür, dass der Thread an dieser Stelle fortgesetzt wird.

PT_YIELD_UNTIL gibt solange Rechenzeit ab, bis eine Bedingung wahr ist. Die Funktion wird mit "return PT_YIELDED" unterbochen. Im obigen Programmbeispiel ist die Bedingung einfach "0" (also immer unwahr), was dazu führt, dass der print_file Thread niemals endet.

Das ist in diesem Fall wichtig, denn ansonsten würde der Thread durch die Hauptschleife gleich wieder neu gestartet werden. Das Programm würde dann die Datei immer wieder erneut ausgeben, was hier nicht gewollt ist. Alternativ dazu hätte ich auch im Hauptprogramm den Rückgabewert des Thread abfragen können, damit er nur so lange aufgerufen wird, bis er beendet ist. Etwa so:

int main() {
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    char status=0;
    while (1) {
        if (status!=PT_ENDED) {
            status=print_file("datei.txt");
        }
        print_x();
    }
}
PT_END schließt den switch Befehl ab, und beendet den Thread mit "return PT_ENDED". Dieses Makro muss immer ganz am Ende des Threads verwendet werden. Falls der Thread nach seinem Ende erneut aufgerufen wird, beginnt er wieder von Anfang an.

PT_INIT setzt die Zustandsvariable auf 0. Damit ist sichergestellt, dass die beiden Threads zunächst von Anfang an ausgeführt werden. Dieses Makro muss unbedingt außerhalb der Thread-Funktionen aufgerufen werden, und zwar bevor sie gestartet werden.

Es gibt noch weitere Makros in der Datei pt.h, die im obigen Beispielprogramm nicht vorkommen:

PT_WAIT_UNTIL wartet, bis eine Bedingung wahr ist. Das Makro fügt mittels case Befehl eine Sprungmarke ein, an der die Funktion beim nächsten Aufruf fortgesetzt wird. Die Funktion wird mit "return PT_WAITING" unterbrochen, um Rechenzeit an andere Threads abzugeben. Der Einzige Unterschied zu PT_YIELD_UNTIL ist der andere Rückgabewert.

PT_WAIT_WHILE wartet, solange eine Bedingung erfüllt ist. Das Makro fügt mittels case Befehl eine Sprungmarke ein, an der die Funktion beim nächsten Aufruf fortgesetzt wird. Die Funktion wird mit "return PT_WAITING", um Rechenzeit an andere Threads abzugeben.

PT_RESTART kann innerhalb eines Threads benutzt werden, um wieder vorn vorne zu beginnen. Das Makro setzt die Zustandsvariable auf 0 und verlässt dann die Funktion mit "return PT_WAITING". Beim nächsten Schleifendurchlauf wird sie daher wieder von Anfang an ausgeführt, so wie nach PT_INIT.

PT_EXIT kann man innerhalb eines Threads benutzen, um ihn vorzeitig abzubrechen. Die Funktion endet mit "return PT_EXITED". Falls der Thread nach seinem Ende erneut aufgerufen wird, beginnt er wieder von Anfang an.

Die Makros zum Starten von Unter-Threads erkläre ich weiter unten in dem entsprechenden Kapitel.

Einschränkungen

ProtoThreads dürfen keine switch/case Befehle verwenden, da die Protothread-Makros selbst schon switch/case Konstrukte nutzen. Verwenden sie stattdessen if und else, wenn nötig.

Die Werte von lokalen Variablen gehen an den Unterbrechungspunkten verloren. Lokale Variablen sind daher nur bedingt verwendbar.

Normalerweise benutzt man ja lokale Variablen, wo immer es geht. Das gehört einfach zum guten Stil dazu, und es bringt den funktionalen Vorteil mit sich, dass diese Funktionen Thread-sicher und reentrant sind - sie können in mehreren Threads quasi gleichzeitig genutzt werden und sie dürfen sich selbst rekursiv aufrufen.

In dem letzten Programmbeispiel hatte ich einige Variablen global deklariert, was dem erfahrenen Programmierer sofort negativ auffallen sollte:

File* file;
char buffer[10];
int bytesRead;
Ohne Threads würde man stattdessen lokale Variablen etwa so nutzen:
void print_file(char* filename) {
    File* file=fopen(filename);
    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);
    }
    fclose(file);
}
Wir wollen die Datei aber in vielen kleinen Stückchen als Thread laden, damit andere Threads auch noch Rechenzeit ab bekommen. Wenn man einfach nur die Protothread Makros einfügt, erhält man folgenden nicht funktionierenden Quelltext:
PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    File* file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];

        int bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);

        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Mit PT_YIELD habe ich Unterbrechungspunkte eingefügt. An diesen Stellen wird die Funktion für eine Weile Unterbrochen, um Rechenzeit an andere Threads abzugeben. Das Unterbrechen geschieht mit return Befehlen. Aus Sicht des C Compilers ist die Funktion also beendet, und darum wird der Speicherplatz, den die lokalen Variablen belegt haben, wieder für andere Zwecke frei gegeben. Beim Fortsetzen des Threads muss man daher damit Rechnen, dass die lokalen Variablen nun zufällige Werte enthalten.

Dagegen kann man nichts tun. Einen return Befehl, der den Speicher von lokalen Variablen beschützt, gibt es nicht - jedenfalls nicht in der Programmiersprache C. Aus diesem Grund kann man lokale Variablen innerhalb von Threads nur sehr bedingt einsetzen. Das ist der Grund, warum ich die Variablen global deklariert habe.

Lokale Variablen in Protothreads richtig verwenden

Das bedeutet allerdings nicht, dass lokale Variablen grundsätzlich verboten sind. Mit Hilfe einer kleinen Änderung kann der Thread print_file zumindest einen Teil seiner Variablen lokal behalten:
File* file;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);

        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Jetzt haben wir nur noch eine globale Variable (file). Die beiden anderen Variablen (buffer und bytesRead) sind lokal und funktionieren so auch korrekt. Denn zwischen der Stelle, wo ihre Werte gesetzt werden (beim Lesen aus der Datei) und der Stelle, wo sie ausgelesen werden (beim Schreiben auf den Bildschirm) befindet sich kein Unterbrechungspunkt mehr.

Diese Änderung hat allerdings auch einen Seiteneffekt. Erinnern sie sich, dass die Ausgabe der Datei immer durch zwei "x" unterbrochen war? Jetzt ist es nur noch ein "x":

xuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXxXxXxXxXxXxXxXxXxXxXxXxXxXxX...
Wir sehen jetzt nur noch ein "x" zwischen den "u", weil ich einen PT_YIELD zwischen dem Lesen aus der Datei und der Bildschirmausgabe entfernt habe. Folglich gibt dieser Thread nur noch halb so oft Rechenzeit an den anderen Thread (print_x) ab. Die Änderung sieht geringfügig aus, doch sie hat einen erheblichen Einfluss auf die Verteilung der Rechenzeit zwischen den Threads.

Wir können die Rechenzeit wieder wie ürsprünglich verteilen indem wir einen PT_YIELD einfügen:

File* file;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);

        PT_YIELD(&thread1);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Jetzt geben wir zwischen der Ausgabe der Text-Stücke immer zweimal Zeit an den anderen Thread ab, so dass die Bildschirmausgabe fast wieder der ursprünglichen entspricht:
xuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxXxXxXxXxXxXxXxXxXxXxXx...
Dennoch besteht hier im Vergleich zur Ursprünglichen Variante des Programms ein kleiner feiner Unterschied im zeitlichen Verhalten, der alleine schon an der veränderten Reihenfolge der großen und kleinen "x" erkennbar ist:

vorhernachher
  • Datei öffnen
  • x
  • Datei lesen (10 Bytes)
  • X
  • Datei anzeigen (10 Bytes)
  • x
  • Datei lesen (10 Bytes)
  • X
  • Datei anzeigen (10 Bytes)
  • x
  • ...
  • Datei öffnen
  • x
  • Datei lesen (10 Bytes)
  • Datei anzeigen (10 Bytes)
  • X
  • x
  • Datei lesen (10 Bytes)
  • Datei anzeigen (10 Bytes)
  • X
  • x
  • ...

Bei zeitkritischen Anwendungen, z.B. wenn ein Thread eine Maschine steuert, muss man manchmal auf solche kleinen Unterschiede achten.

Vielleicht fallen ihn spontan statische lokale Variablen als mögliche Alternative ein:

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    static File* file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        static char buffer[10];

        static int bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);

        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Das sieht vielversprechend aus und bei einem ersten Funktionstest wird es auch prima klappen. Aber einen Haken haben die statischen Variablen: sie liegen im gleichen Speicherbereich, wie globale Variablen. Und deswegen kann man diesen Thread nicht mehrmals zeitgleich ausführen. Also egal ob sie nun globale oder lokale statische Variablen verwenden - dieser Thread darf nur einmal gleichzeitig laufen, sonst überschreiben sich die beiden Ausführungen gegenseitig ihre Variablen.

Wenn sie die Hauptschleife wie folgt ändern, wird das Programm mit Sicherheit abstürzen:

int main() {
   while (1) {
       print_file("datei.txt");
       print_file("noch_eine_datei.txt");
   }  
}
Da die Threads nun zwei Dateien quasi zeitgleich lesen, brauchen Sie auch zwei file Variablen, zwei buffer und zwei readBytes Variablen. Aber die existieren eben nur einmal, weil sie entweder im globalen Bereich deklariert sind oder lokal aber statisch (was letztendlich das Gleiche ist).

Dieses Dilemma kann man lösen, indem man alle Thread-Variablen in Strukturen verpackt und dann den Funktionen als Parameter übergibt. Zum Beispiel so:

#include "pt.h"

struct thread_data {
    File* file;
    char buffer[10];
    int bytesRead;
    struct pt thread;
};

struct thread_data data1;
struct thread_data data2;
struct thread_data data3;


PT_THREAD(print_file(struct file_data* data, char* filename)) {
    PT_BEGIN(&data->thread);

    data->file=fopen(filename);
    PT_YIELD(&data->thread);

    while (!eof(file)) {
        data->bytesRead=fread(data->buffer,10,1,file);
        PT_YIELD(&data->thread);

        fwrite(data->buffer,data->bytesRead,1,stdout);
        PT_YIELD(&data->thread);
    }

    fclose(data->file);
    PT_YIELD_UNTIL(&data->thread,0);

    PT_END(&data->thread);
}

int main() {
    PT_INIT(&data1->thread);
    PT_INIT(&data2->thread);
    PT_INIT(&data3->thread);
    while (1) {
        print_file(&data1->thread, "datei_voller_u.txt");
        print_file(&data2->thread, "datei_voller_s.txt");
        print_file(&data3->thread, "datei_voller_h.txt");
    }
}
Jetzt hat jeder Thread seine eigenen Variablen, so dass sie sich nicht gegenseitig stören. Dieses Programm gibt den Inhalt von drei Dateien quasi gleichzeitig aus:
uuuuuuuuuusssssssssshhhhhhhhhhuuuuuuuuuusssssssssshhhhhhhhhhuuuuuuuuuusssssssssshhhhhhhhhh...

Verschachtelte Protothreads

Je nach dem, wieviel ein Thread zu tun hat, kann er ziemlich umfangreich werden. Wohl kaum jemand möchte allerdings Monster-Funktionen mit hunderten Zeilen von Quelltext schreiben. Ein ordentlich strukturiertes Programm teilt große Aufgaben in viele kleine auf. Das will ich anhand eines neuen Beispielprogrammes demonstrieren. Das Programm soll einen Text über eine serielle Verbindung senden. Fangen wir wieder mit einem Quelltext ohne Threads an:
void send_character(char char) {
    while (!serial_ready()) {};  // warte
    serial_write(char);
}

void send_line(char[] string) {
    int i=0;
    while (string[i]!=0) {
        send_character(string[i]);
        i++;
    }
    send_character('\n'); // Zeilenumbruch
}

void send_text() {
    send_line("Hallo");
    send_line("da bist du ja!");
    send_line("Ich habe dich vermisst.");
}

int main() {
    send_text();
}
Die fiktive Funktion serial_ready() liefert 1 (true) zurück, wenn der serielle Port bereit ist, ein Zeichen zu senden. Das kann eine Weile dauern. Die ebenfalls fiktive Funktion serial_write() übergibt einen Buchstaben an den seriellen Port. Wir wollen uns an dieser Stelle nicht mit den technischen Details des seriellen Ports beschäftigen. Es genügt, zu wissen, dass serielle Ports für jeden Buchstaben eine gewisse Zeit benötigen, ihn zu senden. Während das Programm darauf wartet, kann es andere sinnvolle Threads ausführen.

Worum es mir hier geht, sind die über mehrere Ebenen verschachtelten Funktionen: