[LTNet]OPEN-EVENTS::OPEN MUSIC::MINICONTENT::KNOPPIX LINUXTAG.org
Cornerstone
// 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.
EUROPAS GRヨSSTE GNU/LINUX MESSE UND KONFERENZ
KONFERENZ-CD-ROM 2003
Hauptseite Vortr臠e Bcher History Software Knoppix Sponsoren Abspann Impressum
Hauptseite//Vortr臠e//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 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 Mlichkeit, 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 knen (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 knte 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 Lung 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 grere 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 len kommerzielle Unix-Varianten gewnlich, 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 knen.

Der heilige Gral der Scheduler ist allerdings der O(1)-Scheduler. Im Gesamtsystem wird die Leistung nie vlig unabh舅gig von der Anzahl der Prozesse sein knen, 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 mlicher 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 hhsten 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 grten 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 mhte. Das 2. Array ist fr Schreiben, das 3. fr Fehlerbehandlung. Als letztes Argument bergibt man noch, wie lange man hhstens warten mhte.

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 Gre 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-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舁t sich daher von Hand die Filedeskriptoren bis 15 frei, indem neue Deskriptoren nach oben verschoben werden (das kann man mit dup2 machen). DNS ht 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 Gre 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 unmlich 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 zugehigen 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 here Latenz als mit SIGIO oder epoll gemessen.

Das Multithreading-Debakel

In den letzten Jahren hat sich Multithreading als Lung fr alle Probleme in den Kfen 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 Grenordnung 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 knen.

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 Blke 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 Blke physikalisch auf der Platte verteilt sind, mu゚ das System unnig die Schreib-Lese-Kfe hin- und herbewegen. Asynchroner I/O hingegen erlaubt dem System, die Blke 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 mlich, 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 zugehigen 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 mhte 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 zugehigen Eintrag im Array nicht einfach finden kann, indem ich den Deskriptor als Index nehme! Daraus folgt, da゚ man O(1)-Finden des zugehigen 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 gelt, 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舅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 lcht 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);
    /* lchen 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hte, 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nen. Die Microsoft-Lung 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 Lungen: 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 lt 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 unnig kopiert. Es w舐e effizienter, wenn man die Datei direkt versenden knte.

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lich 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 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() plzlich 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.

Impressum // ゥ 2003 LinuxTag e.V.