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 bedeutet, dass ein Gerät mehrere Aufgaben gleichzeitig erledigen kann, so wie eine Fabrik mit mehreren Fließbändern unterschiedliche Produkte gleichzeitig herstellen kann. Die Fließbänder entsprechen im Programm den Threads, und die Arbeitsschritte an den Bändern entsprechen im Programm den Tasks.

Im Mikrocontroller Umfeld wird dies häufig mit einem Zustandsautomaten realisiert, da dieser wenig Speicher benötigt. Darauf aufbauend entwickelte Adam Dunkels am schwedischen Institut für Computerwissenschaften seine Protothreads.

Zustandsautomat

Zuerst möchte ich ein fiktives Beispielprogramm zeigen, dass den Inhalt einer Textdatei auf den Bildschirm aufgibt. Dieses Programm wird in den nächsten Schritten Multitasking fähig gemacht.
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;
}
Wenn der Computer während dessen noch etwas anderes erledigen soll, muss man entweder
  1. die while Schleife mit einem Timer aktiv unterbrechen (= präemptives Multitasking) oder
  2. sie muss so umgeschrieben werden, dass sie selbst Rechenzeit an andere Threads abgibt (= kooperatives Multitasking)
Ich will hier die Variante b) mit einem Zustandsautomaten erklären. Dieser besteht aus einer endlosen Programmschleife, in der die einzelnen Threads immer abwechselnd aufgerufen werden.

int main() {
    while(1) {
        mache ein bisschen von Thread 1;
        mache ein bisschen von Thread 2;
    }
}

Damit auch wirklich jeder Thread oft genug an die Reihe kommt, darf keiner der Threads stehen bleiben. Jeder Thread darf pro Schleifendurchlauf nur eine kleine kurze Aufgabe erledigen. Aufgaben, die lange dauern (z.B. das Warten auf eine Benutzereingabe) müssen über mehrere Schleifendurchläufe verteilt werden.

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

Das folgende Programmbeispiel zeigt, wie so ein Thread programmiert werden kann. Jeder case ist hier ein Arbeitsschritt (Task):

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

// Thread 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;
    }   
}

// Thread 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");  // Thread 1 aufrufen
       print_x();                         // Thread 2 aufrufen
       // Hier könnten noch weitere Thread stehen
   }      
}
Das Hauptprogramm besteht hier aus einer simplen Endlosschleife, in der alle Threads abwechselnd aufgerufen werden. Der print_file() Thread führt bei jedem Aufruf nur eine kleine Aktion aus, nämlich:

Wenn die Datei komplett ausgegeben wurde, macht der Thread bei allen weiteren Schleifendurchläufen einfach gar nichts mehr. Oder anders formuliert: Er gibt die Rechenzeit sofort an andere Threads ab. Der zweite Thread 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 Threads quasi zeitgleich ablaufen.

Die Variablen "step" und "step2" sind die Zustandsvariablen der Zustandautomaten. Sie geben stets darüber Auskunft, in welchem Zustand sich die Threads befinden, bzw. welche Aufgabe (Task) sie als nächstes ausführen müssen.

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 bloß 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 sind. 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.

In dem letzten Programmbeispiel hatte ich einige Variablen global deklariert, was dem erfahrenen Programmierer sofort 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: