Comment les pipelines sont implémentés sous Unix

Comment les pipelines sont implémentés sous Unix
Cet article décrit l'implémentation des pipelines dans le noyau Unix. J'ai été quelque peu déçu qu'un article récent intitulé "Comment fonctionnent les pipelines sous Unix ?"s'est avéré aucun sur la structure interne. Je suis devenu curieux et j'ai fouillé dans d'anciennes sources pour trouver la réponse.

Qu'est-ce que c'est?

Les pipelines, « probablement l'invention la plus importante d'Unix », sont une caractéristique déterminante de la philosophie sous-jacente d'Unix consistant à relier de petits programmes entre eux, ainsi qu'un signe familier sur la ligne de commande :

$ echo hello | wc -c
6

Cette fonctionnalité dépend de l'appel système fourni par le noyau pipe, qui est décrit sur les pages de documentation tuyau(7) и tuyau(2):

Les pipelines fournissent un canal unidirectionnel pour la communication interprocessus. Le pipeline a une entrée (fin d’écriture) et une sortie (fin de lecture). Les données écrites à l’entrée du pipeline peuvent être lues à la sortie.

Le pipeline est créé à l'aide de l'appel pipe(2), qui renvoie deux descripteurs de fichiers : l'un faisant référence à l'entrée du pipeline, le second à la sortie.

La sortie de trace de la commande ci-dessus montre la création du pipeline et le flux de données qui le traverse d'un processus à un autre :

$ 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

Le processus parent appelle pipe()pour obtenir les descripteurs de fichiers montés. Un processus enfant écrit sur un handle et un autre processus lit les mêmes données à partir d’un autre handle. Le shell utilise dup2 pour "renommer" les descripteurs 3 et 4 afin qu'ils correspondent à stdin et stdout.

Sans canaux, le shell devrait écrire la sortie d'un processus dans un fichier et la transmettre à un autre processus pour lire les données du fichier. En conséquence, nous gaspillerions plus de ressources et d’espace disque. Cependant, les pipelines ne sont pas seulement utiles car ils vous permettent d'éviter l'utilisation de fichiers temporaires :

Si un processus essaie de lire à partir d'un pipeline vide, alors read(2) bloquera jusqu'à ce que les données soient disponibles. Si un processus tente d'écrire dans un pipeline complet, alors write(2) bloquera jusqu'à ce que suffisamment de données aient été lues à partir du pipeline pour effectuer l'écriture.

Comme l'exigence POSIX, il s'agit d'une propriété importante : écrire dans le pipeline jusqu'à PIPE_BUF les octets (au moins 512) doivent être atomiques afin que les processus puissent communiquer entre eux via le pipeline d'une manière que les fichiers normaux (qui ne fournissent pas de telles garanties) ne peuvent pas.

Lors de l'utilisation d'un fichier standard, un processus peut y écrire toute sa sortie et la transmettre à un autre processus. Les processus peuvent également fonctionner de manière hautement parallèle, en utilisant un mécanisme de signalisation externe (comme un sémaphore) pour se notifier mutuellement lorsqu'une écriture ou une lecture est terminée. Les convoyeurs nous épargnent tous ces tracas.

Que cherchons-nous?

Je vais l'expliquer en termes simples afin qu'il vous soit plus facile d'imaginer comment un convoyeur peut fonctionner. Vous devrez allouer un tampon et un état en mémoire. Vous aurez besoin de fonctions pour ajouter et supprimer des données du tampon. Vous aurez besoin de moyens pour appeler des fonctions lors des opérations de lecture et d'écriture sur les descripteurs de fichiers. Et vous aurez besoin de verrous pour implémenter le comportement spécial décrit ci-dessus.

Nous sommes maintenant prêts à interroger le code source du noyau sous la lumière d’une lampe pour confirmer ou infirmer notre vague modèle mental. Mais soyez toujours prêt à faire face à l’inattendu.

Où cherchons-nous ?

Je ne sais pas où est mon exemplaire du célèbre livre »Livre des Lions"avec le code source Unix 6, mais grâce à La société du patrimoine Unix vous pouvez effectuer une recherche en ligne sur code source même les anciennes versions d'Unix.

Se promener dans les archives du TUHS, c'est comme visiter un musée. Nous pouvons examiner notre histoire commune et j’ai du respect pour les nombreuses années d’efforts visant à récupérer petit à petit tout ce matériel à partir d’anciennes bandes et impressions. Et je suis parfaitement conscient de ces fragments qui manquent encore.

Après avoir satisfait notre curiosité sur l’histoire ancienne des convoyeurs, nous pouvons examiner les noyaux modernes à des fins de comparaison.

Soit dit en passant, pipe est l'appel système numéro 42 dans le tableau sysent[]. Coïncidence?

Noyaux Unix traditionnels (1970-1974)

je n'ai trouvé aucune trace pipe(2) ni dans PDP-7 Unix (janvier 1970), ni en première édition d'Unix (novembre 1971), ni dans le code source incomplet deuxième édition (juin 1972).

TUHS déclare que troisième édition d'Unix (février 1973) devient la première version avec convoyeurs :

Unix 1973e édition était la dernière version avec un noyau écrit en langage assembleur, mais aussi la première version avec des pipelines. Au cours de l'année XNUMX, des travaux ont été menés pour améliorer la troisième édition, le noyau a été réécrit en C et c'est ainsi qu'est apparue la quatrième édition d'Unix.

Un lecteur a trouvé une numérisation d'un document dans lequel Doug McIlroy proposait l'idée de « connecter les programmes comme un tuyau d'arrosage ».

Comment les pipelines sont implémentés sous Unix
Dans le livre de Brian KernighanUnix : une histoire et un mémoire", dans l'histoire de l'émergence des convoyeurs, ce document est également mentionné : "... il était accroché au mur de mon bureau aux Bell Labs pendant 30 ans." Ici entretien avec McIlroy, et une autre histoire de L'œuvre de McIlroy, écrite en 2014:

Lorsque Unix est sorti, ma fascination pour les coroutines m'a amené à demander à l'auteur du système d'exploitation, Ken Thompson, d'autoriser les données écrites dans un processus à aller non seulement vers le périphérique, mais également vers un autre processus. Ken a décidé que c'était possible. Cependant, en tant que minimaliste, il souhaitait que chaque fonction du système joue un rôle significatif. L'écriture directe entre processus est-elle vraiment un gros avantage par rapport à l'écriture dans un fichier intermédiaire ? Ce n’est que lorsque j’ai fait une proposition spécifique avec le nom accrocheur de « pipeline » et une description de la syntaxe d’interaction entre les processus que Ken s’est finalement exclamé : « Je vais le faire !

Et a fait. Un soir fatidique, Ken a changé le noyau et le shell, a corrigé plusieurs programmes standards pour standardiser la façon dont ils acceptaient les entrées (qui pouvaient provenir d'un pipeline), et a également modifié les noms de fichiers. Le lendemain, les pipelines ont commencé à être très largement utilisés dans les applications. À la fin de la semaine, les secrétaires les utilisaient pour envoyer des documents du traitement de texte à l'imprimante. Un peu plus tard, Ken a remplacé l'API et la syntaxe d'origine pour envelopper l'utilisation des pipelines par des conventions plus propres, qui sont utilisées depuis lors.

Malheureusement, le code source de la troisième édition du noyau Unix a été perdu. Et bien que nous ayons le code source du noyau écrit en C quatrième édition, sorti en novembre 1973, mais il est sorti plusieurs mois avant la sortie officielle et ne contient pas d'implémentations de pipeline. Il est dommage que le code source de cette fonction Unix légendaire soit perdu, peut-être pour toujours.

Nous disposons d'une documentation textuelle pour pipe(2) des deux versions, vous pouvez donc commencer par rechercher dans la documentation troisième édition (pour certains mots, soulignés « manuellement », une chaîne de littéraux ^H, suivie d'un trait de soulignement !). Ce proto-pipe(2) est écrit en langage assembleur et ne renvoie qu'un seul descripteur de fichier, mais fournit déjà les fonctionnalités de base attendues :

Appel système pipe crée un mécanisme d'entrée/sortie appelé pipeline. Le descripteur de fichier renvoyé peut être utilisé pour les opérations de lecture et d'écriture. Lorsqu'un élément est écrit dans le pipeline, jusqu'à 504 octets de données sont mis en mémoire tampon, après quoi le processus d'écriture est suspendu. Lors de la lecture du pipeline, les données mises en mémoire tampon sont supprimées.

L'année suivante, le noyau avait été réécrit en C, et pipe(2) dans la quatrième édition a acquis son aspect moderne avec le prototype "pipe(fildes)»:

Appel système pipe crée un mécanisme d'entrée/sortie appelé pipeline. Les descripteurs de fichiers renvoyés peuvent être utilisés dans les opérations de lecture et d'écriture. Lorsque quelque chose est écrit dans le pipeline, le handle renvoyé dans r1 (resp. fildes[1]) est utilisé, mis en mémoire tampon sur 4096 0 octets de données, après quoi le processus d'écriture est suspendu. Lors de la lecture depuis le pipeline, le handle renvoyé à r0 (resp. fildes[XNUMX]) prend les données.

On suppose qu'une fois qu'un pipeline est défini, deux (ou plus) processus communicants (créés par des appels ultérieurs à fourche) transférera les données du pipeline à l'aide d'appels lire и écrire.

Le shell a une syntaxe pour définir un tableau linéaire de processus connectés par un pipeline.

Les appels à lire à partir d'un pipeline vide (ne contenant aucune donnée mise en mémoire tampon) qui n'a qu'une seule extrémité (tous les descripteurs de fichier d'écriture sont fermés) renvoient « fin du fichier ». Les appels à écrire dans une situation similaire sont ignorés.

Le plus tôt implémentation de pipeline préservé se rapporte à la cinquième édition d'Unix (juin 1974), mais il est presque identique à celui paru dans la version suivante. Des commentaires viennent d'être ajoutés, vous pouvez donc sauter la cinquième édition.

Sixième édition d'Unix (1975)

Commençons par lire le code source d'Unix sixième édition (Mai 1975). En grande partie grâce à Lions c'est beaucoup plus facile à trouver que les sources des versions antérieures :

Pendant de nombreuses années, le livre Lions était le seul document sur le noyau Unix disponible en dehors des Bell Labs. Bien que la licence de la sixième édition autorisait les enseignants à utiliser son code source, la licence de la septième édition excluait cette possibilité, de sorte que le livre a été distribué sous forme de copies dactylographiées illégales.

Aujourd'hui, vous pouvez acheter une réimpression du livre dont la couverture montre des étudiants devant une photocopieuse. Et grâce à Warren Toomey (qui a lancé le projet TUHS), vous pouvez télécharger Fichier PDF avec le code source de la sixième édition. Je veux vous donner une idée des efforts déployés pour créer le fichier :

Il y a plus de 15 ans, j'ai tapé une copie du code source donné dans Lions, parce que je n'aimais pas la qualité de ma copie parmi un nombre inconnu d'autres copies. TUHS n'existait pas encore et je n'avais pas accès aux anciennes sources. Mais en 1988, j'ai trouvé une vieille cassette 9 pistes contenant une sauvegarde d'un ordinateur PDP11. Il était difficile de savoir si cela fonctionnait, mais il y avait une arborescence /usr/src/ intacte dans laquelle la plupart des fichiers portaient l'année 1979, qui, même à cette époque, semblait ancienne. C'était la septième édition ou son dérivé PWB, comme je le pensais.

J'ai pris la découverte comme base et j'ai édité manuellement les sources jusqu'à la sixième édition. Une partie du code est restée la même, mais d'autres ont dû être légèrement modifiées, en remplaçant le jeton += moderne par le =+ obsolète. Certaines choses ont été simplement supprimées et d’autres ont dû être complètement réécrites, mais pas trop.

Et aujourd'hui nous pouvons lire en ligne sur TUHS le code source de la sixième édition de archives, auxquelles Dennis Ritchie a contribué.

D’ailleurs, à première vue, la principale caractéristique du code C avant l’époque de Kernighan et Ritchie est son brièveté. Ce n'est pas souvent que je suis capable d'insérer des morceaux de code sans modifications approfondies pour s'adapter à une zone d'affichage relativement étroite de mon site.

Très tôt /usr/sys/ken/pipe.c il y a un commentaire explicatif (et oui, il y a plus /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 taille du tampon n'a pas changé depuis la quatrième édition. Mais ici, nous voyons, sans aucune documentation publique, que les pipelines utilisaient autrefois des fichiers comme stockage de sauvegarde !

Quant aux fichiers LARG, ils correspondent à drapeau d'inode LARG, qui est utilisé par le « grand algorithme d'adressage » pour traiter blocs indirects pour prendre en charge des systèmes de fichiers plus volumineux. Puisque Ken a dit qu'il valait mieux ne pas les utiliser, je le croirai avec plaisir sur parole.

Voici le véritable appel système 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;
}

Le commentaire décrit clairement ce qui se passe ici. Mais comprendre le code n'est pas si simple, en partie à cause de la manière "structurer l'utilisateur u» et s'inscrit R0 и R1 les paramètres d'appel système et les valeurs de retour sont transmis.

Essayons avec ialloc() mettre sur disque inode (descripteur d'index), et avec falloc() - en placer deux en mémoire dossier. Si tout se passe bien, nous définirons des indicateurs pour identifier ces fichiers comme les deux extrémités du pipeline, les pointerons vers le même inode (dont le nombre de références sera défini sur 2) et marquerons l'inode comme modifié et en cours d'utilisation. Soyez attentif aux demandes de je mets() dans les chemins d’erreur pour réduire le nombre de références dans le nouvel inode.

pipe() doit passer par R0 и R1 renvoie les numéros de descripteur de fichier pour la lecture et l'écriture. falloc() renvoie un pointeur vers la structure du fichier, mais aussi "retourne" via u.u_ar0[R0] et un descripteur de fichier. Autrement dit, le code est enregistré dans r descripteur de fichier pour la lecture et attribue un descripteur de fichier pour l'écriture directement à partir de u.u_ar0[R0] après le deuxième appel falloc().

Drapeau FPIPE, que nous avons défini lors de la création du pipeline, contrôle le comportement de la fonction rdwr() dans sys2.cappeler des routines d'E/S spécifiques :

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

Alors la fonction readp() в pipe.c lit les données du pipeline. Mais il est préférable de retracer la mise en œuvre à partir de writep(). Encore une fois, le code est devenu plus complexe en raison des conventions de passage des arguments, mais certains détails peuvent être omis.

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

Nous voulons écrire des octets dans l'entrée du pipeline u.u_count. Nous devons d'abord verrouiller l'inode (voir ci-dessous plock/prele).

Ensuite, nous vérifions le compteur de référence d'inode. Tant que les deux extrémités du pipeline restent ouvertes, le compteur doit être égal à 2. Nous maintenons un lien (depuis rp->f_inode), donc si le compteur est inférieur à 2, cela doit signifier que le processus de lecture a fermé sa fin du pipeline. En d’autres termes, nous essayons d’écrire dans un pipeline fermé, et c’est une erreur. Code d'erreur pour la première fois EPIPE et signaler SIGPIPE est apparu dans la sixième édition d'Unix.

Mais même si le convoyeur est ouvert, il peut être plein. Dans ce cas, nous libérons le verrou et nous mettons en veille dans l'espoir qu'un autre processus lira le pipeline et y libérera suffisamment d'espace. Après nous être réveillés, nous revenons au début, raccrochons le verrou et démarrons un nouveau cycle d'enregistrement.

S'il y a suffisamment d'espace libre dans le pipeline, nous y écrivons des données en utilisant écrirei(). Paramètre i_size1 inode (si le pipeline est vide, il peut être égal à 0) indique la fin des données qu'il contient déjà. S'il y a suffisamment d'espace d'enregistrement, nous pouvons remplir le pipeline depuis i_size1 à PIPESIZ. Ensuite, nous libérons le verrou et essayons de réveiller tout processus en attente de lecture à partir du pipeline. Nous revenons au début pour voir si nous étions capables d'écrire autant d'octets que nécessaire. En cas d'échec, nous commençons un nouveau cycle d'enregistrement.

Généralement le paramètre i_mode l'inode est utilisé pour stocker les autorisations r, w и x. Mais dans le cas des pipelines, nous signalons qu'un processus attend une écriture ou une lecture en utilisant des bits IREAD и IWRITE respectivement. Le processus définit le drapeau et appelle sleep(), et on s'attend à ce qu'un autre processus dans le futur provoque wakeup().

La vraie magie se produit dans sleep() и wakeup(). Ils sont mis en œuvre dans slp.c, à l’origine du fameux commentaire « Vous n’êtes pas censé comprendre cela ». Heureusement, nous n'avons pas besoin de comprendre le code, il suffit de regarder quelques commentaires :

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

Le processus qui provoque sleep() pour un canal particulier, peut être réveillé ultérieurement par un autre processus, ce qui entraînera wakeup() pour la même chaîne. writep() и readp() coordonner leurs actions grâce à de tels appels jumelés. noter que pipe.c donne toujours la priorité PPIPE lorsqu'il est appelé sleep(), alors c'est tout sleep() peut être interrompu par un signal.

Maintenant on a tout pour comprendre la fonction 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);
}

Il vous sera peut-être plus facile de lire cette fonction de bas en haut. La branche « lecture et retour » est généralement utilisée lorsqu'il y a des données dans le pipeline. Dans ce cas, nous utilisons lire() nous lisons autant de données que disponibles à partir de la donnée actuelle f_offset lecture, puis mettre à jour la valeur du décalage correspondant.

Lors des lectures suivantes, le pipeline sera vide si le décalage de lecture a atteint i_size1 à l'inode. Nous réinitialisons la position à 0 et essayons de réveiller tout processus souhaitant écrire dans le pipeline. On sait que lorsque le convoyeur est plein, writep() je vais m'endormir ip+1. Et maintenant que le pipeline est vide, nous pouvons le réveiller pour reprendre son cycle d'écriture.

Si vous n'avez rien à lire, alors readp() peut mettre un drapeau IREAD et je m'endors ip+2. Nous savons ce qui va le réveiller writep(), lorsqu'il écrit des données dans le pipeline.

Commentaires sur readi() et writei() vous aidera à comprendre qu'au lieu de passer des paramètres via "u"Nous pouvons les traiter comme des fonctions d'E/S normales qui prennent un fichier, une position, un tampon en mémoire et comptent le nombre d'octets à lire ou à écrire.

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

Quant au blocage « conservateur », alors readp() и writep() bloquer l'inode jusqu'à ce qu'ils terminent leur travail ou reçoivent un résultat (c'est-à-dire appeler wakeup). plock() и prele() travailler simplement : en utilisant un ensemble d'appels différent sleep и wakeup nous permettent de réveiller tout processus nécessitant le verrou que nous venons de libérer :

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

Au début, je ne comprenais pas pourquoi readp() ne cause pas prele(ip) avant l'appel wakeup(ip+1). La première chose est writep() provoque dans son cycle, cela plock(ip), ce qui conduit à une impasse si readp() Je n'ai pas encore supprimé mon bloc, donc d'une manière ou d'une autre, le code doit fonctionner correctement. Si tu regardes wakeup(), il devient alors clair qu'il marque uniquement le processus en veille comme prêt à être exécuté, de sorte qu'à l'avenir sched() vraiment lancé. Donc readp() causes wakeup(), supprime le verrou, définit IREAD et appels sleep(ip+2)- tout ça avant writep() reprend le cycle.

Ceci complète la description des convoyeurs dans la sixième édition. Un code simple, des conséquences considérables.

Septième édition d'Unix (janvier 1979) était une nouvelle version majeure (quatre ans plus tard) qui introduisait de nombreuses nouvelles applications et fonctionnalités du noyau. Il a également subi des changements importants liés à l'utilisation de la fonte de caractères, des unions et des pointeurs typés vers les structures. Cependant code du convoyeur pratiquement inchangé. Nous pouvons sauter cette édition.

Xv6, un noyau simple de type Unix

Pour créer le noyau XV6 influencé par la sixième édition d'Unix, mais il est écrit en C moderne pour fonctionner sur des processeurs x86. Le code est facile à lire et à comprendre. De plus, contrairement aux sources Unix avec TUHS, vous pouvez le compiler, le modifier et l'exécuter sur autre chose qu'un PDP 11/70. Par conséquent, ce noyau est largement utilisé dans les universités comme matériel pédagogique sur les systèmes d’exploitation. Sources sont sur Github.

Le code contient une implémentation claire et réfléchie tuyau.c, soutenu par un tampon en mémoire au lieu d'un inode sur le disque. Ici, je donne uniquement la définition du « pipeline structurel » et la fonction 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() définit l'état du reste de l'implémentation, qui inclut les fonctions piperead(), pipewrite() и pipeclose(). Appel système réel sys_pipe est un wrapper implémenté dans fichiersys.c. Je recommande de lire l'intégralité de son code. La complexité est au niveau du code source de la sixième édition, mais il est beaucoup plus simple et agréable à lire.

Linux 0.01

Le code source de Linux 0.01 peut être trouvé. Il sera instructif d'étudier la mise en œuvre des pipelines dans son fs/pipe.c. Cela utilise un inode pour représenter le pipeline, mais le pipeline lui-même est écrit en C moderne. Si vous avez parcouru le code de la XNUMXe édition, vous n'aurez aucun problème ici. Voici à quoi ressemble la fonction 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;
}

Sans même regarder les définitions de structure, vous pouvez comprendre comment le nombre de références d'inode est utilisé pour vérifier si une opération d'écriture entraîne SIGPIPE. En plus de fonctionner octet par octet, cette fonction est facile à comparer avec les idées décrites ci-dessus. Même la logique sleep_on/wake_up ça n'a pas l'air si étranger.

Noyaux Linux modernes, FreeBSD, NetBSD, OpenBSD

J'ai rapidement parcouru quelques noyaux modernes. Aucun d'entre eux n'a plus d'implémentation de disque (pas surprenant). Linux a sa propre implémentation. Bien que les trois noyaux BSD modernes contiennent des implémentations basées sur du code écrit par John Dyson, au fil des années, ils sont devenus trop différents les uns des autres.

Lire fs/pipe.c (sous Linux) ou sys/kern/sys_pipe.c (sur *BSD), cela demande un réel dévouement. Le code d'aujourd'hui concerne les performances et la prise en charge de fonctionnalités telles que les E/S vectorielles et asynchrones. Et les détails de l'allocation de mémoire, des verrous et de la configuration du noyau varient tous considérablement. Ce n’est pas ce dont les collèges ont besoin pour un cours d’introduction aux systèmes d’exploitation.

Quoi qu'il en soit, j'étais intéressé à déterrer d'anciens modèles (comme générer SIGPIPE et retour EPIPE lors de l'écriture dans un pipeline fermé) dans tous ces différents noyaux modernes. Je ne verrai probablement jamais un ordinateur PDP-11 dans la vraie vie, mais il y a encore beaucoup à apprendre du code écrit des années avant ma naissance.

Un article écrit par Divi Kapoor en 2011 :L'implémentation du noyau Linux des tuyaux et des FIFO" donne un aperçu de la façon dont les pipelines fonctionnent (encore) sous Linux. UN commit récent sous Linux illustre un modèle d'interaction pipeline, dont les capacités dépassent celles des fichiers temporaires ; et montre également à quel point les pipelines ont évolué depuis le "verrouillage très conservateur" de la sixième édition du noyau Unix.

Source: habr.com

Ajouter un commentaire