Jak potoki są implementowane w systemie Unix

Jak potoki są implementowane w systemie Unix
W tym artykule opisano implementację potoków w jądrze systemu Unix. Byłem nieco rozczarowany, że niedawny artykuł zatytułowany „Jak działają potoki w systemie Unix?" okazało się nie o strukturze wewnętrznej. Zainteresowałem się i przekopałem stare źródła, aby znaleźć odpowiedź.

O czym gadamy?

Potoki są „prawdopodobnie najważniejszym wynalazkiem w Uniksie” - cechą charakterystyczną leżącej u podstaw filozofii Uniksa polegającej na łączeniu małych programów oraz znanego sloganu wiersza poleceń:

$ echo hello | wc -c
6

Ta funkcjonalność zależy od wywołania systemowego dostarczonego przez jądro pipe, który jest opisany na stronach dokumentacji fajka(7) и fajka(2):

Potoki zapewniają jednokierunkowy kanał komunikacji między procesami. Potok ma wejście (koniec zapisu) i wyjście (koniec odczytu). Dane zapisane na wejściu potoku można odczytać na wyjściu.

Potok jest tworzony przez wywołanie pipe(2), która zwraca dwa deskryptory plików: jeden odnosi się do wejścia potoku, drugi do wyjścia.

Dane wyjściowe śledzenia z powyższego polecenia pokazują tworzenie potoku i przepływ danych przez niego z jednego procesu do drugiego:

$ 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

Wywołania procesu nadrzędnego pipe()aby uzyskać dołączone deskryptory plików. Jeden proces potomny zapisuje do jednego deskryptora, a inny proces odczytuje te same dane z innego deskryptora. Powłoka „zmienia nazwy” deskryptorów 2 i 3 za pomocą dup4, aby pasowały do ​​stdin i stdout.

Bez potoków powłoka musiałaby zapisywać dane wyjściowe jednego procesu do pliku i przekazywać je do innego procesu w celu odczytania danych z pliku. W rezultacie zmarnowalibyśmy więcej zasobów i miejsca na dysku. Jednak potoki są dobre nie tylko do unikania plików tymczasowych:

Jeśli proces próbuje odczytać z pustego potoku, to read(2) będzie blokować, dopóki dane nie będą dostępne. Jeśli proces próbuje pisać do pełnego potoku, to write(2) będzie blokować, dopóki z potoku nie zostanie odczytana wystarczająca ilość danych, aby zakończyć zapis.

Podobnie jak wymagania POSIX, jest to ważna właściwość: zapis do potoku do PIPE_BUF bajty (co najmniej 512) muszą być atomowe, aby procesy mogły komunikować się ze sobą za pośrednictwem potoku w sposób, w jaki normalne pliki (które nie zapewniają takich gwarancji) nie mogą.

W zwykłym pliku proces może zapisać w nim wszystkie swoje dane wyjściowe i przekazać je innemu procesowi. Lub procesy mogą działać w twardym trybie równoległym, wykorzystując zewnętrzny mechanizm sygnalizacyjny (taki jak semafor), aby informować się nawzajem o zakończeniu zapisu lub odczytu. Przenośniki ratują nas przed tymi wszystkimi kłopotami.

Czego szukamy?

Wyjaśnię na palcach, aby łatwiej było Ci wyobrazić sobie, jak może działać przenośnik. Będziesz musiał przydzielić bufor i trochę stanu w pamięci. Będziesz potrzebował funkcji do dodawania i usuwania danych z bufora. Będziesz potrzebował pewnych ułatwień do wywoływania funkcji podczas operacji odczytu i zapisu na deskryptorach plików. Do zaimplementowania opisanego powyżej specjalnego zachowania potrzebne są blokady.

Jesteśmy teraz gotowi do zbadania kodu źródłowego jądra w jasnym świetle lampy, aby potwierdzić lub obalić nasz niejasny model mentalny. Ale zawsze bądź przygotowany na nieoczekiwane.

Gdzie szukamy?

Nie wiem, gdzie leży mój egzemplarz słynnej książki.Książka Lwy« z kodem źródłowym Unix 6, ale dzięki Towarzystwo Dziedzictwa Uniksa można wyszukać w Internecie kod źródłowy nawet starsze wersje Uniksa.

Wędrówka po archiwach TUHS jest jak wizyta w muzeum. Możemy spojrzeć na naszą wspólną historię i mam szacunek dla lat starań, aby kawałek po kawałku odzyskać cały ten materiał ze starych kaset i wydruków. I doskonale zdaję sobie sprawę z tych fragmentów, których wciąż brakuje.

Zaspokoiwszy naszą ciekawość starożytną historią rurociągów, możemy dla porównania spojrzeć na współczesne rdzenie.

By the way, pipe jest numerem wywołania systemowego 42 w tabeli sysent[]. Zbieg okoliczności?

Tradycyjne jądra Uniksa (1970–1974)

Nie znalazłem żadnego śladu pipe(2) ani w PDP-7 Uniksa (styczeń 1970), ani w pierwsze wydanie Uniksa (listopad 1971), ani w niepełnym kodzie źródłowym Druga edycja (czerwiec 1972).

TUHS twierdzi, że wydanie trzecie Unix (luty 1973) była pierwszą wersją z potokami:

Trzecia edycja Uniksa była ostatnią wersją z jądrem napisanym w asemblerze, ale także pierwszą wersją z potokami. W 1973 roku trwały prace nad ulepszeniem trzeciej edycji, jądro zostało przepisane w C i tak narodziła się czwarta edycja Uniksa.

Jeden z czytelników znalazł skan dokumentu, w którym Doug McIlroy zaproponował pomysł „łączenia programów jak wąż ogrodowy”.

Jak potoki są implementowane w systemie Unix
W książce Briana KernighanaUnix: historia i wspomnienie”, historia pojawienia się przenośników wspomina również ten dokument: „… wisiał na ścianie w moim biurze w Bell Labs przez 30 lat”. Tutaj wywiad z McIlroyemi inna historia z Praca McIlroya, napisana w 2014 roku:

Kiedy pojawił się Unix, moja pasja do współprogramów sprawiła, że ​​poprosiłem autora systemu operacyjnego, Kena Thompsona, aby pozwolił danym zapisanym do jakiegoś procesu trafiać nie tylko do urządzenia, ale także do wyjścia do innego procesu. Ken pomyślał, że to możliwe. Jednak jako minimalista chciał, aby każda funkcja systemu odgrywała znaczącą rolę. Czy bezpośrednie pisanie między procesami ma naprawdę dużą przewagę nad pisaniem do pliku pośredniego? I dopiero gdy złożyłem konkretną propozycję o chwytliwej nazwie „pipeline” i opisie składni interakcji procesów, Ken w końcu wykrzyknął: „Zrobię to!”.

I zrobił. Pewnego pamiętnego wieczoru Ken zmienił jądro i powłokę, poprawił kilka standardowych programów w celu ujednolicenia sposobu przyjmowania danych wejściowych (które mogą pochodzić z potoku) i zmienił nazwy plików. Następnego dnia potoki były bardzo szeroko stosowane w aplikacjach. Do końca tygodnia sekretarki wykorzystywały je do przesyłania dokumentów z edytorów tekstu do drukarki. Nieco później Ken zastąpił oryginalny interfejs API i składnię opakowującą użycie potoków czystszymi konwencjami, które były używane do tej pory.

Niestety, kod źródłowy trzeciej edycji jądra Uniksa zaginął. I chociaż mamy kod źródłowy jądra napisany w C czwarta edycja, który został wydany w listopadzie 1973 roku, ale wyszedł na kilka miesięcy przed oficjalnym wydaniem i nie zawiera implementacji potoków. Szkoda, że ​​kod źródłowy tej legendarnej funkcji Uniksa zaginął, być może na zawsze.

Mamy tekst dokumentacji dla pipe(2) z obu wydań, więc możesz zacząć od przeszukania dokumentacji trzecia edycja (w przypadku niektórych słów, podkreślonych „ręcznie”, ciąg literałów ^H, po których następuje podkreślenie!). ten proto-pipe(2) jest napisany w asemblerze i zwraca tylko jeden deskryptor pliku, ale już zapewnia oczekiwaną podstawową funkcjonalność:

Wywołanie systemowe rura tworzy mechanizm we/wy zwany potokiem. Zwróconego deskryptora pliku można użyć do operacji odczytu i zapisu. Kiedy coś jest zapisywane do potoku, buforuje do 504 bajtów danych, po czym proces zapisu jest zawieszany. Podczas odczytu z potoku pobierane są buforowane dane.

W następnym roku jądro zostało przepisane w C i rura(2) wydanie czwarte uzyskał swój nowoczesny wygląd wraz z prototypem”pipe(fildes)»:

Wywołanie systemowe rura tworzy mechanizm we/wy zwany potokiem. Zwróconych deskryptorów plików można używać w operacjach odczytu i zapisu. Kiedy coś jest zapisywane do potoku, używany jest deskryptor zwrócony w r1 (odp. fildes[1]), buforowany do 4096 bajtów danych, po czym proces zapisu jest zawieszany. Podczas odczytu z potoku deskryptor zwrócony do r0 (odp. fildes[0]) pobiera dane.

Zakłada się, że po zdefiniowaniu potoku dwa (lub więcej) współdziałające procesy (utworzone przez kolejne wywołania widelec) przekaże dane z potoku za pomocą wywołań czytać и napisać.

Powłoka ma składnię do definiowania liniowej tablicy procesów połączonych potokiem.

Wywołania odczytu z pustego potoku (niezawierającego buforowanych danych), który ma tylko jeden koniec (wszystkie deskryptory plików zapisu są zamknięte) zwracają „koniec pliku”. Wywołania zapisu w podobnej sytuacji są ignorowane.

najwcześniej zachowana implementacja potoku dotyczy do piątej edycji Uniksa (czerwiec 1974), ale jest prawie identyczny z tym, który pojawił się w kolejnym wydaniu. Dodano tylko komentarze, więc piątą edycję można pominąć.

Unix wydanie szóste (1975)

Rozpoczęcie czytania kodu źródłowego systemu Unix szósta edycja (maj 1975). W dużej mierze dzięki Lwy jest znacznie łatwiejszy do znalezienia niż źródła wcześniejszych wersji:

Od wielu lat książka Lwy był jedynym dokumentem dotyczącym jądra Uniksa dostępnym poza Bell Labs. Chociaż licencja na szóstą edycję zezwalała nauczycielom na korzystanie z jej kodu źródłowego, licencja na siódmą edycję wykluczała tę możliwość, więc książka była rozpowszechniana w nielegalnych kopiach maszynopisu.

Dziś można kupić przedruk książki, której okładka przedstawia studentów przy kopiarce. A dzięki Warrenowi Toomeyowi (który zapoczątkował projekt TUHS) możesz pobrać Wydanie szóste Źródło PDF. Chcę dać wyobrażenie o tym, ile wysiłku włożono w stworzenie pliku:

Ponad 15 lat temu wpisałem kopię dostarczonego kodu źródłowego Lwyponieważ nie podobała mi się jakość mojego egzemplarza z nieznanej liczby innych egzemplarzy. TUHS jeszcze nie istniało, a ja nie miałem dostępu do starych źródeł. Ale w 1988 roku znalazłem starą kasetę z 9 utworami, która miała kopię zapasową z komputera PDP11. Trudno było stwierdzić, czy to zadziałało, ale istniało nienaruszone drzewo /usr/src/, w którym większość plików była oznaczona jako 1979, który nawet wtedy wyglądał na stary. To było wydanie siódme albo pochodna PWB, pomyślałem.

Wziąłem to znalezisko za podstawę i ręcznie zredagowałem źródła do stanu wydania szóstego. Część kodu pozostała ta sama, część trzeba było nieco zmodyfikować, zmieniając nowoczesny token += na przestarzały =+. Coś zostało po prostu usunięte, a coś musiało zostać całkowicie przepisane, ale nie za dużo.

A dziś możemy przeczytać online na TUHS kod źródłowy szóstej edycji archiwum, do którego przyłożył rękę Dennis Ritchie.

Nawiasem mówiąc, na pierwszy rzut oka główną cechą kodu C sprzed okresu Kernighana i Ritchiego jest jego zwięzłość. Nieczęsto udaje mi się wstawić fragmenty kodu bez szczegółowej edycji, aby zmieściły się na stosunkowo wąskim obszarze wyświetlania w mojej witrynie.

Wcześnie /usr/sys/ken/pipe.c jest komentarz wyjaśniający (i tak, jest więcej /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

Rozmiar bufora nie zmienił się od czwartej edycji. Ale tutaj widzimy, bez żadnej publicznej dokumentacji, że potoki używały kiedyś plików jako zapasowej pamięci masowej!

Jeśli chodzi o pliki LARG, odpowiadają one flaga i-węzła LARG, który jest używany przez „duży algorytm adresowania” do przetwarzania bloki pośrednie do obsługi większych systemów plików. Ponieważ Ken powiedział, że lepiej ich nie używać, cieszę się, że wierzę mu na słowo.

Oto prawdziwe wywołanie systemowe 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;
}

Komentarz jasno opisuje, co się tutaj dzieje. Ale zrozumienie kodu nie jest takie łatwe, częściowo z powodu tego, jak „zbuduj użytkownika u» i rejestrów R0 и R1 przekazywane są parametry wywołania systemowego i zwracane wartości.

Spróbujmy z ialloc() umieścić na dysku i-węzeł (i-węzeł)i z pomocą falloc() - sklep dwa plik. Jeśli wszystko pójdzie dobrze, ustawimy flagi identyfikujące te pliki jako dwa końce potoku, wskażemy im ten sam i-węzeł (którego liczba odwołań wynosi 2) i oznaczymy i-węzeł jako zmodyfikowany i używany. Zwróć uwagę na prośby o włożyłem() w ścieżkach błędów, aby zmniejszyć liczbę odwołań w nowym i-węźle.

pipe() należny przez R0 и R1 zwracają numery deskryptorów plików do odczytu i zapisu. falloc() zwraca wskaźnik do struktury plików, ale także „zwraca” via u.u_ar0[R0] i deskryptor pliku. Oznacza to, że kod jest przechowywany w r deskryptor pliku do odczytu i przypisuje deskryptor do bezpośredniego zapisu u.u_ar0[R0] po drugim wezwaniu falloc().

Flaga FPIPE, który ustawiamy podczas tworzenia potoku, steruje zachowaniem funkcji rdwr() w sys2.c, który wywołuje określone procedury wejścia/wyjścia:

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

Następnie funkcja readp() в pipe.c odczytuje dane z potoku. Ale lepiej jest prześledzić implementację zaczynając od writep(). Ponownie kod stał się bardziej skomplikowany ze względu na naturę konwencji przekazywania argumentów, ale niektóre szczegóły można pominąć.

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

Chcemy zapisać bajty na wejściu potoku u.u_count. Najpierw musimy zablokować i-węzeł (patrz poniżej plock/prele).

Następnie sprawdzamy liczbę odwołań do i-węzłów. Dopóki oba końce rurociągu pozostają otwarte, licznik powinien wynosić 2. Trzymamy się jednego łącza (z rp->f_inode), więc jeśli licznik jest mniejszy niż 2, powinno to oznaczać, że proces odczytu zamknął swój koniec potoku. Innymi słowy, próbujemy pisać do zamkniętego potoku, co jest błędem. Pierwszy kod błędu EPIPE i sygnał SIGPIPE pojawił się w szóstej edycji systemu Unix.

Ale nawet jeśli przenośnik jest otwarty, może być pełny. W takim przypadku zwalniamy blokadę i idziemy spać w nadziei, że inny proces odczyta z potoku i zwolni w nim wystarczająco dużo miejsca. Po przebudzeniu wracamy do początku, ponownie odkładamy blokadę i rozpoczynamy nowy cykl zapisu.

Jeśli w potoku jest wystarczająco dużo wolnego miejsca, zapisujemy do niego dane za pomocą napisz(). Parametr i_size1 i-węzeł (przy pustym potoku może być równy 0) wskazuje na koniec danych, które już zawiera. Jeśli jest wystarczająco dużo miejsca do pisania, możemy wypełnić potok i_size1 do PIPESIZ. Następnie zwalniamy blokadę i próbujemy obudzić każdy proces, który czeka na odczyt z potoku. Wracamy do początku, aby sprawdzić, czy udało nam się zapisać tyle bajtów, ile potrzebowaliśmy. Jeśli to się nie powiedzie, rozpoczynamy nowy cykl nagrywania.

Zazwyczaj parametr i_mode i-węzeł służy do przechowywania uprawnień r, w и x. Ale w przypadku potoków sygnalizujemy za pomocą bitów, że jakiś proces oczekuje na zapis lub odczyt IREAD и IWRITE odpowiednio. Proces ustawia flagę i wywołuje sleep(), i oczekuje się, że w przyszłości wywołany zostanie jakiś inny proces wakeup().

Prawdziwa magia dzieje się w sleep() и wakeup(). Realizowane są w slp.c, źródło słynnego komentarza „Nie oczekuje się, że to zrozumiesz”. Na szczęście nie musimy rozumieć kodu, wystarczy spojrzeć na kilka komentarzy:

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

Proces, który wzywa sleep() dla określonego kanału, może później zostać obudzony przez inny proces, który wywoła wakeup() dla tego samego kanału. writep() и readp() koordynować swoje działania poprzez takie sparowane połączenia. zauważ to pipe.c zawsze ustalaj priorytety PPIPE kiedy wezwany sleep(), więc wszystko sleep() można przerwać sygnałem.

Teraz mamy wszystko, aby zrozumieć tę funkcję 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);
}

Być może łatwiej będzie ci przeczytać tę funkcję od dołu do góry. Gałąź „odczyt i powrót” jest zwykle używana, gdy w potoku znajdują się jakieś dane. W tym przypadku używamy Czytać() odczytać tyle danych, ile jest dostępnych, zaczynając od bieżącego f_offset odczytać, a następnie zaktualizować wartość odpowiedniego przesunięcia.

Przy kolejnych odczytach potok będzie pusty, jeśli osiągnięto przesunięcie odczytu i_size1 w i-węźle. Resetujemy pozycję do 0 i próbujemy obudzić każdy proces, który chce pisać do potoku. Wiemy, że gdy przenośnik jest pełny, writep() zasnąć na ip+1. A teraz, gdy potok jest pusty, możemy go obudzić, aby wznowić cykl zapisu.

Jeśli nie ma co czytać, to tak readp() może ustawić flagę IREAD i zasypiać dalej ip+2. Wiemy, co go obudzi writep()kiedy zapisuje niektóre dane do potoku.

Komentarze do czytaj() i pisz() pomoże ci zrozumieć, że zamiast przekazywać parametry przez „u» możemy je traktować jak zwykłe funkcje I/O, które pobierają plik, pozycję, bufor w pamięci i zliczają liczbę bajtów do odczytania lub zapisania.

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

Jeśli chodzi o blokowanie „konserwatywne”, to tak readp() и writep() zablokuj i-węzły, dopóki nie zakończą lub nie uzyskają wyniku (tj wakeup). plock() и prele() działają po prostu: używając innego zestawu wywołań sleep и wakeup pozwalają nam obudzić każdy proces, który wymaga blokady, którą właśnie zwolniliśmy:

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

Na początku nie mogłem zrozumieć dlaczego readp() nie wywołuje prele(ip) przed wezwaniem wakeup(ip+1). Pierwsza rzecz writep() wywołuje w swojej pętli this plock(ip), co skutkuje zakleszczeniem if readp() nie usunął jeszcze swojej blokady, więc kod musi jakoś działać poprawnie. jeśli spojrzysz wakeup(), staje się jasne, że oznacza to tylko proces uśpienia jako gotowy do wykonania, aby w przyszłości sched() naprawdę go uruchomił. Więc readp() powoduje wakeup(), odblokowuje, ustawia IREAD i dzwoni sleep(ip+2)- to wszystko przed writep() ponownie uruchamia cykl.

Na tym kończy się opis rurociągów w wydaniu szóstym. Prosty kod, dalekosiężne implikacje.

Siódma edycja Uniksa (styczeń 1979) było nowym głównym wydaniem (cztery lata później), które wprowadziło wiele nowych aplikacji i funkcji jądra. Przeszedł również istotne zmiany w związku z wykorzystaniem rzutowania typów, związków i typowanych wskaźników do struktur. Jednakże kod rurociągów praktycznie się nie zmienił. Tę edycję możemy pominąć.

Xv6, proste jądro podobne do systemu Unix

Aby utworzyć jądro Xv6 pod wpływem szóstej edycji Uniksa, ale napisany w nowoczesnym C, aby działał na procesorach x86. Kod jest czytelny i zrozumiały. Ponadto, w przeciwieństwie do źródeł uniksowych z TUHS, możesz je skompilować, zmodyfikować i uruchomić na czymś innym niż PDP 11/70. Dlatego ten rdzeń jest szeroko stosowany na uniwersytetach jako materiał dydaktyczny dotyczący systemów operacyjnych. Źródła są na Githubie.

Kod zawiera przejrzystą i przemyślaną implementację rura.c, wspierany przez bufor w pamięci zamiast i-węzła na dysku. Tutaj podaję tylko definicję „rurociągu strukturalnego” i funkcję 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() ustawia stan całej reszty implementacji, w tym funkcji piperead(), pipewrite() и pipeclose(). Rzeczywiste wywołanie systemowe sys_pipe jest opakowaniem zaimplementowanym w sysfile.c. Polecam przeczytanie całego jego kodu. Złożoność jest na poziomie kodu źródłowego szóstej edycji, ale jest o wiele łatwiejsza i przyjemniejsza w czytaniu.

Linux 0.01

Możesz znaleźć kod źródłowy dla Linuksa 0.01. Pouczające będzie przestudiowanie implementacji potoków w jego fs/pipe.c. Tutaj i-węzeł jest używany do reprezentowania potoku, ale sam potok jest napisany we współczesnym C. Jeśli przełamałeś się przez kod szóstej edycji, tutaj nie będziesz miał żadnych problemów. Tak wygląda ta funkcja 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;
}

Nawet bez patrzenia na definicje struktury można dowiedzieć się, w jaki sposób liczba odwołań do i-węzłów jest używana do sprawdzania, czy operacja zapisu skutkuje SIGPIPE. Oprócz pracy bajt po bajcie, tę funkcję można łatwo porównać z powyższymi pomysłami. Nawet logika sleep_on/wake_up nie wygląda tak obco.

Nowoczesne jądra Linuksa, FreeBSD, NetBSD, OpenBSD

Szybko przejrzałem kilka nowoczesnych jąder. Żaden z nich nie ma jeszcze implementacji dyskowej (nic dziwnego). Linux ma własną implementację. I chociaż trzy nowoczesne jądra BSD zawierają implementacje oparte na kodzie, który napisał John Dyson, z biegiem lat za bardzo się od siebie różnią.

Czytać fs/pipe.c (w Linuksie) lub sys/kern/sys_pipe.c (na *BSD), to wymaga prawdziwego poświęcenia. Wydajność i obsługa funkcji, takich jak wektorowe i asynchroniczne operacje we/wy, są dziś ważne w kodzie. A szczegóły alokacji pamięci, blokad i konfiguracji jądra są bardzo różne. To nie jest to, czego uniwersytety potrzebują na kurs wprowadzający do systemów operacyjnych.

W każdym razie interesujące było dla mnie odkrycie kilku starych wzorców (na przykład generowanie SIGPIPE i powrót EPIPE podczas pisania do zamkniętego potoku) we wszystkich tych, tak różnych, nowoczesnych jądrach. Prawdopodobnie nigdy nie zobaczę na żywo komputera PDP-11, ale wciąż jest wiele do nauczenia się z kodu, który został napisany kilka lat przed moimi narodzinami.

Napisany przez Divi Kapoor w 2011 roku artykuł „Implementacja potoków i FIFO w jądrze Linuksato przegląd tego, jak (do tej pory) działają potoki Linuksa. A ostatnie zatwierdzenie w systemie Linux ilustruje potokowy model interakcji, którego możliwości przewyższają możliwości plików tymczasowych; a także pokazuje, jak daleko potoki przeszły od „bardzo konserwatywnego blokowania” w jądrze Uniksa szóstej edycji.

Źródło: www.habr.com

Dodaj komentarz