In diesem Artikel geht es hauptsächlich um die Implementierung von Coroutinen in C,
die ja eigentlich nicht Bestandteil der Sprache sind. Auf das Coroutinen-Konzept wird daher
nur kurz eingegangen. Wer es gerne etwas ausführlicher hätte, dem sei der Artikel
Coroutinen in Modula-2 empfohlen.
Erklärung
Ein in einer prozeduralen Sprache geschriebenes Programm arbeitet
gewöhnlich hierarchisch. Das bedeutet, Prozeduren geben durch
Aufruf anderer Prozeduren die Kontrolle an diese ab und erhalten
sie auf jeden Fall von diesen wieder zurück, sobald diese ihre
Arbeit erledigt haben.
Coroutinen sind ebenfalls Prozeduren,
gewöhnlich parameterlos und endlos.
Coroutinen geben die Kontrolle jedoch aktiv an eine andere
Coroutine ab, eine passive Rückgabe nach Beendigung an die
aufrufende Coroutine gibt es nicht. Will eine Coroutine ihre
Arbeit unterbrechen, muß sie angeben, welche andere Coroutine nun
zum Zug kommen soll. In einer speziellen Anweisung kann sie die
Kontrolle an diese abgeben. Erhält eine Coroutine die Kontrolle,
wird an der Stelle weitergemacht, an der sie zuletzt selbst die
Kontrolle abgegeben hat.
Ein Beispiel
Als Beispiel kann das folgende Programm dienen, das ein
Würfelspiel mit Coroutinen simuliert:
#include <stdio.h>
#include <corout.h>
#define GEWINN 11
short wuerfel ( void );
void Sp1 ( void );
void Sp2 ( void );
CorBuf Spieler1, Spieler2, Haupt;
long start;
int main()
{
printf ( "Startwert : " );
scanf ( "%ld", &start );
printf ( "*** WUERFELSPIEL ***\n\n" );
printf ( "Gewinnsumme : %d\n\n", GEWINN );
NEWCOR ( Spieler1, Sp1 );
NEWCOR ( Spieler2, Sp2 );
TRANSFER ( Haupt, Spieler1 );
printf ( "*** ENDE DES SPIELS ***\n" );
return(0);
}
void Sp1()
{
short summe, wurf;
for ( summe=0;; )
{
wurf = wuerfel();
printf ( "Spieler 1 // %d\n", summe );
printf ( " gewuerfelt : %d\n", wurf );
if ( summe+wurf>GEWINN )
{
printf ( " Ich kann nicht...\n" );
TRANSFER ( Spieler1, Spieler2 );
}
else if ( summe+wurf==GEWINN )
{
printf ( " Ich habe gewonnen!\n" );
TRANSFER ( Spieler1, Haupt );
}
else
{
summe += wurf;
printf ( " Ich habe jetzt %d\n", summe );
TRANSFER ( Spieler1, Spieler2 );
}
} /* for */
}
void Sp2()
{
short summe, wurf;
for ( summe=0; ; )
{
wurf = wuerfel();
printf ( "Spieler 2 // %d\n", summe );
printf ( " gewuerfelt : %d\n", wurf );
if ( summe+wurf>GEWINN )
{
printf ( " Ich kann nicht...\n" );
TRANSFER ( Spieler2, Spieler1 );
}
else if ( summe+wurf==GEWINN )
{
printf ( " Ich habe gewonnen!\n" );
TRANSFER ( Spieler2, Haupt );
}
else
{
summe += wurf;
printf ( " Ich habe jetzt %d\n", summe );
TRANSFER ( Spieler2, Spieler1 );
}
} /* for */
}
short wuerfel()
{
start = (start*start) % 251;
return ( (short)(start%6+1) );
}
|
Die "Anweisung" TRANSFER dient jeweils der Übergabe der Kontrolle.
Bei dem Programm handelt es sich um ein Würfelspiel,
bei dem zwei Spieler abwechselnd würfeln,
bis einer die Gewinnsumme GEWINN erreicht hat. Jeder der beiden
Spieler wird durch eine Coroutine repräsentiert. Wie in der
Realität ein Spieler dem anderen den Würfel übergibt, übergibt bei
diesem Programm eine Coroutine der anderen die Kontrolle durch
TRANSFER. Hat einer der "Spieler" die Gewinnsumme erreicht, gibt
er die Kontrolle an das Hauptprogramm zurück.
Wie kann das funktionieren?
Als Teil des Sprachumfangs sind Coroutinen z.B. in den
Programmiersprachen Modula-2 und Simula enthalten.
Die "Anweisungen" NEWCOR und TRANSFER sind jedoch nicht
Bestandteil der Sprache C, ebensowenig der Datentyp CorBuf. Diese
alle müssen geeignet definiert werden, damit das soeben gezeigte Programm
ablauffähig ist.
Wie man sieht, muß für jede Coroutine ein gewisser
Speicherplatz, der "Coroutinen-Buffer", reserviert werden. Ob dies
durch statische Definition (wie hier) oder durch
nachträgliches Allokieren geschieht, ist gleichgültig. Es sollten
jedenfalls nur Variablen vom Typ CorBuf verwendet werden.
Dann muß jede Coroutine als solche erklärt werden und ihr
gleichzeitig der Buffer zugewiesen werden. Das geschieht durch
NEWCOR. Die Kontrolle wird durch TRANSFER an eine andere Coroutine
übergeben. Nimmt man an, daß NEWCOR und TRANSFER so arbeiten, ist
klar, daß das Programm wie besprochen arbeitet.
Doch eine Definition von NEWCOR und TRANSFER steht noch aus.
setjmp und longjmp
In C gibt es mit setjmp und longjmp ein Paar von Funktionen, die
ähnliches wie NEWCOR und TRANSFER leisten. setjmp speichert die
gesamte momentane Ablaufumgebung in einem speziellen Bereich. Mit
"Ablaufumgebung" ist der Zustand des Prozessors, d.h. die Inhalte
der Register und des Program-Counters, sowie der Inhalt des
Parameter-Stacks gemeint.
longjmp stellt genau den Zustand des Prozessors und des Stacks
wieder her, der durch einen vorhergehenden Aufruf von setjmp
gesichert worden war. Natürlich muß der Speicherbereich angegeben
werden, in den die betreffenden Daten gesichert wurden. Da mit dem
Zustand des Prozessors auch der Inhalt des Program-Counters auf
den vorherigen Stand gesetzt wurde, wird die Ausführung nun an der
Stelle fortgesetzt, an der zuvor setjmp ausgeführt wurde.
Die nach diesem setjmp folgenden Anweisungen würden nun zum
zweiten Mal durchlaufen. Man muß sich aber darüber klar sein, daß
diese Stelle nun auf andere Art und Weise erreicht worden ist als
zuvor. Denn zuerst war es ja der ganz normale Programmablauf, der
die Kontrolle bis an diese Stelle gebracht hat. Nun jedoch war es
ein künstliches Umsetzen des Program-Counters.
Es wird wünschenswert sein, beide Fälle zu unterscheiden.
Zu diesem Zweck liefert setjmp einen Wert zurück. Dieser ist 0,
falls setjmp normal durchlaufen wurde. Und es ist ein Wert
ungleich 0, falls die Kontrolle durch longjmp an diese Stelle
gelenkt wurde. Dieser Wert kann durch longjmp festgelegt werden,
darf aber nicht 0 betragen.
setjmp und longjmp sind also die Mittel, die benötigt werden. Die
Realisierung von NEWCOR und TRANSFER ist zum einen von Interesse,
um Coroutinen-Programme direkt aus Modula-2 oder Simula nach C zu
portieren. Zum anderen sind setjmp und longjmp in der Handhabung
etwas schwerfällig.
Definition der nötigen Makros
Es sollen nun zwei Makros NEWCOR(buf,proc) und TRANSFER(bufa,bufn)
definiert werden. Wie in Modula-2 soll NEWCOR eine Prozedur als
Coroutine erklären. Der Parameter proc stellt die Adresse der
Prozedur dar, buf den Coroutinen-Buffer. TRANSFER übergibt die
Kontrolle an eine andere Coroutine. Der Buffer der aktuellen
Coroutine (bufa) und der der neuen (bufn) müssen als Argument
angegeben werden.
Bei TRANSFER ist also gar nicht bekannt, zu welcher Coroutine
jetzt eigentlich verzweigt werden soll. Das ist aber auch gar
nicht nötig. Denn die betreffende Coroutine hat ja vorher
irgendwann auch ein TRANSFER durchgeführt, die relevanten Daten
sind in dem Buffer bufn festgehalten.
Das bedeutet, TRANSFER muß zunächst ein setjmp(bufa) durchführen,
um die aktuellen Daten für einen späteren Rücksprung zu sichern.
Sodann muß longjmp(bufn,1) aufgerufen werden, um die Kontrolle der
gewünschten Coroutine zu übergeben. Der zweite Parameter ist der
Wert, den setjmp an der Rücksprungstelle nun zurückliefert.
Dürfen die beiden Aufrufe einfach direkt hintereinandergeschrieben
werden? Nein, sicher nicht. Denn dann würde die Kontrolle, falls
sie jemals an die Coroutine zurückkäme, sofort wieder durch
longjmp abgegeben. Das bedeutet, nur wenn setjmp "normal"
durchlaufen wird, der Rückgabewert also 0 ist, wird longjmp
aufgerufen. Im anderen Fall wird nichts gemacht. Folgende
Definition ist also sinnvoll:
#define TRANSFER(bufa,bufn) {if (!setjmp(bufa) longjmp(bufn,1);}
TRANSFER ist nun erledigt. Die Überlegungen gingen jedoch immer
von der Voraussetzung aus, daß die Coroutinen die Kontrolle
irgendwann einmal schon hatten. Das ist durch TRANSFER allein
jedoch nicht zu bewirken, den ersten Aufruf der Coroutine muß
daher NEWCOR bewerkstelligen. Wie ist das aber möglich? NEWCOR
soll doch nur eine Prozedur als Coroutine erklären.
Nun, ein Aufruf der Coroutine darf selbstverständlich erst nach
dem ersten TRANSFER erfolgen. NEWCOR(buf,proc) muß sich daher
durch setjmp(buf) die Stelle "merken" und proc aufrufen, falls die
Kontrolle wieder an diese Stelle zurückkommt. Das legt folgende
Definition nahe:
#define NEWCOR(buf,proc) {if (setjmp(buf)) proc();}
So hat also die Include-Datei corout.h auszusehen:
#include <setjmp.h>
#define CorBuf jmp_buf
#define NEWCOR(b,c) {if (setjmp(b)) c();}
#define TRANSFER(a,n) {if (!setjmp(a)) longjmp(n,1);}
|
Neben NEWCOR und TRANSFER ist hier auch der Datentyp
des Coroutinen-Buffers erklärt. jmp_buf ist jener Datentyp, der
von setjmp und longjmp benutzt werden muß. In der Datei setjmp.h
stehen die Prototypen von setjmp und longjmp sowie die Definition
des Datentyps jmp_buf. Diese muß daher eingebunden werden.
Randbedingungen
Am Anfang wurde erwähnt, daß eine Coroutine "endlos" zu sein habe.
Sie soll sich nie "normal" beenden, d.h. durch return oder durch
Erreichen des Endes. Warum diese Einschränkung? Nun, vom
programmtechnischen Aspekt her wäre es einfach eine Vermischung
zweier unterschiedlicher Mechanismen, was den Ästhetiker stört. Es
gibt aber auch ganz profane Gründe: In Modula-2 und Simula wäre
dann nicht definiert, an welche Stelle die Kontrolle nun gelenkt
werden müßte.
Für die Sprache C könnte man nachvollziehen, was geschähe:
Angenommen, die Prozedur Sp1 in Listing 1 würde sich (nicht durch
TRANSFER) selbst beenden. Das kann man einfach erreichen, indem
man als Abbruchbedingung der for-Schleife den Ausdruck 1==1
einträgt. Dann würde nach Beendigung von Sp1 die Kontrolle hinter
den (einzigen) Aufruf dieser Prozedur gesetzt. Dieser befindet
sich im NEWCOR im Hauptprogramm.
Kurz darauf folgt im Hauptprogramm aber ein TRANSFER zu Sp1. Die
Kontrolle würde also wieder an die Stelle gesetzt, an der sich Sp1
zuletzt durch TRANSFER selbst unterbrochen hätte. Nach kurzer Zeit
würde sich Sp1 also wieder selbst beenden (nicht durch TRANSFER),
da ja dieselben Verhältnisse wie beim letzten Mal herrschten. Und
dasselbe ginge von vorne los. Eine Endlosschleife wäre also das
Resultat. Die Einschränkung "endlos" ist also vernünftig.
Eine Anregung: Man könnte die Prozeduren Sp1 und Sp2 als eine
Prozedur schreiben, da sie sich recht ähnlich sind. Aus Gründen
der Übersichtlichkeit ist das nicht getan worden. Es wäre dann
aber auch eine Ausweitung auf beliebig viele Spieler möglich.
Es gibt leider C-Compiler, bei denen setjmp nur den Zustand des
Prozessors speichert, nicht jedoch den des Stacks. In diesem Fall
müssen alle lokalen Variablen der Coroutinen als statisch
vereinbart werden. Und dann dürfen niemals zwei Coroutinen durch
eine einzige Prozedur erklärt werden. Eine Ausweitung auf beliebig
viele Spieler ist somit dann nicht möglich.