Ein Zero-System-Call-HTTPS-Server mit io_uring, kTLS und Rust
(blog.habets.se)- Für den Bau eines Hochleistungs-Webservers wurden bisher verschiedene ereignisbasierte Modelle wie select(), poll(), epoll verwendet.
- Aufgrund der Leistungsgrenzen dieser System-Calls entstand jedoch io_uring, das Anfragen in eine Queue stellt, damit der Kernel sie asynchron verarbeitet.
- kTLS übernimmt die TLS-Verschlüsselung im Kernel, wodurch zusätzliche Optimierungen wie die Nutzbarkeit von sendfile() und Hardware-Offloading möglich werden.
- Mit der Einführung von descriptorless files wird ein für io_uring optimierter Ansatz bereitgestellt, bei dem File-Deskriptoren nicht direkt übergeben werden.
- Das Open-Source-Projekt tarweb, das Rust, io_uring und kTLS kombiniert, zeigt HTTPS ohne zusätzliche System-Calls pro Anfrage und diskutiert auch Fragen zu Sicherheit und Speicherverwaltung.
Die Entwicklung der Architektur von Hochleistungs-Webservern
- Seit den frühen 2000er-Jahren ist der Bedarf an Webservern mit hoher Kapazität gestiegen.
- Anfangs war es üblich, für jede Anfrage einen neuen Prozess zu erzeugen, doch wegen der hohen Kosten entstand dafür die Technik des Preforking.
- Danach entwickelte sich das Feld über die Einführung von Threads und die stärkere Nutzung von select() und poll() weiter, um die Kosten für Context Switches zu senken.
- Allerdings stoßen auch select() und poll() bei vielen Verbindungen an Skalierungsgrenzen, da dem Kernel häufig große Arrays übergeben werden müssen.
Das Aufkommen von epoll
- Unter Linux wurde epoll eingeführt, wodurch sich Mehrfachverbindungen effizienter als mit den bisherigen Verfahren verarbeiten lassen.
- epoll verarbeitet nur Änderungen (Deltas) und reduziert damit unnötigen Ressourcenverbrauch.
- Zwar verschwinden nicht alle System-Calls vollständig, aber die Kosten sinken erheblich.
Überblick über io_uring
- io_uring fügt Anfragen in eine Queue im Speicher ein, statt für jede Anfrage einen System-Call auszuführen, sodass der Kernel sie asynchron bearbeiten kann.
- Legt man zum Beispiel accept() in die Queue, verarbeitet der Kernel den Vorgang und gibt das Ergebnis anschließend in der Completion Queue zurück.
- Der Webserver fügt Anfragen in die Queue ein und prüft die Ergebnisse in einem separaten Speicherbereich.
- Um Busy Loops zu vermeiden, rufen sowohl Webserver als auch Kernel nur dann System-Calls auf, wenn es bei unveränderten Queues wirklich nötig ist, was auch Strom spart.
- Mit passenden Bibliotheken kann ein aktiver Server während der Anfrageverarbeitung ohne zusätzliche System-Calls arbeiten.
Multi-Core- und NUMA-Umgebungen
- Mit Blick auf moderne Multi-Core-CPUs ist eine Strategie sinnvoll, bei der pro Core ein einzelner Thread läuft und die gemeinsame Nutzung von Datenstrukturen minimiert wird.
- In NUMA-Umgebungen lässt sich optimieren, indem jeder Thread nur auf den Speicher seines lokalen Knotens zugreift.
- Für eine perfekt ausgewogene Verteilung von Anfragen ist weitere Forschung nötig.
Speicherallokation
- Sowohl im Kernel als auch im Webserver bleibt Speicherallokation ein Thema, und auch Allokationen im User Space führen letztlich zu System-Calls.
- Auf Webserver-Seite werden im Voraus Speicherblöcke fester Größe pro Verbindung reserviert, um Fragmentierung und Engpässe zu vermeiden.
- Auch im Kernel werden pro Verbindung Ein-/Ausgabepuffer benötigt, die sich zum Teil über Socket-Optionen anpassen lassen.
- Kommt es zu Speichermangel, kann das schwerwiegende Ausfälle verursachen.
Einführung in kTLS (Kernel TLS)
- kTLS ist eine Funktion des Linux-Kernels, die Ver- und Entschlüsselung übernimmt.
- Der Handshake wird in der Anwendung verarbeitet, danach behandelt der Kernel die Datenübertragung so, als wäre es Klartext.
- Dadurch wird die Nutzung von sendfile() möglich, was Speicher-Kopien zwischen User Space und Kernel Space reduziert.
- Wenn die Netzwerkkarte es unterstützt, können sogar die Verschlüsselungsoperationen auf die Hardware ausgelagert werden.
Descriptorless Files
- Dieser Ansatz wurde eingeführt, um den Overhead zu reduzieren, der entsteht, wenn File-Deskriptoren direkt vom User Space an den Kernel Space übergeben werden.
- Mit register_files werden separate „ganzzahlige“ Dateinummern verwendet, die nur innerhalb von io_uring gültig sind und in /proc/pid/fd nicht erscheinen.
- Die ulimit-Beschränkungen des Systems gelten weiterhin.
Vorstellung des Projekts tarweb
- tarweb ist ein beispielhafter Open-Source-Webserver, der all diese Techniken einsetzt.
- Er ist darauf ausgelegt, den Inhalt einer einzelnen tar-Datei bereitzustellen, und kombiniert moderne Hochleistungstechnologien wie Rust, io_uring und kTLS.
- Im praktischen Einsatz gab es Kompatibilitätsprobleme zwischen io_uring und kTLS, etwa fehlende Unterstützung für setsockopt, und einige dieser Probleme wurden per Pull Request behoben.
- Das Projekt ist noch nicht fertig, und die Rust-Bibliothek rustls kann während des Handshakes Speicher allokieren.
- Der Kernpunkt ist, dass HTTPS ohne zusätzliche System-Calls pro Anfrage möglich ist.
Benchmarks und Leistungsmessung
- Der Autor hat bisher noch keine ausreichenden Benchmarks durchgeführt und plant Leistungstests nach einer Überarbeitung des Codes.
Sicherheitsfragen bei io_uring und Rust
- Anders als bei synchronen System-Calls dürfen Speicherpuffer bei io_uring vor dem Completion Event nicht freigegeben werden.
- Das io-uring-Crate garantiert die Compile-Time-Sicherheit von Rust nicht und bietet auch zu wenig Laufzeitprüfungen.
- Bei falscher Nutzung kann es, ähnlich wie in C++, zu schwerwiegenden Problemen kommen, wodurch die eigentliche Sicherheit von Rust geschwächt wird.
- Es wird ein separates safer-ring-Crate benötigt, das Pinning und den Borrow Checker aktiv nutzt.
- Dieses Problem wird bereits in der Community diskutiert.
Referenzen und zusätzliche Links
- Dieser Inhalt bezieht sich auf einen Beitrag, der am 2025-08-22 auf Hacker News diskutiert wurde.
1 Kommentare
Hacker-News-Kommentare
Wenn man mit
io_uringSchreibvorgänge einreicht, muss man sicherstellen, dass die Speicheradresse nicht freigegeben oder überschrieben wird; die API desio-uring-Crates scheint dem Rust-Borrow-Checker dabei nicht zu helfen und auch keine Laufzeitprüfungen zu haben.Ich habe Artikel und Kommentare zu dieser Situation gelesen, und mein Eindruck ist letztlich, dass es wirklich schwer ist, eine sichere asynchrone Rust-Bibliothek zu bauen, die
io_uringkapselt.Ich erinnere mich auch daran, dass Alice aus dem
tokio-Team kürzlich erwähnt hat, dass das Interesse, dieses Problem zu überwinden, derzeit nicht besonders groß ist.Der Grund ist, dass die Performance im Moment „gut genug“ ist.
Siehe auch: https://boats.gitlab.io/blog/post/io-uring/
Es gibt vieles, das ich an Rust async bedauerlich finde, und das ist einer dieser Punkte.
Rust async wurde in einer Zeit entworfen, als
epollder Standard war, undIOCPwurde dabei fast gar nicht berücksichtigt.Bei synchronen Syscalls gibt es dieses Problem nicht, weil man beim Aufruf von
readeine veränderliche Referenz auf den Puffer an den Kernel übergibt, was gut zum nativen Ownership-/Borrow-Modell von Rust passt.Bei completion-based I/O müsste man aber, damit es sauber zum Ownership-Modell passt, garantieren, dass der User-Code bis zum Abschluss der Operation nicht weiterläuft, und das lässt sich mit einer State-Machine-Polling-Struktur nicht umsetzen.
Ein Threading-Modell oder eine Green-Thread-Struktur passt hier perfekt.
Wenn Rust ein „async-only target“ hinzugefügt hätte, wäre es womöglich besser gewesen.
Die Rust-Entwickler haben große Erwartungen in das asynchrone stacklose Polling-Modell gesetzt, und wir beobachten nun, wie das ausgeht.
Ich denke, es gibt Ownership-Modelle, die der Rust-Borrow-Checker nicht richtig unterstützen kann.
Ich nenne das vorläufig „Hot-Potato-Ownership“: Man gibt einen Puffer kurz ab und bekommt ihn dann wieder zurück.
Solche Muster in Rust sicher zu implementieren ist ziemlich schwierig, und der Code wird unübersichtlich.
Anders als Alice vom
tokio-Team sagt, gibt es auf der Seite von Datei-I/O durchaus Interesse.Datei-I/O ist bereits über
spawn_blockingimplementiert und hat deshalb schon heute dasselbe Pufferproblem wieio_uring; der Umstieg aufio_uringist also nicht besonders schwer.Allerdings ist die bestehende API von
tokio::netnicht mit der pufferbasierten API vonio_uringkompatibel, sodass man zwar Readiness-Prüfungen machen kann, aber vollständige Unterstützung schwierig ist.Um eine sichere
io_uring-Schnittstelle zu bauen, scheint es am sinnvollsten zu sein, Puffer zu verwenden, die dem Ring gehören, sie zu beschreiben und sie beim Start des Schreibvorgangs wieder zurückzugeben.Man muss nicht unbedingt alles mit Borrows ausdrücken.
Mit Datenstrukturen wie
Slabkann man es cancel-safe machen.Siehe auch: https://github.com/steelcake/io2
Ich fand diesen Artikel wirklich sehr interessant.
Ich freue mich auf Performance-Tests, aber ich fand es bemerkenswert, dass der Autor den Code erst aufräumen will, bevor Benchmarks kommen.
In einer Zeit, in der alle nur auf Benchmarks schauen, ist es erfrischend, dass jemand sich darüber Gedanken macht.
Als ich etwa 11 war und versucht habe, eine Datenbank aufzubauen, bin ich auf
cgi-bingestoßen, und erst jetzt wird mir klar, dass dabei für jede Anfrage ein neuer Prozess gestartet wurde.sendfilewar ein Gamechanger, wenn große Gaming-Foren gleichzeitig Demo-Downloads bedienen mussten, und wenn man Ergebnisse wie die 40 ms Einsparung bei Netflix oder die um 70 % verkürzte Ladezeit bei GTA 5 sieht, hat man das Gefühl, dass sich dahinter noch wirkungsvollere Engineering-Arbeit verbirgt.Verwandte Links: Common Gateway Interface, Netflix-40-ms-Fall, verkürzte GTA-Online-Ladezeit
Nicht nur CGI, auch alte HTTP-Sitzungen aus dem CERN-/Apache-Umfeld liefen durch das Forken des gesamten Servers.
Mit der Zeit wurde das besser, aber wegen der Art, wie Apache konfiguriert war, wurden schlanke Server wie
nginx, die von Anfang an auf ereignisbasiertes I/O ausgelegt waren, enorm populär.Ich bin skeptisch, was die Effizienz von
sendfileangeht.Ende der 90er war es zwar sehr angesagt, aber ich denke, der reale Performance-Gewinn ist gering.
Die meisten Orchestratoren für Cloud-Workloads (
CloudRun,GKE,EKS, lokales Docker usw.) deaktivierenio_uringstandardmäßig.Wenn sich das nicht verbessert, bleibt
io_uringwohl vorerst eine sehr eingeschränkte Technologie.Ich frage mich, warum sie
io_uringdeaktivieren.Unter solchen Umständen müsste man wieder zum Self-Hosting zurückkehren.
Wirklich sehr spannend zu lesen.
Ich warte gern auf Benchmarks, und die Haltung des Autors, Code-Aufräumen höher zu priorisieren als Benchmarks, hat mich wirklich beeindruckt.
Heutzutage gibt es viele Projekte, die völlig auf Benchmark-Zahlen fixiert sind, daher ist diese Denkweise wirklich erfrischend und bewundernswert.
Ich wusste nicht, dass
kTLSoderio_uringso vielseitig eingesetzt werden können.Die Lage bei asynchroner Verarbeitung sieht derzeit ungefähr so aus:
Rust:
Futures,Pin,Waker, async runtime,Send/Sync-Bounds, async Trait-Objekte usw. müssen verstanden werdenC++20: Coroutines
Go: Goroutines
Java21+: virtuelle Threads
C++-Coroutines verwenden Heap-Allokation, um das Problem zu umgehen, das
Pinlöst.Das ist eine deutliche Abweichung vom „Zero-Overhead“-Prinzip, das C++ eigentlich verfolgt.
Dass Rust selbst in Zukunft so lange gebraucht hat, async Traits einzuführen, liegt ebenfalls daran, dass Rust Futures nicht auf dem Heap allokiert.
Der Trade-off zwischen Performance/Portabilität und Komplexität kann je nach Projekt sehr unterschiedlich bewertet werden.
Beschränkungen rund um
Send/Syncsind auch in anderen Sprachen weiterhin sinnvoll, und ohne solche Beschränkungen schreibt man leichter subtil fehlerhaften Code.Wenn man nur „gut genuges“ Rust schreibt und Mid-Level-Primitiven verwendet, die andere bereitgestellt haben, muss man nicht unbedingt all diese Konzepte vollständig verstehen.
Rust erzwingt, dass man diese Konzepte versteht, sonst kompiliert es gar nicht.
In Go sind Goroutines nicht dasselbe wie Asynchronität, und ohne Verständnis von Channels versteht man Goroutines ebenfalls nicht.
Die Channel-Implementierung in Go ist eigenwillig, sodass sich das Verhalten in Grenzfällen nicht immer intuitiv vorhersagen lässt.
In Go kann man auch ohne tiefes Verständnis produktiv programmieren, was Vor- und Nachteile hat.
„Günstige Threads“ sind nicht dasselbe wie Asynchronität.
tarweb(der im Blog gezeigte Server) hat eine Single-Thread-Struktur mitio_uring-basiertem Event-Loop; die Idee ist ein Thread pro CPU-Kern.Statt vom „Stand großskaliger Nebenläufigkeit“ zu sprechen, wäre „Stand günstiger Threads“ wohl treffender.
Der größte Unterschied zwischen cheap threads und async loops ist, dass sich über sie leichter nachdenken lässt.
Es gibt aber auch Nachteile: Jeder Thread ist zwar leichtgewichtig, braucht aber trotzdem Stack-Speicher.
kTLSist eindeutig ein Fortschritt.Ich habe vor ein paar Jahren tatsächlich einen Server gebaut, der real 0 Syscalls pro Anfrage hatte, und darüber einen Blogpost geschrieben (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html).
Es hat allerdings den Nachteil, dass man ständig busy-looping betreiben muss.
io_uringhat sich in den letzten Jahren in wirklich beeindruckendem Tempo weiterentwickelt.Dieses Projekt ist wirklich großartig, und ich habe schon lange etwas Ähnliches im Kopf gehabt, daher freut es mich, dass es jemand umgesetzt hat.
Wenn man auch BPF in Rust schreiben will, würde ich
Ayaempfehlen.Aya-Projekt auf GitHub
Ich frage mich, wie der aktuelle Stand von
kTLSist.Ich habe vor Kurzem einen Cilium-Entwickler gefragt, und Thomas Graf meinte, er sei optimistisch, aber in der Praxis fehlt bei vielen Linux-Distributionen noch die Kernel-Unterstützung, sodass eine standardmäßige Aktivierung noch in weiter Ferne liegt.
Schade, aber mich würde auch interessieren, wie schwierig die Aktivierung ist.
Muss man den Kernel anpassen, oder kann man es zur Laufzeit einfach einschalten?
In FreeBSD ist
kTLSseit Version 13 im Kernel bzw. in OpenSSL enthalten, und persysctl(kern.ipc.tls.enable=1) lässt es sich zur Laufzeit umschalten.In FreeBSD-15 wird der Standardwert auf aktiviert geändert, und Netflix verwendet
kTLSseit fast 10 Jahren zur Traffic-Verschlüsselung.kTLSfühlt sich insgesamt wie eine schlechte Idee an.Ich frage mich, ob eine Ein-Thread-pro-Kern-Struktur in einem Time-Slicing-basierten System wirklich sinnvoll ist.
Meiner Erfahrung nach bringt ein „Oversubscribing“-Ansatz, also mehr Threads als Kerne zu haben, bei der tatsächlichen Wall-Clock-Zeit Vorteile.
Ohne präemptives Scheduling oder bei einem System mit genau einem Kern pro Ausführungseinheit würde ein Thread pro Kern eher passen.
Von Unix reden wir dann allerdings nicht.
Wenn man geringe Latenz und hohen Durchsatz will, ist es effektiv, Kerne zu isolieren und Threads fest daran zu pinnen.
So etwas funktioniert unter Linux gut und wird z. B. in Trading-Systemen trotz der Ineffizienz häufig genutzt.
Die Kerne drehen dann meist untätig im Spin und haben faktisch keine Arbeit, aber für Latenz und Durchsatz ist das optimal.
Die Falle beim Thread-per-Core-Ansatz ist die Illusion, man könne sich nur die bequemen Teile herauspicken.
In der Praxis heißt es fast immer: ganz oder gar nicht.
Eine halbgare Implementierung ist überhaupt nicht effizient.
Wenn man es aber richtig entwirft, ist es in fast allen Situationen sehr effizient.
Entwickler, die sich mit den Design-Kniffen von TPC auskennen, etwa Lastverteilung zwischen Kernen, sind selten.
Thread-per-Core ist nur dann effizient, wenn man „CPU-bound“ ist.
Wenn wie bei diesem Serverprojekt die meiste Arbeit asynchron und ereignisbasiert ist, geht der Server fast ohne Warten auf I/O oder Syscalls direkt zur nächsten Anfrage über, und dann ist theoretisch ein Thread pro Kern genau richtig.
In der realen Welt ist diese ideale Situation aber selten, daher sollte man im Hinterkopf behalten, dass es riskant ist, pauschal auf
nprocThreads zu begrenzen.Bei
io_uringscheint es keine schlechte Wahl zu sein, pro Kern nur einen User-Thread zu haben.Der Kernel arbeitet dabei ohnehin mit einem Thread-Pool.
Ich würde auch gern einen Stil sehen, der wie
DPDKden Kernel vollständig umgeht.LUNAhat das bereits umgesetzt.Link zum Paper: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf