io_uring für Leute, die epoll bereits kennen
Eine praktische Einführung in io_uring, die sich an Ingenieure richtet, die epoll-basierte Ereignisschleifen in der Produktion eingesetzt haben. Was das Modell ist, wo es sich bewährt, wo nicht, und die scharfen Kanten, die niemand in den Folien der Keynote erwähnt.
Diese Seite wurde maschinell aus dem English-Original übersetzt. io_uring for people who already know epoll (EN).
Wenn Sie in den letzten zwanzig Jahren einen Netzwerkdienst in C oder Rust auf Linux geschrieben haben, kennen Sie epoll. Sie kennen den Tanz: epoll_create1, epoll_ctl, epoll_wait, nicht-blockierend setzen, lesen bis EAGAIN, schreiben bis EAGAIN, beachten Sie die flankengetriggerte Semantik, beachten Sie EPOLLRDHUP, beachten Sie EPOLLET + EPOLLONESHOT und das Dutzend Fußnoten, die zu jeder dieser Methoden gehören.
io_uring ist die [Linux-Antwort] (https://kernel.dk/io_uring.pdf
) auf die Frage: “Was wäre, wenn wir aufhören würden, einen Syscall pro E/A auszuführen, und stattdessen gebündelte, asynchrone, abschlussbasierte E/A durchführen würden?” Es wurde in 5.1 (2019) eingeführt und ist seitdem in jeder Kernel-Version gewachsen. Im Jahr 2026 ist die API-Oberfläche groß und das Ökosystem hat endlich aufgeholt.
Dieser Beitrag ist keine Einführung in die asynchrone Programmierung. Er setzt voraus, dass Sie wissen, was epoll ist, warum Sie danach greifen würden und wo Sie schon einmal gebissen wurden. Es ist ein Spaziergang durch das io_uring-Modell von diesem Ausgangspunkt aus: was sich ändert, was gleich bleibt und wo die neuen scharfen Kanten liegen.
Das mentale Modell
Vergessen Sie die Bereitschaft für einen Moment. epoll ist eine Bereitschafts-API: Sie teilt Ihnen mit, dass ein Dateideskriptor für die E/A bereit ist, dann geben Sie den Syscall aus, der die E/A tatsächlich durchführt.
io_uring ist eine Vollendungs-API: Sie sagen dem Kernel, welche E/A Sie durchführen wollen, und der Kernel sagt Ihnen, wann er fertig ist. Es gibt keinen separaten Schritt “das eigentliche Lesen durchführen”; der Kernel führt das Lesen in Ihren Puffer durch.
Dies ist das gleiche Modell wie Windows IOCP und (ältere) Solaris Event Ports. Linux hat die abschlussbasierte E/A zwei Jahrzehnte lang übersprungen, während der Rest der Welt die Bereitschaft nutzte.
In der Praxis zeigt sich das als zwei Ringpuffer, die sich der User-Space und der Kernel teilen:
- Eine Warteschlange (SQ). Der Userspace schreibt Submission Queue Entries (SQEs). Jede SQE ist eine 64-Byte-Struktur, die eine Operation beschreibt: “Lies 4096 Bytes von fd 3 in Puffer X” - Eine Completion Queue (CQ). Der Kernel schreibt Completion-Queue-Einträge (CQEs). Jeder besteht aus 16 Bytes: “Operation Y hat 4096 zurückgegeben.”
Sie korrelieren Einreichungen mit Erledigungen über ein user_data Feld, das Sie kontrollieren. In der Regel ein Zeiger auf Ihre eigene Anforderungsstruktur.
user-space kernel
---------- ------
fill SQE ──► submission queue ──► picks up
performs I/O
reaps CQE ◄── completion queue ◄── posts resultEin Syscall schiebt Anmeldungen und holt Erledigungen: io_uring_enter. Mit IORING_SETUP_SQPOLL gibt es gar keinen; ein Kernel-Thread fragt die SQ in Ihrem Namen ab.
Ein minimales Beispiel
Die vollständige Einrichtung ist sehr ausführlich, daher greifen die meisten Leute zu liburing. Hier ist ein “Lesen einer Datei” auf die io_uring-Art:
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
int main(void) {
struct io_uring ring;
if (io_uring_queue_init(8, &ring, 0) < 0) { perror("queue_init"); return 1; }
int fd = open("./hello.txt", O_RDONLY);
char buf[256] = {0};
struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, /*offset=*/0);
sqe->user_data = 0xCAFEBABE; // correlation token
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
fprintf(stderr, "read failed: %s\n", strerror(-cqe->res));
} else {
printf("read %d bytes: %.*s", cqe->res, cqe->res, buf);
}
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
return 0;
}Kompilieren Sie mit gcc -o ur ur.c -luring. Zwei Dinge sind erwähnenswert:
- Das Lesen erfolgt asynchron, aber wir warten sofort auf den Abschluss. Dies ist der einfachste Modus. In einem echten Dienst senden Sie viele Ops und blockieren nur, wenn es nichts anderes zu tun gibt. 2. Der Fehler steht in
cqe->resals negativer errno, nicht in der thread-lokalenerrno. Es gibt keinerrnofür Beendigungen; der Kernel kann nicht von einem Worker-Thread aus auf Ihr TLS zugreifen.
Was ersetzt was
Die mentale Übersetzung von epoll lautet in etwa:
| epoll-style | io_uring-style | |—|—| | epoll_ctl(EPOLL_CTL_ADD, EPOLLIN) | io_uring_prep_poll_add(fd, POLLIN) - aber normalerweise Bereitschaft und prep_recv direkt überspringen | | read(fd, buf, n) nach EPOLLIN | io_uring_prep_recv(sqe, fd, buf, n, 0) | | accept(listener, ...) nach EPOLLIN | io_uring_prep_accept(sqe, listener, ...) | | connect(fd, ...) non-blocking + EPOLLOUT | io_uring_prep_connect(sqe, fd, ...) | | timerfd in epoll | io_uring_prep_timeout(sqe, ts, count, flags) | | recvmsg für Zusatzdaten | io_uring_prep_recvmsg(sqe, ...) | | splice/sendfile | io_uring_prep_splice + io_uring_prep_send_zc |
Die meisten epoll-Operationen haben eine direkte io_uring-Entsprechung. Die interessanten Einträge befinden sich am Ende der Liste, und dort sind auch die Gewinne zu finden.
Wo io_uring tatsächlich gewinnt
Drei Plätze, grob geordnet nach der Höhe des Gewinns:
1. Syscall-Amortisation
Jeder read oder write in epoll-land ist mindestens ein Syscall: zwei Kontextwechsel und ein TLB-Flush im Minimum. Mit io_uring können Sie mit einem io_uring_enter hundert Lesevorgänge durchführen, mit SQPOLL sogar null. Unter Last dominieren die Kosten pro Syscall das Budget pro Anfrage, und ihre Beseitigung ist der größte Leistungsgewinn.
Die Zahlen stammen von einem Edge-Proxy, an dem ich letztes Jahr gearbeitet habe (Linux 6.6, EPYC 7402P, 25 GbE NIC, ein Ring pro Worker, vier Worker, wrk2 mit festem RPS, p50-Latenz berichtet; Reproduktion auf eigene Gefahr, da das Tail-Verhalten nicht passt):
| Workload | epoll | io_uring | io_uring + SQPOLL | |—|—|—|—| | 1 KiB req/resp, keep-alive | 100% | ~128% | ~140% | | | 1 KiB req/resp, fresh conn | 100% | ~109% | ~111% | | 64 KiB static file | 100% | ~116% | ~120% | | 64 KiB static, zero-copy send | 100% | ~150% | ~158% |
Je näher die Arbeitslast an “viele kleine Ops auf heißen Verbindungen” liegt, desto mehr gewinnt io_uring. SQPOLL fügt einen Kernel-Thread pro Ring hinzu, was bei einem einzigen belebten Ring großartig ist, aber schlecht, wenn man tausend meist ruhende Ringe hat.
2. Operationen, die epoll nicht asynchronisieren kann
Einige wenige Syscalls lassen sich nicht in epoll integrieren, weil sie kein Bereitschaftssignal haben:
open()- inode lookup kann auf der Platte blockieren. -stat(),fstatat()- dasselbe. -mkdir,unlink,rename- Metadaten-Ops, alle blockierend. -fsync,fdatasync- explizit blockierend.
Im Epoll-Land gibt man diese an einen Thread-Pool weiter oder akzeptiert, dass sie blockiert werden. In io_uring sind es normale SQEs:
io_uring_prep_openat(sqe, AT_FDCWD, path, O_RDONLY, 0);
io_uring_prep_statx(sqe, AT_FDCWD, path, 0, STATX_BASIC_STATS, &stx);
io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
io_uring_prep_unlinkat(sqe, AT_FDCWD, path, 0);Festplattenlastige Arbeitslasten können hier echte Vorteile verzeichnen: HTTP-Server, die viele kleine statische Dateien bereitstellen, Mail-Server, Datenbanken, die viele kleine fsyncs durchführen.
3. Null-Kopie-Pfade
io_uring_prep_send_zc() (Linux 6.0+) sendet eine echte Null-Kopie. Das Eigentum am Puffer verbleibt beim Kernel, bis Sie den Vorgang abschließen und der Puffer wieder Ihnen gehört.
Für die TLS-Terminierung ist dies von großer Bedeutung: Die Benutzer-Kernel-Kopie auf der unverschlüsselten Seite ist oft der größte Kostenfaktor in einer splice+sendfile Pipeline. Mit send_zc plus kTLS behalten Sie die Daten in Kernel-pinned pages von read bis zum NIC.
Wo io_uring nicht weiterhilft
Es lohnt sich, dies ausdrücklich zu erwähnen:
- CPU-gebundene Arbeit Wenn Ihr Dienst 80 % seiner Zeit mit dem Parsen von JSON und 5 % mit Syscalls verbringt, sparen Sie 5 %. - Kleine Verbindungszahlen Unterhalb von ein paar Tausend gleichzeitigen Verbindungen ist epoll in Ordnung und wohl auch einfacher zu verstehen. - **Workloads, die bereits
splice/sendfileverwenden. ** Sie sind bereits größtenteils Zero-Copy. - Mixed-Blocking-Workloads, bei denen blockierende Ops unüblich sind. Das Thread-Pool-Modell ist wirklich in Ordnung.
Es gibt auch einen nicht-trivialen technischen Aufwand: io_uring’s API-Oberfläche ist groß, hat sich über Kernel-Versionen hinweg geändert und das auf Vervollständigung basierende Modell hat eine andere Form als das, was Ihr bestehender Code erwartet. Einen epoll-basierten Dienst so umzuschreiben, dass er io_uring richtig nutzt, dauert Wochen, nicht Tage.
Die scharfen Kanten
Nun zu den Teilen, die nicht in der Keynote enthalten sind.
Pufferlebensdauer
In epoll-land kehrt read(fd, buf, n) entweder sofort zurück oder nicht. Der Puffer gehört so oder so Ihnen.
In io_uring-land übergibt man einen Puffer, kehrt zu seiner Ereignisschleife zurück, erledigt andere Dinge und der Puffer ist immer noch im Besitz des Kernels, bis die CQE eintrifft. Wenn Sie ihn freigeben, wiederverwenden oder an eine andere SQE übergeben, haben Sie im besten Fall einen Heap-Corruption-Bug und im schlimmsten Fall einen Memory-Disclosure-Bug.
In Rust bedeutet dies, dass Sie &mut [u8] nicht für io_uring-Puffer verwenden können; die Ausleihe kann den Funktionsaufruf nicht überdauern, aber die Operation schon. Die ausgereiften Rust io_uring Kisten (tokio-uring, rio, glommio) verwenden alle eigene Puffertypen wie Box<[u8]> oder BytesMut aus genau diesem Grund.
Annullierung ist schwierig
Das Abbrechen einer laufenden Operation erfordert die Übermittlung einer io_uring_prep_cancel SQE, die auf die user_data der ursprünglichen Operation verweist. Der Abbruch kann erfolgreich sein (die ursprüngliche Operation gibt -ECANCELED zurück), oder er kann mit dem Abschluss konkurrieren (die Operation wird beendet und der Abbruch gibt -ENOENT zurück).
Ein korrekter Abbruch bei Zeitüberschreitung sieht normalerweise so aus:
// Original op.
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, buf, n, 0);
sqe->user_data = (uintptr_t)req;
sqe->flags |= IOSQE_IO_LINK;
// Linked timeout. Auto-cancels the previous op if it does not finish in 5s.
struct __kernel_timespec ts = { .tv_sec = 5 };
struct io_uring_sqe *to = io_uring_get_sqe(&ring);
io_uring_prep_link_timeout(to, &ts, 0);
to->user_data = (uintptr_t)req_timeout_marker;
io_uring_submit(&ring);IOSQE_IO_LINK ist die Funktion für diesen Zweck. Sie verkettet SQEs, so dass die nächste erst ausgeführt wird, wenn die vorherige beendet ist (oder, bei Link-Timeout-Semantik, wenn die Zeitüberschreitung ausgelöst wird).
Versionierung und Funktionsprüfung
io_uring-Funktionen landen ständig in Kerneln. IORING_OP_FOO existiert möglicherweise nicht in dem Kernel, auf dem Sie laufen. Der portable Weg, damit umzugehen, ist, io_uring_get_probe() einmal beim Start aufzurufen und zu fragen, ob jede Operation unterstützt wird:
struct io_uring_probe *p = io_uring_get_probe();
if (!io_uring_opcode_supported(p, IORING_OP_SEND_ZC)) {
// fall back to non-zero-copy send
}
io_uring_free_probe(p);Wenn Sie dies überspringen, liefern Sie eine Binärdatei aus, die in Ihrer Dev-VM (Kernel 6.6) funktioniert und für die Hälfte der SQEs auf der Prod 5.10 LTS -EINVAL zurückgibt.
Sicherheit
io_uring hat eine holprige Sicherheit Geschichte hinter sich. Einige Hyperscaler (Googles ChromeOS, Teile von Android) deaktivieren es standardmäßig. Der Radius eines Kernel-Fehlers in io_uring ist größer als in epoll, weil viel mehr Code-Pfade von einer einzigen User-Space-SQE aus erreichbar sind.
Wenn Sie io_uring in der Produktion einsetzen: abonnieren Sie die Kernel-Sicherheitsankündigungen, verwenden Sie einen aktuellen LTS-Kernel und erwägen Sie, seccomp-ing die io_uring_* syscalls in Prozessen, die sie nicht benötigen.
Eine Ausgangssituation
Der Satz von Flaggen, der mir für viel genutzte Netzwerkdienste gute Dienste geleistet hat:
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SINGLE_ISSUER // 6.0+: one thread submits
| IORING_SETUP_COOP_TASKRUN // 5.19+: don't wake task on completion
| IORING_SETUP_DEFER_TASKRUN; // 6.1+: batch completions until enter()
if (io_uring_queue_init_params(4096, &ring, ¶ms) < 0) {
// ...
}
// Register a fixed buffer pool. Avoids per-op buffer registration overhead.
io_uring_register_buffers(&ring, iovecs, n_iovecs);
// Register file descriptors. Same reason.
io_uring_register_files(&ring, fds, n_fds);Diese drei Flaggen plus zwei Registrierungen machen den größten Teil des io_uring-Durchsatzes aus. Die restlichen 10-20% kommen von Pufferringen (IORING_REGISTER_PBUF_RING) und Multishot accept / recv, die beide einen eigenen Beitrag wert sind.
Sollten Sie wechseln?
Zunächst sind drei Fragen zu beantworten:
- Ist Ihr Dienst syscall-gebunden? Profilieren Sie ihn. Wenn
read/write/recv/send/acceptan der Spitze Ihres Flamegraphen steht, wird io_uring helfen. Wenn nicht, schauen Sie sich Ihre Anwendung an, bevor Sie sich die Kernel-Schnittstelle anschauen. 2. Benutzen Sie Linux ≥ 6.1 in der Produktion? Auf älteren LTS-Versionen (Debian 11 mit 5.10, Ubuntu 22.04 mit 5.15, RHEL 9 mit 5.14) erhalten Sie ein älteres io_uring ohneSINGLE_ISSUER, ohneDEFER_TASKRUNund mit einem schwächerensend_zc. Die Entscheidung ist schwieriger; manchmal lautet die Antwort “auf die nächste LTS warten”. 3. Haben Sie die technische Bandbreite für die Neufassung? Es handelt sich selten um eine kleine Änderung. Puffereigentum, Lebenszeiten und Fehlerpfade ändern sich alle gleichzeitig.
Wenn die Antwort auf alle drei Fragen “ja” lautet, ist io_uring eine der Umschreibungen mit dem höchsten ROI, die Sie durchführen können. Wenn eine davon nein lautet, ist die richtige Antwort wahrscheinlich “später”
Wenn Sie genau eine Sache mitnehmen, dann die, dass Sie mit der Neufassung nicht einem Benchmark hinterherlaufen, den Sie nicht mit Ihrer tatsächlichen Arbeitslast durchgeführt haben. Die Gewinne sind real, aber sie sind nicht einheitlich, und “wir haben 30% auf wrk2” überlebt selten den Kontakt mit der Anfrageform, die Sie in der Produktion bedienen.
Weitere Lektüre:
- Jens Axboe’s original io_uring paper
- kurz und bündig, vom Autor geschrieben - Linux Kernel Source,
io_uring/, insbesondereio_uring/io_uring.c. Lesbar und gut kommentiert - Programming with io_uring von Jens Axboe, die kanonische längere Referenz -tokio-uring- Rust-Integration, mit einem nützlichen Design-Dokument