Aus Linux-Magazin 05/2020

Kernel- und Treiberprogrammierung mit dem Linux-Kernel – Folge 109

© psdesign1, Fotolia

Async-IO reloaded: Mit dem neuen Io_uring-Subsystem geben die Kernel-Entwickler Programmierern die Möglichkeit, die Performance von Datentransfers signifikant zu steigern. Anstelle von Funktionen stehen Datenstrukturen im Mittelpunkt.

Vor rund 50 Jahren haben die Unix-Schöpfer Thompson und Ritchie ein Interface für den Zugriff auf Dateien und Peripherie (I/O) entworfen, das durch Eleganz, Usability und Effizienz besticht. Über »open«, »close«, »read« und »write« werden Daten – woher und wohin auch immer – gelesen, geschrieben oder per Pipe an andere Applikationen weitergeleitet.

Diese Art des Zugriffs bezeichnet man als synchrone Ein-/Ausgabe, weil die Applikation synchron zum Programmablauf einen Datenblock nach dem anderen verarbeitet (Abbildung 1). Während die Applikation sich Block für Block vornimmt, drehen bei modernen Multicore-CPUs allerdings diverse Prozessorkerne Idle-Runden und bleiben ungenutzt. Dass Linux versucht, die Schreib-/Leseaufträge unterschiedlicher Programme zu parallelisieren, nützt der einzelnen Applikation herzlich wenig.

<a href="#artRef-f1">Abbildung 1</a>: Synchrone (links) versus asynchrone Verarbeitung (rechts).

Abbildung 1: Synchrone (links) versus asynchrone Verarbeitung (rechts).

Mit Async-IO bietet der Torvaldsche Betriebssystemkern schon seit Jahren ein asynchrones Interface. Applikationen spezifizieren einen Haufen Transferaufträge, übergeben sie dem Kernel und holen sich zu einem späteren Zeitpunkt die Ergebnisse ab [1]. Was so einfach und logisch klingt, ist für den Programmierer ein wahrer Alptraum. Bei aller Effizienz bleiben Eleganz und Usability auf der Strecke. Kein Wunder also, dass allein große Datenbanken das Interface nutzen und es ansonsten ein tristes Dasein fristet.

Zweiter Aufschlag

Kernel-Entwickler Jens Axboe hat jetzt mit Io_uring einen neuen Anlauf unternommen, der seit Version 5.1 im Kernel integriert ist und mit jeder neuen Version Optimierung und Erweiterungen erfährt. Sein Hauptaugenmerk hat Axboe auf Performance gelegt – mit der Konsequenz, dass Linux-Profis ihre Transferraten signifikant steigern können, Gelegenheitsprogrammierer aber weiterhin Zaungäste bleiben. Kein Interface für die Massen also.

Eleganz und Pfiffigkeit kann man Io_uring allerdings nicht absprechen. Axboe hat nämlich weniger eine Funktionsschnittstelle entworfen als vielmehr ein Daten-Interface. Anders ausgedrückt: Transferaufträge erfolgen nicht primär durch den Aufruf einer Funktion, sondern vielmehr durch das Beschreiben einer Datenstruktur. Dasselbe gilt für das Abholen der Ergebnisse. So lassen sich aufwendige Kontextwechsel einsparen, und Linux braucht dank des datengetriebenen Ansatzes nur drei neue System-Calls. Die Konsequenz für den Programmierer: Sein Augenmerk richtet sich auf die Datenstrukturen.

Daten statt Funktionen

Zum Verwalten der Ein-/Ausgabeaufträge gibt es drei Datenstrukturen: Eine Submission Queue Entry (»struct io_uring_sqe«) beschreibt die Aufträge selbst, die Completion Queue Entry (»struct io_uring_cqe«) informiert über die Abarbeitung, und die Struktur Params (»struct io_uring_params«) zeichnet für die generelle Verwaltung verantwortlich (Abbildung 2).

<a href="#artRef-f2">Abbildung 2</a>: Die zentralen Datenstrukturen von Io_uring.

Abbildung 2: Die zentralen Datenstrukturen von Io_uring.

Als Erstes spezifiziert eine Applikation die Anzahl der maximal parallel abzuarbeitenden Aufträge, also die Anzahl der benötigten SQEs. Dann fordert sie den Kernel per System-Call »io_uring_setup()« auf, passende Datenstrukturen anzulegen. Der Kernel erledigt das und quittiert seine Aktion durch Ausfüllen einer Instanz der Datenstruktur »io_uring_params«.

Adressrechner

An dieser Stelle kommt es zu Komplikationen: Kernel und Applikation arbeiten zwar auf denselben Datenstrukturen (Shared Memory), haben aber eigene und damit unterschiedliche Adressräume. Daher muss der vom Kernel angelegte Speicher zunächst in den Adressraum der Applikation eingebunden (gemappt) werden.

Außerdem dürfen die Datenstrukturen im gemeinsamen Speicher keine absoluten Adressangaben enthalten, die ja nur für eine Seite (Kernel oder Applikation) gelten. Stattdessen verwenden sie nur Offsets zum Anfang des Speichers, anhand derer Kernel und Applikation die realen Adressen im eigenen Adressraum errechnen. Die Dokumentation zu Io_uring schlägt vor, diese Rechnung zu Beginn einmalig vorzunehmen, zu speichern und dann mit den gespeicherten absoluten Adressen weiterzuarbeiten (Abbildung 3, »app_sq_ring« und »app_cq_ring«).

<a href="#artRef-f3">Abbildung 3</a>: Der Ringpuffer hat dem Subsystem seinen Namen gegeben.

Abbildung 3: Der Ringpuffer hat dem Subsystem seinen Namen gegeben.

Um doppeltes Kopieren (Peripherie zum Kernel, Kernel zur Applikation) zu vermeiden, wie es beim synchronen Zugriff gang und gäbe ist, nutzen Kernel und Applikation auch die Speicherbereiche für die eigentlichen Daten gemeinsam. Diese Bereiche werden im Gegensatz zu den Verwaltungsstrukturen als sogenanntes Vektor-IO (Iovec) durch die Applikation angelegt. Es liegt dann am Kernel, sie in seinen Adressraum einzublenden. Dazu muss die Speicheradresse aber an einer Page-Adresse ausgerichtet sein.

Zur Erinnerung: Linux teilt seinen Speicher in Blöcke ein, die normalerweise 4096 Byte umfassen. Für ein Turbo-Copy muss sich auch die Applikation an dieses Format halten. Dazu verwendet sie statt der klassischen Speicherreservierungsfunktion »malloc()« beispielsweise die Funktion »posix_memalign()«. Die Adressen der fraglichen Speicherbereiche und deren Länge landen in einer Liste (»struct iovec«).

Auftragsvergabe

Nach diesen Vorbereitungen kann es nun losgehen. Vereinfacht gesagt, spezifiziert die Applikation Transferaufträge in jeweils eigene SQEs, die sie per Index referenziert (Abbildung 4). Jeden einzelnen Auftrag kennzeichnen unter anderem ein Opcode, ein File-Deskriptor, ein Offset, Applikationsspeicher, eine Längenangabe und ein frei definierbares Magic.

<a href="#artRef-f4">Abbildung 4</a>: SQEs definieren die einzelnen Auftr&auml;ge.

Abbildung 4: SQEs definieren die einzelnen Aufträge.

Der Opcode spezifiziert, was der Kernel überhaupt machen soll, beispielsweise lesen oder schreiben. Der File-Deskriptor kennzeichnet die beteiligte Datei oder Peripherie; der Offset gibt an, ab welcher Adresse der Datei/Peripherie der Zugriff erfolgen soll. Die Datei (oder Peripherie) wird also vor dem asynchronen Zugriff ganz normal per »open()« geöffnet.

Die Applikation stellt einen oder mehrere Speicherbereiche zur Verfügung, die als Quelle oder Ziel des Transfers dienen, inklusive der Anzahl zu transferierender Bytes. Bleibt noch das Magic, das eine Verbindung zwischen dem Auftrag und der späteren Meldung bezüglich dessen Erledigung herstellen soll.

Ein Ring, sie alle zu finden

Hat die Applikation auf diese Weise einen oder mehrere Aufträge spezifiziert, trägt sie die Indizes der entsprechenden SQEs in die Verwaltungsstruktur »sqe_ring« ein. Das wesentliche Element dieser Datenstruktur bildet der Ringpuffer, der dem ganzen Subsystem den Namen gab und den Kern des Mechanismus bildet.

Beim Ringpuffer handelt es sich um ein Array, dessen Länge ein Vielfaches von zwei Elementen beträgt, beispielsweise 8, 16 oder 32. Es gibt die zwei Zeiger »tail« und »head«. Die Applikation schreibt den Index des ausgefüllten SQEs in das Element im Array, auf das der Tail-Zeiger verweist; danach wird er inkrementiert. Dabei darf der Tail-Zeiger den Head-Zeiger nicht überholen. Tut er das doch (»tail > head«), ist der Ringpuffer voll, und er kann kein weiteres Element aufnehmen. In diesem Fall muss die Applikation auf einen freien Slot warten. Umgekehrt signalisiert »tail == head« einen leeren Ringpuffer.

Initial muss die Applikation dem Kernel-Subsystem einmal mitteilen, dass der Kernel anfangen soll, die Aufträge zu bearbeiten. Einmal in Gang gesetzt, wird allerdings nur der Tail-Zeiger inkrementiert, damit der Kernel sich den nächsten Auftrag vorknöpft. Sobald er einen Auftrag akzeptiert, erhöht er den Head-Zeiger und signalisiert damit zugleich der Applikation, dass der SQE erneut eingesetzt werden kann.

Der Kernel gibt also den SQE frei, obwohl der Auftrag möglicherweise noch gar nicht beendet wurde. Er speichert alle notwendigen Informationen zwischen. Das betrifft freilich nur den SQE, nicht aber den hinter dem Auftrag liegenden Datenbereich. Der lässt sich erst freigeben respektive erneut einsetzen, wenn der Kernel über den CQE-Ring die Erledigung meldet.

Die Applikation hat zwei Möglichkeiten, das zu erkennen: Entweder überwacht sie die Tail- und Head-Zeiger des CQE-Rings, oder sie legt sich mithilfe des System-Calls »io_uring_enter()« bis zur Erledigung schlafen (Abbildung 3). Da die SQEs vor Erledigung des Auftrags der Applikation wieder zur Verfügung stehen, kann es zu einem Backlog bei den Erledigungsnachrichten (Completions) kommen. Aus diesem Grund hält der Kernel doppelt so viele CQEs wie SQEs vor.

Um sich bis zum Ende der Bearbeitung schlafen zu legen, verwendet die Applikation den System-Call »io_uring_enter()«. Problematisch ist noch die Zuordnung eines CQ-Events zum eigentlichen Auftrag. Genau das soll das bereits erwähnte Magic »user_data« gewährleisten. Der Kernel kopiert es vom ursprünglichen Auftrag in die Completion-Struktur. Im einfachsten Fall trägt die Applikation hier die Adresse des Applikationsspeichers ein. Neben dem Magic enthält die Datenstruktur »struct io_uring_cqe« am Ende noch die Anzahl der zu transferierenden Bytes.

Take it easy

Eine elegante Technik? Ja. Performant? Ja. Einfache Handhabung? Fehlanzeige. Die Applikation muss asynchron programmiert werden (Abbildung 1), hinzu kommt die Systemintegration mit beispielsweise »mmap()« und Iovecs. Das wissen auch die Linux-Entwickler, und Jens Axboe hat mit der Bibliothek Liburing zumindest die Programmierung etwas erleichtert.

Zum Setup genügt es, die Datenstruktur »struct io_uring ring« zu definieren und die Funktion »io_uring_queue_init(entries, &ring, 0)« aufzurufen (Listing 1, Zeile 51). Die Liburing verbirgt dabei für den Programmierer das gesamte Adressraum-Mapping. Die Iovecs, die die zu transferierenden Daten aufnehmen, muss er aber weiterhin zum Beispiel durch Aufruf der Funktion »posix_memalign()« in der Applikation bereitstellen (Zeile 65).

Für den eigentlichen Datentransfer lässt sich die Applikation durch Aufruf der Funktion »io_uring_get_sqe()« den nächsten freien Auftragsblock zuweisen (Zeile 74). Mithilfe des Makros »io_uring_pre_rw()« füllt sie den Auftragsblock aus. Nach dem Ausfüllen mindestens eines Auftrags kann der Transfer durch Aufruf von »io_uring_submit()« gestartet werden. In Listing 1 kombiniert die Funktion »io_uring_submit_and_wait()« (Zeile 83) das Abschicken der Aufträge und das Schlafen bis zur deren kompletter Erledigung.

Listing 1

Vereinfachtes Cat auf Basis von Liburing

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <ctype.h>
#include <sys/stat.h>
#include <liburing.h>
static off_t get_file_size(int fd)
{
  struct stat st;
  if (fstat(fd, &st) == 0)
    if (S_ISREG(st.st_mode))
      return st.st_size;
  return 0;
}
int main( int argc, char **argv, char **envp )
{
  struct io_uring ring;
  struct io_uring_sqe *sqe;
  struct io_uring_cqe *cqe;
  struct iovec *iovecs;
  int fd, i, ret;
  off_t filesize;
  off_t offset;
  void *buf;
  char *ptr;
  int entries;
  if (argc!=2) {
    fprintf(stderr,"usage: %s filename\n", argv[0]);
    return -1;
  }
  fd = open( argv[1], O_RDONLY );
  if (fd<0) {
    perror( argv[1] );
    return -1;
  }
  filesize = get_file_size( fd );
  if (filesize==0 || filesize>(4096*64)) {
    fprintf(stderr,"%s: file invalid or too big\n",argv[1]);
    return -1;
  }
  // Verwaltungsstrukturen anlegen lassen
  entries = (filesize+4095) / 4096;
  ret = io_uring_queue_init(entries, &ring, 0);
  if (ret<0) {
    fprintf(stderr,"queue_init: %s\n",strerror(-ret));
    return -1;
  }
  // Memory fuer die Daten reservieren
  iovecs = calloc( entries, sizeof(struct iovec));
  if (iovecs==NULL) {
    perror( "calloc" );
    return -1;
  }
  // Memory allocation
  for (i=0; i<entries; i++) {
    if (posix_memalign(&buf, 4096, 4096))
      return 1;
    iovecs[i].iov_base = buf;
    iovecs[i].iov_len = 4096;
  }
  // Lese-Auftraege vorbereiten
  offset = 0;
  for (i=0;i<entries;i++) {
    sqe = io_uring_get_sqe(&ring);
    if (sqe==NULL)
      break;
    io_uring_prep_readv(sqe,fd,&iovecs[i],1,offset);
    sqe->user_data = (unsigned long)iovecs[i].iov_base;
    offset += iovecs[i].iov_len;
  }
  // Auftraege uebergeben und auf Fertigstellung warten
  ret = io_uring_submit_and_wait(&ring, entries);
  if (ret < 0) {
    fprintf(stderr, "io_uring_submit_and_wait: %s\n",
      strerror(-ret));
    return 1;
  }
  // Ergebnisse verarbeiten
  while (io_uring_peek_cqe(&ring,&cqe)==0 ) {
    fprintf(stderr,"completion %d - magic: %llx\n",
      cqe->res, cqe->user_data);
    ptr = (char *)cqe->user_data;
    for (i=0;i<cqe->res;i++) {
      printf("%c", isascii(*(ptr+i))?*(ptr+i):'.');
    }
    io_uring_cqe_seen(&ring, cqe); // Quittieren
  }
  // Ressourcen freigeben
  io_uring_queue_exit(&ring);
  free( iovecs );
  close( fd );
  return 0;
}

Ergebnisliste

Es gibt auch Funktionen, die beim Verwalten von Erledigungsmeldungen durch den Kernel helfen. Der Beispielcode aus Listing 1 holt diese Completions einzeln per »io_uring_peek_cqe()« ab.

Um den Code übersichtlich zu halten, geht das Beispiel jedoch fälschlicherweise davon aus, dass der Kernel die Aufträge sequenziell abarbeitet. Wegen der Parallelität innerhalb des Kernels können sich die Aufträge aber auch gegenseitig überholen. Dadurch kann die Ausgabe in seltenen Fällen etwas durcheinandergeraten.

Hat die Applikation eine Completion-Meldung verarbeitet, teilt sie das dem Kernel per »io_uring_cqe_seen()« mit. Nach Abschluss des gesamten Transfers gibt sie per »io_uring_queue_exit()« vom Kernel reservierte Ressourcen wieder frei.

Eigene Versuche

Der Code aus Listing 1 lässt sich für erste eigene Gehversuche mit Io_uring und Liburing nutzen. Es handelt sich dabei um eine stark vereinfachte Version des Kommandozeilen-Tools Cat, die die als Parameter mitgegebene Datei auf der Konsole ausgibt.

Haben Sie die Quellcode-Datei »mycat.c« genannt, Liburing auf Ihrem Rechner installiert (siehe Kasten “Liburing-Installation”) und läuft bei Ihnen ein Kernel in der Version 5.1 oder höher, generieren Sie das Executable »mycat« durch folgenden Aufruf:

$ make CFLAGS="-D_GNU_SOURCE" LDLIBS=-luring mycat

Liburing-Installation

Zurzeit fällt noch Handarbeit an, um die von Jens Axboe geschriebene Liburing nutzen zu können. Grundvoraussetzung ist zunächst ein Linux-Kernel ab Version 5.1, den man nur auf topaktuellen Systemen findet. Wer auf einem Ubuntu 18.04 das Paket »linux-generic-hwe-18.04« installiert, bekommt jedoch einen geeigneten Kernel (momentan Version 5.3). Die Bibliothek selbst gibt es gegenwärtig nur im Quellcode, den der Programmierer selbst generieren und installieren muss. Unter Ubuntu 18.04 bereitet das aber erfreulicherweise keinerlei Probleme (Listing 2).

Listing 2

Generierung und Installation der Liburing

$ sudo apt-get install --install-recommends linux-generic-hwe-18.04
$ git clone https://github.com/axboe/liburing.git
$ cd liburing
$ ./configure
$ make
$ sudo make install

Starten Sie das Programm dann mit »./mycat mycat.c«, erscheint der Quellcode – eventuell etwas verwürfelt – auf dem Bildschirm.

Die Liburing bringt weiteren Beispielcode mit. Den gilt es gründlich zu studieren, um erfolgreiche komplexere Szenarien zu programmieren: Aufgrund der zurzeit noch mangelhaften Dokumentation [2] muss man sich das benötigte Know-how via Quellcodestudium erschließen. Das ist sehr schade, denn Io_uring hat wesentlich mehr zu bieten [3], als hier angesprochen wurde [4]. Der Blick auf die Opcodes, also die implementierten Transferkommandos, zeigt aktuell knapp 30 unterschiedliche Varianten, beispielsweise für den Datentransfer übers Netzwerk oder die Kombination mit Timeout-Zeiten.

Tabelle 1
Ausgewählte Funktionen der Liburing

Funktion

Aufgabe

»io_uring_queue_init()«

Anlegen der Verwaltungsstrukturen durch den Kernel

»io_uring_queue_exit()«

Freigabe reservierter Ressourcen

»io_uring_get_sqe()«

Anfordern eines freien Auftragsblocks

»io_uring_prep_rw()«

Auftrag erstellen

»io_uring_prep_readv()«

Leseauftrag (Vektor-IO) erstellen

»io_uring_prep_writev()«

Schreibauftrag (Vektor-IO) erstellen

»io_uring_submit()«

Auftrag zur Abarbeitung übergeben

»io_uring_submit_and_wait()«

Aufträge übergeben und auf Abarbeitung warten

»io_uring_peek_cqe()«

Ergebnis abholen

»io_uring_wait_cqe_nr()«

auf Abarbeitung warten

»io_uring_cqe_seen()«

Ergebnisstruktur zur erneuten Nutzung freigeben

Bleibt zum Schluss noch die Frage, um wie viel Io_uring die Zugriffe überhaupt beschleunigt? Erste Anhaltspunkte dazu liefert ein Vortrag, den Entwickler Jens Axboe im Oktober letzten Jahres auf der Linux-Kernel-Konferenz Kernel Recipes 2019 in Paris gehalten hat [5]. Ohne ins Detail der Tests zu gehen, punktet demzufolge der neue Ansatz im Vergleich zum alten Async-IO mit einer zwei- bis vierfachen Geschwindigkeit. Bereits auf einem Single-Core-System wickelt der neue Ansatz das Lesen einer 16 GByte großen Datei in doppelter Geschwindigkeit ab. Das ist beeindruckend – und für Applikationen mit großen Datenbewegungen eine ausreichende Begründung, sich näher mit Io_uring auseinanderzusetzen. (jlu)

Die Autoren

Eva-Katharina Kunst ist seit den Anfängen von Linux Fan von Open Source. Jürgen Quade, Professor an der Hochschule Niederrhein, führt auch für Unternehmen Schulungen zu den Themen Treiberprogrammierung und Embedded Linux durch.

Infos

  1. Async-I/O: Eva-Katharina Kunst, Jürgen Quade, “Kern-Technik”, LM 02/2005, S. 86, https://www.linux-magazin.de/7282

  2. “Efficient IO with io_uring”: https://kernel.dk/io_uring.pdf

  3. “Ringing in a new asynchronous I/O API”: https://lwn.net/Articles/776703/

  4. “The rapid growth of io_uring”: https://lwn.net/Articles/810414/

  5. “Faster IO through io_uring”: https://kernel-recipes.org/en/2019/talks/faster-io-through-io_uring/

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben