Come vengono implementate le pipeline in Unix

Come vengono implementate le pipeline in Unix
Questo articolo descrive l'implementazione delle pipeline nel kernel Unix. Sono rimasto un po' deluso dal fatto che un recente articolo intitolato "Come funzionano le pipeline in Unix?» si è scoperto no sulla struttura interna. Mi sono incuriosito e ho scavato nelle vecchie fonti per trovare la risposta.

Di cosa stai parlando?

Le pipeline sono "probabilmente l'invenzione più importante in Unix" - una caratteristica distintiva della filosofia alla base di Unix di mettere insieme piccoli programmi e il familiare slogan della riga di comando:

$ echo hello | wc -c
6

Questa funzionalità dipende dalla chiamata di sistema fornita dal kernel pipe, descritto nelle pagine della documentazione tubo(7) и tubo(2):

Le pipeline forniscono un canale unidirezionale per la comunicazione tra processi. La pipeline ha un input (fine scrittura) e un output (fine lettura). I dati scritti nell'input della pipeline possono essere letti nell'output.

La pipeline viene creata chiamando pipe(2), che restituisce due descrittori di file: uno si riferisce all'input della pipeline, il secondo all'output.

L'output di traccia del comando precedente dimostra la creazione di una pipeline e il flusso di dati attraverso di essa da un processo all'altro:

$ 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

Il processo padre chiama pipe()per ottenere descrittori di file allegati. Un processo figlio scrive su un descrittore e un altro processo legge gli stessi dati da un altro descrittore. La shell "rinomina" i descrittori 2 e 3 con dup4 in modo che corrispondano a stdin e stdout.

Senza pipeline, la shell dovrebbe scrivere l'output di un processo in un file e reindirizzarlo a un altro processo per leggere i dati dal file. Di conseguenza, sprecheremmo più risorse e spazio su disco. Tuttavia, le pipeline sono utili per qualcosa di più che evitare i file temporanei:

Se un processo tenta di leggere da una pipeline vuota, allora read(2) si bloccherà fino a quando i dati non saranno disponibili. Se un processo tenta di scrivere su una pipeline completa, allora write(2) si bloccherà fino a quando non saranno stati letti dati sufficienti dalla pipeline per completare la scrittura.

Come il requisito POSIX, questa è una proprietà importante: scrivere nella pipeline fino a PIPE_BUF i byte (almeno 512) devono essere atomici in modo che i processi possano comunicare tra loro attraverso la pipeline in un modo che i file normali (che non forniscono tali garanzie) non possono.

Con un file normale, un processo può scrivervi tutto il suo output e passarlo a un altro processo. Oppure i processi possono operare in modalità hard parallel, utilizzando un meccanismo di segnalazione esterno (come un semaforo) per informarsi a vicenda sul completamento di una scrittura o lettura. I nastri trasportatori ci salvano da tutte queste seccature.

Cosa stiamo cercando?

Spiegherò sulle mie dita per renderti più facile immaginare come può funzionare un nastro trasportatore. Dovrai allocare un buffer e uno stato in memoria. Avrai bisogno di funzioni per aggiungere e rimuovere dati dal buffer. Avrai bisogno di alcune funzionalità per chiamare le funzioni durante le operazioni di lettura e scrittura sui descrittori di file. E i blocchi sono necessari per implementare il comportamento speciale sopra descritto.

Siamo ora pronti per interrogare il codice sorgente del kernel alla luce intensa di una lampada per confermare o smentire il nostro vago modello mentale. Ma sii sempre preparato agli imprevisti.

Dove stiamo cercando?

Non so dove si trovi la mia copia del famoso libro.Libro I leoni« con codice sorgente Unix 6, ma grazie a La Unix Heritage Society può essere cercato online codice sorgente anche versioni precedenti di Unix.

Girovagare per gli archivi TUHS è come visitare un museo. Possiamo guardare alla nostra storia condivisa e ho rispetto per gli anni di sforzi per recuperare tutto questo materiale poco a poco da vecchie cassette e stampe. E sono acutamente consapevole di quei frammenti che mancano ancora.

Soddisfatta la nostra curiosità sulla storia antica delle condutture, possiamo guardare ai nuclei moderni per un confronto.

Tra l'altro, pipe è la chiamata di sistema numero 42 nella tabella sysent[]. Coincidenza?

Kernel Unix tradizionali (1970–1974)

Non ho trovato alcuna traccia pipe(2) né dentro Unix PDP-7 (gennaio 1970), né in prima edizione Unix (novembre 1971), né in codice sorgente incompleto seconda edizione (giugno 1972).

TUHS afferma che terza edizione Unix (Febbraio 1973) è stata la prima versione con pipeline:

La terza edizione di Unix è stata l'ultima versione con un kernel scritto in assembler, ma anche la prima versione con pipeline. Nel corso del 1973 si lavorava per migliorare la terza edizione, il kernel fu riscritto in C, e così nacque la quarta edizione di Unix.

Un lettore ha trovato una scansione di un documento in cui Doug McIlroy proponeva l'idea di "collegare i programmi come un tubo da giardino".

Come vengono implementate le pipeline in Unix
Nel libro di Brian KernighanUnix: una storia e un ricordo", la storia dell'aspetto dei nastri trasportatori menziona anche questo documento: "... è rimasto appeso al muro nel mio ufficio ai Bell Labs per 30 anni". Qui intervista a McIlroye un'altra storia da Il lavoro di McIlroy, scritto nel 2014:

Quando è apparso Unix, la mia passione per le coroutine mi ha fatto chiedere all'autore del sistema operativo, Ken Thompson, di consentire ai dati scritti su un processo di andare non solo al dispositivo, ma anche all'uscita di un altro processo. Ken pensava che fosse possibile. Tuttavia, da minimalista, voleva che ogni caratteristica del sistema giocasse un ruolo significativo. La scrittura diretta tra processi è davvero un grande vantaggio rispetto alla scrittura su un file intermedio? E solo quando ho fatto una proposta specifica con il nome accattivante "pipeline" e una descrizione della sintassi dell'interazione dei processi, Ken ha finalmente esclamato: "Lo farò!".

E lo ha fatto. Una fatidica sera, Ken ha cambiato il kernel e la shell, ha corretto diversi programmi standard per standardizzare il modo in cui accettano l'input (che potrebbe provenire da una pipeline) e ha cambiato i nomi dei file. Il giorno successivo, le condutture sono state ampiamente utilizzate nelle applicazioni. Entro la fine della settimana, le segretarie li usavano per inviare i documenti dagli elaboratori di testi alla stampante. Poco dopo, Ken ha sostituito l'API e la sintassi originali per avvolgere l'uso delle pipeline con convenzioni più pulite che sono state utilizzate da allora.

Sfortunatamente, il codice sorgente del kernel Unix della terza edizione è andato perduto. E sebbene abbiamo il codice sorgente del kernel scritto in C quarta edizione, che è stato rilasciato nel novembre 1973, ma è uscito pochi mesi prima del rilascio ufficiale e non contiene l'implementazione delle pipeline. È un peccato che il codice sorgente di questa leggendaria funzionalità Unix sia andato perso, forse per sempre.

Abbiamo il testo della documentazione per pipe(2) da entrambe le versioni, quindi puoi iniziare cercando la documentazione terza edizione (per certe parole, sottolineate "manualmente", una stringa di ^H letterali seguita da un carattere di sottolineatura!). Questo protocollopipe(2) è scritto in assembler e restituisce solo un descrittore di file, ma fornisce già le funzionalità di base previste:

Chiamata di sistema tubo crea un meccanismo di I/O chiamato pipeline. Il descrittore di file restituito può essere utilizzato per le operazioni di lettura e scrittura. Quando qualcosa viene scritto nella pipeline, memorizza nel buffer fino a 504 byte di dati, dopodiché il processo di scrittura viene sospeso. Durante la lettura dalla pipeline, vengono acquisiti i dati memorizzati nel buffer.

Entro l'anno successivo, il kernel era stato riscritto in C, e pipe(2) quarta edizione acquisito il suo aspetto moderno con il prototipo "pipe(fildes)"

Chiamata di sistema tubo crea un meccanismo di I/O chiamato pipeline. I descrittori di file restituiti possono essere utilizzati nelle operazioni di lettura e scrittura. Quando qualcosa viene scritto nella pipeline, viene utilizzato il descrittore restituito in r1 (risp. fildes[1]), bufferizzato fino a 4096 byte di dati, dopodiché il processo di scrittura viene sospeso. Durante la lettura dalla pipeline, il descrittore restituito a r0 (risp. fildes[0]) prende i dati.

Si presume che una volta definita una pipeline, due (o più) processi interagenti (creati da successive invocazioni forcella) passerà i dati dalla pipeline utilizzando le chiamate read и scrivere.

La shell ha una sintassi per definire un array lineare di processi connessi tramite una pipeline.

Le chiamate per leggere da una pipeline vuota (che non contiene dati bufferizzati) che ha solo un'estremità (tutti i descrittori di file di scrittura chiusi) restituiscono "fine del file". Le chiamate di scrittura in una situazione simile vengono ignorate.

Più presto implementazione della pipeline conservata si applica alla quinta edizione di Unix (giugno 1974), ma è pressoché identico a quello apparso nella release successiva. Aggiunti solo commenti, quindi la quinta edizione può essere saltata.

Unix sesta edizione (1975)

Iniziare a leggere il codice sorgente Unix sesta edizione (Maggio 1975). In gran parte grazie a Lions è molto più facile da trovare rispetto ai sorgenti delle versioni precedenti:

Per molti anni il libro Lions era l'unico documento sul kernel Unix disponibile al di fuori dei Bell Labs. Sebbene la licenza della sesta edizione consentisse agli insegnanti di utilizzare il suo codice sorgente, la licenza della settima edizione escludeva questa possibilità, quindi il libro è stato distribuito in copie dattiloscritte illegali.

Oggi è possibile acquistare una copia ristampata del libro, la cui copertina raffigura gli studenti alla fotocopiatrice. E grazie a Warren Toomey (che ha avviato il progetto TUHS), puoi scaricare PDF di origine della sesta edizione. Voglio darti un'idea di quanto impegno è stato necessario per creare il file:

Oltre 15 anni fa, ho digitato una copia del codice sorgente fornito in Lionsperché non mi piaceva la qualità della mia copia rispetto a un numero imprecisato di altre copie. TUHS non esisteva ancora e non avevo accesso alle vecchie fonti. Ma nel 1988 ho trovato un vecchio nastro con 9 tracce che aveva un backup da un computer PDP11. Era difficile sapere se funzionava, ma c'era un albero /usr/src/ intatto in cui la maggior parte dei file era contrassegnata come 1979, che anche allora sembrava antiquata. Era la settima edizione, o un derivato PWB, pensavo.

Ho preso la scoperta come base e ho modificato manualmente le fonti allo stato della sesta edizione. Parte del codice è rimasta la stessa, parte ha dovuto essere leggermente modificata, cambiando il token moderno += nell'obsoleto =+. Qualcosa è stato semplicemente cancellato e qualcosa doveva essere completamente riscritto, ma non troppo.

E oggi possiamo leggere online al TUHS il codice sorgente della sesta edizione di archivio, a cui Dennis Ritchie ha dato una mano.

A proposito, a prima vista, la caratteristica principale del codice C prima del periodo di Kernighan e Ritchie è la sua brevità. Non capita spesso di poter inserire frammenti di codice senza modifiche approfondite per adattarli a un'area di visualizzazione relativamente ristretta sul mio sito.

Presto /usr/sys/ken/pipe.c c'è un commento esplicativo (e sì, c'è dell'altro /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

La dimensione del buffer non è cambiata dalla quarta edizione. Ma qui vediamo, senza alcuna documentazione pubblica, che una volta le pipeline utilizzavano i file come archiviazione di riserva!

Per quanto riguarda i file LARG, corrispondono a inode flag LARG, utilizzato dall'"algoritmo di indirizzamento di grandi dimensioni" per l'elaborazione blocchi indiretti per supportare file system più grandi. Dato che Ken ha detto che è meglio non usarli, sono felice di credergli sulla parola.

Ecco la vera chiamata di sistema 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;
}

Il commento descrive chiaramente cosa sta succedendo qui. Ma non è così facile capire il codice, anche per come "struct utente u» e registri R0 и R1 vengono passati i parametri della chiamata di sistema e i valori restituiti.

Proviamo con ialloc() posto su disco inode (inode), e con Falloc() - Conservare due файла. Se tutto va bene, imposteremo dei flag per identificare questi file come le due estremità della pipeline, puntarli allo stesso inode (il cui conteggio dei riferimenti diventa 2) e contrassegnare l'inode come modificato e in uso. Prestare attenzione alle richieste a metto() nei percorsi di errore per diminuire il conteggio dei riferimenti nel nuovo inode.

pipe() dovuto attraverso R0 и R1 restituisce i numeri dei descrittori di file per la lettura e la scrittura. falloc() restituisce un puntatore a una struttura di file, ma anche "restituisce" via u.u_ar0[R0] e un descrittore di file. Cioè, il codice è memorizzato in r descrittore di file per la lettura e assegna un descrittore per la scrittura direttamente da u.u_ar0[R0] dopo la seconda convocazione falloc().

bandiera FPIPE, che impostiamo durante la creazione della pipeline, controlla il comportamento della funzione rdwr() in sys2.c, che chiama specifiche routine di I/O:

/*
 * 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);
    }
        /* … */
}

Poi la funzione readp() в pipe.c legge i dati dalla pipeline. Ma è meglio tracciare l'implementazione partendo da writep(). Ancora una volta, il codice è diventato più complicato a causa delle peculiarità della convenzione di passaggio degli argomenti, ma alcuni dettagli possono essere omessi.

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

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

loop:
    /* If all done, return. */

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

    /*
     * If there are not both read and write sides of the
     * pipe active, return error and signal too.
     */

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

    /*
     * If the pipe is full, wait for reads to deplete
     * and truncate it.
     */

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

    /* Write what is possible and loop back. */

    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;
}

Vogliamo scrivere byte nell'input della pipeline u.u_count. Per prima cosa dobbiamo bloccare l'inode (vedi sotto plock/prele).

Quindi controlliamo il conteggio dei riferimenti inode. Finché entrambe le estremità della pipeline rimangono aperte, il contatore dovrebbe essere 2. Manteniamo un collegamento (da rp->f_inode), quindi se il contatore è inferiore a 2, ciò dovrebbe significare che il processo di lettura ha chiuso la fine della pipeline. In altre parole, stiamo cercando di scrivere a una pipeline chiusa, il che è un errore. Primo codice di errore EPIPE e segnale SIGPIPE apparso nella sesta edizione di Unix.

Ma anche se il trasportatore è aperto, potrebbe essere pieno. In questo caso, rilasciamo il blocco e andiamo a dormire nella speranza che un altro processo legga dalla pipeline e liberi abbastanza spazio al suo interno. Quando ci svegliamo, torniamo all'inizio, riattacchiamo il lucchetto e iniziamo un nuovo ciclo di scrittura.

Se c'è abbastanza spazio libero nella pipeline, scriviamo i dati usando scrivii()... Parametro i_size1 l'inode'a (con una pipeline vuota può essere uguale a 0) punta alla fine dei dati che già contiene. Se c'è abbastanza spazio per scrivere, possiamo riempire la pipeline da i_size1 a PIPESIZ. Quindi rilasciamo il blocco e proviamo a riattivare qualsiasi processo in attesa di lettura dalla pipeline. Torniamo all'inizio per vedere se siamo riusciti a scrivere tutti i byte di cui avevamo bisogno. In caso contrario, iniziamo un nuovo ciclo di registrazione.

Di solito parametro i_mode inode viene utilizzato per memorizzare le autorizzazioni r, w и x. Ma nel caso delle pipeline, segnaliamo che qualche processo sta aspettando una scrittura o una lettura usando i bit IREAD и IWRITE rispettivamente. Il processo imposta il flag e chiama sleep(), e si prevede che in futuro chiamerà un altro processo wakeup().

La vera magia avviene dentro sleep() и wakeup(). Sono implementati in slp.c, la fonte del famoso commento "Non ci si aspetta che tu capisca". Fortunatamente, non dobbiamo capire il codice, basta guardare alcuni commenti:

/*
 * 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) /* … */

Il processo che chiama sleep() per un particolare canale, può essere successivamente svegliato da un altro processo, che chiamerà wakeup() per lo stesso canale. writep() и readp() coordinare le loro azioni attraverso tali chiamate accoppiate. notare che pipe.c dare sempre la priorità PPIPE quando chiamato sleep(), quindi tutto sleep() può essere interrotto da un segnale.

Ora abbiamo tutto per capire la funzione readp():

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

    rp = fp;
    ip = rp->f_inode;

loop:
    /* Very conservative locking. */

    plock(ip);

    /*
     * If the head (read) has caught up with
     * the tail (write), reset both to 0.
     */

    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);
            }
        }

        /*
         * If there are not both reader and
         * writer active, return without
         * satisfying read.
         */

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

    /* Read and return */

    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);
}

Potresti trovare più facile leggere questa funzione dal basso verso l'alto. Il ramo "read and return" viene solitamente utilizzato quando sono presenti alcuni dati nella pipeline. In questo caso, usiamo Leggere() leggere tutti i dati disponibili a partire da quello attuale f_offset leggere, quindi aggiornare il valore dell'offset corrispondente.

Nelle letture successive, la pipeline sarà vuota se è stato raggiunto l'offset di lettura i_size1 all'inode. Ripristiniamo la posizione su 0 e proviamo a riattivare qualsiasi processo che desideri scrivere sulla pipeline. Sappiamo che quando il trasportatore è pieno, writep() addormentarsi ip+1. E ora che la pipeline è vuota, possiamo riattivarla per riprendere il suo ciclo di scrittura.

Se non c'è niente da leggere, allora readp() può impostare una bandiera IREAD e addormentarsi ip+2. Sappiamo cosa lo risveglierà writep()quando scrive alcuni dati nella pipeline.

Commenti su leggi() e scrivii() ti aiuterà a capire che invece di passare i parametri attraverso "u» possiamo trattarle come normali funzioni di I/O che prendono un file, una posizione, un buffer in memoria e contano il numero di byte da leggere o scrivere.

/*
 * 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;
/* … */

Per quanto riguarda il blocco "conservativo", quindi readp() и writep() bloccare gli inode fino al termine o ottenere un risultato (ad esempio call wakeup). plock() и prele() funziona semplicemente: utilizzando un diverso insieme di chiamate sleep и wakeup consentirci di riattivare qualsiasi processo che richieda il blocco che abbiamo appena rilasciato:

/*
 * 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);
    }
}

All'inizio non riuscivo a capire perché readp() non causa prele(ip) prima della chiamata wakeup(ip+1). La prima cosa writep() chiama nel suo ciclo, this plock(ip), che si traduce in un deadlock if readp() non ha ancora rimosso il suo blocco, quindi il codice deve in qualche modo funzionare correttamente. Se guardi wakeup(), diventa chiaro che contrassegna solo il processo dormiente come pronto per l'esecuzione, in modo che in futuro sched() l'ha lanciato davvero. COSÌ readp() cause wakeup(), sblocca, imposta IREAD e cause sleep(ip+2)- tutto questo prima writep() ricomincia il ciclo.

Questo completa la descrizione delle pipeline nella sesta edizione. Codice semplice, implicazioni di vasta portata.

Settima edizione Unix (gennaio 1979) è stata una nuova major release (quattro anni dopo) che ha introdotto molte nuove applicazioni e funzionalità del kernel. Ha anche subito modifiche significative in relazione all'uso di getti di caratteri, unioni e puntatori tipizzati alle strutture. Tuttavia codice delle condutture praticamente non è cambiato. Possiamo saltare questa edizione.

Xv6, un semplice kernel simile a Unix

Per creare un nucleo Xv6 influenzato dalla sesta edizione di Unix, ma scritto in C moderno per funzionare su processori x86. Il codice è facile da leggere e comprensibile. Inoltre, a differenza delle fonti Unix con TUHS, puoi compilarlo, modificarlo ed eseguirlo su qualcosa di diverso da PDP 11/70. Pertanto, questo nucleo è ampiamente utilizzato nelle università come materiale didattico sui sistemi operativi. Fonti sono su Github.

Il codice contiene un'implementazione chiara e ponderata tubo.c, supportato da un buffer in memoria anziché da un inode su disco. Qui do solo la definizione di "pipeline strutturale" e la funzione 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() imposta lo stato di tutto il resto dell'implementazione, che include le funzioni piperead(), pipewrite() и pipeclose(). La vera chiamata di sistema sys_pipe è un wrapper implementato in sysfile.c. Consiglio di leggere tutto il suo codice. La complessità è al livello del codice sorgente della sesta edizione, ma è molto più semplice e piacevole da leggere.

Linux 0.01

È possibile trovare il codice sorgente per Linux 0.01. Sarà istruttivo studiare l'implementazione delle condutture nel suo fs/pipe.c. Qui viene utilizzato un inode per rappresentare la pipeline, ma la pipeline stessa è scritta in C moderno. Se ti sei fatto strada attraverso il codice della sesta edizione, non avrai problemi qui. Questo è l'aspetto della funzione 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) { /* no readers */
        current->signal |= (1<<(SIGPIPE-1));
        return -1;
    }
    while (count-->0) {
        while (PIPE_FULL(*inode)) {
            wake_up(&inode->i_wait);
            if (inode->i_count != 2) {
                current->signal |= (1<<(SIGPIPE-1));
                return b-buf;
            }
            sleep_on(&inode->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;
}

Anche senza guardare le definizioni della struttura, puoi capire come viene utilizzato il conteggio dei riferimenti inode per verificare se un'operazione di scrittura risulta in SIGPIPE. Oltre al lavoro byte per byte, questa funzione è facile da confrontare con le idee di cui sopra. Anche logica sleep_on/wake_up non sembra così alieno.

Kernel Linux moderni, FreeBSD, NetBSD, OpenBSD

Ho esaminato rapidamente alcuni kernel moderni. Nessuno di loro ha già un'implementazione basata su disco (non sorprende). Linux ha una sua implementazione. E sebbene i tre moderni kernel BSD contengano implementazioni basate sul codice scritto da John Dyson, nel corso degli anni sono diventati troppo diversi l'uno dall'altro.

Leggere fs/pipe.c (su Linux) o sys/kern/sys_pipe.c (su *BSD), ci vuole vera dedizione. Le prestazioni e il supporto per funzionalità come l'I/O vettoriale e asincrono sono oggi importanti nel codice. E i dettagli dell'allocazione della memoria, dei blocchi e della configurazione del kernel variano notevolmente. Questo non è ciò di cui le università hanno bisogno per un corso introduttivo sui sistemi operativi.

In ogni caso, è stato interessante per me portare alla luce alcuni vecchi schemi (ad esempio, generare SIGPIPE e ritorno EPIPE quando si scrive su una pipeline chiusa) in tutti questi kernel moderni così diversi. Probabilmente non vedrò mai un computer PDP-11 dal vivo, ma c'è ancora molto da imparare dal codice che è stato scritto qualche anno prima che io nascessi.

Scritto da Divi Kapoor nel 2011, l'articolo "L'implementazione del kernel Linux di pipe e FIFOè una panoramica di come funzionano le pipeline Linux (finora). UN commit recente su Linux illustra il modello di interazione della pipeline, le cui capacità superano quelle dei file temporanei; e mostra anche fino a che punto le pipeline sono andate dal "blocco molto conservativo" nel kernel Unix della sesta edizione.

Fonte: habr.com

Aggiungi un commento