Die dunkle Seite der Non-Blocking I/O
Non-Blocking I/O and single-threaded Event Loop: Um dieses Programmiermodell ist in den letzten Jahren ein wahrhafter Hype entstanden, der zahlreiche neue Frameworks und Laufzeitumgebungen hervorgebracht hat. Zweifelsohne bieten solche Architekturen einige Vorteile, um mehrere Millionen TCP-Verbindungen gleichzeitig zu verarbeiten. Doch bei aller Euphorie sollte man sich stets bewusst sein: Wo viel Licht ist, ist starker Schatten.
Im Falle der Non-Blocking I/O reicht dafür einfach ein Blick zurück. Bis Mitte der neunziger Jahre war dieses Programmiermodell nämlich schon einmal sehr verbreitet für Netzwerkanwendungen – um nicht zu sagen vorherrschend. Doch mit der Jahrtausendwende hat es signifikant an Einfluss verloren. Für diesen Rückzug gab es gute Gründe, die noch heute fast genauso gelten. Diese Probleme sollte man sich vor Augen führen, falls man vor der Entscheidung für oder wider asynchrone Programmierung steht.
-
Virtual Memory Management: Zwischen der Anwendung und dem physischen Hauptspeicher liegt heutzutage fast immer eine Abstraktionsebene, die von Hardware und Betriebssystem gemeinsam verwaltet wird. Speicherzugriffe der Anwendung können auf unteren Ebenen komplexe Vorgänge auslösen – insbesondere auch blockierende I/O-Operationen. Ein typisches Beispiel ist die Verwendung einer Auslagerungsdatei, um Prozessen einen größeren Adressraum zur Verfügung zu stellen.
Im User-Space sind diese Fälle praktisch unmöglich zu erkennen, und für die asynchrone Programmierung sind sie fatal. Denn blockiert ein einzelner Speicherzugriff, so hängt die Verarbeitung aller Aktivitäten. Das Swapping lässt sich zwar durch hinreichend viel Hauptspeicher vermeiden, das Paging jedoch nicht. Insbesondere muss bei asynchroner Programmierung auf memory-mapped Dateien verzichtet und der teure Weg über die Extra Copy gegangen werden.
-
Hardware Multithreading: Prozessoren sind mittlerweile um Größenordnungen schneller als Zugriffe auf physischen Hauptspeicher. Prefetching und komplexe Cache-Hierarchien adressieren zwar dieses Missverhältnis. Trotzdem wird die Ausführungsgeschwindigkeit signifikant durch Speicherzugriffe beeinträchtigt. Um dennoch die Kapazitäten sinnvoll nutzen zu können, unterstützen die meisten Prozessoren mehrere Hardware-Threads pro Kern – aktuell meist zwei, vier oder acht. Aus der Anwendung heraus lassen sich diese jedoch nur durch mehrere Threads oder Prozesse nutzen.
-
Serial-Processing Speed: Lange Zeit waren Softwareentwickler in der komfortablen Situation, dass sich die Ausführungsgeschwindigkeit der Prozessorgenerationen ungefähr alle zwei Jahre verdoppelte. Damit skalierten Anwendungen fast automatisch und ohne Änderung mit dem Nutzungswachstum. Diese Zeiten sind vorbei. Stattdessen verdoppelt sich nun alle zwei Jahre die Anzahl der Prozessorkerne. Für eine nachhaltige Performance ist daher entscheidend, wie gut eine Anwendung diese Parallelität nutzen kann.
Um den Engpass in der sequentiellen Ausführung zu verhindern, muss die Anwendung analog zu blockierenden I/O‑Operationen auch CPU-intensive Berechnungen berücksichtigen und in einen Thread-Pool auslagern. In der Praxis ist diese Aufgabe jedoch vergleichsweise schwierig, denn sehr viele auch scheinbar harmlose Operationen können teuer sein. So ist bereits das Einfügen eines Elements in ein dynamisches Array oder in eine Hashtabelle im Worst Case mit linearem Aufwand problematisch. Abgesehen davon kann selbst die Event Loop und das Event Dispatching zum Flaschenhals werden.
-
Blocking System Calls: Die meisten Betriebssysteme bieten zwar viele Schnittstellen für asynchrone Systemaufrufe. Allerdings decken sie nur einen kleinen Teil der gesamten Funktionalität ab. So sind beispielsweise unter Linux praktisch alle Dateisystem-Operationen blockierend.
Die Frameworks und Bibliotheken für Non-Blocking I/O müssen solche Operationen üblicherweise asynchron in Thread-Pools ausführen. Das verursacht einen signifikanten Overhead – insbesondere wenn das Betriebssystem den Aufruf direkt aus dem Cache hätte beantworten können. In entsprechenden Benchmarks ist die rein sequentielle Ausführung mit blockierenden Operationen meist zehnmal so schnell wie die asynchrone Ausführung über einen Thread-Pool mit Callback in den ursprünglichen Thread. Im non-blocking Programmiermodell ist man allerdings zu Letzterem gezwungen, da beim Aufruf nicht zu erkennen ist, ob er blockieren wird.
-
Third-Party Software Components: Die Frameworks und Bibliotheken für Non-Blocking I/O enthalten meist Module für weit verbreitete Netzwerkprotokolle wie beispielsweise HTTP. Das asynchrone Programmiermodell wird dabei direkt unterstützt. Ganz anders sieht es hingegen bei proprietären Schnittstellen aus. Sei es nun ein Oracle-DBMS oder eine MS-Excel-Datei: Die meisten verfügbaren Softwarekomponenten basieren auf blockierenden Operationen, und lassen sich meist nur über Thread-Pools integrieren – mit entsprechend höherer Komplexität und geringerer Performance.
-
Structured Exception Handling: Die meisten modernen Programmiersprachen bieten Möglichkeiten, um die Ausnahmebehandlung vom normalen Anwendungscode zu trennen und in übergeordnete Funktionen zu verlagern. Dadurch wird einerseits die Fehlerbehandlung signifikant vereinfacht und andererseits der Code robuster gegenüber unberücksichtigten Fehlern.
Im asynchronen Programmiermodell sind diese Sprachkonstrukte hingegen nutzlos, da die allgegenwärtigen Callbacks die strukturierte Programmierung aushebeln. Das bedeutet nicht, dass die Fehlerbehandlung mit Non-Blocking I/O schwierig ist. Jedoch erfordert sie ein höheres Maß an Sorgfalt und Disziplin, um die gleiche Robustheit zu erreichen.
Non-Blocking I/O eignet sich ausgezeichnet für Anwendungen, die gleichzeitig mehrere hunderttausend TCP-Verbindungen verarbeiten und primär Netzwerk-Funktionalität realisieren. Typische Beispiele sind Forward und Reverse Proxies sowie Message Oriented Middleware (ohne Persistenz). Falls jedoch mehr Funktionalität benötigt wird, zeigen sich schnell die Schattenseiten: Die Komplexität steigt und die Performance bricht ein. Darum sollte man sich gut überlegen, ob sich dieser Weg lohnt.