12.5 Socket API, framing és bufferkezelés

A socketet C-ben nem „kapcsolatobjektumként”, hanem file descriptoron keresztül kezeljük. A fordító nem akadályozza meg a kétszeres lezárást, a már megszűnt buffer használatát vagy az üzenethatárok téves feltételezését. Ezeket explicit életciklus- és protokollszerződéssel kell kizárnunk.

A file descriptor és robusztus I/O ciklus részleges műveletekre vonatkozó szabályai itt is érvényesek. A hosszmező feldolgozása a biztonságos szerializálásra, a megszakítások kezelése pedig a signalokról szóló részre épül. A cél most ezek összekapcsolása egy teljes socket-életciklussá.

12.5.1 Címek és socket-életciklus

POSIX API: a socket() egy kommunikációs végpont descriptorát hozza létre. Sikeres visszatérése nem jelenti, hogy a végpont már kapcsolódott is.

A szerver alapvető sorrendje:

getaddrinfo → socket → bind → listen → accept

A bind() helyi címhez rendeli a végpontot; a listen() hallgató socketté alakítja. Az accept() új descriptort ad vissza a létrejött kapcsolathoz. A hallgató descriptor ettől továbbra is használható új kapcsolatok fogadására.

A kliens sorrendje:

getaddrinfo → socket → connect

A getaddrinfo() címjelöltek láncolt listáját állítja elő. Minden jelöltnél együtt kell használni az ai_family, ai_socktype, ai_protocol, ai_addr és ai_addrlen mezőket. Egy sikertelen jelölt socketjét le kell zárni, majd új sockettel próbálható a következő cím.

A struct sockaddr az API általános címparaméter-típusa; önmagában nem általános méretű címtároló. IPv4-címhez struct sockaddr_in, IPv6-címhez struct sockaddr_in6 tartozik. Ismeretlen családú visszatérő címhez a megfelelő méretű és igazítású struct sockaddr_storage használható.

A socklen_t a címhossz API-típusa. Az accept() hosszparamétere bemenetként a rendelkezésre álló tároló mérete, kimenetként a visszaadott cím hossza. Ezért minden új próbálkozás előtt inicializáljuk.

Erőforrás vagy tároló Tulajdonos Kölcsönzött használat Élettartam vége
getaddrinfo() eredménylistája végpontnyitó függvény socket, bind, connect előkészítése freeaddrinfo()
Hallgató és kapcsolati descriptor main() munkamenet-kezelő függvény close()
Üzenetbuffer munkamenet-kezelő függvény küldő és fogadó segédfüggvények függvényből kilépés
Fogadott partnercím tárolója main() blokkja accept() kimeneti paramétere blokk vége

A descriptor száma lezárás után újra kiosztható. Egy elavult descriptor használata ezért nem feltétlenül EBADF: akár egy másik erőforrást is elérhet.

12.5.2 Byte stream helyett explicit üzenetszerződés

POSIX stream-socket: a send() és recv() nem őrzi meg az alkalmazás üzenethatárait. Egy küldés több fogadásra darabolódhat, több küldés pedig egy fogadásban jelenhet meg.

Pozitív kért fogadási hossz mellett:

A pozitív send()-eredmény csak az elfogadott adatmennyiséget jelzi, nem a partner alkalmazásának feldolgozási visszaigazolását.

A példában egy keret:

keret=4 oktettes, hálózati sorrendű hossz‖payload. \text{keret} = \text{4 oktettes, hálózati sorrendű hossz} \;\Vert\; \text{payload}.

A hossz kizárólag a payload mérete. A megengedett tartomány 0≤L≤40960 \leq L \leq 4096. A négyezres korlát alkalmazásszintű erőforrás-szabály, nem TCP-korlát.

A feldolgozás sorrendje:

  1. A teljes, négyoktettes fejléc összegyűjtése.
  2. A hálózati bájtsorrend dekódolása.
  3. A hossz ellenőrzése a buffer kapacitásával szemben.
  4. Pontosan LL oktett fogadása.

Bizonyításvázlat. Legyen dd az eddig átvitt, NN a kívánt adatmennyiség. A ciklus invariánsa 0≤d≤N0 \leq d \leq N. Minden hívás legfeljebb N−dN-d elemet kér, pozitív rr eredmény után pedig d′=d+r≤Nd'=d+r\leq N. A pointer csak a még feldolgozatlan tartományra mutat. Hiba esetén nincs állítás teljes üzenetről: a részben módosított buffer tartalma nem publikálható kész payloadként.

12.5.3 Kidolgozott példa: egykeretes kérés és válasz

A következő program egyetlen kapcsolatot kezel. A kliens elküldi a ping payloadot, majd shutdown(fd, SHUT_WR) hívással lezárja a küldési irányát. Ez half-close: a fogadási irány tovább működik. A szerver egy keretet és az azt követő EOF-ot várja, visszaküldi a keretet, majd szintén lezárja küldési irányát.

A protokoll tehát irányonként pontosan egy keretet enged. Az üres payload érvényes; a hiányzó vagy csonka keret nem az.

Profil: ISO C17; Linux hosted user-space, x86-64 System V ABI, LP64, nyolcbites C bájt. Választott fordítási profil: GCC 14.2.0 és glibc 2.40; második fordítóként Clang 19.1.7 ugyanazzal a libc-vel. Az alábbi parancsok helyi ellenőrzésre szolgálnak, nem dokumentált fordítási vagy futtatási eredmények.

/* frame_demo.c */
#define _POSIX_C_SOURCE 200809L

#include <arpa/inet.h>
#include <errno.h>
#include <limits.h>
#include <netdb.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

_Static_assert(CHAR_BIT == 8 && sizeof(uint32_t) == 4,
               "This example requires 8-bit bytes and uint32_t.");

enum { CAPACITY = 4096 };

/* Required data: any EOF before completion is a protocol error. */
static int receive_exact(int fd, void *buffer, size_t size)
{
    unsigned char *p = buffer;
    size_t done = 0;

    while (done < size) {
        ssize_t n = recv(fd, p + done, size - done, 0);
        if (n > 0) {
            done += (size_t)n;
        } else if (n == 0) {
            errno = EPROTO;
            return -1;
        } else if (errno != EINTR) {
            return -1;
        }
    }
    return 0;
}

static int send_all(int fd, const void *buffer, size_t size)
{
    const unsigned char *p = buffer;
    size_t done = 0;

    while (done < size) {
        ssize_t n = send(fd, p + done, size - done, MSG_NOSIGNAL);
        if (n > 0) {
            done += (size_t)n;
        } else if (n == 0) {
            errno = EIO;  /* No progress: do not loop forever. */
            return -1;
        } else if (errno != EINTR) {
            return -1;
        }
    }
    return 0;
}

static int receive_frame(int fd, unsigned char *data, uint32_t *length)
{
    uint32_t wire;
    if (receive_exact(fd, &wire, sizeof wire) < 0)
        return -1;

    uint32_t n = ntohl(wire);
    if (n > CAPACITY) {
        errno = EMSGSIZE;
        return -1;
    }
    if (receive_exact(fd, data, (size_t)n) < 0)
        return -1;

    *length = n;
    return 0;
}

static int send_frame(int fd, const unsigned char *data, uint32_t length)
{
    if (length > CAPACITY) {
        errno = EMSGSIZE;
        return -1;
    }

    uint32_t wire = htonl(length);
    if (send_all(fd, &wire, sizeof wire) < 0)
        return -1;
    return send_all(fd, data, (size_t)length);
}

static int expect_eof(int fd)
{
    unsigned char extra;
    ssize_t n;
    do {
        n = recv(fd, &extra, 1, 0);
    } while (n < 0 && errno == EINTR);

    if (n == 0)
        return 0;
    if (n > 0)
        errno = EPROTO;  /* Extra data violates this protocol. */
    return -1;
}

static int session(int fd, int server)
{
    unsigned char data[CAPACITY] = {'p', 'i', 'n', 'g'};
    uint32_t length = 4;

    if (!server) {
        if (send_frame(fd, data, length) < 0 ||
            shutdown(fd, SHUT_WR) < 0)
            return -1;
    }

    if (receive_frame(fd, data, &length) < 0 || expect_eof(fd) < 0)
        return -1;

    if (server) {
        if (send_frame(fd, data, length) < 0)
            return -1;
        return shutdown(fd, SHUT_WR);
    }

    if (length != 4 || memcmp(data, "ping", 4) != 0) {
        errno = EPROTO;
        return -1;
    }
    return 0;
}

static int open_endpoint(const char *host, const char *service, int server)
{
    struct addrinfo hints = {0};
    struct addrinfo *addresses = NULL;

    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = server ? AI_PASSIVE : 0;

    const char *node = strcmp(host, "-") == 0 ? NULL : host;
    int e = getaddrinfo(node, service, &hints, &addresses);
    if (e != 0) {
        if (e == EAI_SYSTEM)
            perror("getaddrinfo");
        else
            fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(e));
        return -1;
    }

    int fd = -1;
    int saved = EADDRNOTAVAIL;

    for (const struct addrinfo *a = addresses; a != NULL; a = a->ai_next) {
        fd = socket(a->ai_family, a->ai_socktype, a->ai_protocol);
        if (fd < 0) {
            saved = errno;
            continue;
        }

        int ok;
        if (server)
            ok = bind(fd, a->ai_addr, a->ai_addrlen) == 0 &&
                 listen(fd, 8) == 0;
        else
            ok = connect(fd, a->ai_addr, a->ai_addrlen) == 0;

        if (ok)
            break;

        saved = errno;
        (void)close(fd);  /* Preserve the primary failure. */
        fd = -1;
    }

    freeaddrinfo(addresses);
    if (fd < 0) {
        errno = saved;
        perror("open_endpoint");
    }
    return fd;
}

int main(int argc, char **argv)
{
    if (argc != 4 ||
        (strcmp(argv[1], "s") != 0 && strcmp(argv[1], "c") != 0)) {
        fprintf(stderr, "usage: %s s|c host port\n", argv[0]);
        return 1;
    }

    int server = strcmp(argv[1], "s") == 0;
    int endpoint = open_endpoint(argv[2], argv[3], server);
    if (endpoint < 0)
        return 1;

    int fd = endpoint;
    int result = 1;

    if (server) {
        struct sockaddr_storage peer;
        socklen_t peer_length;
        do {
            peer_length = (socklen_t)sizeof peer;
            fd = accept(endpoint, (struct sockaddr *)&peer, &peer_length);
        } while (fd < 0 && errno == EINTR);

        if (fd < 0) {
            perror("accept");
            goto cleanup;
        }
    }

    if (session(fd, server) < 0)
        perror("session");
    else
        result = 0;

cleanup:
    if (server && fd >= 0 && close(fd) < 0) {
        perror("close connection");
        result = 1;
    }
    if (close(endpoint) < 0) {
        perror("close endpoint");
        result = 1;
    }
    return result;
}

Fordítás, majd két külön terminálban indítás:

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

# Első terminál:
./frame_demo s 127.0.0.1 9090

# Második terminál:
./frame_demo c 127.0.0.1 9090

Sikeres esetben a várt eredmény mindkét folyamatnál nulla kilépési státusz. A szerver az első használható címre köt, nem minden IPv4- és IPv6-címre egyszerre. A - host szerveroldalon wildcard címet kér; ezt csak tudatos hálózati kitettséggel használjuk.

A példában minden descriptor a main() tulajdona. A segédfüggvények csak kölcsönzik. A fogadó buffer legalább CAPACITY elemű, írható tároló; a hosszparaméter érvényes kimeneti objektumra mutat. Küldés közben a forrásnak olvashatónak és változatlannak kell maradnia. Hiba után a munkamenet megszakad, a descriptort lezárjuk: egy részben elküldött keretet nem kezdünk újra az elejéről.

12.5.4 SIGPIPE, timeout és nonblocking állapot

Linux-labor: a send(..., MSG_NOSIGNAL) megakadályozza, hogy az adott küldés SIGPIPE jelzéssel leállítsa a folyamatot; a hibát, például az EPIPE értéket továbbra is kezelni kell. A flag nem ISO C-szolgáltatás. Más platformok hordozhatósági rétegében a támogatást, illetve például a SO_NOSIGPIPE lehetőségét külön kell ellenőrizni. A SIGPIPE folyamat egészére történő figyelmen kívül hagyása szélesebb hatású policy.

A példa blokkoló, egyszálú laborprogram, nincs benne időkorlát vagy fork/exec. Egy hibás partner ezért korlátlan ideig feltarthatja, például ha nem küldi el a teljes fejlécet vagy az elvárt EOF-ot.

Linux/POSIX API: a socket fogadási és küldési várakozása SO_RCVTIMEO, illetve SO_SNDTIMEO opcióval korlátozható. Ez azonban nem azonos a teljes üzenetre vonatkozó határidővel. Ha minden rövid művelet új várakozási keretet kap, egy lassan csöpögő partner sokáig fenntarthatja a munkamenetet.

A teljes művelethez monoton órán alapuló abszolút határidő kell:

Tmaradék=max(0,D−t), T_{\text{maradék}}=\max(0,D-t),

ahol DD az egyszer meghatározott határidő, tt az aktuális idő. A névfeloldás idejét külön is számításba kell venni.

Nonblocking módban a fcntl() segítségével, a meglévő flag-ek megtartásával állítjuk be az O_NONBLOCK értéket. Az EAGAIN vagy EWOULDBLOCK nem EOF és nem indok aktív újrapróbálkozási ciklusra: a műveletet később folytatjuk.

Ekkor tartós állapotként meg kell őrizni:

A fenti szinkron segédfüggvények erre nem alkalmasak változtatás nélkül, mert visszatéréskor elveszítik belső kurzorukat. A buffer tulajdonosának az utolsó folytatásig élnie kell; felszabadítás, realloc() vagy felülírás érvénytelenítheti a függőben lévő hozzáférést.

Nonblocking connect() esetén az EINPROGRESS folyamatban lévő kapcsolódást jelent. Az írhatósági esemény után getsockopt(..., SO_ERROR, ...) segítségével kell ellenőrizni a tényleges eredményt. A folytatások szervezését a poll/epoll és explicit eseményállapotgép rész tárgyalja.

12.5.5 Gyakori hibák

A legsúlyosabb hiba az egyetlen recv()-re épített üzenetkezelés. Ugyanilyen veszélyes az ellenőrizetlen hossz alapján történő allokáció vagy pointerléptetés.

A payload nem feltétlenül sztring: nincs garantált lezáró nullkarakter, ezért a %s és a strlen() nem általános feldolgozási eszköz.

Végül az EOF, a protokollhiba és az átmeneti várakozási állapot különböző esemény. A jelen protokollban a teljes keret utáni EOF elvárt; a fejléc vagy payload közbeni EOF csonkolás.

12.5.6 Feladatok

  1. A fejléc és payload három részletben érkezik: 00 00, majd 00 05 61, végül 62 63 64 65. Mekkora a payload, és mikor tekinthető késznek?

  2. Egy általánosabb decoder egyetlen bufferben tárolná a fejlécet és a payloadot. Írj ellenőrzést, amely a dekódolt uint32_t n alapján biztonságosan számolja ki a teljes méretet. A payload felső korlátja size_t limit.

  3. Nonblocking küldésnél a program egy lokális buffer címét elmenti, majd EAGAIN után visszatér. Később a teljes keretet újraküldi. Milyen két, egymástól független hibát tartalmaz?

12.5.7 Megoldások

12.5.7.1 A darabolt keret

A hossz hálózati sorrendben öt. Az első részlet csak két fejlécoktettet tartalmaz. A második befejezi a fejlécet, és egy payloadoktettet ad. A harmadik után áll rendelkezésre az öt payloadoktett.

Ellenőrzési feltétel: ugyanannak a keretnek minden lehetséges darabolása azonos payloadot eredményezzen; a befejezés előtti EOF hibát adjon.

12.5.7.2 Ellenőrzött teljes méret

Az előző Linux/C17 profilhoz tartozó részlet, <stdint.h> és <errno.h> használatával:

if ((uintmax_t)n > (uintmax_t)limit ||
    (uintmax_t)n > (uintmax_t)SIZE_MAX - 4) {
    errno = EMSGSIZE;
    return -1;
}
size_t total = (size_t)n + 4;

Előbb ellenőrzünk, és csak utána adunk össze. Tesztelendő a nulla, a megengedett maximum, az első tiltott érték és — ahol a típusméretek lehetővé teszik — a reprezentálhatatlan teljes méret.

12.5.7.3 Élettartam és részleges haladás

A visszatérés megszüntetheti a lokális buffer élettartamát: a tárolt pointer dangling lesz. Ettől függetlenül az egész keret újraküldése megismétli a korábban már elfogadott prefixet.

A javítás tartós tulajdonoshoz kötött buffer és megőrzött küldési kurzor. Ellenőrzési feltétel: tetszőleges rövid küldések és várakozások mellett a partner pontosan egyszer kapja meg a keret minden oktettjét.

12.5.8 Összefoglaló

A socket API descriptorokat és adatfolyamot ad, nem kész üzenetkezelést. A helyességet az explicit ownership, az ellenőrzött framing, a részleges haladás megőrzése és a megkülönböztetett hibakimenetek biztosítják. Blokkoló kódban ez ciklusokat jelent; nonblocking kódban ugyanez tartós állapotgéppé és hosszabb buffer-élettartammá alakul.