12.3 fork, exec, pipe és gyermekfolyamat

A fork nem egyszerű függvényhívás egy másik végrehajtási környezetbe: siker esetén a hívás után két folyamat folytatja ugyanazt a programot. Ettől kezdve külön kell követnünk a memóriát, a file descriptorok tulajdonlását és a folyamatok lezárását. Az execve pedig nem új folyamatot hoz létre, hanem a hívó folyamat programképét cseréli le.

Az FD-kezelés, a leállási szabályok és a többszintű pointerek ismeretére építünk. Az itt használt folyamatkezelő függvények POSIX API-k, nem az ISO C standard könyvtárának részei.

12.3.1 A fork három kimenete

A fork() visszatérési típusa pid_t. A visszatérési értéket először előjel szerint kell megkülönböztetni:

Visszatérési érték Hol folytatódik a végrehajtás? Következmény
-1 Csak a szülőben Nem jött létre gyermek; az errno jelzi a hibát
0 A gyermekben A gyermek saját végrehajtási ágába lép
Pozitív érték A szülőben Az érték az új gyermek folyamatazonosítója

A két folyamat ütemezési sorrendje nem garantált. Attól, hogy a szülői ág előbb szerepel a forrásban, még végrehajtódhat előbb a gyermek.

POSIX-szemantika: a gyermek a szülő memóriájának másolatával indul, a kifejezetten megosztott leképezések kivételével. Egy örökölt pointer ugyanazt a virtuális címet jelölheti, de a szokásos privát heap- és stackterületeken már a saját folyamat másolatára mutat. A gyermek free(p) hívása ezért nem szabadítja fel a szülő példányát.

Linux-megvalósítás: a privát memória másolását jellemzően copy-on-write késlelteti. Kezdetben több leképezés hivatkozhat ugyanarra a fizikai oldalra; íráskor válik szükségessé a megfelelő elkülönítés. Ez implementációs mechanizmus, nem engedély folyamatok közötti pointerhasználatra.

12.3.1.1 A copy-on-write megfigyelése

Egy külön kísérletben foglaljunk nagy privát területet, és még a fork előtt érintsük meg az oldalait. A gyermek először várakozzon, majd szinkronizált lépésben módosítson oldalanként egy bájtot. Linuxon a két folyamat /proc/<pid>/smaps adatai közül a Pss és a Private_Dirty változása segíthet értelmezni a megosztás megszűnését.

Ez mérési terv, nem rögzített eredmény: az oldalnagyság, a huge page használata és az egyéb memóriaírások befolyásolják a megfigyelést. Az RSS önmagában nem megbízható bizonyíték, mert a megosztott rezidens oldalak is beleszámíthatnak.

12.3.2 Az örökölt FD és az exec szerződése

A descriptorok táblája is másolódik, de a megfelelő FD-k ugyanarra a kernelbeli open file descriptionre hivatkoznak. Emiatt például a fájlpozíció és az olyan állapotjelzők, mint az O_NONBLOCK, közösek lehetnek. Az egyik folyamat close hívása csak a saját descriptorát zárja le.

Az execve(path, argv, envp) siker esetén nem tér vissza. A PID megmarad, de a korábbi program memóriaképe lecserélődik. Nem futnak le a régi program atexit kezelői.

Az argumentumok szerződése:

A tömböknek és sztringjeiknek a hívás alatt érvényesnek kell maradniuk. Siker után nem marad rájuk kölcsönzött hivatkozás a régi címtérben. Sikertelen execve esetén -1 az eredmény, és a gyermek saját errno értéke írja le a hibát.

A close-on-exec, vagyis FD_CLOEXEC descriptorjelző sikeres exec során lezáratja az adott FD-t. A fork nem zárja le, a sikertelen execve pedig nem aktiválja ezt a lezárást.

12.3.3 Pipe, dup2 és lezárási sorrend

A pipe(p) két descriptort ad: p[0] az olvasási, p[1] az írási vég. Ha a gyermek standard kimenetét szeretnénk olvasni, a gyermekben a

/* C17 + POSIX; részlet, p[1] érvényes pipe-íróvég. */
dup2(p[1], STDOUT_FILENO);

művelet a STDOUT_FILENO descriptorhoz rendeli az íróvéget. A dup2 szükség esetén lecseréli a cél korábbi descriptorát; a külön close(1); dup(...) megoldás nem egyenértékű, mert közben más művelet elfoglalhatja az 1-es helyet.

Ha a két FD különböző, az új célon a FD_CLOEXEC törlődik. Ha azonosak, a dup2 nem módosítja a jelzőt sem. Ezért fontos előfeltétel az alábbi példában, hogy a standard descriptorok már nyitva legyenek.

A beállítások után ezt az ownership-térképet akarjuk:

Erőforrás Szülő Gyermek
p[0] Tulajdonolja, olvasás után lezárja Lezárja
Eredeti p[1] Rögtön lezárja A dup2 után lezárja
Gyermek STDOUT_FILENO Nincs ilyen saját descriptor A pipe íróvégére hivatkozik
Gyermek kilépési állapota waitpid hívással begyűjti Kilépéssel rendelkezésre bocsátja

Az EOF feltétele: a pipe kiürült, és már egyetlen folyamatnak sincs nyitott íróvége. Ha a szülő megtartja a saját p[1] descriptorát, akkor maga akadályozza meg az EOF-ot.

12.3.4 Kidolgozott példa: gyermek kimenetének átvétele

A példa /bin/echo futtatásával előállított adatot továbbítja a szülő standard kimenetére.

Profil: ISO C17, POSIX.1-2008 API-k, Linux/x86-64 System V, LP64. Választott laborverziók: GCC 14.2 vagy Clang 19.1, glibc 2.40. Feltételezzük, hogy /bin/echo létezik; a program egyszálú; a 0, 1, 2 descriptor nyitott és blokkoló; nincs más gyermekbegyűjtő, és a SIGCHLD kezelése alapértelmezett. A standard kimenet legyen terminál vagy közönséges fájl.

A process_pipe.c teljes tartalma:

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

/* Borrow: buf legalább n olvasható bájt; itt n legfeljebb 4096. */
static int write_all(int fd, const char *buf, size_t n)
{
    while (n != 0) {
        ssize_t k = write(fd, buf, n);
        if (k > 0) {
            buf += (size_t)k;
            n -= (size_t)k;
        } else if (k == -1 && errno == EINTR) {
            continue;
        } else {
            if (k == 0)
                errno = EIO;
            return -1;
        }
    }
    return 0;
}

static _Noreturn void child_fail(int code)
{
    static const char msg[] = "child: setup or exec failed\n";
    /* A diagnosztika hibája nem változtatja meg a kilépési szándékot. */
    (void)write_all(STDERR_FILENO, msg, sizeof msg - 1);
    _exit(code);
}

int main(void)
{
    int p[2];

    if (fflush(NULL) == EOF) {
        perror("fflush");
        return EXIT_FAILURE;
    }
    if (pipe(p) == -1) {
        perror("pipe");
        return EXIT_FAILURE;
    }

    /* Új pipe-descriptorokon a kezdeti FD_CLOEXEC törölt. */
    if (fcntl(p[0], F_SETFD, FD_CLOEXEC) == -1 ||
        fcntl(p[1], F_SETFD, FD_CLOEXEC) == -1) {
        perror("fcntl");
        (void)close(p[0]);
        (void)close(p[1]);
        return EXIT_FAILURE;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        (void)close(p[0]);
        (void)close(p[1]);
        return EXIT_FAILURE;
    }

    if (pid == 0) {
        char arg0[] = "echo";
        char arg1[] = "gyermek";
        char env0[] = "LC_ALL=C";
        char *argv[] = {arg0, arg1, NULL};
        char *envp[] = {env0, NULL};

        (void)close(p[0]);
        if (dup2(p[1], STDOUT_FILENO) == -1)
            child_fail(126);
        (void)close(p[1]);

        execve("/bin/echo", argv, envp);
        child_fail(127);
    }

    (void)close(p[1]);

    char buf[4096];
    int io_failed = 0;

    for (;;) {
        ssize_t n = read(p[0], buf, sizeof buf);
        if (n > 0) {
            if (write_all(STDOUT_FILENO, buf, (size_t)n) == -1) {
                perror("write");
                io_failed = 1;
                break;
            }
        } else if (n == 0) {
            break;
        } else if (errno != EINTR) {
            perror("read");
            io_failed = 1;
            break;
        }
    }
    (void)close(p[0]);

    int status;
    pid_t result;
    do {
        result = waitpid(pid, &status, 0);
    } while (result == -1 && errno == EINTR);

    if (result == -1) {
        perror("waitpid");
        return EXIT_FAILURE;
    }

    if (WIFEXITED(status)) {
        int code = WEXITSTATUS(status);
        fprintf(stderr, "exit=%d\n", code);
        return (!io_failed && code == 0)
            ? EXIT_SUCCESS : EXIT_FAILURE;
    }

    if (WIFSIGNALED(status))
        fprintf(stderr, "signal=%d\n", WTERMSIG(status));

    return EXIT_FAILURE;
}

Fordítás a megjelölt fordítókiadásokkal:

gcc -std=c17 -Wall -Wextra -Wpedantic -O2 \
    process_pipe.c -o process_pipe_gcc

clang -std=c17 -Wall -Wextra -Wpedantic -O2 \
    process_pipe.c -o process_pipe_clang

Ezek laborparancsok, nem dokumentált fordítási eredmények.

A buf tulajdonosa a szülő main függvénye; a write_all csak a hívás idejére kölcsönzi, nem tárolja el a pointert. Az argumentumtömbök a gyermek lokális objektumai: sikeres execve megszünteti a régi memóriaképet, sikertelenségkor az _exit lezárja a folyamatot.

A szülő előbb olvas, és csak utána vár. Fordított sorrendben nagyobb kimenetnél holtpont keletkezhet: a gyermek a megtelt pipe-ra várna, a szülő pedig a gyermek kilépésére.

12.3.5 Leállás, pufferek és státuszok

A fork a felhasználói térben tárolt stdio puffereket is másolja. Ha mindkét folyamat exit útján áll le, ugyanazt a korábban pufferelt kimenetet mindketten kiírhatják.

Az egyszálú példában ezért történik ellenőrzött fflush(NULL) a fork előtt. A gyermek hibakimenetén pedig _exit szerepel: ez nem üríti a C streameket, és nem futtatja az örökölt atexit kezelőket.

A waitpid(pid, &status, 0):

A status nem közvetlen exit code. Csak WIFEXITED(status) igaz értéke után használható a WEXITSTATUS. Jel általi megszűnésnél a megfelelő pár a WIFSIGNALED és WTERMSIG. A megállítás és folytatás külön várakozási opciókat és további makrókat igényel.

A 126 és 127 itt választott hibajelzés. A 127 önmagában nem bizonyít sikertelen execve hívást: a sikeresen elindított program is visszaadhatja.

12.3.6 Többszálú fork és a posix_spawn alternatívája

Többszálú folyamat fork hívása után a gyermekben csak a hívó szál marad meg. A memória azonban tartalmazhat más szál által zárolt mutexet vagy könyvtári belső állapotot. A zároló szál már nincs jelen, így a gyermek könnyen végleg elakadhat.

POSIX-korlát: a gyermekben a sikeres exec előtt csak az async-signal-safe követelményeknek megfelelő műveleteket szabad végezni. A printf, perror, malloc, free és exit nem megfelelő általános megoldás. A szűk előkészítési út tipikusan close, dup2, write, execve, hiba esetén _exit. Ez nem pusztán teljesítményajánlás, hanem helyességi korlát.

A példában a pipe és az utólagos fcntl közötti időablak egyszálú környezetben elfogadható. Többszálú Linux-programban egy másik szál közben gyermeket indíthatna; az atomi close-on-exec létrehozásra használható pipe2(..., O_CLOEXEC) külön platform- és libc-támogatási ellenőrzést igényel.

Ha a cél csak programindítás rendezett FD-átirányítással, gyakran jobb a posix_spawn: fájlműveleti leírókkal adható meg a dup2 és close, így kevesebb saját gyermekoldali kód szükséges. Hibajelzése közvetlen hibaszám, nem automatikusan errno; a gyermeket siker esetén továbbra is be kell gyűjteni. Bizonyos gyermekoldali indítási hibák 127-es kilépésként jelentkezhetnek.

12.3.7 Feladatok

  1. Magyarázat: miért nem változik a szülő privát változója, ha a gyermek átírja, miközben egy örökölt fájl olvasása mégis módosíthat közös állapotot?
  2. Kódolás: egészítsd ki a példát külön hibapipe-pal, amelyen a gyermek továbbítja az execve hibájának errno értékét. Melyik végnek kell close-on-exec jelző?
  3. Diagnosztika: egy változatban a szülő nem zárja le p[1]-et, és olvasás előtt hívja a waitpid-et. Mutasd meg a két, egymástól független elakadási okot!

12.3.8 Megoldások

12.3.8.1 Privát memória és közös kernelerőforrás

A privát memóriát a folyamatok szemantikailag külön birtokolják; a copy-on-write ezt csak optimalizálja. Az örökölt FD-k viszont közös open file descriptionre hivatkozhatnak, így például a fájlpozíció közös.

Ellenőrzés: külön vizsgáld a memóriában tárolt értéket és a következő olvasás fájlpozícióját; ezek eltérő erőforrásrétegek.

12.3.8.2 Külön exec-hibacsatorna

A gyermek hibapipe-íróvége legyen FD_CLOEXEC. Sikeres execve lezárja; sikertelenségkor a gyermek azonnal mentse az errno értékét, továbbítsa rövid írásokat és EINTR-t kezelve, majd hívjon _exit(127)-et. A szülő a saját íróvégét zárja le, és teljes rekordot vagy EOF-ot olvasson.

Ellenőrzés: nem létező programútvonalnál teljes hibarekord várható. A rekord nélküli EOF önmagában nem abszolút sikerbizonyíték: a gyermek a jelentés előtt is megszűnhet. A várakozási státusz szintén szükséges.

12.3.8.3 Két külön elakadás

A megőrzött szülői íróvég miatt nincs EOF. Ettől függetlenül az olvasás előtti várakozás megtelt pipe-nál holtpontot okozhat.

Javítás: a szülő rögtön zárja le az íróvéget, ürítse a csatornát, végül gyűjtse be a gyermeket. Az ellenőrzéshez kis és a pipe kapacitásánál nagyobb kimenetet is használj.

12.3.9 Összefoglaló

A helyes folyamatindítás három külön életciklust rendez: a memóriaképét, az örökölt FD-két és a gyermek begyűjtését. A sikeres exec képcsere, az EOF descriptor-tulajdonlási következmény, a kilépési státusz pedig külön dekódolandó eredmény. Többszálú szülő esetén a gyermek előkészítési útját különösen szűkre kell szabni.