1 Punkte von GN⁺ 3 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Der Reverse-Proxy TinyGate steigerte seine Performance, als er in einer Worker-basierten Struktur auf epoll umgestellt wurde, stieß später aber an Grenzen und wurde daraufhin erneut mit io_uring umgesetzt
  • epoll ist ein Bereitschaftsmodell, das meldet, wann I/O möglich ist; nach epoll_wait müssen read()/write() daher separat aufgerufen werden
  • io_uring ist ein Abschlussmodell, das sich an abgeschlossenen I/O-Vorgängen orientiert; Anwendung und Kernel tauschen Submission Queue und Completion Queue über einen gemeinsam genutzten Ringpuffer aus
  • io_uring_enter() ist grundsätzlich erforderlich, kann aber mehrere Aufgaben auf einmal einreichen und Ergebnisse gesammelt zurückholen; IORING_SETUP_SQPOLL reduziert Syscalls, hat dafür jedoch CPU-Nutzung als Kosten
  • Wenn man auf einem modernen Linux-Server mit Kernel v5.1+ ein neues Projekt startet, gilt io_uring im Vergleich zu epoll als die passendere Wahl

Die durch TinyGate sichtbar gewordenen Grenzen von epoll

  • TinyGate war ein mit Studierenden entwickelter Reverse-Proxy-Server, und die erste Version hatte eine einfache Worker-basierte Struktur
  • Für ein Ausbildungsprojekt funktionierte das, im Vergleich zu Werkzeugen wie nginx oder haproxy waren die architektonischen Grenzen jedoch deutlich
  • Die zweite Version wurde auf epoll-Basis umgestellt und war deutlich leistungsfähiger als die erste Version
    • In Benchmarks konnte sie nginx/haproxy allerdings weiterhin nicht übertreffen
  • Wegen der Grenzen von epoll wurde später auf io_uring umgestellt, und das Projekt musste von Grund auf neu geschrieben werden

epoll: Bereitschaftsbenachrichtigung und wiederholte Syscalls

  • epoll ist ein unter Linux lange genutztes Verfahren zur Verwaltung asynchroner I/O und wurde 2002 in den Linux-Kernel aufgenommen
  • Der Kern ist die Benachrichtigung über Bereitschaftszustände
    • epoll meldet: „Es kann gelesen oder geschrieben werden“
    • Das eigentliche Lesen und Schreiben der Daten führt die Anwendung anschließend selbst per read()- oder write()-Syscall aus
  • Im üblichen Ablauf wiederholen sich die Syscall-Kosten für jedes Ereignis
    • epoll_ctl ist ein einmaliger Syscall zum Registrieren eines File Descriptors
    • Für jedes tatsächliche I/O-Ereignis werden epoll_wait und read()/write() benötigt
    • Dadurch kommen bei der Ereignisverarbeitung fortlaufend zusätzliche Syscalls hinzu
  • Syscalls verursachen Kontextwechsel zwischen User Mode und Kernel Mode; mit steigender Zahl an Verbindungen wächst dadurch auch der Overhead

io_uring: Abschlussmodell und gemeinsam genutzter Ringpuffer

  • io_uring erschien etwa 17 Jahre nach der Aufnahme von epoll in den Linux-Kernel, also 2019, und wird ab Kernel v5.1+ unterstützt
  • Anders als epoll arbeitet es nicht danach, ob I/O möglich ist, sondern danach, ob I/O abgeschlossen wurde
  • Anwendung und Kernel verwenden gemeinsam Ringpuffer in Shared Memory
    • In die Submission Queue trägt die Anwendung Arbeiten ein, die der Kernel ausführen soll
    • In die Completion Queue stellt der Kernel anschließend die Abschlussergebnisse zurück
  • In der Standardkonfiguration muss io_uring_enter() aufgerufen werden, damit der Kernel die Submission Queue prüft
    • Mit einem einzelnen Aufruf können mehrere Aufgaben eingereicht und mehrere Abschlüsse abgeholt werden
    • Anders als bei der Kombination aus epoll und read() wird nicht für jede Aufgabe erneut ein Syscall-Paar wiederholt
  • Mit IORING_SETUP_SQPOLL pollt ein Kernel-Thread die Submission Queue
    • Im normalen Betrieb lassen sich Syscalls damit nahezu eliminieren
    • Da der Kernel-Thread auch bei leerer Queue weiterläuft, verbraucht er CPU
    • Nach sq_thread_idle kann er in den Schlafmodus gehen, aber die Kosten verschwinden damit nicht

Der Unterschied anhand von Codebeispielen

  • epoll-Beispiel

    • Der File Descriptor von stdin wird registriert, und bei einem Ereignis wird separat read() aufgerufen
    • Mit epoll_create1 wird eine epoll-Instanz erzeugt
    • Mit epoll_ctl wird STDIN_FILENO registriert
    • Mit epoll_wait wird blockiert, bis gelesen werden kann
    • Wenn ein Ereignis eintritt, werden die Daten per read()-Syscall gelesen
    • In diesem Ablauf werden für jedes tatsächliche I/O-Ereignis epoll_wait und read benötigt
  • io_uring-Beispiel

    • Es wird liburing verwendet
    • Mit io_uring_queue_init wird der Ring initialisiert
    • Mit io_uring_get_sqe wird ein Submission-Queue-Eintrag geholt
    • Mit io_uring_prep_read wird ein Lesevorgang für stdin vorbereitet
    • Mit io_uring_submit wird eingereicht, und mit io_uring_wait_cqe wird auf den Abschluss gewartet
    • Im io_uring-Beispiel gibt es keine separate Bereitschaftsprüfung, und beim Abschluss wird read() nicht noch einmal aufgerufen
    • Zur Vereinfachung fehlt in beiden Beispielen wichtige Ausnahmebehandlung
    • Wenn in stdin keine Daten vorhanden sind, kann unbegrenzt blockiert werden
    • Das io_uring-Beispiel prüft nicht den Fall, dass io_uring_get_sqe() NULL zurückgibt, wenn die Submission Queue voll ist

Zusätzliche Bedingungen beim Einsatz von io_uring

  • Für Zero-Copy-I/O müssen Puffer vorab mit io_uring_register_buffers() registriert werden
    • Dadurch lässt sich vermeiden, dass der Kernel für jede Aufgabe den Speicher erneut mappt
    • Bei Netzwerkübertragungen bietet IORING_OP_SEND_ZC ab Kernel 6.0+ Übertragungen, bei denen der Puffer nicht in den Kernel kopiert wird
  • IORING_SETUP_SQPOLL kann Syscalls reduzieren, hat aber CPU-Nutzung als Preis
    • Auch wenn die Queue leer ist, pollt der Kernel-Thread weiter
    • Nach einem Idle-Timeout kann in den Schlafmodus gewechselt werden, aber die Kosten verschwinden nicht vollständig
  • Fehler bei io_uring kommen nicht als direkter Rückgabewert eines synchronen Syscalls zurück, sondern asynchron im res-Feld des Completion-Queue-Eintrags
    • Die Fehlerbehandlung muss daher über cqe->res erfolgen

Die Wahl auf modernen Linux-Servern

  • epoll ist ein älteres Linux-Verfahren für asynchrone I/O, das auf der Meldung des Zeitpunkts möglicher I/O und separaten Syscall-Aufrufen basiert
  • io_uring bietet auf modernem Linux ein abschlussbasiertes Modell sowie gebündelte Einreichung und Verarbeitung von Abschlüssen
  • Wenn auf einem modernen Linux-Server ein neues Projekt von Grund auf entsteht, ist die Wahl von io_uring naheliegend
  • Wenn die Unterstützung älterer Systeme zu einem vernünftigen Zeitpunkt eingestellt werden kann, gibt es in einer Kernel-v5.1+-Umgebung nicht viele Gründe, epoll zu wählen

1 Kommentare

 
GN⁺ 3 시간 전
Hacker-News-Kommentare
  • Ich habe mir das GitHub-Repository https://github.com/sibexico/TinyGate nur ganz kurz angesehen, aber es scheint CPU-Pinning noch nicht zu verwenden.
    Wenn man Threads und den Listening-Socket an CPUs bindet und sockopt SO_INCOMING_CPU nutzt, lässt sich die Leistung noch etwas steigern.
    Wenn man auch ausgehende Sockets an CPUs ausrichtet, dürfte das eine recht große Verbesserung bringen, aber soweit ich weiß gibt es dafür keine gute API. Unter Linux gibt es für kompatible NICs eine API für Traffic Steering / Flow Steering, und wenn man weiß, welchen Hash die NIC verwendet — vermutlich Toeplitz —, kann man die Source-Ports zum Backend passend wählen, sodass der Hash stimmt.
    Das Ziel ist, den Proxy Pakete verarbeiten zu lassen, ohne Kommunikation zwischen CPUs.

    • v0 und v1 des Repositories sind völlig unterschiedliche Implementierungen, die fast komplett neu geschrieben wurden, und derzeit wird an einer dritten Implementierung gearbeitet, die wahrscheinlich die letzte sein wird. Auch die Architekturentscheidungen haben sich vollständig geändert.
    • Ich würde gern die Benchmarks dieses Patches sehen.
  • Ein Blick auf https://github.com/concurrencykit/ck und https://github.com/microsoft/mimalloc könnte sich lohnen. Das dürfte gut zu einem Zero-Copy- und speicherausgerichteten Reverse-Proxy passen.
    Wenn man DDoS-Abwehr und fortgeschrittenere L4-Funktionen einbauen will, ist auch https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ einen Blick wert.

    • Der Plan war, erst auf anderen Ebenen zu optimieren und dann zum Allocator überzugehen. Ich lerne gerade zusammen mit Studierenden etwas über Allocators, und der vorherige Blogbeitrag handelte von einem Custom-Allocator in Zig.
  • Wirklich ein großartiger Artikel.
    Wegen dieses Artikels bin ich in den Kaninchenbau rund um uring, Kernel-Entwicklung und C gefallen. Ich entwickle schon ziemlich lange mit Rust und C++, aber kleine, überschaubare C-Programme haben etwas Schlichtes und fast schon Künstlerisches.

  • In meinem io_uring-basierten Webserver habe ich Shared Buffers noch nicht getestet. Ich sende direkt aus einem mmap-Bereich, statt aus einer Datei zu lesen und dann zu schreiben.
    Eigentlich würde ich gern sendfile mit io_uring nutzen, aber das wird noch nicht unterstützt.
    Ein Artikel mit den Buzzwords Rust und kTLS dazu: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
    Er wurde auch auf HN gepostet: https://news.ycombinator.com/item?id=44980865

    • Nur als Hinweis: splice(2) ist implementiert, sodass man mit uring einen sendfile-ähnlichen Ansatz verwenden kann. Es ist nicht ganz so bequem wie sendfile, sollte aber fast genauso funktionieren.
  • Mit DPDK würde es deutlich komplexer, aber bei der Performance gäbe es dann die Chance, nginx zu deklassieren.
    Wenn man es auf einem FPGA laufen lässt, wird es noch komplexer.
    Die Lehre daraus ist, dass man für Performance bereit sein muss, Abstraktionen wie mit einem heißen Messer durch Butter zu durchdringen — aber dadurch wird auch alles schwieriger. Sockets und das Modell mit einem Thread pro Verbindung waren ein guter Ansatz in einer Zeit, als Netzwerke im Vergleich zur CPU sehr langsam waren, und auch heute ist das oft noch der einfachste Ansatz.

  • Ich habe mich das auch immer gefragt und vor Kurzem ein paar Implementierungen eines HTTP-Dateiservers geschrieben, um die wesentlichen Unterschiede besser zu verstehen.
    https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...

  • Im Proxy-Kontext sollte man auch Busy Polling mit epoll_wait erwähnen. Ich habe mir das vor Kurzem angesehen, als ich Low-Latency-Optionen geprüft habe, und es schien, als könne man selbst ohne DPDK/VMA/io_uring mit simplen Sockets etwas erreichen, das Busy Polling im Userspace nahekommt; Fastly hat dazu beigetragen und setzt es ein.
    Das ist so low-level, dass ich nicht behaupten würde, alles zu verstehen — ich habe eher das Konzept verstanden, daher lasse ich einfach die Links hier. Es funktioniert nur pro NAPI-epoll-Kontext, und die NAPI-ID lässt sich nicht einfach steuern, aber wenn man eine ganze Maschine ausschließlich als Proxy verwendet, ist ein einfacher Hack möglich, bei dem man Sockets nach NAPI-ID dedizierten Pollern zuweist.
    Mein Anwendungsfall war kein Proxy, sondern das Polling von N Sockets auf einer Maschine und die anschließende Verarbeitung der empfangenen Daten. Dafür schien es nicht praktikabel zu sein, obwohl es vielleicht mit einem einzelnen Thread ginge, der NAPI-Kontexte per Round-Robin pollt. Es wäre schön, wenn man dem Kernel eines Tages einfach sagen könnte: „Vertrau mir, ich werde diesen einzelnen Socket ohnehin pollen, also benutze niemals den IRQ-Pfad.“
    Frühere HN-Diskussion zu dieser Kernel-Funktion: https://news.ycombinator.com/item?id=43749271
    Gutes Präsentationsmaterial eines Fastly-Mitwirkenden mit Diagrammen, die das große Ganze leicht verständlich machen: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
    LWN-Artikel: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
    Kernel-Dokumentation: https://docs.kernel.org/networking/napi.html#irq-mitigation

  • Wenn man C++ und asynchrones Networking mag, gibt es Boost.Asio

    • Ich habe Asio kürzlich durch eine selbst geschriebene epoll-Event-Loop ersetzt, und dabei ist der RPS-Wert um etwa 16 % gestiegen. Das Ergebnis stammt von einem SQL-Server mittlerer Größe, daher sollte man vorsichtig sein, wenn man gut verpackte Bibliotheken verwendet
    • Als ich auf einem Datenbankserver das epoll-Backend von Asio auf io_uring umgestellt habe, ist die CPU-Auslastung stark angestiegen. Das hängt wahrscheinlich stark davon ab, wie man es verwendet und wie man es in den Event-Code integriert
    • Boost ist einfach zu umständlich. Es sind riesige dynamische Bibliotheken, die sich schwer bauen und verwenden lassen. Obwohl ich bereits CMake verwendet habe, war der Prozess, Boost zu installieren und auffindbar zu machen, extrem lästig. Allerdings ist mir das auf dem Mac passiert
  • Ich habe das Gefühl, dass es unter Linux bis etwa 2050 ungefähr 20 verschiedene Methoden geben wird, Sockets zu pollen

    • Stimmt, sogar innerhalb von io_uring ist das so. Um noch schneller zu werden, kam erst der One-Shot-Modus von io_uring, und danach sogar ein Multi-Shot-Modus
  • Ja, io_uring ist definitiv schneller als epoll. In meinem Fall war io_uring bei den Requests pro Sekunde wohl etwa 20 % schneller
    Das Problem ist, dass es im Kernel explizit aktiviert werden muss und aus Sicherheitsgründen fast überall deaktiviert ist. Es scheint so zu sein, dass zwischen Kernel und User Space direkt Speicher geteilt wird, und das fühlt sich ziemlich heikel an. In letzter Zeit gab es auch mehrfach Exploits, die auf io_uring abzielten
    Deshalb bauen selbst Engineering-Projekte wie Go, die möglichst auf maximale Performance abzielen, io_uring nicht tief als vernünftigen Standard ein. Wenn man das Risiko in Kauf nehmen will, kann man es in seiner bevorzugten Sprache direkt verwenden. Es ist schneller, aber der Preis ist die potenzielle Anfälligkeit für Exploits

    • Der Hauptgrund für die Deaktivierung ist inzwischen gelöst. In aktuellen RC-Versionen gibt es cBPF-Support, sodass man statt einer vollständigen Abschaltung einschränken kann, welche Operationen ausgeführt werden dürfen
    • Das kommt auf den Fall an. Meine POSIX-artige io_uring-Emulation auf Basis von poll statt epoll war auch schon schneller als io_uring. Bei großen Zero-Copy-Buffern ist io_uring allerdings unschlagbar
      io_uring ist auch dann nützlich, wenn man kein asynchrones I/O braucht. Man kann zum Beispiel eine Kette von Operationen wie mkdir und danach das Öffnen dieses Verzeichnisses als eine einzelne atomare Operation umsetzen
      Wenn man im Networking die Zahl der Pakete pro Sekunde maximieren will, stößt man sehr schnell an Kernel-Grenzen[1] und muss am Ende Funktionen wie GSO/GRO nutzen oder den Netzwerk-Stack komplett umgehen
      1: https://github.com/axboe/liburing/discussions/1346
    • RHEL 9 und 10 unterstützen io_uring inzwischen standardmäßig vollständig. Das ist erst ganz aktuell, deckt aber damit viele Enterprise-Linux-Installationen ab. Gemini „sagte“ auch, dass Ubuntu und SuSE es unterstützen, lieferte dafür aber keinen belegenden Link
      https://access.redhat.com/solutions/4723221
      Auch Go sollte den Support erneut prüfen. Einen Versuch wäre es wert
    • Wäre für Projekte wie Go nicht auch eine Option, beim Start der Runtime nur einmal eine io_uring-Feature-Erkennung durchzuführen? Exploits sind doch nicht nur ein Problem von Programmen, die sich für io_uring entscheiden, sondern des gesamten OS, oder?
    • Alle Arten von Polling-Mode-Networking — RDMA, DPDK, io_uring — haben letztlich die Tendenz, die Verantwortung für Speicherisolation dem Benutzer zu überlassen
      Bei io_uring liegt der Ring allerdings im Kernel, daher kann der Benutzer nicht allzu viel tun
      Ich hoffe, dass es dank LLMs in Zukunft besser wird, aber das ist ein schwer lösbares Problem. Schon im Kernel selbst ist es sehr schwer zu behandeln, und viele Leute verstehen auch nicht richtig, wie man so etwas tuned