Modula-2 Kurs, Teil 10

Willkommen zum zehnten Teil des Modula-Kurses. In dieser Folge geht es um eine Eigenschaft von Modula, die andere auf Mikros verbreitete Sprachen wie Pascal oder C nicht kennen: die Möglichkeit zur nebenläufigen Programmierung. Doch zunächst noch ein anderes Thema.

Prozedurvariablen

Noch immer ist die Auflistung der Datentypen in Modula-2 nicht komplett. Für das heutige Hauptthema benötigen wir eine neue Art von Variablen, die Prozedurvariablen. Sie erhalten nicht einen “normalen” Wert, sondern können eine ganze Prozedur speichern. Übrigens waren Prozedurvariablen auch schon in Pascal vorhanden, wurden jedoch kaum auf Mikros implementiert.

Wie war das? Eine ganze Prozedur in einer Variablen ablegen? Nun, in einer Modula-Implementierung wird sicherlich keine ganze Prozedur abgespeichert, lediglich ein Verweis auf eine Prozedur bildet eine Prozedurvariable.

Der Umgang mit Prozedurvariablen unterscheidet sich zunächst nicht von den bisher bekannten. Sie müssen deklariert werden, es gibt Zuweisungen, und sie können in Ausdrücken verwendet werden.

Welchen Typ hat nun eine Prozedur? Sie besteht aus einer Reihe von Anweisungen, lokalen Variablen und Parametern. Die internen Vorgänge interessieren nicht nach außen, lediglich die Parametertypen und ihre Reihenfolge sind bekannt. Und genau sie charakerisieren den Typ einer Prozedur.

Eine Prozedurvariable wird durch das Schlüsselwort PROCEDURE und eine Liste der Parametertypen deklariert:

VAR p,q:PROCEDURE(INTEGER, INTEGER, REAL);

p und q sind nun Variablen, die jede Prozedur aufnehmen können, die als Parameter zwei INTEGERS und ein REAL verlangt. Zum Speichern von Funktionen muß auch der Ergebnistyp in gewohnter Weise notiert werden:

VAR f,g:PROCEDURE(REAL):REAL;

Hier kann jede Prozedur aufgenommen werden, die ein REAL als Parameter erhält und ein REAL als Ergebnis abliefert.

Wie funktioniert eine Zuweisung? Um eine Prozedur zuzuweisen, muß sie natürlich zunächst deklariert werden, denn es gibt ja keine vordefinierten Werte wie z.B. bei den Zahlen:

PROCEDURE a(x,y:INTEGER; z:REAL); 
BEGIN
...
END a;

und für das Beispiel mit Funktionen:

PROCEDURE double(x:REAL):REAL; 
BEGIN 
    RETURN 2.0*x;
END double;

PROCEDURE triplicate(x:REAL):REAL; 
BEGIN 
    RETURN 3.0*x;
END triplicate;

Nun ist die Zuweisung p:=a; legal, a ist eine Prozedur mit zwei INTEGER- und einem REAL-Parameter. p hat den entsprechenden Typ, und die Zuweisung kann durchgeführt werden.

Ebenso kann man schreiben: q:=p;. Beide Variablen haben den gleichen Typ, und q erhält den Inhalt von p. Die Behandlung von Funktionen läuft ebenso ab.

Nun stellt sich die Frage, was das alles bringt. Bisher sind nur Prozeduren - oder Referenzen auf sie - in Variablen abgespeichert. Wir können damit Zuweisungen ausführen, mehr aber auch nicht, vom Rechnen ganz zu schweigen.

Aber man kann mit Prozedurvariablen das machen, was man auch mit Prozeduren macht: sie aufrufen. Und da sie variabel sind, kann ihr Inhalt während der Laufzeit des Programms bestimmt werden. Die Aufrufe von Prozeduren in einem Programm sind einerseits nicht mehr vorhersehbar, andererseits erhalten wir damit eine ungeheure Flexibilität.

Ist wie oben irgendwann der Prozedurvariablen p die Prozedur a zugewiesen, kann die Variable in der Art einer Anweisung notiert und damit der Aufruf von a ausgelöst werden:

...
p(1,2,42.3);
...

Wird der aktuelle Wert von p eingesetzt, entspricht das dem Aufruf a(1,2,42.3);. Funktionsaufrufe werden immer in Ausdrücken eingesetzt und liefern einen entsprechenden Wert ab. Prozedurvariablen, die Funktionen als Werte erhalten, müssen ebenso behandelt werden:

...
f:=double;
x:=f(1.0);
g:=triplicate;
x:=f(1.0)+g(3.0);
...

Setzt man die Werte der Prozedurvariablen ein, erhielte x zunächst 2.0 und danach 2.0+9.0=11.0. Es ist natürlich auch möglich, die Prozedurvariablen mit Funktionen zu schachteln:

...
x:=f(g(2.0));
...

würde x den Wert 12.0 zuweisen.

Weiterhin lassen sich Prozedurvariablen auch als Parameter für Prozeduren verwenden. Sie werden ganz normal in der Parameterliste notiert:

TYPE IntProz =PROCEDURE(INTEGER);
...
PROCEDURE Count(a:INTEGER; p:IntProz);

Beim Aufruf von Count muß eine Prozedurvariable des Typs IntProz übergeben werden.

Die Flexibilität in diesen Beispielen ist noch nicht ganz offensichtlich. Sie könnten aber ein Programm schreiben, in dem der Benutzer zur Laufzeit zwischen verschiedenen Funktionen auswählt, auf die Routinen angewendet werden können. Denken Sie nur an einen Funktions-Plotter. In einer CASE-Anweisung würde dann einer Variablen f die ausgewählte Funktion zugewiesen. Die restlichen Teile des Programms würden nur noch mit der Variablen f arbeiten und sind damit unabhängig von der statischen Notierung eines bestimmten Funktionsaufrufs.

Abschließend bleibt noch die Frage, welchen Typ parameterlose Prozeduren haben. Bei ihnen ist die Liste der Parametertypen leer, jedoch muß man - wie bei Funktionen - die Klammem notieren:

TYPE ParamloseProz = PROCEDURE();

Für PROCEDURE() ist die Abkürzung PROC vordefiniert.

Prozedurvariablen haben also einen Typ, der sich aus den Parametertypen und deren Reihenfolge ergibt. Sie können in Zuweisungen verwendet werden, wobei es allerdings keinerlei Rechenoperationen gibt. Eine Prozedurvariable kann als Anweisung verwendet werden, was einem Aufruf der Prozedur entspricht, die sie als Wert hat.

# Anworten von Teil IX
  1. Die geforderte Prozedur muß so aussehen:

    MODULE A1;

    FROM SYSTEM IMPORT TSIZE; FROM Storage IMPORT ALLOCATE, DEALLOCATE; TYPE INTPOINTER = POINTER TO INTEGER;

    PROCEDURE NewInt(wert:INTEGER; VAR zeiger:INTPOINTER); BEGIN ALLOCATE(zeiger,TSIZE(INTEGER)); zeiger^:=wert; END NewInt;

Der zweite Parameter von NewInt - der Zeiger - muß mit einem eigenen Typen versehen werden, da es sich bei POINTER TO INTEGER nicht um einen einfachen Typen handelt. NewInt richtet mit ALLOCATE die dynamische INTEGER-Variable ein und weist ihr den übergebenen Wert per Dereferenzierung zu.

  1. Ampeln sind entsprechend dem Konzept von abstrakten Datentypen implementiert: Im Definitions-Modul werden lediglich der Typ Ampel und die gewünschten und notwendigen Prozeduren zur Arbeit damit bereitgestellt. Zur Beschreibung von Werten, die Ampeln annehmen können, gibt es schließlich noch die Typen AmpelFarbe und AmpelFarben. Damit ist es möglich, Ampelfarben als Konstanten im Programm zu notieren. Man könnte auch noch weitere Routinen SetRot, SetGruen etc. einführen und dann auf diese Typen verzichten.

    DEFINITION MODULE Ampeln; (* R.Tolksdorf/ST-Computer Version*: 14.09.8915:1 *)

    TYPE Ampel; AmpelFarben = (Rot, Gelb, Gruen); AmpelFarbe = SET OF AmpelFarben;

    PROCEDURE MakeAmpel(VAR a:Ampel);

    PROCEDURE KillAmpel(VAR a:Ampel);

    PROCEDURE SetAmpel(a:Ampel; f:AmpelFarbe);

    PROCEDURE GetAmpel(a:Ampel):AmpelFarbe;

    PROCEDURE SwitchAmpel(a:Ampel);

    END Ampeln.

Das Implementationsmodul realisiert Ampel als Zeiger auf eine Menge vom Typ AmpelFarbe, in der der Zustand der Ampel gehalten wird. MakeAmpel und KillAmpel richten die dynamische Variable per ALLOCATE und DEALLOCATE ein. Sicherheitshalber wird beim Löschen der Ampel der Zeiger auf NIL gesetzt.

GetAmpel und SetAmpel benutzen wie gewohnt eine dynamische Variable per Dereferenzierung. SwitchAmpel ist schon aus der zweiten Aufgabe im achten Teil dieser Serie bekannt, arbeitet hier aber auf einer dynamischen Variablen.

IMPLEMENTATION MODULE Ampeln;
(* R. Tolksdorf/ST-Computer Version*: 14.09.89 15:19 *)
FROM SYSTEM IMPORT TSIZE;
FROM Storage IMPORT ALLOCATE, DEALLOCATE ;

TYPE Ampel = POINTER TO AmpelFarbe;
(*von AmpelFarben =(Rot, Gelb, Gruen);
.DEF AmpelFarbe =SET OF AmpelFarben;*)

PROCEDURE MakeAmpel(VAR a:Ampel); 
BEGIN
    ALLOCATE(a,TSIZE(AmpelFarbe));
END MakeAmpel;

PROCEDURE KillAmpel(VAR a:Ampel); 
BEGIN
    DEALLOCATE(a); 
    a:=NIL;
END KillAmpel;

PROCEDURE SetAmpel(a:Ampel; f:AmpelFarbe);
BEGIN 
    a^: = f;
END SetAmpel;

PROCEDURE GetAmpel(a:Ampel):AmpelFarbe;
BEGIN
    RETURN a^;
END GetAmpel;

(* Entspricht der Antwort zur 2.Hausaufgabe aus Teil 8	*)

PROCEDURE SwitchAmpel(a:Ampel); 
BEGIN
    IF (Gruen IN a^) THEN 
        a^:=AmpelFarbe{Gelb}
    ELSIF ((Rot IN a^) AND (Gelb IN a^)) THEN 
        a^:=AmpelFarbe{Gruen}
    ELSIF (Gelb IN a^) THEN 
        a^:=AmpelFarbe{Rot}
    ELSE
        a^:=AmpelFarbe{Rot,Gelb}
    END;
END SwitchAmpel;

END Ampeln.

Ihre Implementierung müßte die gleiche Struktur im Definitionsmodul aufweisen und auch beim Umgang mit den dynamischen Variablen der hier abgedruckten Lösung sehr ähnlich sein.

  1. Das Programm zum Aufsummieren beliebig vieler Werte und zur Berechnung von deren Mittelwert lautet:
    MODULE A3;

    FROM INTLists IMPORT List,MakeList, KillList, First, AtLast,
                         Next,SetValue, GetValue, AppendElement,
                         AtFirst;
    FROM InOut IMPORT WriteString, WriteLn, WriteInt, Readlnt;
    VAR L:List;
        i, WerteAnzahl, Wert, Summe : INTEGER;
    BEGIN
        WriteString('Wieviele Werte? '); 
        ReadInt(WerteAnzahl); 
        WriteLn; 
        WriteString('Werte eingeben'); 
        WriteLn;
        MakeList(L);
        FOR i:=1 TO WerteAnzahl DO 
            ReadInt(Wert); 
            WriteLn; 
            AppendElement(L);
            SetValue(L,Wert);
        END;
        Summe:=0;
        First(L);
        REPEAT
            Summe:=Summe+GetValue(L);
            Next(L);
        UNTIL AtLast(L);
        IF ~AtFirst(L) THEN
            Summe:=Summe+GetValue(L);
        END;
        WriteString('Summe aller Werte:'); 
        WriteInt(Summe,5); WriteLn; 
        WriteString('Mittelwert:'); 
        WriteInt(Summe DIV WerteAnzahl,5); WriteLn;
        KillList(L);
    END A3.

Nach dem Abfragen der Anzahl der zu verarbeitenden Werte wird per MakeList die Liste eingerichtet. Die einzelnen Zahlen fragt das Programm in der FOR-Schleife ab. In jedem Durchlauf richtet es ein neues Listenelement per AppendElement ein und schreibt den eingegebenen Wert mit SetValue in die erzeugte dynamische Variable.

Im Verarbeitungsteil muß die Summe aller Werte ermittelt und in der Variablen Summe vermerkt werden. Dazu setzt First den aktuellen Listenzeiger an den Anfang. Mit GetValue wird der aktuelle Werte Summe hinzuaddiert und danach der Listenzeiger mit Next ein Element weitergesetzt. Die Schleife endet, wenn das letzte Element erreicht ist.

Dieses muß nun noch hinzuaddiert werden. Es gibt dabei eine Ausnahme, nämlich die Liste mit nur einem Element. Dann liefern AtFirst und AtLast TRUE, und der Wert ist schon in der Schleife verarbeitet worden.

Der Rest des Programms gibt einfach die Summe und den errechneten Mittelwert aus. Abschließend säubert der Aufruf von KillList den Speicher.

Nebenläufigkeit

Üblicherweise hört man viel von Multitasking-Betriebssystemen, z.B. beim AMIGA oder dem erheblich professionelleren UNIX, das es in unzähligen Versionen auch z.B. als XENIX oder SINIX gibt. Auf dem ATARI ST gibt es mehrere Multitasking-Betriebssysteme wie OS-9 oder MINIX. Modula-2 ermöglicht es, nebenläufige Programme unabhängig vom Betriebssystem zu schreiben.

Aber um nicht zuviele Hoffnungen zu wecken: Mit den Konstrukten von Modula direkt kann man nicht programmieren, daß zwei Zuweisungen gleichzeitig ausgeführt werden sollen. Jedoch lassen sich über Standard-Features von Modula Module programmieren, die eine solche parallele Ausführung unterstützen.

Doch zunächst ein paar theoretische Überlegungen. Wir wollen, daß der Rechner mehrere Dinge gleichzeitig erledigt, also parallel. Ein Mikro-Computer hat aber typischerweise nur einen Prozessor, der immer nur einen Rechenschritt gleichzeitig ausführen kann. Daher arbeitet er immer sequentiell, d.h. die Anweisungen werden Schritt für Schritt nacheinander ausgeführt.

Selbst in der Hochsprache Modula-2 findet sich diese Sequentialität. Das Semikolon trennt nach allem, was Sie bisher gelernt haben, nur die einzelnen Statements, so daß der Compiler das Ende einer Anweisung erkennen kann. Man kann es aber auch so interpretieren, daß bedeutet, “führe das Statement vor dem; aus und danach das nächste”. Damit sequentialisiert das Semikolon den Programmtext.

Da immer nur ein Statement ausgeführt wird, weiß man genau, welchen Zustand die Variablen vor und nach einem Statement haben. Hätten wir eine parallele Programmierung, bei der zwei Statements gleichzeitig zu zwei anderen ausgeführt würden, könnte man nicht genau sagen, welches Statement wann beendet ist. Damit wäre auch der Variablenzustand während der Ausführung nicht feststellbar. Es tun sich also Probleme auf, Zustände zu bestimmten Zeitpunkten vorherzusagen.

Was für eine Maschine bräuchte man, um zwei Anweisungen gleichzeitig auszuführen? Einen Rechner mit zwei Prozessoren natürlich. Die Anweisungen müßten jeweils einem Prozessor zugewiesen werden, der sie dann ausführt. Aber was wäre, wenn wir jetzt drei Anweisungen gleichzeitig rechnen lassen wollten, oder fünf oder...?

Da man nicht jedesmal einen neuen Rechner bauen kann, muß das Problem per Software gelöst werden. Diese Software hat die Aufgabe, die vorhandenen Prozessoren so zu steuern, daß sie ein Stück an dieser, ein Stück an jener Anweisung arbeiten. Ist diese Steuerung einigermaßen gerecht, werden alle Anweisungen irgendwann abgearbeitet sein.

Aber ist das noch wirklich parallel? Nein, denn die vorhandenen Prozessoren arbeiten nacheinander einmal an dieser, einmal an jener Aufgabe. Richtigerweise muß man ein solches Verhalten “quasi-parallel” nennen.

Aber wir schweifen bei der Betrachtung des Problems etwas ab. Wir wollen lediglich mehrere Anweisungen quasi-parallel auf einem Prozessor ablaufen lassen. D.h. es gibt nur einen Prozessor, der auf die verschiedenen Aufgaben möglichst gerecht angesetzt werden muß. Genau das läßt sich in Modula unabhängig vom Betriebssystem beschreiben.

Genauer betrachtet

Der Begriff “verschiedene Aufgaben” ist vielleicht etwas zu schwammig. Eine Reihe von Statements, die zusammen ein Problem bilden, an dem der Prozessor gleichzeitig zu einem anderen arbeiten soll, nennen wir entsprechend den Bezeichnungen für Modula-2 “Co-Routine”. Stellen Sie sich unter einer Co-Routine eine Art Prozedur vor, die quasi-parallel zu einer anderen ausgeführt werden soll.

Eine solche Co-Routine hat einen bestimmten Zustand. Dieser besteht einerseits - wie bei einer Prozedur - aus einer Reihe von lokalen Variablen, die im Rechner auf einem Stapelspeicher abgelegt sind. Für jede Co-Routine ist ein Speicherbereich nötig, in dem diese Variablen liegen. Da der Prozessor “immer ein Stück” in einer Co-Routine arbeiten und dann zu einer anderen wechseln soll, gehört zum Zustand einer Co-Routine auch noch die Information darüber, an welcher Stelle der Prozessor Weiterarbeiten muß, wenn er “ein weiteres Stück” darin rechnen soll.

Was geschieht, wenn der Prozessor veranlaßt wird, in einer anderen Co-Routine zu arbeiten? Sämtliche Zustandsinformationen der aktuellen Co-Routine müssen gerettet werden, eine neue Co-Routine ist auszuwählen und deren Zustand muß wiederhergestellt werden.

Dieses Umschalten in eine andere Co-Routine nennt man Kontextwechsel. Es handelt sich um die grundlegendste Operation, die man für parallele Routinen benötigt.

Jetzt in Modula

In einem ersten Beispiel sollen zwei Co-Routinen arbeiten, von denen eine von 0 an einen INTEGER hochzählt und die Werte ausgibt. Die zweite Co-Routine arbeitet analog, nur daß von 1000 herabgezählt werden soll. Die entsprechenden Anweisungen sind:

PROCEDURE K1;
VAR i:INTEGER;
BEGIN
    i:=-1;
    LOOP 
        i:=i+1;
        WriteInt(i,5); WriteLn;
    END;
END K1;

und

PROCEDURE K2;
VAR i:INTEGER;
BEGIN
    i:=1001;
    LOOP
        i:=i-1;
        WriteInt(i,5); WriteLn;
    END;
END K2;

Die Co-Routinen sind zunächst mit unbedingten Schleifen versehen; eine Abfrage auf einen Endwert wird später eingebaut.

Nun sind Co-Routinen aber nicht einfach Prozeduren. In einem normalen Programm gibt es einen einzigen Zustand und einen Punkt, an dem momentan gerechnet wird. Bei Co-Routinen benötigt jede einen eigenen Zustand. In Modula muß dieser zunächst eingerichtet und die Co-Routine angemeldet werden.

Alle Prozeduren für Co-Routinen sind im Modul Coroutines oder Process zusammengefaßt (in älteren Systemen können sie auch in SYSTEM stehen). In LPR-Modula müssen Sie Process benutzen.

Die Prozedur zum Einrichten einer Co-Routine heißt NEWPROCESS. Sie erhält vier Parameter. Der erste ist eine parameterlose Prozedur, die die Co-Routine beschreibt, also im Beispiel K1 bzw. K2.

Die nächsten beiden Parameter beschreiben einen Speicherbereich, in dem der Zustand der Co-Routine gehalten wird, also der Stack und weitere Informationen. Benötigt wird die Angabe eines Speicherbereiches und dessen Größe. Schließlich setzt NEWPROCESS eine Co-Routinen-Variable. über die die installierte Co-Routine angesprochen werden kann. Sie hat hier den Typ PROCESS aus Process. Dies ist allerdings nur wieder speziell für den LPR-Compiler nötig. Es kann sein, daß in Ihrem System ADDRESS verwendet wird. PROCESS ist zwar als ein POINTER implementiert und eigentlich ist ja ein Zeiger kompatibel zu ADDRESS. Wegen der Typenstriktheit bemängelt LPR jedoch einen Fehler.

Im Beispiel müßte das Rahmenprogramm mit dem Anmelden der Co-Routinen wie in Listing 1 aussehen. Es werden zwei Co-Routinen angemeldet. Die Prozedur K1 beschreibt die Co-Routine Ko1 und erhält den Speicher ab ADR(Zu1) mit der Größe SIZE(Zu1). Analog wird Ko2 eingerichtet. NEWPROCESS erzeugt die Co-Routinen, indem der Code der Prozeduren kopiert und ihnen der Speicher nach den übergebenen Parametern zugewiesen wird. Schließlich setzt die Routine die Co-Routinen-Variablen.

MODULE CoDem;
FROM SYSTEM IMPORT ADDRESS, ADR ;
FROM Process IMPORT PROCESS, NEWPROCESS, TRANSFER ;

VAR Ko1, Ko2 : PROCESS;
    Zu1, Zu2 : ARRAY [0..1000] OF INTEGER;

PROCEDURE Kl ...
PROCEDURE K2 ...

BEGIN
    NEWPROCESS(K1,ADR(Zul),SIZE(Zu1),Ko1) ;
    NEWPROCESS(K2,ADR(Zu2),SIZE(Zu2),Ko2);
    ...

Listing 1

Bild 1: Die verschiedenen Zustände der Koroutinen bei den TRANSFERs

Oben wurde beschrieben, daß der Wechsel zwischen zwei Co-Routinen die grundlegende Operation für nebenläufige Programme ist. In Modula übernimmt diese Aufgabe die Prozedur TRANSFER.

Sie hat zwei Parameter, nämlich zwei Co-Routinen-Variablen. Das ergibt sich aus den Aufgaben von TRANSFER. Die Operation muß den aktuellen Zustand abspeichern und einen anderen wiederherstellen. Zu beidem wird jeweils eine Co-Routinen-Variable benötigt.

Erweitern wir also die beiden Co-Routinen so, daß sie jeweils einen TRANSFER durchführen.

PROCEDURE K1;
VAR i:INTEGER;
BEGIN
    i:=1;
    LOOP
        WriteInt(i,5); WriteLn;
        TRANSFER(Ko1,Ko2);
    END;
END K1;

und

PROCEDURE K2;
VAR i:INTEGER;
BEGIN
    i:=1001;
    LOOP
        i:=i-1;
        WriteInt(i,5); WriteLn;
        TRANSFER(Ko2,Ko1);
    END;
END K2;

Was geschieht nun, wenn die beiden Co-Routinen ausgeführt werden? Nehmen wir an, momentan ist K1 in der Schleife bei der Arbeit. Es werden die Operationen i:=i+1; und Write...; ausgeführt. Sie verändern den Zustand von K1, nämlich durch das Schreiben in i. Als nächste Anweisung folgt der TRANSFER. Er soll von der Co-Routine, die durch Ko1 bezeichnet wird, in die umschalten, die unter Ko2 angemeldet wurde. Im Bild 1 sehen Sie diesen und die folgenden Schritte dargestellt. Links steht Ko1, rechts Ko2, jeweils einschließlich ihres Zustands, also dem Programmzähler PC und der lokalen Variablen. Die jeweils schwarz dargestellte Co-Routine ist aktiv. Momentan sieht die Situation wie in A) aus.

Was passiert? Der Zustand von K1 wird in der Co-Routinen-Variablen Ko1 abgelegt. Sie enthält insbesondere den Wert des Programmzählers, der besagt, daß als nächstes der Schleifensprung von LOOP...END ausgeführt werden soll.

Nehmen wir an, auch K2 war vorher genau an dieser Stelle. In Ko2 ist also der Zustand so vermerkt, daß die nächste auszuführende Anweisung die nach dem TRANSFER ist. Bei TRANSFER wird der Zustand wieder restauriert (Schritt B). Wir befinden und nun in Ko2. Vermerkt war dort der Programmzähler, also führt der Rechner den Sprung in K2 aus (Schritt C). Die folgenden Anweisungen sind i:=i-1; und Write...;.

Nach diesen Anweisungen folgt in K2 wiederum ein TRANSFER, und zwar von Ko2 nach Ko1 (Schritt D). Wie sieht die Situation nun aus? Gespeichert wird zunächst in Ko2 der aktuelle Zustand, d.h. der Zustand der Variablen, also i und der Programmzähler, der auf das Statement nach dem TRANSFER in K2 verweist.

Im zweiten Schritt kann Ko1 restauriert werden. Wie oben beschrieben, sind dort die Variable i und der Programmzähler vermerkt. Dieser zeigt auf das Statement nach dem TRANSFER in K1 (Schritt E). Damit beginnt das Spielchen von neuem (Schritt F und G).

Aber ganz realistisch ist diese Beschreibung noch nicht. Zunächst muß der ganze Ablauf des Hin- und Herschaltens zwischen den Co-Routinen angeworfen werden. Und er muß irgendwann zu einem Ende kommen.

Zum Starten der Co-Routinen muß irgendwann die Kontrolle vom Hauptprogramm - damit sind die “normalen” Statements zwischen BEGIN und END eines Moduls gemeint - auf eine der Co-Routinen übergehen. Sehen wir das Hauptprogramm als Co-Routine an, für die schon Arbeitsspeicher eingerichtet wurde, so reicht ein einfaches TRANSFER, um die Co-Routinen in Gang zu bringen. Dazu benötigt wird natürlich eine dritte Co-Routinen-Variable, Main. Der TRANSFER speichert in ihr den Zustand des Hauptprogramms, den Programmzähler.

Und wie enden Co-Routinen? Im Prinzip nie, ein reales Programm muß jedoch irgendwann zum Schluß kommen. Daher wird in einer Co-Routine eine Abfrage eingebaut, die eventuell mit einem abschließendem TRANSFER zum Hauptprogramm zurückschaltet. Dort kann es dann wie in einem normalen Programm bis zum END. weitergehen. Das Ziel des TRANSFERS ist in Main enthalten, so daß das jetzt vollständige Programm wie in Listing 2 aussieht. Führen Sie das Programm einmal aus und rekonstruieren Sie anhand der Bildschirmausgaben den Kontrollfluß!

MODULE CoDem;
FROM SYSTEM     IMPORT ADDRESS, ADR ;
FROM Process    IMPORT PROCESS, NEWPROCESS, TRANSFER ; 
FROM InOut      IMPORT WriteInt, WriteLn;

VAR Ko1, Ko2, Main : PROCESS;
    Zu1, Zu2 : ARRAY [0..1000] OF INTEGER;

PROCEDURE K1;
VAR i:INTEGER;
BEGIN 
    i:=-1;
    LOOP
        i:=i+1;
        IF i>100 THEN
            TRANSFER(Ko1,Main);
        END;
        WriteInt(i,5); WriteLn;
        TRANSFER(Kol,Ko2);
    END;
END K1;

PROCEDURE K2;
VAR i:INTEGER;
BEGIN
    i:=1001;
    LOOP
        i:=i-1;
        WriteInt(i,5); WriteLn;
        TRANSFER(Ko2,Ko1);
    END;
END K2;

BEGIN
    NEWPROCESS(K1,ADR(Zu1),SIZE(Zu1),Ko1);
    NEWPROCESS(K2,ADR(Zu2),SIZE(Zu2),Ko2);
    TRANSFER(Main,Ko1);
END CoDem.

Listing 2

Synchronisation

Nun ist diese direkte Programmierung mit TRANSFERS nicht sonderlich aufregend, man könnte den Kontrollfluß auch auf herkömmliche Weise steuern. Zudem hat ein solches TRANSFER einiges vom zurecht geschmähten GOTO, das es in Modula übrigens glücklicherweise nicht mehr gibt.

In der Praxis benutzt man die TRANSFERS, um eine Reihe von Funktionen zu implementieren, die ein bestimmtes Synchronisationsmodell beschreiben. Was ist das? Stellen Sie sich vor, daß verschiedene Co-Routinen auf einem Rechner quasi-parallel arbeiten und ihre jeweilige Rechenzeit nicht vorhersagbar ist. Solche Co-Routinen nennt man Prozesse. Dazu bräuchte man natürlich eine Art von Multitasking, bei dem z.B. ein Systemprogramm dafür sorgt, daß jeder angemeldete Prozeß eine bestimmte Zeit lang rechnen darf, dann “schlafengelegt” wird und ein anderer Prozeß drankommt. Dieses wäre ein “Zeitscheiben”-Verfahren. Es könnte auch möglich sein, daß jeder Prozeß auf einem eigenen Prozessor arbeitet (denken Sie an in OCCAM programmierte Transputer).

In einer solchen Situation kann es zu Problemen kommen, wenn zwei Prozesse das gleiche tun oder sich untereinander verständigen wollen. So könnten gleichzeitig zwei Prozesse auf denselben Block der Festplatte schreiben wollen, oder ein Sortierprozeß einem Druckprozeß mitteilen, daß z.B. ein Feld fertig sortiert ist. Damit keine Konflikte entstehen und die Prozesse wie gewünscht Zusammenarbeiten, müssen sie sich synchronisieren. Bei korrekter Programmierung dürfen Sie dazu ausschließlich die angebotenen Synchronisationsfunktionen benutzen.

Diese Synchronisationsfunktionen sorgen dafür, daß der Kontrollfluß geregelt wird. Nehmen wir das Beispiel mit dem gleichzeitigen Versuch, die Festplatte zu beschreiben. In dem Synchronisationsmodell, das hier beschrieben wird, müssen beide Prozesse mit einem Funktionsaufruf bekanntgeben, daß sie jetzt diese Operation vornehmen wollen. Nennen wir die Funktion P, dann hieße der Aufruf jeweils P(festplatte). Da die Prozesse korrekt programmiert sind, geben sie nach Ausführung des Plattenzugriffs bekannt, daß sie aus diesem Programmabschnitt heraus sind. Nennen wir diese Funktion V, so hätten wir das Statement V(festplatte).

Die Routinen P und V enthalten nun die Steuerung zum Kontrollfluß per TRANSFER. Was muß geschehen? Bei P ist zu prüfen, ob vielleicht ein anderer Prozeß P(festplatte) aufgerufen hatte und noch nicht mit V(festplatte) die Beendigung der Arbeit bekanntgegeben hat. Ist dies der Fall, wird der Prozeß in einer Liste vermerkt und braucht auch nicht mehr bei dem oben genannten automatischen Zeitscheiben-Verfahren berücksichtigt zu werden. In dieser Liste finden sich also geordnet alle Prozesse, die auf die Freigabe warten. Ist die Plattenbenutzung unkritisch, wird lediglich vermerkt, daß ein Prozeß momentan an der Harddisk arbeitet.

Bei V gibt es wiederum zwei Möglichkeiten. Befinden sich in der angesprochenen Liste Prozesse, die bei einem P aufgelaufen sind, kann der erste in der Liste aus ihr entfernt und aktiviert werden. Dazu würde man in Modula selbstverständlich ein TRANSFER verwenden. Die anderen müssen weiterhin warten, und nach wie vor arbeitet ein Prozeß mit der Platte. Gibt es keine solchen Prozesse, ist die Arbeit mit der Festplatte unkritisch, und das kann vermerkt werden. Dann würde der nächste Prozeß, der ein P aufruft, sofort drankommen.

P und V sorgen also dafür, daß immer nur ein Prozeß in einem geschützten Abschnitt arbeiten kann. Sie gewährleisten auch, daß alle Prozesse, die einmal ein P aufgerufen haben, irgendwann bei einem V aktiviert werden. Für die Aktivierung sorgt die TRANSFER-Routine, die den Zustand der wartenden Prozesse einfriert. Informatiker wissen natürlich längst, wie dieses Synchronisationsmodell genannt wird: “Semaphore”. Man geht dabei von einem “kritischen Abschnitt” aus, einem Programmteil, bei dem ein Prozeß nicht von anderen gestört werden darf. Der Name der P-Operation stammt von “Passieren” - der Prozeß will quasi die Schranke zum kritischen Abschnitt passieren. Entsprechend kommt V von “Verlassen” - der Prozeß verläßt den kritischen Abschnitt. Der Schöpfer dieses ganzen Modells ist einer der bekanntesten Informatiker, E.W. Dijkstra.

Sie ersehen aus dieser langen Beschreibung, daß die Implementierung der Funktionen nicht unbedingt einfach ist. Bei der Arbeit zu dieser Serie hat sich herausgestellt, daß sie den Umfang einer Folge überschreiten würde. Zudem haben einige Modula-Systeme ihre Besonderheiten in diesem Bereich, dieser Teil wird von Modula für den kommenden DIN-Entwurf überarbeitet; und schließlich bietet das GEMDOS leider so wenig Unterstützung für ein Zeitscheibenverfahren, daß ich auf eine vollständige Darstellung hier verzichten muß.

Es gibt weitere Synchronisationsmodelle, die bekanntesten sind die Monitore und Signale. Schauen Sie doch einmal in Ihrem Modula-System nach, ob vielleicht in Modulen wie Process oder Signals entsprechende Routinen zu finden sind.

Ausblick

Damit sind wir zusammen am Ende dieser Einführung in Modula-2 angelangt. In der nächsten, abschließenden Folge werden, wie angekündigt, noch einzelne weitere Aspekte und einige Leseranfragen geklärt.

Sie haben nun einen Überblick und vielleicht auch einen Einblick in die wichtigsten Features von Modula-2 gewonnen. Sicher sind viele kleinere Tricks und Programmiermöglichkeiten offengeblieben. Dennoch glaube ich, daß Sie nun -insbesondere nach Beschäftigung mit den Aufgaben - in der Lage sein müßten, auch etwas anspruchsvollere Projekte in Modula-2 zu verwirklichen.

Vielleicht haben Sie auch erkannt, daß die gesamte Serie einen Programmierstil andeutet, der sehr konservativ ist und auf implementationsnahe Tricks verzichtet. So habe ich beispielsweise bewußt nicht mit den verfügbaren Modulen zur Benutzung von GEMDOS gearbeitet und dafür den Standardmodulen den Vorzug gegeben. Vielleicht haben Sie gesehen, daß Modula-2 diesen Programmierstil durch die hohen Anforderungen - z.B. bezüglich der Typenstriktheit - fördert.

Zum Schluß des in sich geschlossenen Teils der Serie noch eine Bitte: Als Autor wäre ich für jede Zuschrift dankbar, die den Kurs, der Ihnen nun komplett vorliegt, bewertet. Welche Teile fanden Sie leicht, welche schwer verständlich? Wo würden Sie mehr Informationen und Beispiele erwarten? Was hat Ihnen gefehlt, welche Teile fanden Sie überflüssig? Zuschriften wie gewohnt an ST-Computer, z. Hd. R.Tolksdorf. Damit verabschiede ich Sie zum vorletzten Mal bis in vier Wochen, dann mit einem Frage-Antwort-System zur Klärung noch offener Fragen und einem besonderen Bonbon.

RT

# Hausaufgaben
  1. Was macht folgendes Statement, und welche Variablen müßten dazu wie deklariert sein?

    ...
    Funktion^(10.0);
    ...

  2. Schreiben Sie unter Benutzung des Moduls INTLists eine Prozedur ProcessList, die eine beliebige Prozedur auf alle Listenelemente anwendet. Ihr soll jeweils der Wert eines Listenelements als Parameter übergeben werden.

  3. Es gibt auch ein Modell mit Semaphoren, bei dem mehrere Prozesse in einem kritischen Abschnitt arbeiten können. Beschreiben Sie, wie der geschilderte Ablauf von P und V dafür geändert werden müßte.


Robert Tolksdorf
Aus: ST-Computer 11 / 1989, Seite 110

Links

Copyright-Bestimmungen: siehe Über diese Seite