
Kürzlich wurde die Aufmerksamkeit des Autors auf einen Artikel über LWN über eine neue Kernel-Schnittstelle für die Abfrage gelenkt. Es wird der neue Abfragemechanismus in der Linux AIO-API (eine Schnittstelle für die asynchrone Dateiverwaltung) erläutert, der der Kernelversion 4.18 hinzugefügt wurde. Die Idee ist sehr interessant: Der Autor des Patches schlägt vor, die Linux AIO-API für die Arbeit mit dem Netzwerk zu verwenden.
Aber warte einen Moment! Immerhin wurde Linux AIO entwickelt, um mit asynchronen E / A von Festplatte zu Festplatte zu arbeiten! Dateien auf der Festplatte sind nicht mit Netzwerkverbindungen identisch. Ist es überhaupt möglich, die Linux AIO API für das Networking zu verwenden?
Es stellt sich heraus, ja, es ist möglich! In diesem Artikel wird erläutert, wie Sie die Stärken der Linux AIO-API nutzen können, um schnellere und bessere Netzwerkserver zu erstellen.
Aber lassen Sie uns zunächst erklären, was Linux AIO ist.
Einführung in Linux AIO
Linux AIO bietet asynchrone Disk-to-Disk-E / A für Benutzersoftware.
In der Vergangenheit wurden unter Linux alle Festplattenvorgänge blockiert. Wenn Sie
open()
,
read()
,
write()
oder
fsync()
, stoppt der Stream, bis die Metadaten im Festplatten-Cache angezeigt werden. Dies ist normalerweise kein Problem. Wenn Sie nicht über viele E / A-Vorgänge und genügend Speicher verfügen, füllen Systemaufrufe den Cache nach und nach und alles funktioniert schnell genug.
Die Leistung von E / A-Vorgängen nimmt ab, wenn ihre Anzahl groß genug ist, beispielsweise in Fällen mit Datenbanken und Proxys. Für solche Anwendungen ist es nicht akzeptabel, den gesamten Prozess anzuhalten, um auf einen Systemaufruf
read()
zu warten.
Um dieses Problem zu lösen, können Anwendungen drei Methoden verwenden:
- Verwenden Sie Thread-Pools und Anrufblockierungsfunktionen für separate Threads. So funktioniert POSIX AIO in glibc (nicht mit Linux AIO verwechseln). Weitere Informationen finden Sie in der IBM Dokumentation . So haben wir das Problem in Cloudflare gelöst: Wir verwenden den Thread-Pool , um
read()
und open()
aufzurufen.
posix_fadvise(2)
den Festplatten-Cache mit posix_fadvise(2)
und hoffen Sie auf das Beste.
- Verwenden Sie Linux AIO in Verbindung mit dem XFS-Dateisystem, öffnen Sie Dateien mit dem O_DIRECT-Flag und vermeiden Sie undokumentierte Probleme .
Keine dieser Methoden ist jedoch ideal. Selbst Linux AIO kann, wenn es gedankenlos verwendet wird, im Aufruf von
io_submit()
blockiert werden. Dies wurde kürzlich in einem anderen
Artikel über LWN erwähnt :
„Die asynchrone Linux-E / A-Schnittstelle hat viele Kritiker und wenige Unterstützer, aber die meisten Leute erwarten zumindest Asynchronität von ihr. Tatsächlich kann die AIO-Operation im Kernel aus verschiedenen Gründen in Situationen blockiert werden, in denen sich der aufrufende Thread dies nicht leisten kann. “
Nachdem wir die Schwächen der Linux AIO API kennen, schauen wir uns ihre Stärken an.
Ein einfaches Programm mit Linux AIO
Um Linux AIO verwenden zu können, müssen Sie zunächst
alle fünf erforderlichen Systemaufrufe selbst ermitteln - glibc stellt sie nicht zur Verfügung.
- Zuerst müssen Sie
io_setup()
aufrufen, um die aio_context
Struktur zu initialisieren. Der Kernel gibt einen undurchsichtigen Zeiger auf die Struktur zurück.
- Danach können Sie
io_submit()
aufrufen, um den Vektor der „E / A-Steuerblöcke“ in Form einer Struktur-iocb-Struktur zur Verarbeitungswarteschlange hinzuzufügen.
- Jetzt können wir endlich
io_getevents()
aufrufen und auf eine Antwort in Form eines Vektors von struct io_event
Strukturen io_event
- die Ergebnisse jedes der iocb-Blöcke.
Es gibt acht Befehle, die Sie in iocb verwenden können. Zwei Befehle zum Lesen, zwei zum Schreiben, zwei fsync-Optionen und der POLL-Befehl, der in Kernel-Version 4.18 hinzugefügt wurde (der achte Befehl ist NOOP):
IOCB_CMD_PREAD = 0, IOCB_CMD_PWRITE = 1, IOCB_CMD_FSYNC = 2, IOCB_CMD_FDSYNC = 3, IOCB_CMD_POLL = 5, /* from 4.18 */ IOCB_CMD_NOOP = 6, IOCB_CMD_PREADV = 7, IOCB_CMD_PWRITEV = 8,
iocb
, die an die Funktion
io_submit
wird, ist ziemlich groß und für die Arbeit mit der Festplatte ausgelegt. Hier ist die vereinfachte Version:
struct iocb { __u64 data; /* user data */ ... __u16 aio_lio_opcode; /* see IOCB_CMD_ above */ ... __u32 aio_fildes; /* file descriptor */ __u64 aio_buf; /* pointer to buffer */ __u64 aio_nbytes; /* buffer size */ ... }
Die vollständige
io_event
Struktur, die
io_getevents
zurückgibt:
struct io_event { __u64 data; /* user data */ __u64 obj; /* pointer to request iocb */ __s64 res; /* result code for this event */ __s64 res2; /* secondary result */ };
Ein Beispiel. Ein einfaches Programm, das die Datei / etc / passwd mithilfe der Linux-AIO-API liest:
fd = open("/etc/passwd", O_RDONLY); aio_context_t ctx = 0; r = io_setup(128, &ctx); char buf[4096]; struct iocb cb = {.aio_fildes = fd, .aio_lio_opcode = IOCB_CMD_PREAD, .aio_buf = (uint64_t)buf, .aio_nbytes = sizeof(buf)}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); struct io_event events[1] = {{0}}; r = io_getevents(ctx, 1, 1, events, NULL); bytes_read = events[0].res; printf("read %lld bytes from /etc/passwd\n", bytes_read);
Vollständige Quellen sind natürlich
auf GitHub verfügbar . Hier ist die Strace-Ausgabe dieses Programms:
openat(AT_FDCWD, "/etc/passwd", O_RDONLY) io_setup(128, [0x7f4fd60ea000]) io_submit(0x7f4fd60ea000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7ffc5ff703d0, aio_nbytes=4096, aio_offset=0}]) io_getevents(0x7f4fd60ea000, 1, 1, [{data=0, obj=0x7ffc5ff70390, res=2494, res2=0}], NULL)
Alles lief gut, aber das Lesen von der Festplatte war nicht asynchron: Der Aufruf von io_submit wurde blockiert und erledigte die gesamte Arbeit. Die Funktion
io_getevents
sofort ausgeführt. Wir könnten versuchen, asynchron zu lesen, aber dies erfordert das O_DIRECT-Flag, mit dem Festplattenoperationen den Cache umgehen.
Lassen Sie uns besser veranschaulichen, wie
io_submit
reguläre Dateien sperrt. Hier ist ein ähnliches Beispiel, das die Ausgabe von strace als Ergebnis des Lesens eines 1-GB-Blocks aus
/dev/zero
:
io_submit(0x7fe1e800a000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7fe1a79f4000, aio_nbytes=1073741824, aio_offset=0}]) \ = 1 <0.738380> io_getevents(0x7fe1e800a000, 1, 1, [{data=0, obj=0x7fffb9588910, res=1073741824, res2=0}], NULL) \ = 1 <0.000015>
Der Kernel verbrachte 738 ms mit einem
io_submit
Aufruf und nur 15 ns mit
io_getevents
. Ähnlich verhält es sich mit Netzwerkverbindungen - alle Arbeiten werden von
io_submit
.
Foto Helix84 CC / BY-SA / 3.0
Linux AIO und Netzwerk
Die Implementierung von
io_submit
recht konservativ: Wenn der übergebene Dateideskriptor nicht mit dem Flag O_DIRECT geöffnet wurde, blockiert die Funktion einfach die angegebene Aktion und führt sie aus. Bei Netzwerkverbindungen bedeutet dies:
- Zum Blockieren von Verbindungen wartet IOCV_CMD_PREAD auf ein Antwortpaket.
- Bei nicht blockierenden Verbindungen gibt IOCB_CMD_PREAD den Code -11 (EAGAIN) zurück.
Dieselbe Semantik wird auch im regulären Systemaufruf
read()
verwendet, sodass wir sagen können, dass io_submit beim Arbeiten mit Netzwerkverbindungen nicht intelligenter ist als die guten alten
read() / write()
-Aufrufe.
Es ist wichtig zu beachten, dass
iocb
Anforderungen vom Kernel nacheinander ausgeführt werden.
Trotz der Tatsache, dass Linux AIO uns bei asynchronen Vorgängen nicht hilft, kann es verwendet werden, um Systemaufrufe in Stapeln zu kombinieren.
Wenn der Webserver Daten von Hunderten von Netzwerkverbindungen senden und empfangen muss, ist die Verwendung von
io_submit
möglicherweise eine gute Idee, da Hunderte von Sende- und Empfangsanrufen vermieden werden. Dies wird die Leistung verbessern - der Wechsel vom Benutzerbereich zum Kernel und umgekehrt ist nicht kostenlos, insbesondere nach Einführung von
Maßnahmen zur Bekämpfung von Spectre und Meltdown .
| Ein Puffer
| Mehrere Puffer
|
Ein Dateideskriptor
| read ()
| readv ()
|
Mehrere Dateideskriptoren
| io_submit + IOCB_CMD_PREAD
| io_submit + IOCB_CMD_PREADV
|
Um die Gruppierung von Systemaufrufen in Pakete mit
io_submit
schreiben wir ein kleines Programm, das Daten von einer TCP-Verbindung zu einer anderen sendet. In seiner einfachsten Form (ohne Linux AIO) sieht es ungefähr so aus:
while True: d = sd1.read(4096) sd2.write(d)
Wir können die gleiche Funktionalität durch Linux AIO ausdrücken. Der Code in diesem Fall lautet wie folgt:
struct iocb cb[2] = {{.aio_fildes = sd2, .aio_lio_opcode = IOCB_CMD_PWRITE, .aio_buf = (uint64_t)&buf[0], .aio_nbytes = 0}, {.aio_fildes = sd1, .aio_lio_opcode = IOCB_CMD_PREAD, .aio_buf = (uint64_t)&buf[0], .aio_nbytes = BUF_SZ}}; struct iocb *list_of_iocb[2] = {&cb[0], &cb[1]}; while(1) { r = io_submit(ctx, 2, list_of_iocb); struct io_event events[2] = {}; r = io_getevents(ctx, 2, 2, events, NULL); cb[0].aio_nbytes = events[1].res; }
Dieser Code fügt
io_submit
zwei Jobs
io_submit
: zuerst eine Schreibanforderung an
sd2
und dann eine Leseanforderung von sd1. Nach dem Lesen korrigiert der Code die Größe des Schreibpuffers und wiederholt die Schleife von Anfang an. Es gibt einen Trick: Das erste Mal, wenn ein Schreibvorgang mit einem Puffer der Größe 0 ausgeführt wird. Dies ist erforderlich, da wir die Möglichkeit haben, Schreiben + Lesen in einem
io_submit
Aufruf zu kombinieren (jedoch nicht Lesen + Schreiben).
Ist dieser Code schneller als normales
read()
/
write()
? Noch nicht. Beide Versionen verwenden zwei Systemaufrufe: read + write und io_submit + io_getevents. Glücklicherweise kann der Code verbessert werden.
Io_getevents loswerden
Zur Laufzeit
io_setup()
Kernel mehrere Seiten Speicher für den Prozess zu. So sieht dieser Speicherblock in / proc // maps aus:
marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted) ...
Dem Speicherblock [aio] (in diesem Fall 12 Kb) wurde
io_setup
zugewiesen. Es wird für den Ringpuffer verwendet, in dem Ereignisse gespeichert werden. In den meisten Fällen gibt es keinen Grund,
io_getevents
Ereignisabschlussdaten können aus dem Ringpuffer abgerufen werden, ohne in den Kernelmodus wechseln zu müssen. Hier ist die korrigierte Version des Codes:
int io_getevents(aio_context_t ctx, long min_nr, long max_nr, struct io_event *events, struct timespec *timeout) { int i = 0; struct aio_ring *ring = (struct aio_ring*)ctx; if (ring == NULL || ring->magic != AIO_RING_MAGIC) { goto do_syscall; } while (i < max_nr) { unsigned head = ring->head; if (head == ring->tail) { /* There are no more completions */ break; } else { /* There is another completion to reap */ events[i] = ring->events[head]; read_barrier(); ring->head = (head + 1) % ring->nr; i++; } } if (i == 0 && timeout != NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) { /* Requested non blocking operation. */ return 0; } if (i && i >= min_nr) { return i; } do_syscall: return syscall(__NR_io_getevents, ctx, min_nr-i, max_nr-i, &events[i], timeout); }
Die Vollversion des Codes ist
auf GitHub verfügbar. Die Schnittstelle dieses Ringpuffers ist schlecht dokumentiert, der Autor hat den Code aus dem
Axboe / Fio-Projekt angepasst.
Nach dieser Änderung erfordert unsere Version des Codes unter Linux AIO nur einen Systemaufruf in einer Schleife, wodurch er etwas schneller als der ursprüngliche Code unter Verwendung von Lesen + Schreiben ist.
Foto Zug Fotos CC / BY-SA / 2.0
Epoll Alternative
Mit der Hinzufügung von IOCB_CMD_POLL zur Kernel-Version 4.18 wurde es möglich,
io_submit
als Ersatz für select / poll / epoll zu verwenden. Dieser Code erwartet beispielsweise Daten von einer Netzwerkverbindung:
struct iocb cb = {.aio_fildes = sd, .aio_lio_opcode = IOCB_CMD_POLL, .aio_buf = POLLIN}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); r = io_getevents(ctx, 1, 1, events, NULL);
Vollständiger Code . Hier ist seine Strace-Ausgabe:
io_submit(0x7fe44bddd000, 1, [{aio_lio_opcode=IOCB_CMD_POLL, aio_fildes=3}]) \ = 1 <0.000015> io_getevents(0x7fe44bddd000, 1, 1, [{data=0, obj=0x7ffef65c11a8, res=1, res2=0}], NULL) \ = 1 <1.000377>
Wie Sie sehen, funktionierte diesmal die Asynchronität: io_submit wurde sofort ausgeführt, und
io_getevents
für eine Sekunde blockiert und wartete auf Daten. Dies kann anstelle des
epoll_wait()
verwendet werden.
Darüber hinaus erfordert die Arbeit mit
epoll
normalerweise die Verwendung der Systemaufrufe epoll_ctl. Und Anwendungsentwickler versuchen, häufige Aufrufe dieser Funktion zu vermeiden. Um die Gründe zu verstehen,
lesen Sie einfach
die EPOLLONESHOT- und EPOLLET-Flags
im Handbuch . Mit io_submit zum Abfragen von Verbindungen können Sie diese Schwierigkeiten und zusätzliche Systemaufrufe vermeiden. Fügen Sie einfach die Verbindungen zum iocb-Vektor hinzu, rufen Sie io_submit einmal auf und warten Sie auf die Ausführung. Alles ist sehr einfach.
Zusammenfassung
In diesem Beitrag haben wir die Linux AIO API behandelt. Diese API wurde ursprünglich für die Verwendung mit der Festplatte entwickelt, funktioniert jedoch auch mit Netzwerkverbindungen. Im Gegensatz zu normalen read () + write () -Aufrufen können Sie mit io_submit Systemaufrufe gruppieren und so die Leistung steigern.
Ab Kernel Version 4.18 können
io_submit io_getevents
bei Netzwerkverbindungen für Ereignisse der Form POLLIN und POLLOUT verwendet werden. Dies ist eine Alternative zu
epoll()
.
Ich kann mir einen Netzwerkdienst vorstellen, der nur
io_submit io_getevents
anstelle des Standardsatzes von Lesen, Schreiben, epoll_ctl und epoll_wait verwendet. In diesem Fall kann das Gruppieren von Systemaufrufen in
io_submit
einen großen Vorteil bringen. Ein solcher Server wäre viel schneller.
Leider werden die Diskussionen über ihre Nützlichkeit auch nach den jüngsten Verbesserungen der Linux-AIO-API fortgesetzt. Es ist bekannt, dass
Linus ihn hasst :
"AIO ist ein schreckliches Beispiel für kniehohes Design, bei dem die Hauptausrede lautet:" Andere, weniger talentierte Leute haben sich das ausgedacht, daher müssen wir die Kompatibilität einhalten, damit Datenbankentwickler (die selten geschmackvoll sind) es verwenden können. " Aber AIO war schon immer sehr, sehr schief. “
Es wurden mehrere Versuche unternommen, eine bessere Schnittstelle für die Gruppierung von Anrufen und Asynchronität zu schaffen, aber es fehlte ihnen eine gemeinsame Vision. Das kürzlich
hinzugefügte Hinzufügen von sendto (MSG_ZEROCOPY) ermöglicht beispielsweise eine wirklich asynchrone Datenübertragung, sieht jedoch keine Gruppierung vor.
io_submit
sorgt für Gruppierung, aber nicht für Asynchronität. Schlimmer noch - es gibt derzeit drei Möglichkeiten, asynchrone Ereignisse unter Linux zu liefern: Signale,
io_getevents
und MSG_ERRQUEUE.
In jedem Fall ist es großartig, dass neue Möglichkeiten zur Beschleunigung der Arbeit von Netzwerkdiensten auftauchen.