Wie Pipelines in Unix umgesetzt sind

Wie Pipelines in Unix umgesetzt sind
In diesem Artikel wird die Implementierung von Pipes im Unix-Kernel beschrieben. Ich war etwas enttäuscht, dass der kürzlich erschienene Artikel mit dem Titel „Wie funktionieren Pipes in Unix?“ sich als nicht über die interne Funktionsweise herausstellte. Es hat mich neugierig gemacht, und ich habe in alten Quellen nach Antworten gesucht.

Worum geht es?

Pipes – „wahrscheinlich die wichtigste Erfindung in Unix“ – sind ein charakteristisches Merkmal der zugrunde liegenden Unix-Philosophie, kleine Programme zusammenzuführen, sowie eine vertraute Eingabeaufforderung:

$ echo hello | wc -c
6

Diese Funktionalität hängt von dem im Kernel verfügbaren Systemaufruf pipe, der in den Dokumentationsseiten beschrieben ist pipe(7) und pipe(2):

Pipes bieten einen unidirektionalen Kanal für die zwischenprozessliche Kommunikation. Eine Pipe hat einen Eingang (write end) und einen Ausgang (read end). Daten, die in den Eingang der Pipe geschrieben werden, können am Ausgang gelesen werden.

Eine Pipe wird mit einem Aufruf von pipe(2), der zwei Dateideskriptoren zurückgibt: einer verweist auf den Eingang der Pipe, der andere auf den Ausgang.

Die Ergebnisse der oben genannten Befehlstrail zeigen die Erstellung einer Pipe und den Datenstrom durch diese von einem Prozess zum anderen:

$ strace -qf -e execve,pipe,dup2,read,write 
    sh -c 'echo hello | wc -c'

execve("/bin/sh", ["sh", "-c", "echo hello | wc -c"], …)
pipe([3, 4])                            = 0
[pid 2604795] dup2(4, 1)                = 1
[pid 2604795] write(1, "hellon", 6)    = 6
[pid 2604796] dup2(3, 0)                = 0
[pid 2604796] execve("/usr/bin/wc", ["wc", "-c"], …)
[pid 2604796] read(0, "hellon", 16384) = 6
[pid 2604796] write(1, "6n", 2)        = 2

Der Elternprozess ruft pipe(), um verbundene Dateideskriptoren zu erhalten. Ein Kindprozess schreibt in einen Deskriptor, während ein anderer Prozess dieselben Daten aus einem anderen Deskriptor liest. Die Shell „benennt“ die Deskriptoren 3 und 4 mit dup2 um, damit sie stdin und stdout entsprechen.

Ohne Pipes müsste die Shell das Ergebnis eines Prozesses in eine Datei schreiben und es an einen anderen Prozess weitergeben, damit dieser die Daten aus der Datei liest. Dadurch würden wir mehr Ressourcen und Speicherplatz verwenden. Pipes sind jedoch nicht nur deshalb nützlich, weil sie die Verwendung von temporären Dateien vermeiden:

Wenn ein Prozess versucht, aus einer leeren Pipe zu lesen, dann read(2) blockiert, bis Daten verfügbar sind. Wenn ein Prozess versucht, in eine volle Pipe zu schreiben, dann write(2) wird blockiert, bis genügend Daten aus der Pipeline gelesen wurden, um die Aufzeichnung durchzuführen.

Wie die POSIX-Anforderung, ist dies eine wichtige Eigenschaft: das Schreiben in die Pipeline bis zu PIPE_BUF Bytes (mindestens 512) muss atomar sein, damit die Prozesse über die Pipeline miteinander interagieren können, wie es einfache Dateien (die diese Garantien nicht bieten) nicht können.

Wenn ein einfacher Datei verwendet wird, kann der Prozess alle seine Ausgaben darin speichern und sie an einen anderen Prozess weitergeben. Oder die Prozesse können im Modus der harten Parallelisierung agieren, indem sie sich durch ein externes Signalmechanismus (wie ein Semaphore) über den Abschluss des Schreibens oder Lesens informieren. Pipelines befreien uns von all diesen Schwierigkeiten.

Was suchen wir?

Ich erkläre es einfach, damit Sie sich besser vorstellen können, wie eine Pipeline funktionieren könnte. Sie benötigen einen Puffer im Speicher und einen bestimmten Status. Sie benötigen Funktionen zum Hinzufügen und Entfernen von Daten aus dem Puffer. Sie benötigen ein Mittel, um die Funktionen während der Lese- und Schreibvorgänge in den Dateideskriptoren aufzurufen. Und Sie benötigen Sperren, um das oben beschriebene spezielle Verhalten zu realisieren.

Jetzt sind wir bereit, den Quellcode des Kernels im grellen Licht der Lampen zu befragen, um unser vages mentales Modell zu bestätigen oder zu widerlegen. Aber seien Sie immer auf Überraschungen gefasst.

Wo suchen wir?

Ich weiß nicht, wo mein Exemplar des bekannten Buches „Lions book“ mit dem Quellcode von Unix 6 liegt, aber dank The Unix Heritage Society kann man online nachsehen in dem Quellcode noch früherer Versionen von Unix.

Die Erkundung der Archive von TUHS ähnelt dem Besuch eines Museums. Wir können einen Blick auf unsere gemeinsame Geschichte werfen und ich habe Respekt vor den jahrelangen Bemühungen, all dieses Material Bit für Bit von alten Kassetten und Drucken wiederherzustellen. Und ich bin mir der Fragmente bewusst, die noch fehlen.

Nachdem ich meine Neugier über die alte Geschichte der Pipelines befriedigt habe, können wir zum Vergleich moderne Kerne betrachten.

Übrigens, pipe ist der Systemaufruf Nummer 42 in der Tabelle sysent[]. Zufall?

Traditionelle Unix-Kerne (1970–1974)

Ich habe keine Spuren gefunden pipe(2) weder in PDP-7 Unix (Januar 1970), noch in der ersten Auflage von Unix (November 1971), noch im unvollständigen Quellcode der zweiten Auflage (Juni 1972).

TUHS behauptet, dass die dritte Auflage von Unix (Februar 1973) wurde zur ersten Version mit Pipelines:

Die dritte Auflage von Unix war die letzte Version mit einem Kernel, der in Assembler geschrieben wurde, aber gleichzeitig die erste Version mit Pipelines. Im Jahr 1973 wurden Arbeiten zur Verbesserung der dritten Auflage vorgenommen, der Kernel wurde in C umgeschrieben, und so entstand die vierte Auflage von Unix.

Ein Leser fand einen Scan eines Dokuments, in dem Doug McIlroy die Idee des „Verbindens von Programmen nach dem Prinzip eines Gartenschlauchs“ vorschlug.

Wie Pipelines in Unix umgesetzt sind
In Brian Kernighans Buch „Unix: Eine Geschichte und ein Erinnerungsstück“ wird ebenfalls auf dieses Dokument hingewiesen: „… es hing 30 Jahre lang an der Wand meines Büros in Bell Labs“. Hier ist ein Interview mit McIlroy, und noch eine Geschichte aus McIlroys Arbeit, verfasst im Jahr 2014.:

Als Unix erschien, brachte meine Leidenschaft für Koroutinen mich dazu, den Autor des Betriebssystems, Ken Thompson, zu fragen, ob Daten, die in einen Prozess geschrieben wurden, nicht nur auf das Gerät, sondern auch an einen anderen Prozess ausgegeben werden könnten. Ken war der Meinung, dass dies möglich ist. Als Minimalist wollte er jedoch, dass jede Systemfunktion eine bedeutende Rolle spielt. Hat das direkte Schreiben zwischen Prozessen tatsächlich einen großen Vorteil gegenüber dem Schreiben in eine Zwischendatei? Und erst als ich einen konkreten Vorschlag mit dem einprägsamen Namen „Pipeline“ und einer Beschreibung der Syntax für die Interaktion von Prozessen einbrachte, rief Ken schließlich: „Ich mache das!“

Und er tat es. An einem schicksalhaften Abend änderte Ken den Kernel und die Shell, korrigierte einige Standardprogramme, indem er deren Eingabeverfahren (die von der Pipeline stammen können) standardisierte und auch die Dateinamen änderte. Am nächsten Tag begannen Sekretärinnen, damit Dokumente aus Texteditoren an Drucker zu senden. Kurz darauf ersetzte Ken die ursprüngliche API und die Syntax für die Verwendung von Pipelines in der Shell durch klarere Vereinbarungen, die seitdem verwendet werden.

Leider ist der Quellcode des Kernels der dritten Auflage von Unix verloren. Und obwohl wir den in C geschriebenen Quellcode des vierten Auflage, die im November 1973 veröffentlicht wurde, haben, wurde dieser einige Monate vor dem offiziellen Release veröffentlicht und enthält keine Implementierung von Pipelines. Schade, dass der Quellcode dieser legendären Unix-Funktion verloren gegangen ist, möglicherweise für immer.

Wir haben den Dokumentationstext zu pipe(2) aus beiden Versionen, daher können wir mit der Suche in der Dokumentation beginnen der dritten Auflage (nach bestimmten Wörtern, die "manuell" unterstrichen sind, Zeile von Literalen ^H, gefolgt von einem Unterstrich!). Dieser Proto-pipe(2) ist in Assembler geschrieben und gibt nur einen Dateideskriptor zurück, bietet aber bereits die erwartete grundlegende Funktionalität:

Systemaufruf pipe erschafft einen Eingabe-/Ausgabemechanismus, der als Pipeline bezeichnet wird. Der zurückgegebene Dateideskriptor kann für Lese- und Schreiboperationen verwendet werden. Wenn etwas in die Pipeline geschrieben wird, wird es bis zu 504 Bytes Daten gepuffert, danach wird der Schreibvorgang angehalten. Beim Lesen aus der Pipeline werden die gepufferten Daten entnommen.

Im nächsten Jahr wurde der Kernel in C umgeschrieben, und pipe(2) in der vierten Auflage erhielt sein modernes Aussehen mit dem Prototyp "pipe(fildes)»:

Systemaufruf pipe erschafft einen Eingabe-/Ausgabemechanismus, der als Pipeline bezeichnet wird. Die zurückgegebenen Dateideskriptoren können für Lese- und Schreiboperationen verwendet werden. Wenn etwas in die Pipeline geschrieben wird, wird der Deskriptor verwendet, der in r1 zurückgegeben wird (entsprechend fildes[1]), und es wird bis zu 4096 Bytes Daten gepuffert, danach wird der Schreibvorgang angehalten. Beim Lesen aus der Pipeline entnimmt der Deskriptor, der in r0 zurückgegeben wird (entsprechend fildes[0]), die Daten.

Es wird angenommen, dass nach der Definition der Pipeline zwei (oder mehr) interagierende Prozesse (die durch nachfolgende Aufrufe erstellt wurden, fork) Daten aus der Pipeline mittels Aufrufen übertragen werden. lesen und schreiben.

In der Shell gibt es eine Syntax zur Definition eines linearen Arrays von Prozessen, die durch eine Pipeline verbunden sind.

Aufrufe zum Lesen aus einer leeren Pipeline (die keine gepufferten Daten enthält) und nur ein Ende hat (alle schreibenden Dateideskriptoren sind geschlossen), geben "Ende der Datei" zurück. Aufrufe zum Schreiben in einer ähnlichen Situation werden ignoriert.

Die früheste überlieferte Implementierung der Pipeline gehört in der fünften Auflage von Unix (Juni 1974), aber sie ist fast identisch mit der, die in der nächsten Version erschien. Es wurden nur Kommentare hinzugefügt, sodass man die fünfte Auflage überspringen kann.

Die sechste Auflage von Unix (1975)

Beginnen wir, den Quellcode von Unix der sechsten Auflage (Mai 1975). Dies ist zum großen Teil wegen Lions viel leichter zu finden als die Quelltexte früherer Versionen:

Viele Jahre Buch Lions war das einzige Dokument zum Unix-Kern, das außerhalb der Wände von Bell Labs verfügbar war. Obwohl die Lizenz der sechsten Ausgabe es Lehrenden erlaubte, den Quellcode zu verwenden, schloss die Lizenz der siebten Ausgabe diese Möglichkeit aus, weshalb das Buch in Form illegaler maschinenschriftlicher Kopien verbreitet wurde.

Heute kann man ein Reprintexemplar des Buches kaufen, dessen Einband Studenten an einem Kopierer zeigt. Und dank Warren Toomey (der das TUHS-Projekt gestartet hat) können Sie herunterladen eine PDF-Datei mit dem Quellcode der sechsten Ausgabe. Ich möchte Ihnen einen Eindruck vermitteln, wie viel Mühe in die Erstellung der Datei geflossen ist:

Vor mehr als 15 Jahren tippte ich eine Kopie des Quellcodes ein, die in Lions, weil mir die Qualität meiner Kopie aus unzähligen anderen Kopien nicht gefiel. TUHS existierte noch nicht, und ich hatte keinen Zugang zu alten Quellcodes. Aber 1988 fand ich ein altes Band mit 9 Spuren, auf dem sich ein Backup vom PDP11-Computer befand. Es war schwer zu sagen, ob es funktionierte, aber es gab ein intaktes Verzeichnis /usr/src/, in dem die meisten Dateien mit dem Jahr 1979 versehen waren, was damals schon antik erschien. Es war die siebte Ausgabe oder ihre Ableitung PWB, so dachte ich.

Ich nahm den Fund als Grundlage und bearbeitete die Quellen manuell auf den Stand der sechsten Ausgabe. Ein Teil des Codes blieb unverändert, ein Teil musste leicht bearbeitet werden, indem ich das moderne Token += auf das veraltete =+ änderte. Etwas entfernte ich einfach, während ich anderes komplett neu schreiben musste, aber nicht allzu viel.

Und heute können wir online auf der TUHS den Quellcode der sechsten Ausgabe aus dem Archiv lesen, an dem Dennis Ritchie mitgewirkt hat..

Übrigens ist auf den ersten Blick das Hauptmerkmal des C-Codes aus der Zeit vor Kernighan und Ritchie seine Kürze. Es kommt nicht oft vor, dass ich Codefragmente ohne umfangreiche Bearbeitung einfügen kann, damit sie in dem relativ schmalen Bereich auf meiner Website angezeigt werden.

Am Anfang /usr/sys/ken/pipe.c gibt es einen erläuternden Kommentar (und ja, da gibt es noch /usr/sys/dmr):

/*
 * Max allowable buffering per pipe.
 * This is also the max size of the
 * file created to implement the pipe.
 * If this size is bigger than 4096,
 * pipes will be implemented in LARG
 * files, which is probably not good.
 */
#define    PIPSIZ    4096

Die Buffergröße hat sich seit der vierten Ausgabe nicht geändert. Aber hier sehen wir ohne jegliche öffentliche Dokumentation, dass einst die Pipelines Dateien als Backup-Speicher verwendeten!

Was die LARG-Dateien betrifft, so entsprechen sie dem inode-Flag LARG, das vom „großen Adressierungsalgorithmus“ verwendet wird, um zu verarbeiten indirekte Blöcke um größere Dateisysteme zu unterstützen. Da Ken gesagt hat, dass es besser ist, sie nicht zu verwenden, glaube ich ihm gerne.

Hier ist der echte Systemaufruf pipe:

/*
 * The sys-pipe entry.
 * Allocate an inode on the root device.
 * Allocate 2 file structures.
 * Put it all together with flags.
 */
pipe()
{
    register *ip, *rf, *wf;
    int r;

    ip = ialloc(rootdev);
    if(ip == NULL)
        return;
    rf = falloc();
    if(rf == NULL) {
        iput(ip);
        return;
    }
    r = u.u_ar0[R0];
    wf = falloc();
    if(wf == NULL) {
        rf->f_count = 0;
        u.u_ofile[r] = NULL;
        iput(ip);
        return;
    }
    u.u_ar0[R1] = u.u_ar0[R0]; /* wf's fd */
    u.u_ar0[R0] = r;           /* rf's fd */
    wf->f_flag = FWRITE|FPIPE;
    wf->f_inode = ip;
    rf->f_flag = FREAD|FPIPE;
    rf->f_inode = ip;
    ip->i_count = 2;
    ip->i_flag = IACC|IUPD;
    ip->i_mode = IALLOC;
}

Der Kommentar beschreibt klar, was hier passiert. Aber es ist nicht so einfach, den Code zu verstehen, zumindest teilweise wegen der Art und Weise, wie mitstruct user uund den Registern R0 und R1 die Parameter von Systemaufrufen und Rückgabewerte übergeben werden.

Lassen Sie uns versuchen, mit ialloc() auf der Festplatte zu platzieren inode (Indexdescriptor), und mit falloc() zweimal im Speicher zu platzieren. der DateiWenn alles gut geht, setzen wir die Flags, um diese Dateien als zwei Enden einer Pipeline zu deklarieren, geben sie im selben inode an (dessen Referenzzähler auf 2 erhöht wird) und markieren den inode als geändert und verwendet. Achten Sie auf die Aufrufe von iput() in den Fehlermeldungen, um den Referenzzähler im neuen inode zu reduzieren.

pipe() sollte durch R0 und R1 Dateideskriptoren für das Lesen und Schreiben zurückgeben. falloc() gibt einen Zeiger auf die Dateistruktur zurück, sendet jedoch auch über u.u_ar0[R0] und den Dateideskriptor zurück. Das heißt, der Code speichert in r den Dateideskriptor zum Lesen und weist den Deskriptor für das Schreiben direkt aus u.u_ar0[R0] nach dem zweiten Aufruf falloc().

Flag FPIPE, den wir beim Erstellen der Pipeline festgelegt haben, steuert das Verhalten der Funktion rdwr() in sys2.c, die spezifische I/O-Unterprogramme aufruft:

/*
 * common code for read and write calls:
 * check permissions, set base, count, and offset,
 * and switch out to readi, writei, or pipe code.
 */
rdwr(mode)
{
    register *fp, m;

    m = mode;
    fp = getf(u.u_ar0[R0]);
        /* … */

    if(fp->f_flag&FPIPE) {
        if(m==FREAD)
            readp(fp); else
            writep(fp);
    }
        /* … */
}

Dann liest die Funktion readp() in pipe.c Daten aus der Pipeline. Aber es ist besser, die Implementierung von writep()zu verfolgen. Ich wiederhole, der Code ist aufgrund der Besonderheiten des Argumentübergabeverfahrens komplizierter geworden, aber einige Einzelheiten können weggelassen werden.

writep(fp)
{
    register *rp, *ip, c;

    rp = fp;
    ip = rp->f_inode;
    c = u.u_count;

loop:
    /* Wenn alles erledigt ist, zurückgeben. */

    plock(ip);
    if(c == 0) {
        prele(ip);
        u.u_count = 0;
        return;
    }

    /*
     * Wenn nicht beide Lese- und Schreibseiten der
     * Pipe aktiv sind, geben Sie einen Fehler zurück und signalisieren Sie ebenfalls.
     */

    if(ip->i_count < 2) {
        prele(ip);
        u.u_error = EPIPE;
        psignal(u.u_procp, SIGPIPE);
        return;
    }

    /*
     * Wenn die Pipe voll ist, warten Sie, bis Lesevorgänge ablaufen
     * und kürzen Sie es.
     */

    if(ip->i_size1 == PIPSIZ) {
        ip->i_mode =| IWRITE;
        prele(ip);
        sleep(ip+1, PPIPE);
        goto loop;
    }

    /* Schreiben, was möglich ist, und zurückloop.
    * /

    u.u_offset[0] = 0;
    u.u_offset[1] = ip->i_size1;
    u.u_count = min(c, PIPSIZ-u.u_offset[1]);
    c =- u.u_count;
    writei(ip);
    prele(ip);
    if(ip->i_mode&IREAD) {
        ip->i_mode =& ~IREAD;
        wakeup(ip+2);
    }
    goto loop;
}

In die Eingabe der Pipeline möchten wir Bytes schreiben u.u_count. Zuerst müssen wir den Indexdescriptor (siehe unten) plock/prele).

Dann überprüfen wir den Inode-Verlinkungszähler. Solange beide Enden der Pipe offen bleiben, sollte der Zähler 2 betragen. Wir halten einen Link (aus rp->f_inode), sodass, wenn der Zähler weniger als 2 beträgt, dies bedeuten sollte, dass der lesende Prozess sein Ende der Pipe geschlossen hat. Mit anderen Worten, wir versuchen, in eine geschlossene Pipe zu schreiben, was einen Fehler darstellt. Der Fehlercode EPIPE und das Signal SIGPIPE tauchten in der sechsten Auflage von Unix auf.

Aber selbst wenn die Pipe geöffnet ist, kann sie gefüllt sein. In diesem Fall heben wir die Sperre auf und gehen schlafen in der Hoffnung, dass ein anderer Prozess aus der Pipe liest und genügend Platz darin schafft. Wenn wir aufwachen, gehen wir zurück zum Anfang, sperren erneut und starten einen neuen Schreibzyklus.

Wenn genügend Platz in der Pipe vorhanden ist, schreiben wir die Daten mit Hilfe von writei(). Der Parameter Der i_size1 des Inodes (bei einer leeren Pipe kann er 0 betragen) zeigt das Ende der bereits darin befindlichen Daten an. Wenn genügend Platz zum Schreiben vorhanden ist, können wir die Pipe bis PIPESIZ Der i_size1 des Inodes (bei einer leeren Pipe kann er 0 betragen) zeigt das Ende der bereits darin befindlichen Daten an. Wenn genügend Platz zum Schreiben vorhanden ist, können wir die Pipe bis bis füllen. Dann heben wir die Sperre auf und versuchen, jeden Prozess aufzuwecken, der darauf wartet, aus der Pipe zu lesen. Wir kehren zum Anfang zurück, um zu überprüfen, ob wir so viele Bytes schreiben konnten, wie wir benötigten. Wenn nicht, beginnen wir einen neuen Schreibzyklus.Normalerweise wird das Attribut

i_mode des Inodes verwendet, um Berechtigungen zu speichern. Aber im Falle von Pipes signalisieren wir auf irgendwelche Prozesse mit den Bits r, w und xIREAD IWRITE und entsprechend, dass sie auf eine Schreib- oder Leseoperation warten. Der Prozess setzt ein Flag und ruft sleep() auf, und es wird erwartet, dass künftig ein anderer Prozesswakeup() aufruft. Der wahre Zauber findet in.

statt. Sie sind in auf, und es wird erwartet, dass künftig ein anderer Prozess und aufruft. Der wahre Zauber findet inslp.c , der Quelle des berühmten Kommentars «Sie müssen das nicht verstehen» (You are not expected to understand this). Zum Glück müssen wir den Code nicht verstehen, schauen wir uns einfach einige Kommentare an:Der Prozess, der

/*
 * Give up the processor till a wakeup occurs
 * on chan, at which time the process
 * enters the scheduling queue at priority pri.
 * The most important effect of pri is that when
 * pri<0 a signal cannot disturb the sleep;
 * if pri>=0 signals will be processed.
 * Callers of this routine must be prepared for
 * premature return, and check that the reason for
 * sleeping has gone away.
 */
sleep(chan, pri) /* … */

/*
 * Wake up all processes sleeping on chan.
 */
wakeup(chan) /* … */

für einen bestimmten Kanal aufruft, kann später von einem anderen Prozess, der auf, und es wird erwartet, dass künftig ein anderer Prozess für denselben Kanal aufruft, geweckt werden. aufruft. Der wahre Zauber findet in Koordinieren ihre Aktionen durch solche geparten Aufrufe. Beachten Sie, dass writep() und readp() immer Priorität hat pipe.c PPIPE , sodass alle beim Aufruf auf, und es wird erwartet, dass künftig ein anderer Prozessdurch ein Signal unterbrochen werden können. auf, und es wird erwartet, dass künftig ein anderer Prozess Jetzt haben wir alles, um die Funktion zu verstehen.

Jetzt haben wir alles, um die Funktion zu verstehen. readp():

readp(fp)
int *fp;
{
    register *rp, *ip;

    rp = fp;
    ip = rp->f_inode;

loop:
    /* Sehr konservative Sperrung. */

    plock(ip);

    /*
     * Wenn der Kopf (lesen) mit
     * dem Schwanz (schreiben) aufgeholt hat,
     * setzt beide auf 0 zurück.
     * /

    if(rp->f_offset[1] == ip->i_size1) {
        if(rp->f_offset[1] != 0) {
            rp->f_offset[1] = 0;
            ip->i_size1 = 0;
            if(ip->i_mode&IWRITE) {
                ip->i_mode &= ~IWRITE;
                wakeup(ip+1);
            }
        }

        /*
         * Wenn nicht sowohl Leser als auch
         * Schreiber aktiv sind, zurückkehren
         * ohne das Lesen zu erfüllen.
         * /

        prele(ip);
        if(ip->i_count i_mode |= IREAD;
        sleep(ip+2, PPIPE);
        goto loop;
    }

    /* Lesen und zurückgeben */

    u.u_offset[0] = 0;
    u.u_offset[1] = rp->f_offset[1];
    readi(ip);
    rp->f_offset[1] = u.u_offset[1];
    prele(ip);
}

Möglicherweise ist es einfacher, diese Funktion von unten nach oben zu lesen. Der Abschnitt „lesen und zurückgeben“ wird normalerweise verwendet, wenn Daten im Kanal vorhanden sind. In diesem Fall verwenden wir readi() um so viele Daten zu lesen, wie verfügbar sind, beginnend mit dem aktuellen f_offset des Lesens, und aktualisieren dann den entsprechenden Versatz.

Bei nachfolgenden Lesevorgängen wird der Kanal leer sein, wenn der Leseversatz das Niveau erreicht hat Der i_size1 des Inodes (bei einer leeren Pipe kann er 0 betragen) zeigt das Ende der bereits darin befindlichen Daten an. Wenn genügend Platz zum Schreiben vorhanden ist, können wir die Pipe bis des inode. Wir setzen die Position auf 0 zurück und versuchen, jeden Prozess zu wecken, der in den Kanal schreiben möchte. Wir wissen, dass, wenn der Kanal voll ist, writep() er schlafen wird auf ip+1. Und jetzt, da der Kanal leer ist, können wir ihn wecken, damit er seinen Schreibzyklus fortsetzt.

Wenn es nichts zu lesen gibt, kann er ein Flag setzen readp() und auf IWRITE ip+2 schlafen. Wir wissen, dass er geweckt wird,wenn er einige Daten in den Kanal schreibt. writep()Die Kommentare zu

readi() und writei() helfen zu verstehen, dass wir anstelle des Übergebens von Parametern über „ u“ wie mit normalen Ein- und Ausgabe-Funktionen umgehen können, die eine Datei, eine Position, einen Puffer im Speicher nehmen und die Anzahl der zu lesenden oder zu schreibenden Bytes zählen.Was die „konservative“ Sperrung betrifft, so

/*
 * Read the file corresponding to
 * the inode pointed at by the argument.
 * The actual read arguments are found
 * in the variables:
 *    u_base        core address for destination
 *    u_offset    byte offset in file
 *    u_count        number of bytes to read
 *    u_segflg    read to kernel/user
 */
readi(aip)
struct inode *aip;
/* … */

/*
 * Write the file corresponding to
 * the inode pointed at by the argument.
 * The actual write arguments are found
 * in the variables:
 *    u_base        core address for source
 *    u_offset    byte offset in file
 *    u_count        number of bytes to write
 *    u_segflg    write to kernel/user
 */
writei(aip)
struct inode *aip;
/* … */

sperren sie inode, bis sie ihre Arbeit abgeschlossen haben oder ein Ergebnis erhalten (das heißt, sie rufen readp() und writep() wakeup plock()). prele() und arbeiten einfach: Mit einem anderen Satz von Aufrufen können wir jeden Prozess wecken, der die Sperre benötigt, die wir gerade freigegeben haben: sleep und plock() Zuerst konnte ich nicht verstehen, warum

/*
 * Lock a pipe.
 * If its already locked, set the WANT bit and sleep.
 */
plock(ip)
int *ip;
{
    register *rp;

    rp = ip;
    while(rp->i_flag&ILOCK) {
        rp->i_flag =| IWANT;
        sleep(rp, PPIPE);
    }
    rp->i_flag =| ILOCK;
}

/*
 * Unlock a pipe.
 * If WANT bit is on, wakeup.
 * This routine is also used to unlock inodes in general.
 */
prele(ip)
int *ip;
{
    register *rp;

    rp = ip;
    rp->i_flag =& ~ILOCK;
    if(rp->i_flag&IWANT) {
        rp->i_flag =& ~IWANT;
        wakeup(rp);
    }
}

prele(ip) nicht aufruft readp() bevor ich wakeup(ip+1) aufrufe. Das Erste, was in seiner Schleife aufruft, ist plock(ip),was zu einer Blockierung führt, wenn writep() er seine Sperre noch nicht gelöst hat, sodass der Code irgendwie korrekt funktionieren muss. Wenn man sich ansieht, was als Nächstes passiert,plock(ip), readp() das führt zu einer Überlappung, falls aufruft. Der wahre Zauber findet in, dann wird klar, dass er nur den schlafenden Prozess als bereit zur Ausführung markiert, um ihn später sched() tatsächlich zu starten. Also readp() ruft auf aufruft. Der wahre Zauber findet in, hebt die Sperre auf, setzt IWRITE und ruft auf sleep(ip+2)— all dies bevor writep() der Zyklus fortgesetzt wird.

Damit endet die Beschreibung von Pipelines in der sechsten Ausgabe. Einfacher Code, weitreichende Folgen.

Die siebte Ausgabe von Unix (Januar 1979) war ein neuer Hauptrelease (vier Jahre später), in dem viele neue Anwendungen und Funktionen des Kerns aufgenommen wurden. Auch gab es signifikante Änderungen im Hinblick auf Typsicherheit, unions und typisierte Zeiger auf Strukturen. Dennoch blieb der Pipeline-Code praktisch unverändert. Wir können diese Ausgabe überspringen.

Xv6, ein einfaches Unix-ähnliches Kern

Das Design des Kerns Xv6 wurde von der sechsten Ausgabe von Unix beeinflusst, ist jedoch in modernem C geschrieben, um auf x86-Prozessoren ausgeführt zu werden. Der Code ist leicht zu lesen und verständlich. Zudem können Sie im Gegensatz zu den Unix-Quellen von TUHS den Code kompilieren, modifizieren und auf etwas anderem als dem PDP 11/70 ausführen. Deshalb wird dieser Kern in Universitäten weitreichend als Lehrmaterial für Betriebssysteme verwendet. Die Quellcodes sind auf Github.

Der Code enthält eine verständliche und durchdachte Implementierung pipe.c, unterstützt durch einen Puffer im Speicher anstelle eines inodes auf der Festplatte. Hier bringe ich nur die Definition von "struktureller Pipeline" und den Funktionen pipealloc():

#define PIPESIZE 512

struct pipe {
  struct spinlock lock;
  char data[PIPESIZE];
  uint nread;     // number of bytes read
  uint nwrite;    // number of bytes written
  int readopen;   // read fd is still open
  int writeopen;  // write fd is still open
};

int
pipealloc(struct file **f0, struct file **f1)
{
  struct pipe *p;

  p = 0;
  *f0 = *f1 = 0;
  if((*f0 = filealloc()) == 0 || (*f1 = filealloc()) == 0)
    goto bad;
  if((p = (struct pipe*)kalloc()) == 0)
    goto bad;
  p->readopen = 1;
  p->writeopen = 1;
  p->nwrite = 0;
  p->nread = 0;
  initlock(&p->lock, "pipe");
  (*f0)->type = FD_PIPE;
  (*f0)->readable = 1;
  (*f0)->writable = 0;
  (*f0)->pipe = p;
  (*f1)->type = FD_PIPE;
  (*f1)->readable = 0;
  (*f1)->writable = 1;
  (*f1)->pipe = p;
  return 0;

 bad:
  if(p)
    kfree((char*)p);
  if(*f0)
    fileclose(*f0);
  if(*f1)
    fileclose(*f1);
  return -1;
}

pipealloc() setzt den Zustand für die gesamte übrige Implementierung, die die Funktionen piperead(), pipewrite() und pipeclose(). Der tatsächliche Systemaufruf sys_pipe ist ein Wrapper, implementiert in sysfile.c. Ich empfehle, den gesamten Code zu lesen. Die Komplexität ist auf dem Niveau des Quellcodes der sechsten Ausgabe, aber das Lesen ist viel einfacher und angenehmer.

Linux 0.01

Der Quellcode von Linux 0.01 kann gefunden werden. Es wird lehrreich sein, die Implementierung der Pipelines in seiner fs/pipe.c. Hier wird für die Darstellung der Pipeline ein inode verwendet, aber die Pipeline selbst ist in modernem C geschrieben. Wenn Sie sich durch den Code der sechsten Ausgabe gekämpft haben, sollten Sie hier keine Schwierigkeiten haben. So sieht die Funktion write_pipe():

int write_pipe(struct m_inode * inode, char * buf, int count)
{
    char * b=buf;

    wake_up(&inode->i_wait);
    if (inode->i_count != 2) { /* keine Leser */
        current->signal |= (1< 0) {
        while (PIPE_FULL(*inode)) {
            wake_up(&inode->i_wait);
            if (inode->i_count != 2) {
                current->signal |= (1<i_wait);
        }
        ((char *)inode->i_size)[PIPE_HEAD(*inode)] =
            get_fs_byte(b++);
        INC_PIPE(PIPE_HEAD(*inode));
        wake_up(&inode->i_wait);
    }
    wake_up(&inode->i_wait);
    return b-buf;
}

Selbst ohne die Definitionen der Strukturen kann man verstehen, wie der Inode-Zähler verwendet wird, um zu überprüfen, ob die Schreiboperation zu SIGPIPEführt. Neben der byteweisen Verarbeitung lässt sich diese Funktion leicht mit den oben beschriebenen Ideen in Einklang bringen. Selbst die Logik sleep_on/wake_up wirkt nicht so fremd.

Moderne Linux-Kernel, FreeBSD, NetBSD, OpenBSD

Ich habe mich schnell über einige moderne Kernel informiert. Keiner von ihnen enthält noch eine Implementierung, die das Diskbetriebssystem nutzt (nicht überraschend). Linux hat seine eigene Implementierung. Und obwohl die drei modernen BSD-Kernel Implementierungen enthalten, die auf dem Code basieren, der von John Dyson geschrieben wurde, unterscheiden sie sich im Laufe der Jahre stark voneinander.

Um zu lesen fs/pipe.c (unter Linux) oder sys/kern/sys_pipe.c (unter *BSD) erfordert echte Hingabe. Heutzutage sind Leistung und Unterstützung für Funktionen wie vektorielle und asynchrone Ein-/Ausgabeoperationen im Code wichtig. Die Details zur Speicherzuweisung, Sperren und Konfiguration des Kernels variieren stark. Das ist nicht das, was Hochschulen für einen Einführungskurs in Betriebssysteme benötigen.

Jedenfalls fand ich es interessant, einige alte Muster (wie das Erzeugen SIGPIPE und Zurückgeben EPIPE bei der Schreiboperation auf ein geschlossenes Pipe) in all diesen so unterschiedlichen modernen Kernen zu entdecken. Wahrscheinlich werde ich nie einen PDP-11-Computer live sehen, aber es gibt immer noch viel zu lernen aus dem Code, der Jahre vor meiner Geburt geschrieben wurde.

Der Artikel von Divi Kapoor aus dem Jahr 2011 "The Linux Kernel Implementation of Pipes and FIFOs" gibt einen Überblick darüber, wie (bis heute) Pipes in Linux funktionieren. Und ein aktueller Commit in Linux veranschaulicht das Pipe-Modell der Interaktion, dessen Funktionen die von temporären Dateien übersteigt; es zeigt auch, wie weit sich Pipes von der "sehr konservativen Sperrung" im Unix-Kernel der sechsten Edition entfernt haben.

Quelle: habr.com

60GB SSD 8Gb DDR4