Die Computerseite von Eckart Winkler
Coroutinen in C

 

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.

 

Übersicht Programmiersprache C Index