Vergleich von epoll und io_uring unter Linux
(sibexi.co)- 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_waitmüssenread()/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_SQPOLLreduziert 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()- oderwrite()-Syscall aus
- Im üblichen Ablauf wiederholen sich die Syscall-Kosten für jedes Ereignis
epoll_ctlist ein einmaliger Syscall zum Registrieren eines File Descriptors- Für jedes tatsächliche I/O-Ereignis werden
epoll_waitundread()/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_SQPOLLpollt 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_idlekann er in den Schlafmodus gehen, aber die Kosten verschwinden damit nicht
Der Unterschied anhand von Codebeispielen
-
epoll-Beispiel
- Der File Descriptor von
stdinwird registriert, und bei einem Ereignis wird separatread()aufgerufen - Mit
epoll_create1wird eine epoll-Instanz erzeugt - Mit
epoll_ctlwirdSTDIN_FILENOregistriert - Mit
epoll_waitwird 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_waitundreadbenötigt
- Der File Descriptor von
-
io_uring-Beispiel
- Es wird
liburingverwendet - Mit
io_uring_queue_initwird der Ring initialisiert - Mit
io_uring_get_sqewird ein Submission-Queue-Eintrag geholt - Mit
io_uring_prep_readwird ein Lesevorgang fürstdinvorbereitet - Mit
io_uring_submitwird eingereicht, und mitio_uring_wait_cqewird 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
stdinkeine Daten vorhanden sind, kann unbegrenzt blockiert werden - Das io_uring-Beispiel prüft nicht den Fall, dass
io_uring_get_sqe()NULLzurückgibt, wenn die Submission Queue voll ist
- Es wird
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_ZCab Kernel 6.0+ Übertragungen, bei denen der Puffer nicht in den Kernel kopiert wird
IORING_SETUP_SQPOLLkann 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->reserfolgen
- Die Fehlerbehandlung muss daher über
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
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_CPUnutzt, 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.
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.
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 einemmmap-Bereich, statt aus einer Datei zu lesen und dann zu schreiben.Eigentlich würde ich gern
sendfilemitio_uringnutzen, 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
splice(2)ist implementiert, sodass man mituringeinen sendfile-ähnlichen Ansatz verwenden kann. Es ist nicht ganz so bequem wiesendfile, 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_waiterwä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
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 verwendetepoll-Backend von Asio aufio_uringumgestellt 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 integriertIch habe das Gefühl, dass es unter Linux bis etwa 2050 ungefähr 20 verschiedene Methoden geben wird, Sockets zu pollen
io_uringist das so. Um noch schneller zu werden, kam erst der One-Shot-Modus vonio_uring, und danach sogar ein Multi-Shot-ModusJa,
io_uringist definitiv schneller alsepoll. In meinem Fall wario_uringbei den Requests pro Sekunde wohl etwa 20 % schnellerDas 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_uringabzieltenDeshalb bauen selbst Engineering-Projekte wie Go, die möglichst auf maximale Performance abzielen,
io_uringnicht 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 Exploitsio_uring-Emulation auf Basis vonpollstattepollwar auch schon schneller alsio_uring. Bei großen Zero-Copy-Buffern istio_uringallerdings unschlagbario_uringist auch dann nützlich, wenn man kein asynchrones I/O braucht. Man kann zum Beispiel eine Kette von Operationen wiemkdirund danach das Öffnen dieses Verzeichnisses als eine einzelne atomare Operation umsetzenWenn 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
io_uringinzwischen 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 Linkhttps://access.redhat.com/solutions/4723221
Auch Go sollte den Support erneut prüfen. Einen Versuch wäre es wert
io_uring-Feature-Erkennung durchzuführen? Exploits sind doch nicht nur ein Problem von Programmen, die sich fürio_uringentscheiden, sondern des gesamten OS, oder?io_uring— haben letztlich die Tendenz, die Verantwortung für Speicherisolation dem Benutzer zu überlassenBei
io_uringliegt der Ring allerdings im Kernel, daher kann der Benutzer nicht allzu viel tunIch 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