[ L T Net ] OPEN-EVENTS :: OPEN MUSIC :: MINICONTENT :: KNOPPIX LINUXTAG.org 
Cornerstone
// LinuxTag 2004
Besuchen Sie uns auch nächstes Jahr wieder auf dem LinuxTag 2004 im Karlsruher Messe- und Kongresszentrum. Für nähere Details und den genauen Termin besuchen Sie bitte die LinuxTag Homepage.
EUROPAS GRÖSSTE GNU/LINUX MESSE UND KONFERENZ
KONFERENZ-CD-ROM 2003
 Hauptseite   Vorträge   Bücher   History   Software   Knoppix   Sponsoren   Abspann   Impressum 
Hauptseite // Vorträge // Skalierbare High-Performance Netzwerkprogrammierung unter Linux

Skalierbare High-Performance Netzwerkprogrammierung unter Linux

Felix von Leitner

Hochperformanter, gut skalierender Netzwerk-Code ist heute eines der wichtigsten Verkaufsargumente für Server-Betriebssysteme. Die Standard-Interfaces von POSIX und der Open Group eignen sich nur bedingt für 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äte und IPC-Mechanismen wie Dateien angesprochen. Man öffnet 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 zurückliefert, d.h. eine Zahl. Diese dient als Referenz auf ein Kernel-Objekt; man kann damit in seinem Programm nichts tun, als sie dem Kernel gegenüber als Referenz zu benutzen. Damit Rechnen ist sinnlos, und es gibt auch keine Möglichkeit, zu einem solchen File Deskriptor den Dateinamen zu erfragen.

Für jeden Prozeß fangen die Deskriptoren bei 0 an, und 0 bis 2 sind für Eingabe, Ausgabe und Fehlermeldungen reserviert. POSIX sagt, daß der Kernel als neuen Deskriptor immer den kleinsten noch nicht vergebenen zuweist. Mehrere Prozesse können (verschiedene) Deskriptoren für die selbe Datei haben.

Das API ist offensichtlich: read() kehrt zurück, wenn es Daten gelesen hat. write() kehrt zurück, 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 öffnet 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 könnte 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ängen.

Die traditionelle Lösung dafür ist, pro Verbindung einen neuen Prozeß zu starten.

Ein Prozeß pro Verbindung

Unter Unix ist das glücklicherweise sehr einfach; man kann den gerade laufenden Prozeß einfach klonen, man muß nicht ein weiteres Programm dafür 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 für 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 größere Server zum Swappen, selbst wenn die Clients gar keine Daten anfordern. Manche Systeme (Solaris, Windows) brauchen außerdem sehr lange für die Prozeßerzeugung und eignen sich daher für dieses Modell nicht.

Auch stellt sich bei diesem Modell die Frage, wie man Timeouts realisieren soll. Wenn ein Client nach dem Verbindungsaufbau für 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ählt, heißt Scheduler.

Wenn ein Prozeß wegen eines read() oder write() blockiert, wählt der Scheduler auch einen neuen Prozeß. Wenn also hunderte von Prozessen konkurrierend lesen und schreiben und dabei ständig 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ächlich laufen wollen, während die anderen gerade auf eine Eingabe oder ein Signal warten. Es ist also sinnvoll, wenn das System zwei Prozeßlisten hat - eine für die laufen wollenden Prozesse und eine für 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 sekündlich für alle Prozesse der nice-Wert neu berechnet. Dafür schaut sich der Scheduler alle Prozesse an, und muß dabei bei einer sehr großen Prozeßanzahl sehr viele Daten lesen. Das führt besonders bei älteren oder Desktop-CPUs zum Cache Trashing.

Ein weiteres typisches Problem ist auch, daß die Prozeßtabelle mit einem Kernel Lock geschützt wird. Bei einem System mit mehr als einem Prozessor wird also kann also der zweite Prozessor während der Nice-Berechnung keinen Prozeßwechsel vornehmen. Dieses Problem lösen kommerzielle Unix-Varianten gewöhnlich, indem sie die Datenstruktur auf die Prozessoren verteilen, so daß jeder Prozessor eine eigene Run Queue hat.

Es gibt noch weiteren Spielraum für Optimierungen im Scheduler. Man kann z.B. die Run Queue sortiert vorhalten, und hierfür bieten sich diverse schlaue Datenstrukturen wie Heaps an, die den Aufwand von O(n) auf O(log n) senken können.

Der heilige Gral der Scheduler ist allerdings der O(1)-Scheduler. Im Gesamtsystem wird die Leistung nie völlig unabhängig von der Anzahl der Prozesse sein können, weil die Leistung heutiger Prozessoren stark davon abhängig ist, daß alle häufig benutzten Daten im Prozessor-Cache Platz finden.

Der Linux 2.4 Scheduler hat eine einzelne unsortierte Run Queue mit allen lauffähigen Prozessen ("runnable"), und eine Liste mit allen blockenden Prozessen und Zombies. Alle Operationen auf der Run Queue sind hinter einem Spinlock geschützt. Der Scheduler bemüht sich um CPU-Affinität

Für 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 möglicher Prozeß-Priorität gibt es eine Liste in diesen Arrays. Da alle Einträge in diesen Listen die gleiche Priorität haben, müssen die Listen nicht weiter sortiert werden. Das eine Array pro CPU hat die lauffähigen 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ägt 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ängig 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 dafür gibt es häufig 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äuft, sind es 10.000, über ein Ethernet-Interface sogar 20.000. Offensichtlich zählt hier jede Mikrosekunde. Architekturen mit wenig Registern wie x86 haben hier einen Erbvorteil gegenüber 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 höchsten Startup-Kosten.

Ich habe daher eine eigene libc für 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 Ausführen 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ängt, wird der Prozeß dann vom System beendet. Für triviale Netzwerk-Servlets ist das wie geschaffen.

Unix bietet aber auch andere Methoden, um einen Timeout zu implementieren. Die bekannteste (und älteste - eingeführt 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 größten übergebenen Filedeskriptors plus 1 (das ist die eine Ausnahme, bei der Rechnen mit den File Deskriptoren doch sinnvoll ist). Die nächsten 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 möchte. Das 2. Array ist für Schreiben, das 3. für Fehlerbehandlung. Als letztes Argument übergibt man noch, wie lange man höchstens warten möchte.

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ächlich gewartet hat. Man muß also noch mal gettimeofday() o.ä. aufrufen.

  • select() arbeitet mit Bitfeldern. Die Größe hängt vom Betriebssystem ab. Wenn man Glück hat, kann man so 1024 Deskriptoren ansprechen, häufig noch weniger.

Unter Linux gibt select() doch zurück, 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ädigt auch portable Software, die sich darauf verläßt, daß der Timeout-Wert konstant bleibt. Die Bitfeld-Größe 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ält sich daher von Hand die Filedeskriptoren bis 15 frei, indem neue Deskriptoren nach oben verschoben werden (das kann man mit dup2 machen). DNS hört sonst einfach zu funktionieren auf, sobald man mal alle Deskriptoren bis 1024 belegt hat.

Eine Variation auf select() ist poll(), eingeführt 1986 mit System V Release 3. Man benutzt es ziemlich ähnlich 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 Größe der Deskriptoren, dafür wird es auf manchen Museumsexponaten nicht unterstützt. Es gibt sogar ein aktuelles Unix-Derivat, das kein poll() unterstützt, und das es damit unmöglich 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ährend 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 zugehörigen Socket-Objekte aus seinen Datenstrukturen heraussuchen, auf neuen Daten oder Schreibbarkeit prüfen, 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ändert 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äre daher, wenn man die Verwaltung der Liste der gewünschten Events dem Kernel überläßt und ihm nur Veränderungen mitteilt.

Es ist normalerweise nicht so kritisch, wenn poll() langsam ist, weil die Events ja nicht verloren gehen, sie kommen nur später an. Der Durchsatz leidet daher nicht so stark wie die Latenz. Ich habe mit poll() eine bis zu um den Faktor 20 höhere Latenz als mit SIGIO oder epoll gemessen.

Das Multithreading-Debakel

In den letzten Jahren hat sich Multithreading als Lösung für alle Probleme in den Köpfen etabliert, insbesondere für Skalierbarkeit und Performance auch und vor allem bei Netzwerkprogrammierung. Die Realität sieht anders aus. Die meisten Systeme haben ein hartes Limit für die Anzahl der gleichzeitigen Threads. Eine übliche Größenordnung ist 1024. Von Skalierbarkeit kann unter solchen Umständen 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 für Netzwerkprogrammierung verwenden können.

Das Multiprozeß-Modell hat den Vorteil, daß es fehlertolerant arbeitet. Wenn ein Server-Prozeß abstürzt, 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äufig 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 natürlich auch bei poll-basierten Servern darum kümmern, 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ätedateien 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 für asynchronen I/O ausgedacht, bei dem das read-Äquivalent sofort zurück kehrt und man später ein Signal kriegt, wenn die Operation fertig ist (analog natürlich für write). Das Problem ist, daß dieses API fast niemand implementiert; Solaris hat die Funktionen, die liefern aber alle Fehler zurück (Solaris hat auch ein funktionierendes proprietäres API). Linux glibc implementiert diese Funktionen, indem pro Operation ein Thread aufgemacht wird. Weil das so fürchterlich ist, benutzt dieses API so gut wie niemand.

Asynchroner I/O hätte dabei durchaus Vorzüge gegenüber normalen Lesen. Wenn man z.B. 1000 Blöcke aus einer Datenbank lesen will, und man liest sie alle sequentiell, dann muß das System sie in der gewählten Reihenfolge lesen. Da das User-Space Programm keine Ahnung haben kann, wie die Blöcke physikalisch auf der Platte verteilt sind, muß das System unnötig die Schreib-Lese-Köpfe hin- und herbewegen. Asynchroner I/O hingegen erlaubt dem System, die Blöcke in der effizientesten Reihenfolge zu lesen. Es gibt Ansätze, asynchronen I/O auch in Linux anständig 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änderungen 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 für 1000 Deskriptoren Signals über den gleichen Handler bearbeiten lassen kann.

Aus Performance-Gründen kann man auf die Zustellung der Signals auch verzichten und stattdessen das gewählte Signal blocken. Es ist dann trotzdem noch möglich, 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ächste poll-Aufruf signalisiert dann das selbe Event noch einmal. So kann man sehr schön für 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äter nochmal probieren"). Diese Fehlermeldung kann read nur erzeugen, wenn man non-blocking I/O für 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 für Programmierer einfacher zu verstehen. Das SIGIO-Modell hat aber den Vorteil, daß sich das Programm länger am Stück mit der gleichen Verbindung beschäftigt, so daß die zugehörigen Daten alle im Cache sind. So ist es geringfügig effizienter. Man kann auch mit poll arbeiten, als wäre 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 für die Liste der ausstehenden Events. Wenn dieser Speicherbereich voll ist, würden 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 für 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 möchte man sich ja aber eigentlich gerade sparen, deshalb benutzt man ja SIGIO! Das klingt vielleicht auf den ersten Blick nicht nach Arbeit, aber pollfds müssen 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 zugehörigen Eintrag im Array nicht einfach finden kann, indem ich den Deskriptor als Index nehme! Daraus folgt, daß man O(1)-Finden des zugehörigen Eintrags nur über eine Indirektion mit einem zweiten Array implementieren kann, und der Code wird dann entsprechend unübersichtlich.

Es gibt noch einen Nachteil von SIGIO: man hat einen Syscall pro mitgeteiltem Event. Bei poll ist zwar die Abarbeitung des Syscalls teuer, aber dafür hat man da nicht viele von. Unter Linux ist der Aufruf eines Syscalls sehr effizient gelöst, daher muß man sich da nicht viel Sorgen machen; die kommerziellen Unixen haben da teilweise mehr als eine Größenordnung größere Latenzen. Aber unabhängig 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 öffnet, 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 für die Deskriptoren Aufwand, bei denen auch tatsächlich 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ätze, 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änkt.

Linux: epoll

Für 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 fügt ein Event an, indem man auf das Device einen pollfd schreibt. Man löscht 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 Begründung 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ägt man sich einen Cookie ein */
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&x);
    /* ändern ist analog */
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&x);
    /* löschen 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 möchte, 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 können. Die Microsoft-Lösung hierfür ist, daß man einen Thread Pool aufmachen, und dann in jedem Thread mit einem SIGIO-ähnlichen Verfahren die Events benachrichtigt bekommt.

Dieses Verfahren verbindet die Nachteile von Threads mit den Nachteilen von SIGIO. Leider gibt es unter Windows keine Alternativen. Für 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 unübersichtlich 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 für beides einfach write() aufruft, erzeugt das ein kleines TCP-Paket für den Header, und danach noch ein TCP-Paket für die Daten. Wenn man nur eine kleine Datei per HTTP übertragen will, hätte 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 Lösungen: 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 zurück setzt. Das sagt dem Kernel metaphorisch, daß man die TCP-Flasche verkorkt hält, und erst am Ende löst 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 zurück setzen muß, nicht danach.

sendfile

Typische hochskalierbare Server lesen immer Daten aus Dateien und schreiben sie über das Netzwerk hinaus. Dabei hält 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 unnötig kopiert. Es wäre effizienter, wenn man die Datei direkt versenden könnte.

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 möglich 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 Größenordnungen effizienter als früher, 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. für den Aufruf von CGIs) vfork() vorzuziehen.

Bei Servern mit sehr vielen offenen Deskriptoren kann open() plötzlich 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ält. Diese Idee geistert seit Jahren über diverse Mailinglisten; mir ist aber kein Fall bekannt, wo das tatsächlich 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 dafür, 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älfte. In den HTTP-Benchmarks war die Auswirkung aber mit 2-5% nur knapp über der statistischen Rauschgrenze.

 
Impressum // © 2003 LinuxTag e.V.