![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
// LinuxTag 2004
Besuchen Sie uns auch n臘hstes Jahr wieder auf dem LinuxTag 2004
im Karlsruher Messe- und Kongresszentrum. Fr n臧ere Details und den
genauen Termin besuchen Sie bitte die LinuxTag Homepage.
|
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
|
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
|
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
EUROPAS GRヨSSTE GNU/LINUX MESSE UND KONFERENZ KONFERENZ-CD-ROM 2003 |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
|
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
|
|
Hauptseite//Vortr臠e//Skalierbare High-Performance Netzwerkprogrammierung unter Linux |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
Skalierbare High-Performance Netzwerkprogrammierung unter Linux
Felix von Leitner
Hochperformanter, gut skalierender Netzwerk-Code ist heute
eines der wichtigsten Verkaufsargumente fr
Server-Betriebssysteme. Die Standard-Interfaces von POSIX und der
Open Group eignen sich nur bedingt fr hohe Skalierbarkeit.
Daher haben alle Anbieter in diesem Marktsegment eigene
Interfaces entwickelt. Dieser Artikel beleuchtet die Unterschiede
stellvertretend an der Evolution dieser Interfaces unter
Linux.
Grundlagen der Netzwerk-Programmierung
Unter Unix werden Ger舩e und IPC-Mechanismen wie Dateien
angesprochen. Man fnet eine Datei mit
open(), liest mit
read(), schreibt mit
write() und schlie゚t die Datei
wieder mit close(). Hier ist ein
kurzer Beispiel-Code:
char buf[4096];
int fd=open("index.html", O_RDONLY);
int len=read(fd,buf,sizeof buf);
write(outfd,buf,len);
close(fd);
|
Man beachte, da゚ open() einen
Integer zurckliefert, d.h. eine Zahl. Diese dient als Referenz auf
ein Kernel-Objekt; man kann damit in seinem Programm nichts tun, als
sie dem Kernel gegenber als Referenz zu benutzen. Damit Rechnen
ist sinnlos, und es gibt auch keine Mlichkeit, zu einem solchen
File Deskriptor den Dateinamen zu erfragen.
Fr jeden Proze゚ fangen die Deskriptoren bei 0 an, und 0 bis 2
sind fr Eingabe, Ausgabe und Fehlermeldungen reserviert. POSIX
sagt, da゚ der Kernel als neuen Deskriptor immer den kleinsten noch
nicht vergebenen zuweist. Mehrere Prozesse knen (verschiedene)
Deskriptoren fr die selbe Datei haben.
Das API ist offensichtlich:
read() kehrt zurck, wenn es Daten
gelesen hat. write() kehrt zurck,
wenn die Daten geschrieben wurden. Danach kann man den Puffer
anderweitig verwenden.
Netzwerkprogrammierung funktioniert analog. Die Deskriptoren
verweisen nicht auf Dateien, sondern auf Sockets (Steckdose). Einen
Socket richtet man mit socket()
ein. Hier ist ein Code-Beispiel:
char buf[4096];
int len;
int fd=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
struct sockaddr_in si;
si.sin_family=PF_INET;
inet_aton("127.0.0.1",&si.sin_addr);
si.sin_port=htons(80);
connect(fd,(struct sockaddr*)si,sizeof si);
write(fd,"GET / HTTP/1.0\r\n\r\n");
len=read(fd,buf,sizeof buf);
close(fd);
|
Dieser Code fnet eine TCP-Verbindung zum Rechner 127.0.0.1 auf
Port 80 und fragt nach /. Es handelt sich hier also um einen
simplen HTTP-Client.
Das Socket-API trennt das Erstellen des Sockets von dem
Erstellen einer Verbindung. Der Hintergrund sind Server, die sich
nie mit einer Gegenstelle verbinden, sondern zu denen die
Gegenstellen sich verbinden. Hier ist ein Mini-Server:
int len,s,fd=socket(PF_INET,SOCK_STREAM,IPPROTO_TCP);
struct sockaddr_in si;
si.sin_family=PF_INET;
inet_aton("127.0.0.1",&si.sin_addr);
si.sin_port=htons(80);
bind(fd,(struct sockaddr*)si,sizeof si);
listen(fd,16);
s=accept(fd,(struct sockaddr*)&si,&len);
|
Handhabung mehrerer paralleler Verbindungen
Wie kann ein Server mehr als einen Client verwalten?
Offensichtlich ist das Modell mit dem blockierenden
read() und
write() nicht geeignet, um in einem
Proze゚ mehr als eine Verbindung zu halten - man knte immer
nur Daten ber eine Verbindung zur Zeit schicken. Schlimmer noch,
sobald der erste Client mal nichts schickt, bleibt das
read() und damit die anderen
Clients h舅gen.
Die traditionelle Lung dafr ist, pro Verbindung einen neuen
Proze゚ zu starten.
Ein Proze゚ pro Verbindung
Unter Unix ist das glcklicherweise sehr einfach;
man kann den gerade laufenden Proze゚ einfach klonen, man mu゚ nicht
ein weiteres Programm dafr von der Festplatte laden, und der neue
Proze゚ bernimmt auch alle Variablen und offenen File
Deskriptoren.
Trotzdem hat dieses Modell Nachteile und Probleme,
insbesondere bei der Skalierbarkeit. Typische Betriebssysteme sind
fr den Fall von ein paar Dutzend bis zu Hundert Prozessen
optimiert.
Dazu kommt, da゚ pro Proze゚ normalerweise bedeutende Mengen
Systemspeicher verbraucht werden, insbesondere bei dynamisch
gelinkten Programmen. 10.000 Clients gleichzeitig bringen auch
grere Server zum Swappen, selbst wenn die Clients gar keine Daten
anfordern. Manche Systeme (Solaris, Windows) brauchen au゚erdem sehr
lange fr die Proze゚erzeugung und eignen sich daher fr dieses
Modell nicht.
Auch stellt sich bei diesem Modell die Frage, wie man Timeouts
realisieren soll. Wenn ein Client nach dem Verbindungsaufbau fr 20
Minuten nichts sagt, sollte der Server in der Lage sein, das zu
erkennen und die Verbindung zu schlie゚en.
Skalierbares Scheduling
Das Betriebssystem verwaltet eine Liste von Prozessen
und unterbricht periodisch (blicher Wert: 100 Mal pro Sekunde) den
laufenden Proze゚, um andere laufen zu lassen. Dieser Teil des
Systems, der den neuen Proze゚ ausw臧lt, hei゚t Scheduler.
Wenn ein Proze゚ wegen eines read()
oder write() blockiert, w臧lt der
Scheduler auch einen neuen Proze゚. Wenn also hunderte von Prozessen
konkurrierend lesen und schreiben und dabei st舅dig blocken, dann
verbringt das System einen signifikanten Teil der CPU-Zeit im
Scheduler.
Typischerweise laufen auf einem System eine paar Dutzend Prozesse,
von denen ein oder zwei tats臘hlich laufen wollen, w臧rend die
anderen gerade auf eine Eingabe oder ein Signal warten. Es ist
also sinnvoll, wenn das System zwei Proze゚listen hat - eine
fr die laufen wollenden Prozesse und eine fr die auf Eingabe
wartenden. Die erste Liste wird dann sehr kurz sein und der
Scheduler spart sich das Anschauen der gro゚en Gesamtliste.
Unix hat einen Mechanismus, um interaktive Prozesse zu bevorzugen
und Batch-Prozesse zu benachteiligen. Dazu wird sekndlich fr alle
Prozesse der nice-Wert neu
berechnet. Dafr schaut sich der Scheduler alle Prozesse an, und
mu゚ dabei bei einer sehr gro゚en Proze゚anzahl sehr viele Daten lesen.
Das fhrt besonders bei 舁teren oder Desktop-CPUs zum Cache
Trashing.
Ein weiteres typisches Problem ist auch, da゚ die Proze゚tabelle mit
einem Kernel Lock geschtzt wird. Bei einem System mit mehr als
einem Prozessor wird also kann also der zweite Prozessor w臧rend der
Nice-Berechnung keinen Proze゚wechsel vornehmen. Dieses Problem
len kommerzielle Unix-Varianten gewnlich, indem sie die
Datenstruktur auf die Prozessoren verteilen, so da゚ jeder Prozessor
eine eigene Run Queue hat.
Es gibt noch weiteren Spielraum fr Optimierungen im Scheduler. Man
kann z.B. die Run Queue sortiert vorhalten, und hierfr bieten sich
diverse schlaue Datenstrukturen wie Heaps an, die den Aufwand von
O(n) auf O(log n) senken
knen.
Der heilige Gral der Scheduler ist allerdings der O(1)-Scheduler. Im
Gesamtsystem wird die Leistung nie vlig unabh舅gig von der Anzahl
der Prozesse sein knen, weil die Leistung heutiger Prozessoren
stark davon abh舅gig ist, da゚ alle h舫fig benutzten Daten im
Prozessor-Cache Platz finden.
Der Linux 2.4 Scheduler hat eine einzelne unsortierte Run Queue mit
allen lauff臧igen Prozessen ("runnable"), und eine Liste mit allen
blockenden Prozessen und Zombies. Alle Operationen auf der Run
Queue sind hinter einem Spinlock geschtzt. Der Scheduler bemht
sich um CPU-Affinit舩
Fr Linux gibt es diverse neue Scheduler, mit Heaps und multiplen
Run Queues, aber der erstaunlichste Scheduler ist der O(1)-Scheduler
von Ingo Molnar. Dieser Scheduler ist in den 2.5 "Hacker-Kerneln"
Standard.
Der O(1)-Scheduler hat pro CPU zwei Arrays mit jeweils einer
verketteten Liste. Pro mlicher Proze゚-Priorit舩 gibt es eine
Liste in diesen Arrays. Da alle Eintr臠e in diesen Listen die
gleiche Priorit舩 haben, mssen die Listen nicht weiter sortiert
werden. Das eine Array pro CPU hat die lauff臧igen Prozesse, das
andere ist leer. Das Ein- und Austragen eines Prozesses in einer
doppelt verkettete Liste ist O(1). Der Scheduler nimmt sich also
immer die volle Liste, l葹t jeden Proze゚ darin einmal laufen, tr臠t
den Proze゚ dann in dieser Liste aus und in der anderen Liste ein,
und wenn die Liste leer ist, tauscht er die Zeiger auf die Arrays
aus. Die nice Berechnung wird in
diesem Scheduler umgekehrt: jetzt werden nicht mehr interaktive
Prozesse belohnt, sondern Batch-Prozesse werden bestraft
(interaktive Prozesse blocken, Batch-Prozesse werden vom Scheduler
unterbrochen).
Dieser geniale Scheduler skaliert praktisch unabh舅gig von der
Anzahl der Prozesse. In Tests hat man selbst bei 100.000
gestarteten Threads keine Verlangsamung beobachtet.
Kontextwechsel und Proze゚erzeugungskosten
Der Kontextwechsel ist auf heutigen Prozessoren eine der langsamsten
Operationen berhaupt. Ein Kontextwechsel findet statt, wenn ein
Proze゚ zu einem anderen gewechselt wird (der ワbergang von einem
Proze゚ zum Betriebssystem ist auch sehr aufwendig, aber dafr gibt
es h舫fig sehr effizienten Hardware-Support).
Bei einem Kontextwechsel mu゚ das Betriebssystem die Register sichern
und laden, den TLB flushen und bei manchen Architekturen mu゚ auch
manuell die Pipeline geleert werden und die Caches geflusht werden.
Auf einem blichen Desktop-System mit laufendem mp3-Player kommen
10-70 Kontextwechsel pro Sekunde vor. Wenn ein HTTP-Benchmark ber
Loopback l舫ft, sind es 10.000, ber ein Ethernet-Interface sogar
20.000. Offensichtlich z臧lt hier jede Mikrosekunde. Architekturen
mit wenig Registern wie x86 haben hier einen Erbvorteil gegenber
Architekturen mit vielen Registern oder komplizierten Anordnungen
wie SPARC oder IA-64.
Der Speicherbedarf eines leeren Prozesses ist bei aktuellen
Linux-System in der Tat erschreckend gro゚. Schuld ist aber nicht
Linux, sondern die libc unter Linux, die GNU libc. Diese verbraucht
mit gro゚em Abstand von allen libc-Implementationen den meisten
Speicher und hat die hhsten Startup-Kosten.
Ich habe daher eine eigene libc fr Embedded-Systeme und
Server-Anwendungen wie CGI-Programme und Servlets geschrieben, und
damit erreicht man ein statisches Hallo-Welt-Binary von unter 300
Bytes. Das Ausfhren dieses Programmes inklusive
fork(),
exec() und
wait kostet auf einem Athlon ca.
200.000 CPU-Zyklen. Ein 2 GHz Athlon kann also rein rechnerisch pro
Sekunde 10.000 Prozesse erzeugen.
Timeout-Handling
Die einfachste Methode zum Timeout-Handling ist
alarm().
alarm(10) schickt dem aktuellen
Proze゚ nach 10 Sekunden ein Signal; wenn man dieses nicht explizit
abf舅gt, wird der Proze゚ dann vom System beendet. Fr triviale
Netzwerk-Servlets ist das wie geschaffen.
Unix bietet aber auch andere Methoden, um einen Timeout zu
implementieren. Die bekannteste (und 舁teste - eingefhrt
1983 mit 4.2BSD) ist
select():
fd_set rfd;
struct timeval tv;
FD_ZERO(&rfd); FD_SET(0,&rfd); /* fd 0 */
tv.tv_sec=5; tv.tv_usec=0; /* 5 Sekunden */
if (select(1,&rfd,0,0,&tv)==0) /* read, write, error, timeout */
handle_timeout();
if (FD_ISSET(0, &rfd))
can_read_on_fd_0();
|
Das erste Argument ist die Nummer des grten bergebenen
Filedeskriptors plus 1 (das ist die eine Ausnahme, bei der Rechnen
mit den File Deskriptoren doch sinnvoll ist). Die n臘hsten drei
Argumente sind Bitfelder; man setzt das 23. Bit im 1. Array
(readfds, read file descriptors),
wenn auf auf dem File Deskriptor 23 Daten lesen mhte. Das 2.
Array ist fr Schreiben, das 3. fr Fehlerbehandlung. Als letztes
Argument bergibt man noch, wie lange man hhstens warten mhte.
Mit select() lassen sich
offensichtlich nicht nur Timeouts implementieren, sondern man kann
auch mehrere Deskriptoren auf einmal zum Lesen oder Schreiben
benutzen, d.h. man kann einen Server schreiben, der mehr als eine
Verbindung gleichzeitig behandeln kann.
select() hat aber auch wichtige
Nachteile:
select() sagt
nicht, wie lange es denn tats臘hlich gewartet hat. Man mu゚ also
noch mal gettimeofday() o..
aufrufen.
select() arbeitet
mit Bitfeldern. Die Gre h舅gt vom Betriebssystem ab. Wenn man
Glck hat, kann man so 1024 Deskriptoren ansprechen, h舫fig noch
weniger.
Unter Linux gibt select() doch
zurck, wie lange es gewartet hat, indem es den Timeout-Wert um den
Betrag senkt, den es gewartet hat. Das ist nicht nur unportabel, es
besch臈igt auch portable Software, die sich darauf verl葹t, da゚ der
Timeout-Wert konstant bleibt. Die Bitfeld-Gre ist unter Linux
1024, bei NetBSD 256.
Das Problem mit select() ist
schlimmer als es aussieht, weil es manchmal von irgendwelchen
Unter-Bibliotheken benutzt wird. Apache hat an dieser Stelle z.B.
Probleme mit den DNS-Bibliotheken bekommen und h舁t sich daher
von Hand die Filedeskriptoren bis 15 frei, indem neue Deskriptoren
nach oben verschoben werden (das kann man mit
dup2 machen). DNS ht sonst
einfach zu funktionieren auf, sobald man mal alle Deskriptoren bis
1024 belegt hat.
Eine Variation auf select() ist
poll(), eingefhrt 1986 mit System V
Release 3. Man benutzt es ziemlich 臧nlich wie
select():
struct pollfd pfd[2];
pfd[0].fd=0; pfd[0].events=POLLIN;
pfd[1].fd=1; pfd[1].events=POLLOUT|POLLERR;
if (poll(pfd,2,1000)==0) /* 2 records, 1000 milliseconds timeout */
handle_timeout();
if (pfd[0].revents&POLLIN) can_read_on_fd_0();
if (pfd[1].revents&POLLOUT) can_write_on_fd_1();
|
poll() hat kein Limit auf die
Anzahl oder Gre der Deskriptoren, dafr wird es auf manchen
Museumsexponaten nicht untersttzt. Es gibt sogar ein aktuelles
Unix-Derivat, das kein poll()
untersttzt, und das es damit unmlich macht, auf ihm portabel
skalierbare Serversoftware laufen zu lassen: MacOS X. Von der
Benutzung von MacOS X kann daher nur abgeraten werden, bis jemand
dem Hersteller fundamentales Unix-Wissen vermitteln konnte.
W臧rend man mit poll() berhaupt
erst mal in die Lage versetzt wird, Programme mit 10.000 offenen
Client-Verbindungen zu schreiben, kann ein solches Programm mit
poll() nicht effizient laufen. Der
Kernel mu゚ das gesamte Array aus dem User-Space lesen, die
zugehigen Socket-Objekte aus seinen Datenstrukturen heraussuchen,
auf neuen Daten oder Schreibbarkeit prfen, und dann im User-Space
das entsprechende Bit setzen. Heutige Prozessoren sind weitgehend
von der Speicherbandbreite limitiert; das langwierige Durchgehen von
gro゚en Arrays im User-Space ist daher sehr ineffizient, insbesondere
wenn sich an dem Inhalt des Arrays seit dem letzten Aufruf von
poll() gar nichts ge舅dert hat.
Das klingt auf den ersten Blick unvermeidlich, aber wenn man sich
typische Server-Lasten mit 10.000 Verbindungen ansieht, dann sind
nur wenige Verbindungen davon aktiv, die meisten sind sehr langsam
oder gar ganz inaktiv. Ein viel effizienteres Schema w舐e daher,
wenn man die Verwaltung der Liste der gewnschten Events dem Kernel
berl葹t und ihm nur Ver舅derungen mitteilt.
Es ist normalerweise nicht so kritisch, wenn
poll() langsam ist, weil die Events
ja nicht verloren gehen, sie kommen nur sp舩er an. Der Durchsatz
leidet daher nicht so stark wie die Latenz. Ich habe mit
poll() eine bis zu um den Faktor 20
here Latenz als mit SIGIO oder epoll gemessen.
Das Multithreading-Debakel
In den letzten Jahren hat sich Multithreading als Lung fr alle
Probleme in den Kfen etabliert, insbesondere fr Skalierbarkeit
und Performance auch und vor allem bei Netzwerkprogrammierung. Die
Realit舩 sieht anders aus. Die meisten Systeme haben ein hartes
Limit fr die Anzahl der gleichzeitigen Threads. Eine bliche
Grenordnung ist 1024. Von Skalierbarkeit kann unter solchen
Umst舅den keine Rede sein. Auch ansonsten kann man mit Thread
bizarre Effekte erleben: pthread_create kann keine Threads anlegen,
selbst wenn nur bereits 4 laufen, oder manchmal sogar wenn gar keine
anderen Threads laufen, solange vorher viele Threads erzeugt wurden.
Es ist mir unbegreiflich, wie Leute Threads fr
Netzwerkprogrammierung verwenden knen.
Das Multiproze゚-Modell hat den Vorteil, da゚ es fehlertolerant
arbeitet. Wenn ein Server-Proze゚ abstrzt, laufen die anderen
weiter. Es gibt auch keine Memory Leaks: wenn sich ein
Server-Proze゚ beendet, gibt das System alle Resourcen automatisch
frei. Bei Threads ist beides nicht so. Wenn ein Thread einen
Absturz erzeugt, schie゚t das System den ganzen Proze゚ mit allen
anderen Threads mit ab. Multithreading-Programme haben daher h舫fig
die Eigenschaft, schrankenlos vor sich hin zu wachsen. Manch
kommerzielle Software kommt daher mit einem Shell Script, das sie
nachts automatisch abschie゚t und neu startet, um den Speicherlecks
entgegen zu arbeiten.
Man mu゚ sich natrlich auch bei
poll-basierten Servern darum
kmmern, da゚ man keinen Speicher leckt, insofern ist das nicht den
Threads alleine anzulasten, aber viele Multithread-Projekte sind aus
Multiproze゚-Projekten hervorgegangen und kriegen ihre Lecks nie
wieder in den Griff.
Asynchroner I/O
poll und
select funktionieren nicht auf
Dateien, nur auf Sockets, Ger舩edateien und Pipes. Das ist bei
Festplatten normalerweise nicht so schlimm, aber wenn man einen
Server schreibt, der per NFS gemountete Dateien frei geben soll,
dann blockt bei einem NFS-Timeout der gesamte Server, selbst wenn
die anderen Requests alle Dateien von der lokalen Festplatte
betreffen.
Die Unix-Standardisierungsgremien haben sich daher ein Modell fr
asynchronen I/O ausgedacht, bei dem das
read-トquivalent sofort zurck kehrt
und man sp舩er ein Signal kriegt, wenn die Operation fertig ist
(analog natrlich fr write). Das
Problem ist, da゚ dieses API fast niemand implementiert; Solaris hat
die Funktionen, die liefern aber alle Fehler zurck (Solaris hat
auch ein funktionierendes propriet舐es API). Linux glibc
implementiert diese Funktionen, indem pro Operation ein Thread
aufgemacht wird. Weil das so frchterlich ist, benutzt dieses API
so gut wie niemand.
Asynchroner I/O h舩te dabei durchaus Vorzge gegenber normalen
Lesen. Wenn man z.B. 1000 Blke aus einer Datenbank lesen will,
und man liest sie alle sequentiell, dann mu゚ das System sie in der
gew臧lten Reihenfolge lesen. Da das User-Space Programm keine
Ahnung haben kann, wie die Blke physikalisch auf der Platte
verteilt sind, mu゚ das System unnig die Schreib-Lese-Kfe hin-
und herbewegen. Asynchroner I/O hingegen erlaubt dem System, die
Blke in der effizientesten Reihenfolge zu lesen. Es gibt Ans舩ze,
asynchronen I/O auch in Linux anst舅dig zu implementieren.
Linux 2.4: SIGIO
Linux 2.4 kann poll-Events auch ber Signals mitteilen. Die
Idee ist, da゚ man mit einem fcntl
auf den File Deskriptor dem System mitteilt, da゚ man an
Status舅derungen interessiert ist, und dann kriegt man immer ein
Signal, wenn sich an dem Deskriptor etwas tut. Der Vorteil ist, da゚
das im System mit O(1) implementierbar ist. Hier ist Beispiel-Code:
int sigio_add_fd(int fd) {
static const int signum=SIGRTMIN+1;
static pid_t mypid=0;
if (!mypid) mypid=getpid();
fcntl(fd,F_SETOWN,mypid);
fcntl(fd,F_SETSIG,signum);
fcntl(fd,F_SETFL,fcntl(fd,F_GETFL)|O_NONBLOCK|O_ASYNC);
}
int sigio_rm_fd(struct sigio* s,int fd) {
fcntl(fd,F_SETFL,fcntl(fd,F_GETFL)&(~O_ASYNC));
}
|
Signal-Handler bekommen traditionell die Signal-Nummer bergeben.
Bei Linux gibt es noch ein zweites Argument, eine struct, in der
u.a. der File Deskriptor drin steht, so da゚ man fr 1000
Deskriptoren Signals ber den gleichen Handler bearbeiten lassen
kann.
Aus Performance-Grnden kann man auf die Zustellung der Signals auch
verzichten und stattdessen das gew臧lte Signal blocken. Es ist dann
trotzdem noch mlich, dieses Signal zu empfangen, indem man in
einer Schleife sigtimedwait
aufruft. Da kann brigens man auch noch einen Timeout angeben, was
das Timeout-Handling vereinfacht.
SIGIO ist ein sehr elegantes API, das allerdings ein Umdenken
erfordert. Da man nirgendwo sagt, an welchen Events man
interessiert ist, kriegt man alle Events zugestellt. Bei
select und
poll ist es so, da゚ man auf ein
Event auch einfach nicht reagieren kann. Der n臘hste
poll-Aufruf signalisiert dann das
selbe Event noch einmal. So kann man sehr sch fr Fairness
sorgen und Rate Limiting implementieren. SIGIO hingegen teilt nur
das erste Event mit. Wenn man darauf nicht reagiert, wird man nie
wieder auf diesen Deskriptor hingewiesen werden.
Die Angelegenheit ist sogar noch etwas schwieriger: Wenn man bei
poll ein Lese-Event bekommt,
read aufruft, 100 Bytes liest, und
wieder poll aufruft, funktioniert
das. Bei SIGIO mu゚ man read so
lange aufrufen, bis man explizit
EAGAIN als Fehlermeldung kriegt
("sp舩er nochmal probieren"). Diese Fehlermeldung kann
read nur erzeugen, wenn man
non-blocking I/O fr diesen File Deskriptor
aktiviert hat:
#include <fcntl.h>
fcntl(fd,F_SETFL,fcntl(fd,F_GETFL,0) | O_NDELAY);
|
Das poll-Modell nennt man
level triggered, das
SIGIO-Modell hei゚t edge
triggered. In der Praxis ist ersteres fr Programmierer
einfacher zu verstehen. Das SIGIO-Modell hat
aber den Vorteil, da゚ sich das Programm l舅ger am Stck mit der
gleichen Verbindung besch臟tigt, so da゚ die zugehigen Daten alle
im Cache sind. So ist es geringfgig effizienter. Man kann
auch mit poll arbeiten, als w舐e es
edge triggered, aber nicht umgekehrt.
for (;;) {
timeout.tv_sec=0;
timeout.tv_nsec=10000;
switch (r=sigtimedwait(&s.ss,&info,&timeout)) {
case -1: if (errno!=EAGAIN) error("sigtimedwait");
case SIGIO: puts("SIGIO! overflow!"); return 1;
}
if (r==signum) handle_io(info.si_fd,info.si_band);
}
|
Aber auch SIGIO hat Nachteile: der Kernel
reserviert pro Proze゚ einen Speicherbereich fr die Liste der
ausstehenden Events. Wenn dieser Speicherbereich voll ist, wrden
Events verloren gehen. Der Kernel signalisiert daher dann mit SIGIO
(mit dem Signal namens SIGIO, nicht dem Konzept), da゚ die Queue
vollgelaufen ist. Das Programm mu゚ dann die Queue manuell leeren,
indem fr den Signal Handler kurzzeitig
SIG_DFL eingetragen wird, und sich
die Events mit poll abholen.
In der Praxis hei゚t das, da゚ man bei SIGIO eben doch noch ein Array
mit struct pollfd verwalten mu゚; das
mhte man sich ja aber eigentlich gerade sparen, deshalb benutzt
man ja SIGIO! Das klingt vielleicht auf den ersten Blick nicht nach
Arbeit, aber pollfds mssen kontinuierlich hintereinander liegen;
man kann nicht in der Mitte des Arrays einen leeren Eintrag haben,
sonst bricht poll mit
EBADFD ab. Wenn mein Server also
viele Verbindungen offen hat, und in der Mitte wird eine
geschlossen, mu゚ ich mein Array anpassen. Das hei゚t aber auch, da゚
ich den zugehigen Eintrag im Array nicht einfach finden kann,
indem ich den Deskriptor als Index nehme! Daraus folgt, da゚ man
O(1)-Finden des zugehigen Eintrags nur ber eine Indirektion
mit einem zweiten Array implementieren kann, und der Code wird dann
entsprechend unbersichtlich.
Es gibt noch einen Nachteil von SIGIO: man hat einen Syscall pro
mitgeteiltem Event. Bei poll ist zwar die Abarbeitung des Syscalls
teuer, aber dafr hat man da nicht viele von. Unter Linux ist der
Aufruf eines Syscalls sehr effizient gelt, daher mu゚ man sich da
nicht viel Sorgen machen; die kommerziellen Unixen haben da
teilweise mehr als eine Grenordnung grere Latenzen. Aber
unabh舅gig davon, ob das jetzt sehr oder nur m葹ig viel
verschwendete Leistung ist, man kann sich auch ein API vorstellen,
bei dem man mehr als ein Event auf einmal gemeldet bekommen kann.
Solaris: /dev/poll
Bei Solaris kann man seit zwei-drei Jahren poll beschleunigen, indem
man das Device /dev/poll fnet,
dort sein pollfd-Array hinein
schreibt, und dann mit einem ioctl
auf Events wartet.
Der ioctl sagt einem, wie viele
Events anliegen, und so viele struct
pollfd liest man dann von dem Device. Man hat
also nur fr die Deskriptoren Aufwand, bei denen auch tats臘hlich
Events anliegen. Das spart sowohl im Kernel als auch im User-Space
gewaltig Aufwand und ist auch mit minimalem Aufwand auf bestehende
Programme portierbar.
Es gab Ans舩ze, ein solches Device auch unter Linux zu
implementieren. Keiner von ihnen hat es in den Standard-Kernel
geschafft, und die Patches verhielten sich unter hoher Last
teilweise undeterministisch, daher ist dieses API auf Solaris
beschr舅kt.
Linux: epoll
Fr Linux 2.4 gibt es einen Patch, der
/dev/epoll implementiert. Die
Anwendung entspricht weitgehend der Solaris-Variante:
int epollfd=open("/dev/misc/eventpoll",O_RDWR);
char* map;
ioctl(epollfd,EP_ALLOC,maxfds); /* Hint: Anzahl der Deskriptoren */
map=mmap(0, EP_MAP_SIZE(maxfds), PROT_READ, MAP_PRIVATE, epollfd, 0);
|
Man fgt ein Event an, indem man auf das Device einen pollfd schreibt.
Man lcht ein Event, indem man auf das Device einen
pollfd mit
events==0 schreibt. Wie bei Solaris holt man die Events mit einem
ioctl ab:
struct evpoll evp;
for (;;) {
int n;
evp.ep_timeout=1000;
evp.ep_resoff=0;
n=ioctl(e.fd,EP_POLL,&evp);
pfds=(struct pollfd*)(e.map+evp.ep_resoff);
/* jetzt hat man n pollfds mit Events in pfds */
}
|
Dank mmap findet hier berhaupt
kein Kopieren zwischen Kernel und User-Space statt. Dieses API hat
es nie in den Standard-Kernel geschafft, weil Linus
ioctl nicht mag. Seine Begrndung
ist, da゚ das ein Dispatcher sei, und man habe ber die
Syscall-Nummer ja schon einen Dispatcher, und daher solle man doch
bitte den benutzen. Der Autor von epoll hat die Patches daraufhin
noch einmal mit Syscalls implementiert, und diese API ist seit
2.5.51 in der dokumentierten Form im Standard-Kernel enthalten.
int epollfd=epoll_create(maxfds);
struct epoll_event x;
x.events=EPOLLIN|EPOLLERR;
x.data.ptr=whatever; /* hier tr臠t man sich einen Cookie ein */
epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&x);
/* 舅dern ist analog */
epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&x);
/* lchen ist analog, nur fd mu゚ eingetragen sein */
epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&x);
|
Die EPOLLIN, ... Konstanten sind im Moment identisch mit POLLIN, aber
der Autor wollte sich da alle Optionen offen halten. Das epoll-API
sagt einem nicht automatisch den Deskriptor zu dem Event. Wenn man
das mhte, mu゚ man den Deskriptor als Cookie eintragen. Der Cookie
ist ein 64-bit Wert, hier kann man also auch einen Zeiger auf eine
Datenstruktur eintragen, in der dann neben anderen Daten auch der
Deskriptor steht. Seine Events holt man sich dann mit
epoll_wait ab:
for (;;) {
struct epoll_event x[100];
int n=epoll_wait(epollfd,x,100,1000); /* 1000 Millisekunden */
/* x[0] .. x[n-1] sind die Events */
}
|
Windows: Completion Ports
Auch Microsoft mu゚ skalierbare Netzwerk-Server anbieten
knen. Die Microsoft-Lung hierfr ist, da゚ man einen Thread Pool
aufmachen, und dann in jedem Thread mit einem SIGIO-臧nlichen
Verfahren die Events benachrichtigt bekommt.
Dieses Verfahren verbindet die Nachteile von Threads mit den
Nachteilen von SIGIO. Leider gibt es unter Windows keine
Alternativen. Fr die Neugierigen:
select() wird unter Windows ber ein
leeres Off-Screen Fenster implementiert, das dann ber den Fenster
Input-Event Mechanismus die Netzwerk-Events mitgeteilt bekommt.
FreeBSD: kqueue
kqueue ist eine Kreuzung aus epoll und
/dev/poll. Man kann sich
aussuchen, ob man nach level oder egde getriggert werden will.
Au゚erdem hat man noch eine Art SIGIO und File und Directory Status
Notification eingebaut. Dabei ist das API leider ziemlich
unbersichtlich geworden. Von der Performance her ist kqueue mit
epoll vergleichbar.
Sonstige Tricks zur Performance-Steigerung
writev, TCP_CORK und TCP_NOPUSH
Ein HTTP-Server schreibt erst einen (kleinen) Antwort-Header und
dann die Datei in den Socket. Wenn er fr beides einfach
write() aufruft, erzeugt das ein
kleines TCP-Paket fr den Header, und danach noch ein TCP-Paket fr
die Daten. Wenn man nur eine kleine Datei per HTTP bertragen will,
h舩te beides vielleicht in ein Paket gepa゚t.
Offensichtlich kann man einen Puffer nehmen, und in diesen erst den
Header und dann den Dateiinhalt hineinkopieren, und dann den Puffer
schreiben. Das ist aber Verschwendung von RAM-Bandbreite. Es gibt
drei andere Lungen: writev(),
TCP_CORK (Linux) und
TCP_NOPUSH (BSD).
writev() ist wie ein
Batch-write(). Man bergibt ein
Array mit Puffern, writev()
schreibt sie alle hintereinander. Der Unterschied ist normalerweise
nicht me゚bar, aber bei TCP-Servern macht er sich bemerktbar.
struct iovec x[2];
x[0].iov_base=header; x[0].iov_len=strlen(header);
x[1].iov_base=mapped_file; x[1].iov_len=file_size;
writev(sock,x,2); /* returns bytes written */
|
TCP_CORK ist eine Socket-Option,
die man mit setsockopt vor dem
Schreiben setzt und nach dem letzten Schreiben wieder zurck setzt.
Das sagt dem Kernel metaphorisch, da゚ man die TCP-Flasche verkorkt
h舁t, und erst am Ende lt man den Korken und alles sprudelt auf
einmal ber das Netz hinaus.
int null=0, eins=1;
/* Korken reinstecken */
setsockopt(sock,IPPROTO_TCP,TCP_CORK,&eins,sizeof(eins));
write(sock,header,strlen(header));
write(sock,mapped_file,file_size);
/* Korken ziehen */
setsockopt(sock,IPPROTO_TCP,TCP_CORK,&null,sizeof(null));
|
TCP_NOPUSH ist das BSD-トquivalent
zu TCP_CORK. Der einzige
Unterschied ist, da゚ man TCP_NOPUSH
vor dem letzten Schreiben zurck setzen mu゚, nicht danach.
sendfile
Typische hochskalierbare Server lesen immer Daten aus Dateien
und schreiben sie ber das Netzwerk hinaus. Dabei h舁t der
Server-Proze゚ einen Puffer vor, in den er die Daten aus der Datei
liest, und den er dann ber das Netz schreibt. Hier werden also
Daten unnig kopiert. Es w舐e effizienter, wenn man die Datei
direkt versenden knte.
Das haben sich auch diverse Unix-Hersteller gedacht, und Microsoft
hat einen solchen Syscall auch in Windows eingebaut. Die meisten
Server-Netzwerkkarten (alle Gigabit-Karten) beherrschen
Scatter-Gather I/O, sozusagen
writev in Hardware. Das
Betriebssystem kann dann die IP, TCP und Ethernet-Header in einem
Puffer zusammenbauen, und die Netzwerkkarte holt sich den Inhalt des
Pakets direkt aus dem Buffer Cache. Dieses Zusammenspiel nennt man
zero copy TCP und es ist sozusagen der heilige Gral
der Netzwerkprogrammierung.
Man kann die Daten aus dem Buffer Cache auch per
mmap direkt in den User-Space
Speicher einblenden, ohne da゚ Daten kopiert werden m゚ten. Leider
kann Linux bei einem write() auf
einen solchen Speicherbereich kein zero copy TCP initiieren, dazu
mu゚ man sendfile benutzen.
Trotzdem macht es Sinn, alle Daten wenn mlich mit
mmap statt
read zu lesen, weil man so im
System Speicherplatz spart, den das System dann zum Cachen verwenden
kann.
Das gro゚e Problem bei sendfile() ist,
da゚ es jeder Anbieter mit einer anderen Semantik und anderen
Argumenten implementiert hat. Nicht einmal die freien Unixe haben
sich an das gleiche Format gehalten.
Sonstige Performance-Tweaks
Auf heutiger Hardware ist fork()
sehr effizient implementiert. Die Datenbereiche der Prozesse
werden nicht mehr kopiert, sondern es werden nur noch die
Page-Mappings bernommen. Das ist Grenordnungen effizienter als
frher, aber bei einem Server-Proze゚ mit 20000 gemappten Dateien
kann es sich schon um einen me゚baren Zeitraum handeln. Es macht
daher auch heute noch Sinn, in seinen Servern (z.B. fr den Aufruf
von CGIs) vfork() vorzuziehen.
Bei Servern mit sehr vielen offenen Deskriptoren kann
open() plzlich me゚bar langsamer
werden. Das liegt daran, da゚ POSIX vorschreibt, da゚ immer der
kleinste unbelegte Deskriptor genommen werden mu゚. Das
implementiert der Kernel, indem er Bitfelder linear durchgeht. Es
ist also theoretisch denkbar, da゚ man ein paar Nanosekunden sparen
kann, wenn man die untersten Dateideskriptoren mit
dup2 frei h舁t. Diese Idee
geistert seit Jahren ber diverse Mailinglisten; mir ist aber kein
Fall bekannt, wo das tats臘hlich mal experimentell nachgewiesen
worden ist.
Auf neueren x86-Prozessoren gibt es neben dem
Standard-Syscall-Mechanismus ber Software Interrupt 80 auch noch
einen anderen Syscall-Mechanismus. Der Linux Kernel hat relativ
frischen Support dafr, indem er neben dem Programm im User Space
noch eine weitere Code Page mit dem neuen Syscall einblendet. Die
Adresse der Page wird Programmen ber den ELF AUX Vektor bergeben
(Daten auf dem Stack hinter argv
und dem Environment). Meinen Messungen zufolge reduziert sich
damit die Syscall-Latenz auf die H舁fte. In den HTTP-Benchmarks
war die Auswirkung aber mit 2-5% nur knapp ber der statistischen
Rauschgrenze.
|
![](/file/22360/VPR0403.ISO/gfx/pixel.gif) |
|