Paralleles Programmieren

Aus technischer Sicht ist das Programmieren des normalen, seriellen Programmcodes die bestmögliche Lösung. Die Programmiermodelle aller verwendeten populären Programmiersprachen basieren auf der sequentiellen Abarbeitung einer Abfolge von Instruktionen, unterbrochen nur durch Entscheidungspunkte und Schleifen.

Dieses Programmiermodell entspricht zudem auch der täglichen, praktischen Lebenserfahrung der Programmierer. Schon bei der Vorbereitung des Frühstücks erfolgen morgens die notwendigen Tätigkeiten in einer zwar variablen aber streng genommen seriell ablaufenden Folge von Schritten: erst wird das Toastbrot geröstet, dann wird die Butter auf das Toast geschmiert, um erst danach Wurst, Käse oder Marmelade als Belag zu verwenden. Gegebenenfalls hat man sich bereits einmal Gedanken über den Ablauf gemacht und diesen Vorgang optimiert: anstelle das Rösten untätig abzuwarten, geht man währenddessen zur Küchenzeile, entnimmt dem Schrank eine Tasse, stellt diese unter die Kaffeemaschine und zieht seinen Morgenkaffee. Gerade wenn man mit seinem Kaffee zum Tisch zurückkehrt, ist auch das Toast fertig und kann mit Butter geschmiert werden. So spart man Zeit, indem unterschiedliche Aufgaben miteinander verschachtelt ausgeführt werden. Notwendige Wartezeiten werden überbrückt, im täglichen Leben zahlen wir für diese Effizienz jedoch einen Preis. In unserem Frühstücksbeispiel ist die der zusätzliche Weg zur Kaffeemaschine, den wir bei einer alternativen Abfolge einsparen könnten. In anderen Fällen erfordert es vielleicht zusätzlichen Platz auf einer Werkbank oder eine Vervielfachung der genutzten Werkzeugen.

In jedem Fall steigt die Komplexität an, da plötzlich an unterschiedliche Dinge gleichzeitig gedacht werden muss. Dennoch gelingt es uns in diesen Fällen auch morgens noch, diesen Ablauf erfolgreich, ohne größere Unglücke zu durchlaufen. Nur selten vergessen wir, zum Beispiel die Kaffeetasse unter die Kaffeemaschine zu stellen. Der Grund dafür ist die Tatsache, dass wir bei unserer Frühstücksvorbereitung, auch wenn die einzelnen Schritte unterschiedlichen Zielen zugeordnet sind, in einer vorgegebenen sequentiellen und logischen Reihenfolge ablaufen. Die eigentliche Arbeit in diesem Fall wurde einmal zu einem früheren Zeitpunkt investiert, als man sich überlegt hat, dass diese Tätigkeiten gut ineinander greifen und der Preis des zusätzlichen Wegs zur Kaffeemaschine durch die Zeitersparnis wett gemacht wird.

Im täglichen Leben erfahren wir also eine Verschränkung unserer Tätigkeiten, lösen aber die uns gestellten Aufgaben durch sequentiell ablaufende Sequenzen einzelner Schritte. Bedingt ist das durch die Tatsache, dass wir uns gedanklich eigentlich immer nur wirklich auf eine Sache konzentrieren müssen. Diese tägliche Lebenserfahrung findet sich eben auch in der Hardware der ersten Computer. Seither benutzen eigentlich alle Computer von Neumann Architekturen, bei denen ein Schritt nach dem anderen ausgeführt wird.

Schon Mitte der 80‘er Jahre erhielten die Mikroprozessoren Erweiterungen, um sie schneller zu machen. Intern wurden mehrstufige Pipelines implementiert, um die unterschiedliche im Prozessor ablaufenden Ausführungsschritte gleichzeitig zu verarbeiten. Damit wurde die Ausführungszeit des einzelnen Assembler Befehls zwar nicht verkürzt, es dauerte immer noch drei bis vier Taktzyklen, bis dieser die Pipeline durchquert hat und das Ergebnis tatsächlich zur Verfügung stand, aber der Durchsatz des Prozessor stieg, denn in jedem Taktzyklus wurde das Ergebnis eines Befehls sichtbar. Das entspricht also mehr oder minder unserem Frühstücksbeispiel. Die Einzelschritte wurden verschränkt, Wartezeiten wurden reduziert und der Gesamtdurchsatz damit gesteigert.

Einige Prozessoren — zum Beispiel der glorios wenig erfolgreichen Motorola 88000 — haben damals in den Prozessorkern mehrere unabhängige Funktionseinheiten integriert. Es wurden mehrere ALUs für die Ganzzahlverarbeitung und Adressrechnung integriert und natürlich auch die FPU für die Zahl mit Fließpunktzahlen. Ein zentrales Scoreboard hat Informationen über das woher und wohin gespeichert und die Befehle nacheinander auf die Funktionseinheiten verteilt. Die Befehlspipeline war dazu aber immer wohlgefüllt. Mehrere Assembler Befehle wurden bereits analysiert und warteten in der Pipeline bereits auf ihre Ausführung, So konnte die jeweils ausführungsbereiten Befehle, diejenigen deren Daten durch vorangehende Instruktionen bereits fertig berechnet waren, vor anderen in die Pipeline übernommen werden. Als Compilerbauer haben wir damals darauf geachtet, für diesen Prozessor tatsächlich auch die Befehle unterschiedlicher Statements mit einander verschränkt in den Programmcode zu schreiben.

Andere Prozessoren — zum Beispiel die Prozessoren der Pentium Familie — verwendeten lange Pipelines und sind dem unerwünschte Leeren der Pipelines bei Sprunginstruktionen entgegen getreten, indem sie jeweils spekulativ einen der beiden Zweige schon vorab betrachtet haben.

Wenn auch diese Optimierungen innerhalb der Prozessoren einen eine immense Steigerung des Durchsatzes gebracht haben, an der eigentlich sequentiellen Ausführung haben sie nichts geändert.

Die Programmiersprachen und ihre Modelle für die Ausführung des Programmcodes und auch ihre Speichermodelle basieren seit dieser Zeit auf dem Ausführungsmodell der Assemblersprache des Prozessors. Daher ist es eigentlich nicht verwunderlich, dass in vielen Programmiersprachen auch heute noch kaum Konstrukte vorhanden sind, die auf anderen Paradigmen basieren.

Meiner Meinung nach ist dies auch gut so, denn alternative Ausführungsmodelle können auf dieser von Neumann Architektur nur algorithmisch simuliert werden. Und sie widersprechen der Erfahrungswelt der Programmierer.

Daher sollte auch heute noch die Notation sequentieller Verarbeitungsvorschriften die Regel sein, wenn komplexe Algorithmen niedergeschrieben werden müssen. Dieser Programmierstil wird durch alle aktuellen Programmiersprachen gefördert. Eine Anforderung an die Architektur der Anwendungen und technischen Komponenten ist daher die Notwendigkeit, in der fachlichen Programmierung den Fachabteilungen und Entwicklern ein der Erfahrungswelt entsprechendes, sequentiell ablaufendes Programmiermodell bereit zu stellen. Soll aus Performanz-Gründen eine parallele Ausführung ermöglicht werden, wird diese Erfahrungswelt verlassen. Dann ist es die Aufgabe einer technischen Architektur, die geeigneten technische Abstraktionen für diesen schwerwiegenden Schritt bereitzustellen und die Vorstellung des unabhängigen, sequentiell ablaufenden Programmcodes aufrecht zu erhalten.

Für den Software-Architekten bedeutet diese Thema daher, sich beständig in einem Spannungsfeld zwischen der Aufrechterhaltung eines stabilen sequentiellen Programmiermodells und den technischen Anforderungen hinsichtlich Performanz und Latenz zu befinden. Hier muss für jedes Anwendungssystem aufs Neue der bestmögliche Lösungsweg gefunden werden, um einen effizienten und qualitativ hochwertigen Entwicklungsprozess zu erhalten.

Parallelprogrammierung in den Programmiersprachen

Ist aufgrund bestehender technischer oder fachlicher Anforderungen die Nutzung parallel ablaufender Prozesse notwendig, sollte vor der Realisierung eigener Lösungen erst einmal betrachtet werden, welche Sprachmittel unterschiedliche Programmiersprachen zu diesem Zweck implementiert haben.

Die Auswahl zwischen den vielen Konzepten gestattet uns heute, eine an das jeweilige Problem angepasste Lösung zu finden. Daher lohnt sich auch huete ein kurzer Blick in die Historie der Programmiersprachen.

Simula

Bereits in den 60‘er Jahren wurde die Programmiersprache am Nosrk Regnesental der Universität Oslo von Ole Johan Dahl und Kristen Nygaard entwickelt. Neben den heute gebräuchlichen objektorientierten Konzepten umfasste diese Sprache in ihrem Simula-67 Standard bereits das Konzept der parallel ablaufenden Koroutinen.

Eine Koroutine entspricht dabei einem zusätzlich existierenden Ausführungskontext in einem Programm. Um diesen Ausführungskontext herzustellen, benötigt man einen eigenen PC (Program Counter) und einen unabhängigen Stack. Neben dem Erzeugen neuer Koroutinen existiert in der Regel eine yield() Operation, mit der die Kontrolle über den Prozessor zwischen zwei Koroutinen ausgetauscht werden kann.

Werden Koroutinen nicht bereits durch die Programmiersprache unterstützt, können diese in der Regel durch eine Bibliothek nachträglich hinzugefügt werden. Die Manipulation von PC und Stack erfordert dabei eine Implementierung eines maschinenabhängigen Programms in Assemblersprache.

Auf Basis dieses grundlegenden Konzepts konnten damals komplexere Abstraktionen realisiert werden. So wurden durch Klassenbibliotheken Monitore, Semaphore, Warteschlangen und Scheduler implementiert.

Auch wenn durch Koroutinen erste Ansätze einer Parallelprogrammierung erforscht und praktisch genutzt wurden, ist dies keine universelle Lösung für die Implementierung parallel ablaufender Programme. Das ablaufende Programm nutzt stets nur einen aktiven Programmkontext, alle anderen Koroutinen bleiben so lange inaktiv, bis die Programmkontrolle explizit einer anderen Koroutine übergeben wird. Ein präemptiver Wechsel in eine Koroutine ist nicht möglich, eine nicht terminierende Schleife führt daher zu einem Halt des vollständigen Programmsystems. Dennoch werden durch dieses Konzept die Systemressourcen effizient genutzt. Gleichzeitig war zum damaligen Zeitpunkt keine Beschreibung eines Speichermodells, dem Umgang des Programms mit dem Hauptspeicher, notwendig. Da immer nur eine Koroutine aktiv ausgeführt wird, kann keine parallele Veränderung der Speicherinhalte erfolgen.

Smalltalk

Mit Smalltalk wurde im Laufe der 70‘er Jahre eine innovative neue Programmierumgebung am Xerox PARC Forschungszentrum entwickelt. In die Programmiersprache wurde sehr früh schon der Begriff eines Thread Objekts integriert. Diese Threads konnten auf der diesem System zugrunde liegenden virtuellen Maschine, die für die Interpretation des Bytecodes zuständig war, unmittelbar umgesetzt werden. Wenn aber auch für den ablaufenden Smalltalk Bytecode es so erscheint, dass die Ausführung faktisch parallel erfolgt, werden die einzelnen Bytecode Ausdrücke nacheinander durch die CPU des Host-Systems abgearbeitet.

Wird eine klassische Smalltalk-80 Umgebung gestartet, wird durch die CPU der Maschine nur der eine Bytecode Interpreter ausgeführt. Damit sind auch die Auswirkungen einzelnen Bytecode Instruktionen immer vollständig für parallel ablaufende Threads sichtbar. Nur für die Synchronisation zwischen einzelnen Threads muss ein Bytecode zur Realisierung von Semaphor Objekten angelegt werden, der den Test und die Veränderung einer Speicherzelle in einer atomischen Operation gestattet.

Mit dem Blick auf die Parallelprogrammierung ist der größte Verdienst dieser Programmiersprache vielleicht die Tatsache, dass auf dieser Plattform eine interaktive Entwicklungsumgebung geschaffen werden konnte, die ein simultanes Editieren und Compilieren an einem laufenden System gestattet hat. Hier wurden die Grundlagen gelegt, die für die heutigen grafischen Bedienoberflächen notwendig sind.

Gleichzeitig ist die virtuelle Maschine auch ein Grund, warum mit dieser Sprache keine weitere

Modula-2

Auch 1978 durch Niklaus Wirth entworfene Sprache MODULA-2 hatte bereits ein aktiv genutztes Konzept für Koroutinen. Mit NEWPROCESS und TRANSFER konnten wir in Simula kooperierende Algorithmen entworfen werden. Zusätzlich aber wurde ein Monitor Objekt definiert, mit dem atomisch eine Sperre erstellt werden kann. Dieses Sperrobjekt ermöglicht, auch bei einer präemptiven Umsetzung der Modula-2 PROCESS Objekte, atomische Sperren aufzubauen. Für die Implementierung kamen dabei CPU Befehle wie TAS („Test and Swap“) oder CAS („Check and Set“) zum tragen, die von den damaligen Prozessoren angeboten wurden.

Mit diesen Hilfsmitteln wurden, im Rahmen der Sprachdefinition, Runtime Umgebungen geschaffen und Echtzeit-Kernel für den Ablauf von Modula-2 Programmen implementiert. Diese System wurden zum Beispiel für die Ansteuerung von Schweißautomaten verwendet.

Über die Speichermodelle jedoch hat man sich als Compilerbauer damals noch kaum Gedanken gemacht. Eher zufällig jedoch wurden die damaligen Compiler korrekt implementiert. Der Aufruf eines Monitors wurde als Unterprogrammaufruf gekapselt und die Optimizer für den erzeugten Assemblercodes haben diese Unterprogrammaufrufe als Grenze für ihre Optimierungsversuche anerkannt. Aber auch hier gilt noch immer, dass ein Programm, egal wie viele PROCESS Objekte angelegt werden, nur einen tatsächlichen Ausführungskontext besitzt. Die Zuordnung des Ausführungskontexts (der CPU) zu einem der ablauffähigen PROCESS Objekte erfolgt nun zwar automatisiert, in der Regel durch einen zentralen Scheduler, doch weiterhin sind diese Programme nur in der Lage, auf einer einzelnen CPU korrekt abzulaufen.

Occam

Occam wurde 1985 durch David May bei der Firma INMOS entwickelt. Diese Sprache wurde notwendig, um die neuartigen Transputer CPUs effizient nutzen zu können. Die Transputer waren im Prinzip noch immer von Neumann Prozessoren, die jedoch zusätzlich jeweils über vier physische Kommunikationsverbindung „Links“ verfügten. Über diese „Hochgeschwindigkeitsverbindungen“ (10MBit/s) konnten zwischen den Transputern Daten ausgetauscht werden. In der Regel bestand ein Transputersystem daher aus mehreren Transputer Prozessoren, die zu eine festen Topologie verschaltet wurden.

Occam unterstützte diese Prozessoren durch die Sprachbefehle PAR und SEQ. Während mit SEQ die normale sequentielle Abfolge der Befehle eingeleitet wurde, konnte mit PAR ein paralleler Ablauf initiiert werden. Über das Sprachmittel eines Kanals konnte die Kommunikation zwischen den parallel ablaufenden Teilvorgängen koordiniert werden. Durch die Ablaufumgebung wurden Programme auf der vorliegenden Topologie abgebildet und mehrere Kanäle über die zur Verfügung gestellten physischen Links geleitet.

Zwischen den einzelnen parallel ablaufenden Codesequenzen können Informationen nur über Kanalstrukturen ausgetauscht werden. Diese stellen damit gleichzeitig die notwendigen Mechanismen zur Synchronisierung bereit. Da Programmen weder durch die Programmiersprache noch durch die Hardware gemeinsam nutzbarer Speicher zur Verfügung gestellt wird, ist damit das Speichermodell bereits vorgegeben.

Auf dieser Basis konnten massiv parallele Algorithmen realisiert werden. Zusatzboards mit einem Transputer oder einem Transputer Link-Adapter waren für viele Computer (Amiga, Atari, PC) verfügbar. Daher wurden in der Industrie Cluster aus Transputern für unterschiedlichste Aufgaben der Steuerungstechnik und Bilderkennung eingesetzt. Große Cluster wurden zum Beispiel durch die deutsche Firma Parsytec realisiert und u.a. für die mathematische Optimierung verwendet.

Um Transputer Cluster effizient zu nutzen, mussten Programme in Occam geschrieben werden. Eine Anpassung bestehender Programme in Pascal, MODULA-2 oder C war zwar möglich, die fehlenden Sprachelemente erschwerten aber die Nutzung der Infrastruktur.

1989 wurde die englische Firma durch SGS-Thomson übernommen und so endete Mitte der 90‘er Jahre die weitere Entwicklung und Produktion der Transputer.

Erlang

Die Programmiersprache Erlang wurde 1986 durch Joe Armstrong für das dänische Telekommunikationsunternehmen Ericsson entwickelt. Bei Erlang handelt es sich um eine funktionale Programmiersprache, die auf einer eigenen virtuellen Maschine („Beam“) ausgeführt wird.

In die Programmiersprache integriert sind Mechanismen für die Erzeugung, Kontrolle und Steuerung nebenläufiger Threads. Anders als in vielen anderen Programmiersprachen sind auch hier — wie in Occam — die Programmierkonzepte unmittelbar in die Sprache integriert. Die Kommunikation zwischen den Threads erfolgt asynchron durch den Austausch von Nachrichten.

Die ungewöhnliche Konzeption als streng funktionale Programmiersprache ermöglicht die weitgehende Nutzung unveränderlicher Speicherstrukturen. Aufgrund dieser einfachen Repräsentation der Daten sind einfache und schnelle Algorithmen für eine Speicherverwaltung mit Garbage Collection möglich. Gleichzeitig gestatten diese unveränderbaren Datenstrukturen den Datenaustausch zwischen parallel ablaufenden Threads, da auf diesen Strukturen keine Synchronisierung zwischen nebenläufigen Programmteilen erfolgen muss.

Diese Programmiersprache wurde für den Einsatz in den Backend Systemen der Telekommunikationsunternehmen konzipiert. Sie muss daher neben hoher Performanz insbesondere auch eine hohe Toleranz gegen Software und Hardwarefehler besitzen. Ein Bestandteil der Erlang Sprache ist daher die Erlang/OTP Bibliothek die es gestattet, Anwendungen auf mehreren Maschinen zu betreiben und den Ausfall einzelner Systeme zu kompensieren.

Im Gegensatz zu den vorangehenden Programmiersprachen sind viele Konzepte beim Entwurf und während der Implementierung gewählt worden, dass massiv parallele Anwendungen auf dieser Plattform erstellt werden können. Wenn auch jede virtuelle Maschine selbst nur über einen ausführenden CPU Kontext verfügt, können auf dieser virtuellen Maschine unterschiedliche Erlang Threads ausgeführt werden. Gleichzeitig können CPU Ressourcen genutzt werden, indem mehrere Beam Instanzen parallel ausgeführt werden.

Am Rande interessant ist, dass in Erlang Programmcode auch zur Laufzeit der Anwendung ausgetauscht werden kann. ohne dass ein Neustart des Anwendungssystem notwendig ist.

Objective-C und Grand Central Dispatch

In den 80‘er Jahren wurde Objective-C durch Brad Cox und Tom Love bei Stepstone als objektorientierte Erweiterung der Programmiersprache C entwickelt. Ursprünglich als Präprozessor ausgelegt, wurden die Sprache in normales C umgesetzt.

Die objekt-orientierten Konzepte und die Verwandschaft zu Smalltalk haben 1989 die Firma NeXT bewogen, diese Sprache als Grundlage für die Entwicklung der grafischen Oberfläche NeXTSTEP zu nutzen. Von dort hat es über die Jahre Einzug in die grafischen Oberflächen von Mac OSX und iOS gehalten.

Im Jahre 2006 wurde diese Programmiersprache um das Konzept von „Blöcken“ erweitert. Es handelt sich dabei um eine Implementierung eines „Closure“, also um compilierte C-Literale, die Funktionen als Parameter übergeben werden können. Zeitgleich wurde für die Laufzeitumgebung eine Bibliothek namens „Grand Central Dispatch“ (gcd) veröffentlicht, die ein nebenläufiges Ausführen von Blocks gestattet. Synchronisationsmittel für die parallele Ausführung und die Steuerung des Zugriffs auf die Resourcen sind dabei Warteschlangen, in denen ablauffähige Blocks auf die Ausführung warten. Diese Warteschlangen werden den zu schützenden Datenstrukturen oder Algorithmen zugeordnet. Aufgrund der Zusicherungen für die Ausführungsreihenfolge der Blocks können daher komplexe nebenläufige Interaktionen realisiert werden.

Im Gegensatz zu der ansonsten Thread orientierten Nebenläufigkeit anderer Programmiersprachen wird hier nur in Ausnahmefällen gezielt ein Thread manipuliert. Stattdessen entscheiden System und Laufzeitumgebung über die sinnvolle Anzahl von Threads, die das Programm während der Ausführung nutzt. Entsprechend der Hardwareausstattung und jeweiligen Nutzung der Systemressourcen kann diese Zahl während der Laufzeit variieren.

Java

Java wurde 1995 durch James Gosling bei Sun Microsystems entwickelt. Es handelt sich um eine objekt-orientierte Programmiersprache der C-Familie die auf einer eigenen Java virtuellen Maschine (JVM) ausgeführt wird.

Bereits in der ersten Sprachversion enthielt Java Mechanismen für die parallele Programmierung. Als Teil der Laufzeitumgebung kann über die Erzeugung von Thread Objekten ein nebenläufiger Ausführungskontext erzeugt werden. Für die Synchronisierung des Zugriffs auf die Datenstrukturen verfügt jedes Objekt über einen Monitor, der über die Operationen Object.wait() und Object.notify() kontrolliert werden kann. Ein zusätzliches Schlüsselwort synchronized gestattet auf einfach Weise, einzelne Codestrecken oder vollständige Methoden vor einer nebenläufigen Ausführung zu schützen.

In der ursprünglichen Form enthält Java nur die notwendigsten Operationen für die Entwicklung parallel ablaufender Programme. Mit der Optimierung der JVM und dem Aufkommen von Multi-Prozessor- und Multi-Core-Systemen wurden in der Vergangenheit hier Probleme in der praktische Nutzung dieser Mechanismen deutlich, die aber in den meisten anderen Programmiersprachen ebenso auftreten. Weit diskutiert wurde in dieser Beziehung insbesondere das „Double-checked and the Singleton pattern.“ Hier führt eine von den Entwicklern wohlgemeinte Optimierung der Initialisierung eines Singletons zu nicht deterministischen Programmzuständen.

Seit der Java Version 5 ist die Definition der Standardbibliothek um eine zusätzliches Programmpaket java.util.concurrent erweitert. In diesem Paket sind zusätzliche Bausteinen für die Programmierung nebenläufiger Algorithmen enthalten.

JavaScript / node.js

In der Betrachtung der Programmiersprachen stellt JavaScript ein ganz besonderes Extrem dar. 1995 durch Netscape als LiveScript entworfen, ist es später als Erweiterungssprache in nahezu alle Internet-Browser integriert worden. JavaScript ist ebenfalls eine von der Syntax her C-ähnliche Sprache. Anstelle der ansonsten üblichen Klassen-basierten Objekt-Orientierung wird hier eine Prototypen-basierte Lösung umgesetzt. Zusammen mit der dynamischen Übersetzung des Programmcodes können damit neue Objekte aus einem Prototype erzeugt und um spezifische Methoden erweitert werden.

Die Nutzung von JavaScript innerhalb eines Back-Ends ist erst in den letzten Jahren mit der Entwicklung der Node.js Umgebung wieder populär geworden. Sie nutzt insbesondere der Prototypen-basierten Charakter der Programmiersprache. Der JavaScript Interpreter — aktuelle JavaScrip Engines übersetzen den JavaScript Code selbstverständlich während der Ausführung in die Maschinensprache der genutzten Prozessorarchitektur — ist streng sequentiell. Es werden keine Mechanismen für die Implementierung einer nebenläufigen Programmierung bereitgestellt.

Stattdessen arbeitet alle Ein- und Ausgabefunktionen Ereignis-basiert. Sollte eine Operation blockieren können, wird diese durch das Betriebssystem im Hintergrund abgewickelt. Ist die Operation erfolgreich abgeschlossen wird eine JavaScript Callback Funktion aufgerufen. Da alle Bibliotheken in Node.js dieses nicht-blockierende I/O unterstützen, kann der JavaScript Interpreter immer zwischen den ablauffähigen Code-Sequenzen hin- und herspringen. Die so erreichte Parallelität entspricht konzeptionell dem kooperativen Modell der Simula Programme. Allerdings ist der Auslöser an dieser Stelle nicht der explizite Transfer eines Zustands, sondern eine potenziell blockierende Ein-/Ausgabeoperation.

Weitere Artikel: