12.1 File descriptor és robusztus I/O ciklus

A rendszerhívás sikeres visszatérése nem feltétlenül jelenti a teljes alkalmazásszintű művelet befejezését. Egy read() kevesebbet olvashat a kértnél, egy write() csak a buffer elejét továbbíthatja, a close() pedig akkor is jelezhet hibát, amikor a descriptor már nem használható. A robusztus I/O ezért nem egyetlen hívás, hanem dokumentált állapotátmenetek sorozata.

Az errno kezelésénél megismert szabály változatlan: az errno értékét csak hibát jelző visszatérés után értelmezzük, és cleanup előtt elmentjük. A bufferekhez a byte view és memóriatartományok szerződését használjuk. A standard descriptorok kapcsolatát a programindulással a main, argumentumok és környezet tárgyalta.

12.1.1 A descriptor mint tulajdonolt erőforrás

POSIX: a file descriptor, röviden FD, egy folyamat descriptortáblájának nemnegatív egész indexe. Nem maga a fájl, és nem C-pointer. Az FD egy megnyitott fájlleírásra (open file description) hivatkozik; ehhez tartozhat például az aktuális fájlpozíció és az O_NONBLOCK állapot.

Az FD-t tároló int objektum élettartama és az OS-erőforrás élettartama különbözik. Az egész érték lemásolása nem hoz létre új descriptort.

Művelet Tulajdonlási hatás Érvénytelenítés, hibakimenet
open() Siker esetén új FD tulajdonosa a hívó Hiba: -1, nincs új erőforrás
read(), write() Csak kölcsönzi az FD-t és a buffert Nem zárja le az FD-t; hiba: -1 és errno
dup() Új, külön lezárandó FD-t ad Hiba: -1; az eredeti változatlan
dup2() A cél-FD kötését lecserélheti A régi célhoz tartozó kölcsönzések szemantikailag elavulnak
close() Lezárási kísérlet; a pontos hibapolicy platformfüggő A szám később más erőforráshoz kiosztható
fileno() A FILE által kezelt FD kölcsönzése fclose() után nem használható

A dup() eredménye ugyanarra a megnyitott fájlleírásra hivatkozik: a fájlpozíció és a fájlállapotjelzők közösek. A két FD azonban külön lezárandó. A dup2(oldfd, newfd) az ellenőrzés és csere közötti versenyablak nélkül helyettesíti a célkötést; nem azonos egy kézzel írt close(newfd); dup(oldfd); sorozattal. Érvényes, azonos argumentumoknál nem hoz létre új FD-t. A lecserélt cél lezárásának esetleges hibáját nem jelenti külön.

POSIX, Linuxon is: az O_CLOEXEC az új FD-hez close-on-exec jelzőt állít be: sikeres exec esetén az FD bezáródik. Ez nem helyettesíti a normál cleanupot, viszont elkerüli a megnyitás és egy későbbi jelzőbeállítás közötti öröklési versenyablakot. A dup() és az eltérő FD-k közötti dup2() nem viszi tovább ezt a jelzőt az új FD-re; azonos argumentumú dup2() változatlanul hagyja.

12.1.2 A visszatérési értékből következő állapot

A read() és write() eredménye ssize_t, mert nemnegatív darabszám mellett a -1 hibajelzést is reprezentálnia kell. Csak a hibavizsgálat után alakítsuk size_t típusra.

POSIX, pozitív kérési méret mellett:

Eredmény read() write()
Pozitív érték Ennyi C bájt érvényes a bufferben Ennyi C bájtnyi prefixet elfogadott
0 Szokásos fájlnál vagy byte-stream végpontnál EOF A helpernek előrehaladás nélküli esetet kell kezelnie
-1, EINTR Megszakítás; a hívás nem közölt átvitt adatot Megszakítás; a hívás nem közölt átvitt adatot
-1, EAGAIN vagy EWOULDBLOCK Most nem végezhető el blokkolás nélkül Most nem végezhető el blokkolás nélkül
Más hiba Hibapolicy szükséges Hibapolicy szükséges

A rövid művelet (partial I/O) pozitív, de a kértnél kisebb eredmény. Önmagában nem hiba és nem EOF. Ha megszakítás előtt történt adatátvitel, a hívás pozitív darabszámot is visszaadhat.

Az EOF sem feltétlenül végleges tulajdonság: egy később megnövelt fájlból újra lehet adatot olvasni. Pipe esetén az üres buffer és az összes íróvég lezárása együtt vezet EOF-hoz.

A következő helper szerződése normál fájlokra, pipe-okra és byte-stream végpontokra vonatkozik, nem speciális eszközök minden üzemmódjára. Egyes terminálbeállításoknál például a nulla olvasási eredmény más jelentésű lehet.

A blocking mód nem garantál teljes átvitelt. A nonblocking mód pedig azt követeli meg az alkalmazástól, hogy EAGAIN után várjon egy megfelelő eseményre, ne foglalja a processzort azonnali újrapróbálkozással. Linuxon az O_NONBLOCK normál fájlokra nem jelent általános garanciát arra, hogy az I/O soha nem vár háttértárra.

12.1.3 Hibakezelt read/write helper

A szerződés külön kezeli az előrehaladást, a leállás okát és a hibakódot:

Példaprofil: ISO C17, POSIX API, Linux x86-64 System V, LP64, 8 bites C bájt. Választott laborfordítók: GCC 14.2.0 és Clang 19.1.7; libc: glibc 2.39. A kódhoz itt nem tartozik fordítási vagy futtatási jegyzőkönyv.

Az alábbi fd_io.c önállóan fordítható modul:

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <limits.h>
#include <stddef.h>
#include <unistd.h>

typedef enum {
    IO_OK, IO_EOF, IO_WAIT, IO_INTR, IO_ERROR
} io_state;

typedef struct {
    size_t done;
    io_state state;
    int error;
} io_result;

static size_t request_size(size_t remaining)
{
    const size_t limit = (size_t)SSIZE_MAX;
    return remaining < limit ? remaining : limit;
}

static io_result stopped(size_t done, int error)
{
    io_state state = IO_ERROR;

    if (error == EINTR)
        state = IO_INTR;
    else if (error == EAGAIN || error == EWOULDBLOCK)
        state = IO_WAIT;

    return (io_result){done, state, error};
}

io_result read_some(int fd, void *buffer, size_t capacity)
{
    if (capacity == 0)
        return (io_result){0, IO_OK, 0};

    ssize_t n = read(fd, buffer, request_size(capacity));

    if (n > 0)
        return (io_result){(size_t)n, IO_OK, 0};
    if (n == 0)
        return (io_result){0, IO_EOF, 0};

    int saved = errno;
    return stopped(0, saved);
}

io_result write_all(int fd, const void *buffer, size_t length)
{
    const unsigned char *bytes = buffer;
    size_t done = 0;

    while (done < length) {
        ssize_t n = write(fd, bytes + done,
                          request_size(length - done));

        if (n > 0) {
            done += (size_t)n;
            continue;
        }

        if (n == 0)
            return (io_result){done, IO_ERROR, EIO};

        int saved = errno;
        return stopped(done, saved);
    }

    return (io_result){done, IO_OK, 0};
}

/* Kifejezetten Linux-policy: a tulajdonjogot elfogyasztja. */
int close_owned_linux(int *owner)
{
    int fd = *owner;
    *owner = -1;

    if (fd == -1)
        return 0;
    if (close(fd) == 0)
        return 0;

    return errno;
}

Fordítás, linkelés és futtatás nélkül:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O2 \
    -c fd_io.c -o fd_io.gcc.o

clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion -O2 \
    -c fd_io.c -o fd_io.clang.o

12.1.3.1 Előfeltételek és határértékek

A buffer tulajdonosa a hívó. Pozitív hossz esetén a teljes megadott tartomány legyen élő és megfelelően hozzáférhető: olvasásnál írható, írásnál olvasható. Felszabadítás, áthelyező realloc vagy az objektum élettartamának vége érvényteleníti a kölcsönzést. A buffer és az FD a hívás közben nem szüntethető meg más végrehajtási szálból.

A példa nem támogat külön igazítási szerződést igénylő O_DIRECT I/O-t. Pipe vagy socket írásánál a hívónak a SIGPIPE policy-t is rendeznie kell: különben a folyamat megszűnhet, mielőtt az EPIPE eredményt feldolgozná.

Bizonyításvázlat a méretszámítás helyességére. Legyen NN a teljes hossz, dd a már továbbított mennyiség. A ciklusinvariáns:

0≤d≤N≤SIZE_MAX. 0 \le d \le N \le \mathrm{SIZE\_MAX}.

A következő kérés:

k=min(N−d,SSIZE_MAX). k=\min(N-d,\mathrm{SSIZE\_MAX}).

Pozitív eredménynél 0<r≤k0<r\le k, ezért:

d+r≤d+(N−d)=N. d+r\le d+(N-d)=N.

Így sem a kivonás, sem az összeadás nem csordul túl. A pointert csak d<Nd<N mellett képezzük; nulla hosszhoz null pointer is adható. Az SSIZE_MAX korlát elkerüli az ennél nagyobb kérés implementációfüggő kezelésére támaszkodást.

A nulla eredményű íráshoz rendelt EIO a helper saját hibapolicy-ja: megakadályozza az előrehaladás nélküli végtelen ciklust.

12.1.3.2 Kidolgozott folytatási példa

Tegyük fel, hogy read_some() egy 4096 C bájtos bufferbe 1200 C bájtot olvas. Ez siker, nem EOF. A write_all() először 700-at továbbít, majd EINTR miatt visszatér:

/* Szemléltető eredmény, nem futtatási mérés. */
io_result result = {700, IO_INTR, EINTR};

A következő próbálkozás csak a maradék 500 C bájtot kaphatja:

/* Részlet: buffer él, out_fd kölcsönzött, újrapróbálás engedélyezett. */
io_result next = write_all(out_fd, buffer + 700, 1200 - 700);

A teljes 1200 ismételt elküldése adatduplikáció lenne. A buffer csak az összes hátralévő adat továbbítása vagy tudatos eldobása után használható új olvasáshoz. IO_WAIT esetén ugyanezt a maradékot kell megőrizni a későbbi folytatáshoz.

12.1.4 Megnyitás és a close hibapolicy-ja

A megnyitás szokásos mintája, POSIX-függvényen belüli részletként:

/* Szükséges headerek: <fcntl.h>, <errno.h>. */
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd == -1)
    return errno;

/* fd tulajdonosa ez a függvény; I/O közben csak kölcsönadja. */

A hibás ellenőrzés, fd <= 0, elutasítaná a teljesen érvényes nulla descriptort is.

Linux-policy: a fenti close_owned_linux() egyszer próbál lezárni, és a tulajdonosi változót -1 értékre állítja. A függvény kizárólag érvényes, saját tulajdonú FD-t vagy -1 értéket fogadhat. Az owner pointer egy írható, élő int objektum kölcsönzése.

Linuxon a lezárás hibája — az eleve érvénytelen FD-t jelző EBADF esetétől eltekintve — nem jogosít fel ugyanazon szám újabb lezárására. A szám addigra más erőforráshoz tartozhat. Ez az EINTR esetre is vonatkozik.

Platform- és verzióhatár: ez nem univerzális POSIX-policy. Más rendszereken az EINTR utáni FD-állapot eltérhet; a célzott POSIX-kiadás és a tényleges libc/OS szerződését együtt kell ellenőrizni. Egy POSIX.1-2024 követelmény megnevezése önmagában nem bizonyítja a telepített Linux/libc viselkedését. Az EINTR kezelését ezért műveletenként, nem általános retry-makróval tervezzük.

A lezárási hibát nem szabad elhallgatni: korábbi írás késleltetett hibáját is jelezheti. A korábbi I/O-hibát azonban előbb el kell menteni; a cleanup hibája másodlagos diagnosztika legyen. A sikeres write() és close() sem általános tartóssági garancia; ezt a fájlrendszer és tartósság tárgyalja.

12.1.5 Stdio és descriptor keverése

A FILE saját felhasználói pufferrel dolgozik. Emiatt a fprintf() után közvetlenül kiadott write(fileno(stream), ...) nem feltétlenül a kívánt sorrendben jelenik meg.

POSIX: az ugyanazon megnyitott fájlleírást használó streamek és descriptorok között az aktív kezelőre (active handle) vonatkozó szinkronizálási szabályokat kell követni. Kimeneti streamről descriptorra váltás előtt tipikusan ellenőrzött fflush() szükséges. Seekelhető bemenetnél a read-ahead puffer miatt a logikai streampozíció és a kernelpozíció eltérhet; például egy ellenőrzött fseek(stream, 0, SEEK_CUR) rendezheti a pozíciót a váltás előtt.

Nem seekelhető bemenetnél az előre beolvasott adat általában nem adható vissza a pipe-nak. Ilyenkor célszerű egyetlen I/O-rétegnél maradni. Az fflush(stdin) nem hordozható ISO C-megoldás.

A fileno() nem ad tulajdonjogot. A sikeres fdopen() után a lezárási felelősséget a streamhez kell rendelni: a fclose() mellett külön close() dupla lezárási hiba.

12.1.6 Feladatok

  1. Magyarázó: egy 8192 C bájtos olvasási kérés 1500-at ad vissza. Mi bizonyított, és mi nem?

  2. Kódolási: írj blocking write_all_blocking() wrappert, amely kizárólag IO_INTR után próbál újra, megőrzi az összesített előrehaladást, és nem pörög IO_WAIT esetén.

  3. Diagnosztikai: Linuxon a program addig ismétli a close(fd) hívást, amíg az EINTR eltűnik. Közben másik szál fájlokat nyit. Mi a hiba, és hogyan javítanád?

12.1.7 Megoldások

12.1.7.1 Rövid olvasás értelmezése

A buffer első 1500 C bájtja érvényes új adat. Nem bizonyított az EOF, és nem bizonyított alkalmazásszintű üzenethatár sem. Ellenőrzési feltétel: a feldolgozó pontosan ezt az 1500-at fogyassza el; a buffer többi részét ne tekintse frissen olvasottnak.

12.1.7.2 Blocking wrapper

Az előző modulhoz hozzáadható részlet:

io_result write_all_blocking(int fd, const void *buffer, size_t length)
{
    const unsigned char *bytes = buffer;
    size_t total = 0;

    while (total < length) {
        io_result r = write_all(fd, bytes + total, length - total);
        total += r.done;

        if (r.state == IO_OK)
            return (io_result){total, IO_OK, 0};
        if (r.state != IO_INTR)
            return (io_result){total, r.state, r.error};
    }

    return (io_result){total, IO_OK, 0};
}

Az előfeltételek változatlanok; a hívó engedélyezi a megszakítás utáni újrapróbálást. Ez nem megfelelő policy, ha a signal leállítási kérés. Ellenőrzési feltétel: részleges írás, majd EINTR után minden adat pontosan egyszer szerepeljen a továbbított sorozatban; IO_WAIT azonnal térjen vissza.

12.1.7.3 Hibás close-retry

Az első hívás már felszabadíthatta az FD számát. Az ismételt hívás így a másik szál új fájlját zárhatja le. Javítás: egyszeri Linux-lezárás, a tulajdonosi változó érvénytelenítése, a hibakód rögzítése, újrapróbálás nélkül. Ellenőrzési feltétel: egy tulajdonlási életciklusból legfeljebb egy close() kísérlet induljon.

12.1.8 Összefoglaló

A robusztus I/O három külön adatot őriz: az előrehaladást, a megállás okát és az erőforrás tulajdonosát. A rövid művelet siker; az EOF, az átmeneti várakozás és a megszakítás külön állapot. Folytatáskor csak a maradék tartomány használható, lezáráskor pedig platformhoz kötött policy szükséges — nem általános retry-szabály.