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.
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.
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:
recv hívást
EAGAIN/EWOULDBLOCK, EOF vagy végleges hiba
eléréséig;send EAGAIN/EWOULDBLOCK eredményt
ad;EAGAIN-ig kell
folytatni.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.
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:
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.
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.oA 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
tehát siker esetén
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.
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:
EPOLL_CTL_DEL segítségével megszüntetjük a
regisztrációt.close-t.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.
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.
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.
conn_begin_shutdown függvénnyel, amely megőrzi a kimenetet,
és jelzi, ha már azonnal lezárható a kapcsolat.(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?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.
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.
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.
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.