Patterns in der Softwarearchitektur: Das Reactor-Muster
![Zwei Verkehrsampeln vor wolkigem Abendhimmel.](https://heise.cloudimg.io/width/700/q75.png-lossy-75.webp-lossy-75.foil1/_www-heise-de_/imgs/18/4/1/4/6/5/5/2/shutterstock_1476252518-30d3e1aecde96b83.jpeg)
(Bild: monticello/Shutterstock.com)
Ereignisgesteuerte Anwendungen wie GUIs oder Server verwenden oft das Architekturmuster Reactor.
Patterns sind eine wichtige Abstraktion in der modernen Softwareentwicklung und Softwarearchitektur. Sie bieten eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Ereignisgesteuerte Anwendungen wie Server oder GUIs verwenden oft das Architekturmuster Reactor. Dieser kann mehrere Anfragen gleichzeitig annehmen und sie auf verschiedene Handler verteilen.
![](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/1/4/6/5/5/2/Reactor-4868f2965af33928.png)
Das Reactor-Pattern ist ein ereignisgesteuertes Framework, um Serviceanfragen zu demultiplexen und gleichzeitig an verschiedene Serviceanbieter zu verteilen. Die Anfragen werden synchron verarbeitet.
Reactor
Auch bekannt als
- Dispatcher
- Notifier
Problem
Ein Server soll
- mehrere Kundenanfragen gleichzeitig beantworten,
- performant, stabil und skalierbar sein sowie
- erweiterbar sein, um neue oder verbesserte Dienste zu unterstützen.
Die Anwendung sollte vor Multithreading und Synchronisierungsproblemen geschützt werden.
Lösung
- Jeder unterstützte Dienst wird in einem Handler gekapselt.
- Die Handler werden im Reactor registriert.
- Der Reactor verwendet einen Ereignis-Demultiplexer, um synchron auf alle eingehenden Ereignisse zu warten.
- Wenn der Reactor benachrichtigt wird, leitet er die Dienstanforderung an den entsprechenden Handler weiter.
Struktur
![](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/1/4/6/5/5/2/reactorUML-7fab3b079bc67199.png)
![](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/1/4/6/5/5/2/reactorCRCUpdate-c553b7ec805e11a5.png)
Handles
- Die Handles identifizieren verschiedene Ereignisquellen wie Netzwerkverbindungen, geöffnete Dateien oder GUI-Ereignisse.
- Die Ereignisquelle erzeugt Ereignisse wie Verbinden, Lesen oder Schreiben, die auf dem zugehörigen Handle in eine Warteschlange gestellt werden.
Synchonous Event Demultiplexer
- Der synchrone Ereignis-Demultiplexer wartet auf ein oder mehrere Indikationsereignisse und blockiert, bis das zugehörige Handle das Ereignis verarbeiten kann.
- Mit den Systemaufrufen select [1], poll [2], epoll [3], kqueue [4] oder WaitForMultipleObjects [5] kann er auf Indication Events warten.
Event Handler
- Der Event Handler definiert die Schnittstelle für die Verarbeitung der Indication Events.
- Der Event Handler definiert die unterstützten Dienste der Anwendung.
Concrete Event Handler
- Der konkrete Event Handler implementiert die Schnittstelle der Anwendung, die durch den Event Handler definiert wird.
Reactor
Der Reactor
- unterstützt eine Schnittstelle zum Registrieren und Deregistrieren des konkreten Ereignis-Handler mithilfe von Dateideskriptoren,
- verwendet einen synchronen Ereignisdemultiplexer, um auf Indikationsereignisse zu warten; ein Indication Event kann ein Lese-, Schreib- oder Fehlerereignis sein,
- ordnet die Ereignisse ihren konkreten Ereignis-Handlers zu und
- verwaltet die Lebensdauer der Ereignisschleife.
Der Reactor (und nicht die Anwendung) wartet auf die Indikationsereignisse, um das Ereignis zu demultiplexen und zu versenden. Die konkreten Ereignis-Handler werden im Reactor registriert. Der Reactor kehrt den Kontrollfluss um. Diese Umkehrung der Kontrolle wird oft als Hollywood-Prinzip [6] bezeichnet.
Das dynamische Verhalten eines Reactor ist ziemlich interessant.
Dynamisches Verhalten
Die folgenden Punkte veranschaulichen den Kontrollfluss zwischen dem Reactor und dem Ereignis-Handler:
- Die Anwendung registriert einen Event-Handler für bestimmte Ereignisse im Reactor.
- Jeder Event-Handler stellt dem Reactor seinen spezifischen Handler zur Verfügung.
- Die Anwendung startet die Ereignisschleife. Die Ereignisschleife wartet auf Indikationsereignisse.
- Der Ereignis-Demultiplexer kehrt zum Reactor zurück, wenn eine Ereignisquelle bereit wird.
- Der Reactor sendet die Handles an den entsprechenden Event-Handler.
- Der Ereignishandler verarbeitet das Ereignis.
Schauen wir uns den Reactor in Aktion an.
Beispiel
In diesem Beispiel wird das POCO-Framework [7] verwendet. "The POCO C++ Libraries are powerful cross-platform C++ libraries for building network- and internet-based applications that run on desktop, server, mobile, IoT, and embedded systems."
// reactor.cpp
#include <fstream>
#include <string>
#include "Poco/Net/SocketReactor.h"
#include "Poco/Net/SocketAcceptor.h"
#include "Poco/Net/SocketNotification.h"
#include "Poco/Net/StreamSocket.h"
#include "Poco/Net/ServerSocket.h"
#include "Poco/Observer.h"
#include "Poco/Thread.h"
#include "Poco/Util/ServerApplication.h"
using Poco::Observer;
using Poco::Thread;
using Poco::Net::ReadableNotification;
using Poco::Net::ServerSocket;
using Poco::Net::ShutdownNotification;
using Poco::Net::SocketAcceptor;
using Poco::Net::SocketReactor;
using Poco::Net::StreamSocket;
using Poco::Util::Application;
class EchoHandler {
public:
EchoHandler(const StreamSocket& s,
SocketReactor& r): socket(s), reactor(r) { // (11)
reactor.addEventHandler(socket,
Observer<EchoHandler, ReadableNotification>
(*this, &EchoHandler::socketReadable));
}
void socketReadable(ReadableNotification*) {
char buffer[8];
int n = socket.receiveBytes(buffer, sizeof(buffer));
if (n > 0) {
socket.sendBytes(buffer, n); // (13)
}
else {
reactor.removeEventHandler(socket, // (12)
Observer<EchoHandler,
ReadableNotification>
(*this, &EchoHandler::socketReadable));
delete this;
}
}
private:
StreamSocket socket;
SocketReactor& reactor;
};
class DataHandler {
public:
DataHandler(StreamSocket& s,
SocketReactor& r):
socket(s), reactor(r), outFile("reactorOutput.txt") {
reactor.addEventHandler(socket, // (14)
Observer<DataHandler,
ReadableNotification>
(*this, &DataHandler::socketReadable));
reactor.addEventHandler(socket, // (15)
Observer<DataHandler,
ShutdownNotification>
(*this, &DataHandler::socketShutdown));
socket.setBlocking(false);
}
~DataHandler() { // (16)
reactor.removeEventHandler(socket,
Observer<DataHandler,
ReadableNotification>
(*this, &DataHandler::socketReadable));
reactor.removeEventHandler(socket,
Observer<DataHandler,
ShutdownNotification>
(*this, &DataHandler::socketShutdown));
}
void socketReadable(ReadableNotification*) {
char buffer[64];
int n = 0;
do {
n = socket.receiveBytes(&buffer[0], sizeof(buffer));
if (n > 0) {
std::string s(buffer, n);
outFile << s << std::flush; // (17)
}
else break;
} while (true);
}
void socketShutdown(ShutdownNotification*) {
delete this;
}
private:
StreamSocket socket;
SocketReactor& reactor;
std::ofstream outFile;
};
class Server: public Poco::Util::ServerApplication {
protected:
void initialize(Application& self) { // (3)
ServerApplication::initialize(self);
}
void uninitialize() { // (4)
ServerApplication::uninitialize();
}
int main(const std::vector<std::string>&) { // (2)
ServerSocket serverSocketEcho(4711); // (5)
ServerSocket serverSocketData(4712); // (6)
SocketReactor reactor;
SocketAcceptor<EchoHandler>
acceptorEcho(serverSocketEcho, reactor); // (7)
SocketAcceptor<DataHandler>
acceptorData(serverSocketData, reactor); // (8)
Thread thread;
thread.start(reactor); // (9)
waitForTerminationRequest();
reactor.stop(); // (10)
thread.join();
return Application::EXIT_OK;
}
};
int main(int argc, char** argv) {
Server app; // (1)
return app.run(argc, argv);
}
(1) erzeugt den TCP-Server. Dieser führt die main
-Funktion (2) aus und wird in (3) initialisiert und in (4) deinitialisiert. Die main
-Funktion des TCP-Servers erstellt zwei Server-Sockets, die auf Port 4711 5) und Port 4712 (6) lauschen. In den (7) und (5) werden die Server-Sockets mit dem EchoHandler
und dem DataHandler
erstellt. Der SocketAcceptor
modelliert die Acceptor-Komponente des Accepter-Connector-Designmusters [8]. Der Reactor läuft in einem separaten Thread (9), bis er seine Abbruchanforderung erhält (10).
Der EchoHandler
registriert sein Lese-Handle im Konstruktor (11) und hebt die Registrierung seines Lese-Handle in der Mitgliedsfunktion socketReadable
auf (12). Er sendet die Nachricht des Kunden zurück (13). Im Gegensatz dazu ermöglicht der DataHandler
einem Client, Daten an den Server zu übertragen. Der Handler
registriert in seinem Konstruktor seine Aktion für Leseereignisse (14) und Shutdown-Ereignisse (Zeile 15). Beide Handler
werden im Destruktor des DataHandler
(16) wieder abgemeldet. Das Ergebnis der Datenübertragung wird direkt in das Dateihandle outFile
geschrieben (17).
Die folgende Ausgabe zeigt auf der linken Seite den Server und auf der rechten Seite die beiden Clients. Eine Telnet-Sitzung dient als Client. Der erste Client verbindet sich mit Port 4711: telnet 127.0.0.1 4711
. Dieser Client verbindet sich mit dem Echo-Server und zeigt daher seine Anfrage an. Der zweite Client stellt eine Verbindung zu Port 4712 her: telnet 127.0.0.1 4712
. Die Ausgabe des Servers zeigt, dass die Daten des Clients an den Server übertragen werden.
![](https://heise.cloudimg.io/width/696/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/1/4/6/5/5/2/reactorRun-6abc0bad5c8d3ffa.png)
Was sind die Vor- und Nachteile des Reactors?
Vor- und Nachteile
Vorteile
- Eine klare Trennung von Framework und Anwendungslogik.
- Die Modularität der verschiedenen konkreten Event-Handler.
- Der Reactor kann auf verschiedene Plattformen portiert werden, da die zugrundeliegenden Funktionen zum Demultiplexen von Ereignissen wie select [9], poll [10], epoll [11], kqueue [12]oder WaitForMultipleObjects [13] auf Unix- (select, epoll) und Windows-Plattformen (WaitForMultipleObjects) verfügbar sind.
- Die Trennung von Schnittstelle und Implementierung ermöglicht eine einfache Anpassung oder Erweiterung der Dienste.
- Die Gesamtarchitektur unterstützt die parallele Ausführung.
Nachteile
- Der Reactor erfordert einen Systemaufruf zum Demultiplexen von Ereignissen.
- Ein langlaufender Event-Handler kann den Reactor blockieren.
- Die Umkehrung der Kontrolle macht das Testen und Debuggen schwieriger.
Wie geht's weiter?
Es gibt viele bewährte Muster, die im Bereich der Concurrency verwendet werden. Sie befassen sich mit Synchronisierungsproblemen wie Sharing und Mutation, aber auch mit nebenläufigen Architekturen. In meinem nächsten Beitrag beginne ich mit den Mustern, die sich auf die gemeinsame Nutzung von Daten konzentrieren. (rme [14])
URL dieses Artikels:
https://www.heise.de/-8973433
Links in diesem Artikel:
[1] https://en.wikipedia.org/wiki/Select_(Unix)
[2] https://man7.org/linux/man-pages/man2/poll.2.html
[3] https://en.wikipedia.org/wiki/Epoll
[4] https://en.wikipedia.org/wiki/Kqueue
[5] https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjects
[6] https://en.wikipedia.org/wiki/Inversion_of_control
[7] https://pocoproject.org/
[8] http://software-pattern.org/Book/29
[9] https://en.wikipedia.org/wiki/Select_(Unix)
[10] https://man7.org/linux/man-pages/man2/poll.2.html
[11] https://en.wikipedia.org/wiki/Epoll
[12] https://en.wikipedia.org/wiki/Kqueue
[13] https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjects
[14] mailto:rme@ix.de
Copyright © 2023 Heise Medien