Tutorial “Enterprise Service Bus mit Mule ESB”: Performance und Threads

1 Kommentar

Ein ESB sitzt meistens nicht in einer langweiligen Ecke der Unternehmens-IT, sondern mitten drin. Da wo es wichtig ist. Da wo auch mal etwas mehr Last zu bewältigen ist. Da wo ein Ausfall oder Performance-Problem richtig weh tut. In diesem Artikel geht es um Tuning-Möglichkeiten und damit um die Thread-Architektur von Mule. Deren Kenntnis führt direkt zu den Stellschrauben, an denen man zur Optimierung der Performance drehen kann.
Liest man im Internet einen Artikel rund um das Thema ESB und Performance, taucht beinahe immer auch die Abkürzung SEDA auf. Was ist SEDA? Die deutsche Wikipedia löst immerhin die Abkürzung auf: Staged Event Driven Architecture. Das war’s dann aber auch, für mehr Details kann man es ja mal mit der englischen Variante probieren: Neben der Übersichtsseite (mit Verweis auf berühmte Personen und einen Serienkiller) findet sich dort wenigstens auch ein Artikel, der SEDA im Zusammenhang mit Informatik erklärt. Er ist zwar arg kurz geraten, aber man soll seine Erkenntnisse ja auch besser aus der Primärliteratur ziehen, die ist zumindest referenziert. Unter den Referenzen findet sich auch ein kritischer Blog-Artikel zu SEDA.  Aber der Reihe nach, in Mule ist nämlich nicht alles SEDA, was auf den ersten Blick so aussieht.

Ein einfacher Flow mit http

Fangen wir mit einem einfachem Flow an, dem Debug-Flow aus dem letzten Artikel mit einer kleinen Veränderung: Die Java-Component wartet eine – über URL-Parameter – einstellbare Zeit. Das sieht dann folgendermaßen aus:

SlowFlow

Vorne ein http-Inbound-Endpoint, ein Transformer, der aus dem http-Request die Parameter extrahiert, anschließend die Java-Component. In XML sieht es folgendermaßen aus:

<flow name="SlowFlow" doc:name="SlowFlow">
    <http:inbound-endpoint exchange-pattern="request-response" 
                           host="localhost" 
                           port="8080" path="slow"
                           doc:name="slow"
                           mimeType="text/plain"
                           connector-ref="HTTP" />
    <http:body-to-parameter-map-transformer doc:name="Body to Parameter Map" />
    <component class="de.codecentric.perftest.SlowComponent" doc:name="SlowComponent" />
</flow>

Die Java-Klasse ist auch nicht sonderlich kompliziert:

package de.codecentric.perftest;

import java.util.Map;

public class SlowComponent {
    public String processMessage(Map<String, String> parameters)
    throws InterruptedException {
        int sleepTime;
        try {
            sleepTime = Integer.parseInt(parameters.get("sleep"));
        } catch (Exception e) {
            sleepTime = 0;
        }
        Thread.sleep(sleepTime * 1000); // Millisekunden -> Sekunden
        return "Hello, waited " + sleepTime + " seconds";
    }
}

Was macht dieser Flow? Er beantwortet Requests der Art http://localhost:8080/slow?sleep=5 mit einer Zeile Text. Dies geschieht jedoch nicht sofort, sondern erst nach der einstellbaren Wartezeit (in Sekunden), per URL-Parameter „sleep“. Im Beispiel würde also nach fünf Sekunden im Browser der Text „Hello, waited 5 seconds“ erscheinen.

Die Warterei simuliert Aufrufe an andere Services oder I/O-Operationen. Beide zeichnen sich dadurch aus, dass die CPU (zumindest die auf dem eigenen Server) nichts zu tun hat und – wie im Beispiel – auf ein externes Ereignis wartet.

Was passiert, wenn man einen Request abschickt? Er wird nach der eingestellten Zeit – zuzüglich ca. 10 µs – 2 ms für die Verarbeitung – eine Zeile an den Browser zurückliefern. So weit, so langweilig. Was passiert, wenn man 10 oder 100 Requests direkt hintereinander abschickt? Werden alle Requests sequentiell abgearbeitet? Oder alle parallel? Kommt auf die Einstellungen im Flow an…

Der http-Inbound-Endpoint von Mule funktioniert wie gängige Servlet-Container: Er erzeugt an dem http-Server, der für einen Port zuständig ist, einen Thread-Pool. Requests werden an Threads aus dem Pool gebunden und ausgeführt. Während der Wartezeit ist der Thread blockiert, er wird erst wieder freigegeben, wenn der Reply zurück an den Browser geschickt wird.

Der Pool enthält standardmäßig 25 Threads, die jedoch nicht alle sofort gestartet werden, sondern „on demand“. Die Anzahl stellt man nicht am Endpoint ein, sondern am Connector. Ist auch logisch, schließlich können sich mehrere Endpoints einen Port (und damit http-Server) teilen. Die Konfiguration des Connectors sieht folgendermaßen aus:

<http:connector name="HTTP" doc:name="HTTP\HTTPS" receiveBacklog="100" >
    <receiver-threading-profile maxThreadsActive="10" />
</http:connector>

Im Beispiel wurde die Anzahl der Threads auf zehn begrenzt, das „receiveBacklog“ jedoch auf 100 gesetzt. Es legt fest, wie viele Verbindungsanfragen ein Socket in seine Warteschlange einreiht, bevor er neue Anfragen mit der Fehlermeldung „Connection refused“ abweist. Für die Thread-Einstellungen muss man übrigens in die XML-Ansicht wechseln, in der GUI sind sie nicht sichtbar. Welche weiteren Parameter im Threading-Provile existieren, zeigt der XML-Editor an, wenn man Strg+Leertaste betätigt. (Die XSD-Datei von Mule ist sehr gut mit Hilfetexten versehen.)

Kleiner Benchmark

Für Tests habe ich einen Web-Client mit 40 Threads gestartet, von denen jeder 10 Requests abschickt, die Wartezeit der Java-Component war auf eine Sekunde eingestellt. Die Ergebnisse sind (beinahe) wie erwartet: Bei einem Thread im Mule Server dauert es 163 Sekunden, bei 10 Threads 27 Sekunden, bei 50 Threads 11 Sekunden. Warum nicht genau wie erwartet? Mule hat noch einige Reserve-Threads in der Hinterhand, die bei „zu wenigen“ http-Threads einspringen und das Ergebnis schneller als erwartet ausfallen lassen. 50 Threads in Mule sind dann „genug“ (ihnen stehen ja nur 40 Client-Threads gegenüber): Hier hätten wir im Idealfall 10 Sekunden erwarten können, die Abweichung von ca. einer Sekunde dürfte dem Overhead der vielen Threads geschuldet sein.

Alles SEDA oder nicht?

Liest man das Paper „SEDA: An Architecture for Well-Conditioned, Scalable Internet Services“ von Matt Welsh, David Culler und Eric Brewer und hat die Aussage im Hinterkopf, dass Mule (und andere ESBs) SEDA umsetzen, so kann man sich am Kopf kratzen: SEDA funktioniert anders. Bei echtem SEDA hätte für das Beispiel ein Thread für maximale Performance gereicht, schließlich hat die CPU beinahe nichts zu tun, neben dem Request-Parsing wird ja nur eine Sekunde gewartet.

Aber was bedeutet SEDA nun? Wer’s genau wissen will: Oben genanntes Paper lesen. Kurze Zusammenfassung: Statt viele Threads zu nutzen, die jeweils einem Request zugeordnet werden, gibt es bei SEDA nur wenige (im Extremfall einen) Threads, die jedoch nicht blockieren: In unserem Beispiel wird der Request-Thread für die eine Sekunde blockiert und steht auch nicht für andere Aufgaben (z.B. nächsten Request annehmen) zur Verfügung. In SEDA sieht die Struktur komplett anders aus, dort gibt es mehrere Stages und in einem Stage wird nie blockiert.

In unserem Beispiel würde ein erster Stage den Request (man könnte ihn auch Event nennen) entgegennehmen und bis zur „Wartestelle“ treiben. In der Praxis wäre dies der Aufruf eines weiteren Systems, bei uns nur ein Sleep. Ein zweiter Stage würde jetzt auf das Event „Antwort vom anderen System“ (bzw. Wartezeit beendet) reagieren. Damit keine Events verloren gehen, werden sie bei Bedarf in einer Warteschlange vor den Stages gepuffert.

SEDA benötigt weniger Threads als der Ansatz in typischen Webservern, der Nachteil ist eine aufwändigere Programmierung: Jeder potentiell blockierende Zugriff auf eine Ressource (Datei, entferntes System, etc.) führt dazu, dass der Stage aufgeteilt werden muss. Im Extremfall reicht ein einzelner Thread, in der Praxis wird einen gemeinsamen Thread-Pool oder einen Pool pro Stage verwenden.

Die Idee von SEDA ist schon ein paar Jahre alt, inzwischen haben sich die Rahmenbedingungen geändert. Betriebssystem (und das JDK) sind in Bezug auf Threads deutlich effizienter geworden, so dass man sich mehr Threads „leisten“ kann, ohne ineffizient zu werden. Sehr schön dargelegt ist dies in dem Blog-Artikel Is it Raining Comets and Threads… Again? von Jose Maria Arranz aus dem Jahr 2008.

Die Tatsache, dass Mule bei synchronen Flows kein SEDA einsetzt, kann man daher durchaus verschmerzen. Ich habe hier bewusst auf „synchrone Flows“ verallgemeinert, was oben exemplarisch für http beschrieben wurde, gilt auch für andere Inbound Endpoints mit Reqest-Reply-Pattern.

Asynchrone Flows

Wenn ich schon so oft SEDA erwähne, dann muss es irgendwann auch mal kommen, jetzt ist es so weit. Das Beispiel dafür hatten wir schon mal in einem anderen Zusammenhang, der einfache File-Copy-Flow aus Teil 2 der Artikelserie:

FileCopyFlowWas macht der Flow? Er überwacht ein Verzeichnis, greift sich alle Dateien daraus, loggt jede Datei, kopiert sie in ein Zielverzeichnis. Diesen Flow setzt Mule gemäß SEDA um, es kommen drei Stages mit jeweils einem eigenen Thread-Pool zum Einsatz. Zwischen den Stages befinden sich Queues, über die Messages (SEDA: Events) weitergeleitet werden:

  1. Ein Stage für den Inbound-File-Endpoint
  2. Ein Stage für den Kern des Flows (hier nur der Logger, wären es mehrere Komponenten, würden alle im gleichen Pool ausgeführt)
  3. Ein Stage für den Outbound-File-Endpoint

Mule-Default sind hier wieder 25 Threads pro Pool, die Anzahl lässt sich natürlich wieder konfigurieren. Für die beiden File-Endpoints benötigt man – wie schon bei http – einen Connector. Darin werden jedoch jetzt zwei Pools über sogenannte Threading-Profiles konfiguriert: Ein Receiver-Profile für Inbound-Endpoints, ein Dispatcher für Outbound-Endpoints. So sieht’s im XML aus:

<file:connector name="FILE">
    <receiver-threading-profile maxThreadsActive="1" />
    <dispatcher-threading-profile maxThreadsActive="1" />
</file:connector>

Den Thread-Pool für den Flow konfiguriert man ebenso nicht direkt am Flow, sondern über eine Processing-Strategy:

<asynchronous-processing-strategy name="async" maxThreads="10">

Die Strategie muss anschließend im Flow refernziert werden, der Connector in den beiden Endpoints:

<flow name="FileFlow" doc:name="FileFlow" processingStrategy="async">
    <file:inbound-endpoint path="files/in" doc:name="File In" connector-ref="FILE"/>
    <logger level="INFO" doc:name="Logger" />
    <file:outbound-endpoint path="files/out" doc:name="File Out" connector-ref="FILE"/>
</flow>

Welche Größen für die einzelnen Pools sinnvoll sind, lässt sich nicht pauschal beantworten, dafür muss man wissen, wie lange die einzelnen Stages (oder Components) benötigen und wieviel Last man anderen beteiligten System zumuten möchte. Einige Hinweise und Formeln dazu findet man in der Mule-Dokumentation unter dem Titel „Tuning Performance„.
In unserem Beispiel müsste ein Pool nicht groß sein: Der Receiver-Pool des File-Connectors. Warum? Weil der File-Connector nicht viel Arbeit hat: Er überwacht zwar das Verzeichnis und öffnet die Dateien, aber er liest sie nicht. Stattdessen wird der InputStream als Payload in der Message weitergereicht. Gelesen wird die Datei erst im Flow selbst (zweiter Stage). Anders sieht es beim Outbound-Endpoint aus: Hier muss wirklich in die Datei geschrieben werden, was bei großen Dateien schon mal länger dauern kann.

Gemischte Flows

Die Welt der Flows lässt sich nicht scharf in synchron und asynchron aufteilen, es gibt auch Mischformen, die sich manchmal implizit ergeben, oder die man mit Hilfe eines Async-Scopes erzwingen kann. Hier ein einfaches Beispiel eins synchronen http-Flows, mit einem asynchron laufenden File-Outbound-Endpoint:

 

HybridFlow

 

Was passiert in dem Flow? Im Thread des Inbound-Endpoints wird Echo-Component ausgeführt, anschließend schickt der Endpoint das Ergebnis an den Aufrufer zurück. Hinter der Echo-Component wird die Message jedoch in eine Queue für einen zweiten Thread-Pool eingestellt, der zum File-Outbound-Endpoint gehört. Das Verschicken der Antwort per http und das Schreiben der Datei werden in getrennten Threads ausgeführt, so dass sie sich nicht gegenseitig beeinflussen.

Zusammenfassung

Ein oder mehrere Flows sind in Mule schnell zusammengeklickt, die Details der Verarbeitung mit den diversen Thread-Pools bleibt dabei erst einmal verborgen. Auch bei der Programmierung von Java-Components muss man sich um Threads und Synchronisierung nicht kümmern (man sollte nur keine static-Variablen nutzen).

Geht es darum, die Performance eines Systems unter hoher Last zu analysieren oder zu tunen, sollte man sich den Aufbau genauer anschauen und die Stellschrauben kennen. Der Artikel hat dazu hoffentlich einen Grundstock gelegt. Im Zweifelsfall sollte man die Pools übrigens nicht zu klein wählen: Mule erzeugt nicht sofort beim Start die volle Zahl von Threads, dies geschieht erst dann, wenn sie wirklich benötigt werden.

Weitere Teile dieser Artikelserie

  1. Was ist ein ESB und wofür kann man ihn nutzen?
  2. Tutorial “Enterprise Service Bus mit Mule ESB”: Hello World/Bus
  3. Tutorial “Enterprise Service Bus mit Mule ESB”: MuleMessage und Java-Komponenten
  4. Tutorial „Enterprise Service Bus mit Mule ESB“: Nachrichten mit Java transformieren
  5. Tutorial „Enterprise Service Bus mit Mule ESB“: Integration von CICS Programmen
  6. Tutorial “Enterprise Service Bus mit Mule ESB”: Transport, Connector, Endpoint: Ein paar Grundlagen…
  7. Tutorial “Enterprise Service Bus mit Mule ESB”: Performance und Threads
  8. Tutorial “Enterprise Service Bus mit Mule ESB”: Steuerung und Kontrolle per JMX
  9. Veröffentlichen von Informationen zu Mule Applikationen im Maven-Umfeld
  10. Tutorial “Enterprise Service Bus mit Mule ESB”: Exceptions und Email
Roger Butenuth

Dr. Roger Butenuth hat in Karlsruhe Informatik studiert und anschließend in Paderborn promoviert (Kommunikation in Parallelrechnern). Er hat langjährige Erfahrung in der Projekt- und Produktentwicklung.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentare

  • Peter

    Hallo Roger,

    prima Artikel!

    Ich wollte mir schon seit langem die SEDA- Zusammenhänge mal klar machen. Jetzt hast Du es mir leicht gemacht.

    Besten Dank

    Peter

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.