12.6 poll/epoll és explicit eseményállapotgép

Egy nonblocking TCP szolgáltatásban a vezérlés visszatérése nem jelenti a művelet befejezését. A kérésből még hiányozhatnak bájtok, a válasz egy része a felhasználói bufferben várakozhat, a kapcsolat pedig már leállítás alatt állhat. Az eseménykezelő feladata ezért nem pusztán az „olvasható socketek” feldolgozása, hanem a kapcsolatok explicit állapotának és erőforrás-életciklusának fenntartása.

A Socket API, framing és bufferkezelés rész rövid műveletekre vonatkozó szabályaira építünk. A dinamikus buffer helyett most rögzített kapacitást választunk, hogy a memóriakorlát közvetlenül bizonyítható legyen. A lezárás alapja a korábban megismert tulajdonlási és cleanup-szerződés.

12.6.1 Készenlétjelzés: poll és epoll

POSIX: a poll egy struct pollfd tömbben megadott descriptorok készenlétét vizsgálja. A program minden várakozáskor átadja a figyelt halmazt, majd végignézi a visszakapott revents mezőket. Kevés kapcsolatnál ez egyszerű és megfelelő megoldás.

Linux: az epoll külön kernelobjektumban tartja a regisztrációkat. Az epoll_ctl módosítja a figyelt halmazt, az epoll_wait pedig eseményrekordokat ad vissza. Ez más költségmodellt jelent, de nem igazolja azt az általános állítást, hogy „az epoll minden művelete konstans idejű”.

A készenlétjelzés (readiness notification) nem befejezett I/O-művelet és nem helyfoglalás. Olvashatóság után a recv visszaadhat adatot, EOF-ot, hibát vagy akár EAGAIN értéket. Minden kezelt socket legyen nonblocking; a visszatérési érték marad a tényleges művelet eredménye.

Írási készenlétet csak akkor kérjünk, ha van elküldendő adat. Egy üres kimeneti sorhoz folyamatosan figyelt POLLOUT vagy EPOLLOUT könnyen CPU-t fogyasztó üres ciklust eredményez.

12.6.2 Level-triggered és edge-triggered feldolgozás

A poll, valamint az epoll alapértelmezett, szintvezérelt (level-triggered, LT) működése ismét jelez, amíg a figyelt készenléti feltétel fennáll. Ez megengedi a korlátozott munkavégzést: egy kapcsolat egy fordulóban csak néhány műveletet kap, majd más kapcsolatok következhetnek.

A Linux EPOLLET kapcsolója élvezérelt (edge-triggered, ET) működést kér. Itt nem szabad arra számítani, hogy a feldolgozatlan készenlét miatt újabb értesítés érkezik.

A drain, vagyis kiürítés szabálya:

Az EINTR önmagában nem bizonyítja a kiürítést.

Két lényeges kivételt az alkalmazásnak kell kezelnie. Ha elfogyott a memóriakeret, az olvasást megállítjuk; ha elfogyott az egy kapcsolatra jutó munkakeret, átadjuk a vezérlést. ET módban mindkét esetben saját futásra kész sor szükséges: amikor ismét lehet dolgozni, aktívan folytatjuk a műveletet, nem várunk feltétlenül új kernelértesítésre. Ugyanez fontos, amikor az üres kimeneti sorba először kerül adat.

Kis szolgáltatáshoz az LT mód jó kiindulás: egyszerűbb helyességi érvelést és természetesebb méltányosságot ad.

12.6.3 Korlátozott bájtvisszhang-szolgáltatás

Példánk legfeljebb 32 kapcsolatot kezel egyetlen eseménykezelő szálon. A protokoll bájtvisszhang: minden befogadott bájtot változatlanul visszaküldünk; nincs külön üzenetkeretezés.

Kapcsolatonként 1024 C bájtos olvasási munkabuffer és 4096 C bájtos kimeneti buffer tartozik az állapotobjektumhoz. Az olvasott adat azonnal a kimeneti sorba kerül, ezért a feldolgozó visszatérésekor nincs külön feldolgozatlan bemenet.

Erőforrás Tulajdonos Kölcsönzött hozzáférés Érvénytelenné válás
Kapcsolatobjektum A dispatcher slotja Az aktuális feldolgozó hívás free
Socket FD Kapcsolatobjektum recv, send close; a szám később újra kiosztható
Beágyazott bufferek Kapcsolatobjektum Szinkron I/O és másolás Objektumfelszabadítás; a tartalom korábban is változik
epoll-regisztráció Eseménykezelő Slot–generáció token Törlés; már visszaadott esemény még létezhet

A visszanyomás (backpressure) itt az olvasási érdeklődés szüneteltetése. Ha a kimenet eléri a 4096 bájtot, nem fogadunk be több adatot; 2048 bájtra csökkenéskor újra engedélyezzük az olvasást. A két küszöb közötti hiszterézis csökkenti a kapcsolgatást. Ez nem azonos az EPOLLET élvezérlésével.

A felhasználói bufferek felső korlátja:

Mbuffer≤32(1024+4096)=163840 M_{\mathrm{buffer}} \leq 32(1024+4096)=163840

C bájt. Ehhez hozzáadódnak az állapotmezők, az allokátor és a slotok költségei. A kernel socketbufferei és az epoll belső tárolója nem szerepelnek ebben a korlátban.

12.6.3.1 LT feldolgozómag C-ben

Célprofil: ISO C17, Linux, x86-64 System V ABI, LP64, CHAR_BIT == 8; választott laborfordítók GCC 14.2.0 és Clang 19.1.7, glibc 2.40 környezettel. Az alábbi echo_core.c önállóan objektumfájllá fordítható mag, nem teljes szerver. Nem állítunk róla elvégzett fordítást vagy futtatást.

A dispatcher hozza létre a kapcsolatot: érvényes, nonblocking FD, ACTIVE állapot, nulla used, hamis paused. A c pointer kölcsönzött; a hívás alatt a tulajdonos nem zárhatja le vagy szabadíthatja fel az objektumot.

#include <errno.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>

enum conn_state { ACTIVE, DRAINING };
enum { RX_CAP = 1024, TX_CAP = 4096, TX_LOW = 2048 };

struct conn {
    int fd;
    enum conn_state state;
    bool paused;
    size_t used;
    unsigned char rx[RX_CAP];
    unsigned char tx[TX_CAP];
};

static bool temporary_error(int e)
{
    return e == EAGAIN || e == EWOULDBLOCK || e == EINTR;
}

uint32_t conn_interest(const struct conn *c)
{
    uint32_t mask = 0;

    if (c->state == ACTIVE && !c->paused)
        mask |= EPOLLIN | EPOLLRDHUP;
    if (c->used != 0)
        mask |= EPOLLOUT;

    return mask; /* Szándékosan nincs EPOLLET. */
}

/*
 * Eredmény: 0 = megtartás, 1 = szabályos befejezés,
 *          -1 = I/O-hiba; errno azonnal elmentendő.
 * Az EPOLLERR/EPOLLHUP kezelése a dispatcher feladata.
 */
int conn_step(struct conn *c, uint32_t events)
{
    if ((events & EPOLLOUT) && c->used != 0) {
        ssize_t n = send(c->fd, c->tx, c->used, MSG_NOSIGNAL);

        if (n > 0) {
            size_t done = (size_t)n;
            c->used -= done;
            memmove(c->tx, c->tx + done, c->used);
        } else if (n == 0) {
            errno = EIO; /* Nem fogadunk el nulla előrehaladást. */
            return -1;
        } else if (!temporary_error(errno)) {
            return -1;
        }
    }

    if (c->used <= TX_LOW)
        c->paused = false;

    if (c->state == ACTIVE && !c->paused &&
        (events & (EPOLLIN | EPOLLRDHUP))) {
        size_t want = TX_CAP - c->used;
        if (want > sizeof c->rx)
            want = sizeof c->rx;

        ssize_t n = recv(c->fd, c->rx, want, 0);

        if (n > 0) {
            size_t got = (size_t)n;
            memcpy(c->tx + c->used, c->rx, got);
            c->used += got;
        } else if (n == 0) {
            c->state = DRAINING;
        } else if (!temporary_error(errno)) {
            return -1;
        }
    }

    if (c->used == TX_CAP)
        c->paused = true;

    return c->state == DRAINING && c->used == 0 ? 1 : 0;
}

A két külön fordítási ellenőrzés parancsa:

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

A mag eseményenként legfeljebb egy küldést és egy fogadást végez. Ez LT-re helyes stratégia, ET-re változtatás nélkül nem az. Minden megtartott kapcsolatnál a dispatcher a conn_interest eredményével frissíti a regisztrációt; az epoll_ctl hibája is lezárási ok.

Bizonyításvázlat a bufferkorlátra. Kezdetben used == 0. A küldés legfeljebb used bájtot távolít el. Fogadáskor

want=min(1024,4096−used), \mathrm{want}=\min(1024,\ 4096-\mathrm{used}),

tehát siker esetén

usedúj=usedrégi+n≤usedrégi+want≤4096. \mathrm{used}_{\mathrm{új}} =\mathrm{used}_{\mathrm{régi}}+n \leq \mathrm{used}_{\mathrm{régi}}+\mathrm{want} \leq4096.

A teljes bufferhez mindig szüneteltetett olvasás tartozik, így a fogadás nem kap nulla hosszúságú kérést.

A memmove elküldésenként legfeljebb 4096 bájtot mozgat. Ez nem optimális, de kis terhelésnél egyszerű, folytonos tárolású megoldás, további allokáció nélkül. Nagyobb terhelésnél körbufferrel csökkenthető a másolás.

12.6.4 FD-újrahasználat és elavult események

Az FD egész szám, nem tartós kapcsolat-azonosító. Tegyük fel, hogy egy eseményköteg feldolgozásakor bezárjuk a 41-es descriptort, majd egy új kapcsolat ismét a 41-et kapja. A korábbi eseményt nem szabad az új kapcsolatra alkalmazni.

Az epoll data.u64 mezőjébe ezért slotot és generációt csomagolunk, például:

((uint64_t)generation << 32) | slot

A slot-tábla pointert és generációt tárol. Feldolgozáskor először ellenőrizzük a slot tartományát, foglaltságát és a generáció egyezését; csak ezután dereferáljuk a pointert. Generációtúlcsorduláskor a példabeli slotot végleg kivonjuk, nem engedjük az azonosítók körbefordulását. poll esetén az eseménytömb mellé ugyanilyen azonosító-pillanatkép rendelhető.

A lezárási út egyetlen központi művelet:

  1. A slotot érvénytelenítjük, generációját léptetjük.
  2. EPOLL_CTL_DEL segítségével megszüntetjük a regisztrációt.
  3. Egyszer meghívjuk a close-t.
  4. Felszabadítjuk a kapcsolatobjektumot és vele mindkét buffert.

Linux: a close hibája után nem próbáljuk vakon újrazárni ugyanazt az FD-számot: közben más erőforráshoz kerülhetett. A cleanup hibáit rögzítjük, de nem tartjuk életben miattuk az objektumot.

Ez a modell kizárja az FD külső duplikálását és a párhuzamos lezárást. Nyers data.ptr használatakor az elavult esemény már felszabadított objektumra mutathatna; a generációs ellenőrzés ezt a dereferálást előzi meg.

12.6.5 Shutdown, timeout és cancellation

A szabályos leállítás és a megszakítás külön átmenet:

Állapot és kiváltó ok Művelet Következő állapot
ACTIVE, pozitív fogadás Adat a kimeneti sorba ACTIVE
ACTIVE, recv == 0 További olvasás tiltása DRAINING
ACTIVE, szolgáltatásleállítás Új bemenet tiltása, határidő indítása DRAINING
DRAINING, üres kimenet Központi cleanup Lezárt
Bármely élő állapot, végleges hiba vagy cancellation Függő adatok eldobása, cleanup Lezárt
Bármely élő állapot, lejárt határidő Cleanup Lezárt

Az EPOLLRDHUP csak jelzés: lehet még olvasatlan adat. A mag ezért a tényleges EOF-ot a recv == 0 alapján állapítja meg. A dispatcher EPOLLERR esetén a SO_ERROR segítségével rögzíti a socket hibáját; e szolgáltatás EPOLLERR és teljes EPOLLHUP esetén megszakítja a kapcsolatot, akár függő adat eldobásával.

A lassú kliens nemcsak memóriát, hanem kapcsolati helyet is foglal. Például 30 másodperces inaktivitási és 5 másodperces leállítási határidőt használhatunk. Az inaktivitást csak pozitív bájtszámú I/O szünteti meg, nem egy készenlétjelzés. A folyamatosan csöpögtető kliens ellen külön maximális kapcsolatélettartam vagy kimeneti várakozási korlát szükséges.

A határidőket monoton órával kezeljük. A várakozás ideje a legközelebbi határidőhöz igazodik; EINTR után újraszámítjuk. Folyamatos eseményáradat mellett is ellenőrizzük az időzítőket minden korlátozott feldolgozási fordulóban.

A szolgáltatásleállítás először megszünteti az új kapcsolatok fogadását. A már befogadott kimenetet megpróbáljuk elküldeni, majd a határidőnél kényszerítetten lezárunk. A sikeres send itt sem bizonyítja a másik alkalmazás általi feldolgozást.

12.6.6 Gyakori hibák

A memóriakorlát nélküli kimeneti sor csak elrejti a lassú kliens problémáját. Az állandó írási érdeklődés üres ciklust okozhat. Az ET módban egyszer végrehajtott recv kapcsolatot akaszthat meg. A regisztráció törlése pedig nem tünteti el a már visszaadott eseményrekordokat: az objektumazonosságot feldolgozáskor is ellenőrizni kell.

12.6.7 Feladatok

  1. Magyarázat: miért nem elegendő ET módban az olvasási érdeklődés későbbi visszakapcsolása után egyszerűen új eseményre várni?
  2. Kódolás: egészítsd ki a magot egy conn_begin_shutdown függvénnyel, amely megőrzi a kimenetet, és jelzi, ha már azonnal lezárható a kapcsolat.
  3. Diagnosztika: egy esemény tokenje (slot=7, generation=12). A slotban már a 13-as generáció él, ugyanazzal az FD-számmal. Mi történjen? Milyen további lezárási tesztet végeznél?

12.6.8 Megoldások

12.6.8.1 ET és az újraindítás

Az olvasatlan adat már jelen lehet, ezért új készenléti átmenetre nem támaszkodhatunk. A kapcsolatot saját futásra kész sorba tesszük, majd a megengedett kereten belül folytatjuk a fogadást.

Ellenőrzési feltétel: az olvasás akkor is folytatódik, ha a kliens a szünet után egyetlen új bájtot sem küld.

12.6.8.2 Szabályos leállítás kezdeményezése

Az előző C17 fordítási egységhez hozzáadható:

int conn_begin_shutdown(struct conn *c)
{
    c->state = DRAINING;
    return c->used == 0 ? 1 : 0;
}

A dispatcher külön rögzíti a határidőt. Nulla eredménynél frissíti az érdeklődést; egy esetén azonnal lezár.

Ellenőrzési feltétel: nincs új fogadás, a meglévő kimenet változatlan, ismételt leállításkérés nem hosszabbítja meg a határidőt.

12.6.8.3 Elavult token

Az eseményt eldobjuk, még az új kapcsolat pointerének dereferálása előtt. Az FD azonossága nem írja felül a generáció eltérését.

További tesztben ugyanarra a kapcsolatra több eseményrekordot adunk egy kötegben, és az első feldolgozása lezárást okoz.

Ellenőrzési feltétel: minden későbbi rekord kiesik az ellenőrzésen; pontosan egy close és egy felszabadítás történik.

12.6.9 Összefoglalás

A robusztus eseménykezelő alapja a készenlét és a tényleges I/O-eredmény megkülönböztetése. A korlátozott bufferek, a backpressure és a határidők együtt védik a szolgáltatást. Az explicit állapotgép és a generációs azonosítás pedig biztosítja, hogy a lezárás ne hagyjon életben sem erőforrást, sem régi kapcsolatra mutató feldolgozható eseményt.