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.
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.
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.
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:
path nullterminált elérési út; az execve
nem keres a PATH alapján;argv null pointerrel lezárt pointertömb, elemei
nullterminált sztringek;argv[0] szokásosan a program neve;envp hasonló tömb, tipikusan NÉV=érték
sztringekkel.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.
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.
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_clangEzek 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.
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):
EINTR hibával;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.
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.
execve hibájának
errno értékét. Melyik végnek kell close-on-exec jelző?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!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.
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.
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.
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.