Wieviele Seiten schafft mein Web-Server pro Sekunde? Wieviele Clients kann ich mit CGI-Skripts pro Sekunde bedienen? Was bringen die Apache-Beschleuniger fast-cgi und mod_perl wirklich? Ein Testskript simuliert parallel anstürmende Web-Browser und zeigt, wo die Grenzen des eigenen Web-Servers liegen.
Spricht man von der Performance eines Web-Servers, sind zwei Parameter ausschlaggebend. Da ist einmal die Verzögerung (Latency), die Zeit, die verstreicht, bis der Server die Anfrage eines Clients bearbeitet hat und die verlangte Seite ausliefert. Wichtiger noch ist allerdings der maximale Durchsatz (Throughput), der angibt, wieviele Anfragen der Server pro Sekunde bearbeiten kann, bis parallel anstürmende Clients die CPU des Servers so stark belasten, daß dieser mit der Abarbeitung nicht mehr nachkommt und manche Clients irgendwann die Geduld verlieren und einen Timeout melden.
So reicht es denn nicht, von einem Test-Client aus hintereinander Anfragen
an den Server zu schicken und die verstrichene Zeit zu messen,
denn viele Server sind zu merklich mehr imstande und handeln Requests parallel
ab. Vielmehr muß der Test-Client eine einstellbare Anzahl von Requests
quasi
gleichzeitig an den Server stellen und deren Ergebnisse asynchron bearbeiten.
Für diesen Zweck ist das Modul LWP::Parallel::UserAgent von
Marc Langheinrich hervorragend geeignet, denn mit ihm lassen sich
parallel laufende UserAgents erzeugen und kontrollieren.
Die neueste Version 2.32 liegt auf dem CPAN (wo sonst!) unter
modules/by-module/LWP/ParallelUserAgent-2.32.tar.gz
vor. Es setzt die installierte libwww-Distribution voraus, die auf
dem CPAN unter
modules/by-module/LWP/libwww-perl-5.33.tar.gz
zur Abholung bereitliegt.
Um die zwischen zwei Meßpunkten verstrichene Zeit nicht nur sekundengenau
zu ermitteln, sondern auch Bruchteile von Sekunden zu erfassen, muß das
Modul Time::HiRes von Douglas E. Wegscheid 'ran. Die neueste
Version 1.16 liegt unter
modules/by-module/Time/Time-HiRes-01.16.tar.gz
auf dem CPAN. Die Funktion Time::HiRes::gettimeofday ermittelt die
aktuelle Uhrzeit so genau, daß ein anschließender Aufruf von
Time::HiRes::tv_interval mit zwei Meßpunkten als Parametern die
dazwischen verstrichene Zeit als Fließkommazahl in Millisekunden-Auflösung
zurückliefert. Das Skript nach Listing hires.pl ermittelt die Zeit, die ein
Aufruf von sleep(1) verbrät und gibt sie aus:
Verstrichene Zeit: 0.99861 Sekunden
Das Modul LWP::Parallel::UserAgent erzeugt UserAgents,
die (beinahe) gleichzeitig auf einen Webserver losfeuern.
Der Konstruktor
$ua = LWP::Parallel::UserAgent->new();
erzeugt das Mutter-Objekt, das die wilde Horde unter Kontrolle hält. Jeder einzelne Request muß mit
$ua->register($request);
beim Parallel::UserAgent registriert werden,
wobei $request eine Referenz auf ein Objekt vom Typ HTTP::Request ist,
das vorher zum Beispiel mit
my $request = HTTP::Request->new('GET', $url);
erzeugt wurde. Die Methode
$ua->wait($timeout);
startet dann den Ansturm. Jeder Client wartet maximal $timeout
Sekunden auf prompte Bedienung. Dabei achtet der multiple
UserAgent darauf, nur eine über
$ua->max_req($max_clients);
voreingestellte Anzahl von Clients gleichzeitig zu starten und erst dann neue Clients nachzulegen, falls alte ihre Mission beendet haben.
Das ist genau, was das Skript zur Performance-Messung
nach Listing pounder.pl tut:
Die Zeilen 60 bis 71 erzeugen
das Mutter-Objekt und pressen den gleichen Request so oft hinein,
wie es die Variable $nof_requests_total aus der Konfigurationssektion
ab Zeile 9 vorgibt. Die Zeilen dort sind vor dem Skriptstart an die
lokalen Gegebenheiten anzupassen, $nof_parallel_connections legt
die Anzahl ``gleichzeitig'' loslegender Clients fest, $url den URL
der zu testenden Webseite und $timeout die maximale Anzahl von
Sekunden, die ein Client auf Bedienung wartet.
Zeile 76 hält noch schnell die Startzeit fest, bevor die wait-Methode
aus Zeile 77 den Massentest startet und erst zurückkehrt, wenn auch
der letzte Client Erfolg oder Misserfolg gemeldet hat.
Nach Abschluß des Rennens hält Zeile 78 die Stoppuhr an, indem es
die Funktion tv_interval aus dem Paket Time::HiRes mit nur einem Parameter
(der Startzeit) aufruft, was tv_interval dazu veranlaßt, die seit dem
Startzeitpunkt vergangene Zeit in Sekunden plus Bruchteilen
als Fließkommazahl zurückzugeben.
Die wait-Methode liefert eine Referenz auf einen Hash zurück, der
als Values Referenzen auf Objekte vom Typ
LWP::Parallel::UserAgent::Entry enthält: Das Ergebnis jeder während des Tests
aufgebauten HTTP::Verbindung läßt sich so nochmal abfragen.
Ein Entry-Objekt liefert, falls es
mit der response-Methode dazu aufgefordert wird,
eine Referenz auf ein HTTP::Response-Objekt zurück,
das wiederum mit der Methode is_success
Erfolg meldet oder im Fehlerfall mit den Methoden code und message
Fehler-Code und -Meldung bereitstellt.
Im
Erfolgsfall zählt pounder.pl die Variable $succeeded um Eins hoch,
falls etwas schieflief,
speichert der Hash %errors die Fehlermeldung als Key und die
Häufigkeit des aufgetretenen Fehlers als Value (Zeile 93). Die
Zeilen 100-102 machen daraus eine komma-separierte Liste von
Fehlermeldungen und deren Häufigkeiten und legen sie im String
$errors ab.
Die Ausgabe der Ergebnisse im Format
URL: http://localhost/perl/dump.cgi
Total Requests: 100
Parallel Agents: 5
Succeeded: 100 (100.00%)
Errors: NONE
Total Time: 22.75 secs
Throughput: 4.39 Requests/sec
Latency: 1.02 secs/Request
übernehmen die Zeilen 107 bis 143. Zunächst legt das Skript im Array
@P die linken und rechten Seiten der Ausgabe als aufeinanderfolgende
Elemente ab.
Die korrekte Formatierung übernimmt anschließend die Formatanweisung
aus den Zeilen 136-139, die die linke Spalte der ausgegebenen Tabelle
jeweils 16 Zeichen breit linksbündig setzt und eine beliebig breite
rechte Spalte daneben setzt.
Die Zeilen 141 bis 143 extrahieren jeweils zwei Elemente aus @P und
die write-Anweisung gibt sie schön formatiert aus. Nun war ich bislang
kein besonderer Fan von Perls Format-Befehlen, aber für diese Anwendung
taugt's mir -- öfter mal was Neues! Näheres hierzu zeigt übrigens
die Manualseite, die auf perldoc perlform zum Vorschein kommt.
Die wichtigste Ausgabe ist zweifellos der Durchsatz. Die Zeile
Throughput: 4.39 Requests/sec
kommt dadurch zustande, daß das Skript die Zeit mißt, die zwischen
dem Starten der $ua->wait-Methode und deren Beendigung vergeht und
anschließend durch die Anzahl der erfolgreichen Requests teilt.
Wahrscheinlich hat sich der ein oder andere bislang schon gewundert --
``Wieso ist das erzeugte Mutter-Objekt vom Typ MyParallelAgent?''
Der Grund dafür ist, daß es LWP::Parallel::UserAgent nicht
gestattet, direkt Messungen der Latency vorzunehmen, also der
Zeitspanne zwischen dem Absetzen eines (einzelnen!) Requests und dessen
Abarbeitung. In weiser Voraussicht hat der Entwickler von
LWP::Parallel::UserAgent jedoch eine Hintertür eingebaut: Objekte vom
Typ LWP::Parallel::UserAgent rufen
vor jedem Einzel-Request die Methode on_connect auf und springen
nach getaner Arbeit je nach Erfolgsstatus on_return oder on_failure
an. Diese Methoden läßt die Implementierung in LWP::Parallel::UserAgent
bewußt leer -- abgeleitete Klassen dürfen sie überschreiben und
zusätzliche Funktionalität integrieren.
Das Mutterobjekt ruft on_connect, on_return und on_failure
mit drei zusätzlichen Parametern auf:
Neben der für die objektorientierte Programmierung mit Perl obligatorischen
Objektreferenz kommen
$request eine Referenz auf das HTTP::Request-Objekt,
mit $response eine Referenz auf das HTTP::Response-Objekt und
mit $entry eine Referenz auf ein von LWP::Parallel:UserAgent intern
verwendetes Objekt vom Typ LWP::Parallel:UserAgent::Entry
als Parameter herein.
In pounder.pl definieren die Zeilen 17 bis 51 eine neue, von
LWP::Parallel::UserAgent abgeleitete Klasse MyParallelAgent, die
folglich alles kann, wozu LWP::Parallel::UserAgent imstande ist.
Der @ISA-Array aus Zeile 19 legt die Vererbungshierarchie fest.
Die in der abgeleiteten Klasse überschriebene Methode
on_connect bringt das Kunststück fertig, die Startzeit des
jeweiligen Requests in einer Instanzvariablen für die spätere
Latency-Berechnung abzulegen. Da das Mutter-Objekt die Startzeit aller
Einzel-Requests speichern muß, speichert es die Einzel-Startzeiten in
einem Hash __start_times, den sie mit der als letzten Parameter
nach on_connect hereingereichten Referenz $entry indiziert, da
diese Variable für jeden Einzel-Request eindeutig ist.
Der Name __start_times enthält deshalb zwei Unterstriche, damit es nicht
zu unbeabsichtigten Überlappungen mit eventuell in LWP::Parallel::UserAgent
schon definierten gleichnamigen Instanzvariablen kommt.
$self->{__start_times}->{$entry} enthält also für jeden Request
eine für das Time::HiRes-Modul taugliche Startzeit.
Die on_return-Methode addiert im Erfolgsfall am Ende eines Requests
die inzwischen verstrichene Zeit zu einer Instanzvariablen des Mutter-Objekts:
__latency_total, einer Fließkommazahl, die einen Sekundenwert für
alle bislang ausgeführten Requests festhält.
on_failure verhält sich analog -- da im Fehlerfall genau derselbe
Ablauf erwünscht ist, ruft die überschriebene on_failure-Methode
einfach on_return mit allen übergebenen Parametern auf.
Um die Definition der Klasse MyParallelAgent abzuschließen und mit
dem eigentlichen Skript anzufangen, steht package main in Zeile
54.
Abbildung 1 zeigt Meßwerte für verschiedene URLs. Die erste Testserie
holte ständig die statische Seite http://localhost/index.html und
der Server lieferte mit mehr als 6 Requests pro Sekunde ein respektables
Ergebnis. Für ein CGI-Skript, das noch dazu das CGI.pm-Modul einbindet,
wird's schon sehr langsam: Die zweite Testreihe zeigt, daß der Server
nur noch einen Requests pro Sekunde schafft und es etwa fünf Sekunden dauert,
bevor der Anwender eine Antwort bekommt. Die dritte Testreihe spricht mit
http://localhost/perl/dump.cgi dasselbe Skript an, nur daß dieses diesmal
mit dem Apache-Beschleuniger mod_perl ausgeführt wird - das Ergebnis:
Fast dreimal so schnell. Daß pounder.pl auch Fehlerfälle korrekt behandelt,
zeigt die vierte Testserie: Für den nicht existierenden URL
http://localhost/bogus meldet es korrekt 100 mal den Fehler
File Not Found.
Bei Performance-Messungen spielt natürlich auch die Netzwerkverbindung eine wichtige Rolle: Kommen die übertragenen Daten nicht schnell genug durch die Leitung, spiegelt das Meßergebnis letztlich die Bandbreite der Leitung wider und nicht die Serverleistung.
Laufen, wie bei den durchgeführten Tests, Client und Server auf ein und
demselben Rechner, zieht auch
der Client nicht unerheblichen Saft aus der CPU. Zwar hält sich dies
beim LWP::Parallel::UserAgent-Client in Grenzen, da dieser
alle parallelen Verbindungen mit nur einem einzigen Prozeß und
dem guten alten select-Trick kontrolliert,
doch sollten für exakte Messungen Client und Server auf getrennten
Maschinen mit guter Netzwerkverbindung laufen.
Und noch eine persönliche Bitte, meine lieben Leser: pounder.pl belastet
einen angesprochenen Web-Server erheblich -- tut mir den Gefallen und
testet damit nur Eure eigenen Installationen. Ich denke, wer genügend
Grips besitzt, das Skript zu starten und anzupassen, ist auch ein
guter Netizen. Daran denken: Wir sind alle gute Freunde! Bis zum nächsten Mal!
hires.pl
01 #!/usr/bin/perl -w
02 ######################################################################
03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
04 ######################################################################
05
06 use Time::HiRes;
07
08 # Zeit erfassen ...
09 $start = [Time::HiRes::gettimeofday];
10
11 sleep(1); # ... schlafen ...
12
13 # ... und abermals die Zeit erfassen
14 $stop = [Time::HiRes::gettimeofday];
15
16 # Verstrichene Zeit ermitteln
17 $elapsed = Time::HiRes::tv_interval($start, $stop);
18
19 print "Verstrichene Zeit: $elapsed Sekunden\n";
pounder.pl
001 #!/usr/bin/perl -w
002 ##################################################
003 # Michael Schilli, 1998 (mschilli@perlmeister.com)
004 ##################################################
005
006 use LWP::Parallel::UserAgent;
007 use Time::HiRes qw(gettimeofday tv_interval);
008
009 ###
010 # Configuration
011 ###
012 $nof_parallel_connections = 1;
013 $nof_requests_total = 1000;
014 $url = "http://localhost/perl/dump.cgi";
015 $timeout = 10;
016
017 ##################################################
018 # Derived Class for latency timing
019 ##################################################
020 package MyParallelAgent;
021
022 @ISA = qw(LWP::Parallel::UserAgent);
023
024 ###
025 # Is called when connection is openend
026 ###
027 sub on_connect {
028 my ($self, $request, $response, $entry) = @_;
029 $self->{__start_times}->{$entry} =
030 [Time::HiRes::gettimeofday];
031 }
032
033 ###
034 # Are called when connection is closed
035 ###
036 sub on_return {
037 my ($self, $request, $response, $entry) = @_;
038
039 my $start = $self->{__start_times}->{$entry};
040
041 $self->{__latency_total} +=
042 Time::HiRes::tv_interval($start);
043 }
044
045 sub on_failure {
046 on_return(@_); # Same procedure
047 }
048
049 ###
050 # Access function for new instance var
051 ###
052 sub get_latency_total {
053 return shift->{__latency_total};
054 }
055
056 ##################################################
057 package main;
058 ##################################################
059
060 ###
061 # Init parallel user agent
062 ###
063 $ua = MyParallelAgent->new();
064 $ua->agent("pounder/1.0");
065 $ua->max_req($nof_parallel_connections);
066 $ua->redirect(0); # No redirects
067
068 ###
069 # Register all requests
070 ###
071 foreach (1..$nof_requests_total) {
072 my $request = HTTP::Request->new('GET', $url);
073 $ua->register($request);
074 }
075
076 ###
077 # Launch processes and check time
078 ###
079 $start_time = [gettimeofday];
080 $results = $ua->wait($timeout);
081 $total_time = tv_interval($start_time);
082
083 ###
084 # Requests all done, check results
085 ###
086 $succeeded = 0;
087 foreach $entry (values %$results) {
088 my $response = $entry->response();
089
090 if($response->is_success()) {
091 $succeeded++; # Another satisfied customer
092 } else {
093 # Error, save the message
094 $response->message("TIMEOUT")
095 unless $response->code();
096 $errors{$response->message}++;
097 }
098 }
099
100 ###
101 # Format errors if any from %errors
102 ###
103 $errors =
104 join(',', map "$_ ($errors{$_})", keys %errors);
105 $errors = "NONE" unless $errors;
106
107 ###
108 # Format results
109 ###
110 @P = (
111 "URL" => $url,
112
113 "Total Requests" => "$nof_requests_total",
114
115 "Parallel Agents" => $nof_parallel_connections,
116
117 "Succeeded" =>
118 sprintf("$succeeded (%.2f%%)\n",
119 $succeeded * 100 / $nof_requests_total),
120
121 "Errors" => $errors,
122
123 "Total Time" =>
124 sprintf("%.2f secs\n", $total_time),
125
126 "Throughput" =>
127 sprintf("%.2f Requests/sec\n",
128 $nof_requests_total / $total_time),
129
130 "Latency" =>
131 sprintf("%.2f secs/Request",
132 $ua->get_latency_total() /
133 $nof_requests_total),
134 );
135
136 ###
137 # Print out statistics
138 ###
139 format STDOUT =
140 @<<<<<<<<<<<<<<< @*
141 "$left:", $right
142 .
143
144 while(($left, $right) = splice(@P, 0, 2)) {
145 write;
146 }
Die Meßwerte aus einer Testreihe mit 100 Requests und 5 parallelen
User-Agents. Dargestellt sind die Ergebnisse für eine statische
Seite (index.html), ein CGI-Skript (cgi-bin/dump.cgi), ein
CGI-Skript unter dem Apache-Beschleuniger mod_perl (perl/dump.cgi)
und einen Fehlerfall, der nach einem nicht existierenden URL verlangt.
URL: http://localhost/index.html
Total Requests: 100
Parallel Agents: 5
Succeeded: 100 (100.00%)
Errors: NONE
Total Time: 15.75 secs
Throughput: 6.35 Requests/sec
Latency: 0.67 secs/Request
URL: http://localhost/cgi-bin/dump.cgi
Total Requests: 100
Parallel Agents: 5
Succeeded: 100 (100.00%)
Errors: NONE
Total Time: 106.73 secs
Throughput: 0.94 Requests/sec
Latency: 5.22 secs/Request
URL: http://localhost/perl/dump.cgi
Total Requests: 100
Parallel Agents: 5
Succeeded: 100 (100.00%)
Errors: NONE
Total Time: 38.07 secs
Throughput: 2.63 Requests/sec
Latency: 1.84 secs/Request
URL: http://localhost/bogus
Total Requests: 100
Parallel Agents: 5
Succeeded: 0 (0.00%)
Errors: File Not Found (100)
Total Time: 12.86 secs
Throughput: 7.77 Requests/sec
Latency: 0.57 secs/Request
![]() |
Michael Schilliarbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com. |