„Mindig a TCP_NODELAY az”: a 40 ms, amit a Nagle-algoritmus és a delayed ACK együtt lop el

A Nagle-algoritmus és a delayed ACK külön-külön értelmes optimalizáció, együtt viszont pontosan 40 ms-ot ülnek minden write–write–read mintájú kéréseden. Megmértem Node-ban: 0,1 ms helyett 41. És kiderült az is, ki kapcsolja ki helyetted – és hol maradsz magadra.

Marc Brooker, aki az AWS-nél többek között a Lambdán és a DynamoDB-n dolgozik, 2024-ben egy teljes blogposztot szentelt egyetlen mondatnak: „It’s always TCP_NODELAY. Every damn time.” Amikor egy elosztott rendszerben megmagyarázhatatlan késleltetést lát, nem a kódot kezdi olvasni, hanem először megkérdezi, ki van-e kapcsolva a Nagle-algoritmus.

A jelenség, amire vadászik, ránézésre felismerhető: a válaszidő-hisztogramon púp nő pontosan 40 ms környékén. Nem 30-nál és nem 50-nél – 40-nél, mert ez a szám egy Linux-kernelkonstans. Két, önmagában teljesen értelmes optimalizáció ül egymással szemben a kapcsolat két végén, és mindkettő udvariasan előre engedi a másikat. Az egyiket ráadásul még 1984-ben írták.

Szekvenciadiagram: a kliens első csomagja átér a szerverhez, a második a Nagle-algoritmus miatt várakozik, a szerver delayed ACK időzítője 40 ms holtidőt okoz, majd a megkésett nyugta után megy ki a második csomag és érkezik a válasz

1984: két jó szándék, egymásról mit sem tudva

John Nagle problémája a telnet-korszak terméke volt: egy interaktív terminálkapcsolaton minden leütött karakter külön TCP-csomagban ment ki – 1 bájt hasznos adat 40 bájt fejléccel. Az RFC 896 ezt szó szerint „4000% overheadnek” nevezi, és elegáns szabályt ad rá: amíg van nyugtázatlan adat úton, a teljes szegmensnél kisebb darabokat gyűjtsd össze, és csak az ACK megérkezésekor küldd ki egyben. Nincs benne fix időzítő, a hálózat sebességéhez skálázódik. Önmagában tényleg szép.

Nagyjából ugyanekkor Berkeley-ben feltették a fordított kérdést: minek külön csomagot égetni egy puszta nyugtára? Várjon a fogadó egy kicsit, hátha születik visszafelé menő adat – például a telnet karakter-echója –, és arra ráülhet az ACK. Ebből lett a delayed ACK, amit az RFC 1122 úgy rögzít, hogy a késleltetés legfeljebb 500 ms lehet, és minden második teljes szegmensre nyugtát kell küldeni. Linuxon a timer 40 és 200 ms között mozog – a kernelforrásban ott a két konstans: TCP_DELACK_MIN (HZ/25, azaz 40 ms) és TCP_DELACK_MAX (HZ/5, azaz 200 ms).

Külön-külön mindkettő csomagokat spórol. Együtt viszont előáll a hálózati protokollok egyik legszebb patthelyzete.

A patthelyzet: write–write–read

A halálos minta az, amikor egy logikai kérést több write() hívással írsz ki – mondjuk előbb a hossz-prefixet vagy a fejlécet, aztán a törzset –, a túloldal pedig csak a teljes kérés után válaszol. Az első darab kimegy. A második kicsi, és van nyugtázatlan adat úton, ezért a Nagle visszatartja, amíg meg nem jön az ACK. A szerver közben megkapta a kérés első felét: válaszolni még nem tud, az ACK-kal pedig vár, hátha ráteheti majd a válaszra. A kliens a szerverre vár, a szerver a kliensre – két udvarias ember az ajtóban, csak ön után. A helyzetet végül a delayed ACK időzítője oldja fel, Linuxon tipikusan 40 ms után.

Mérjük meg

A jelenséghez nem kell adatközpont, egy loopback interfész is elég. A szerver egy 2000 bájtos „kérésre” válaszol, a kliens a kérést két külön write()-tal küldi – a másodikat csak akkor, amikor az első már biztosan a kernelnél van:

import net from 'node:net';

// szerver: a teljes 2000 bájtos kérés után válaszol
const server = net.createServer((sock) => {
  sock.setNoDelay(true);
  let got = 0;
  sock.on('data', (d) => {
    got += d.length;
    if (got >= 2000) { got -= 2000; sock.write('OK'); }
  });
});

// kliens: a kérés KÉT write()-tal megy ki (write–write–read)
function sendRequest(sock) {
  sock.write(Buffer.alloc(1000, 65), () => {
    sock.write(Buffer.alloc(1000, 66)); // ez ragad be a Nagle alatt
  });
}

Harminc egymás utáni kérés ugyanazon a kapcsolaton, Node 22, Linux, loopback:

Nagle BE (net.Socket alapértelmezés): min 1,2 ms | medián 41,1 ms
Nagle KI (setNoDelay(true)):          min 0,1 ms | medián 0,1 ms

Négyszázszoros különbség, egyetlen sor miatt. De az igazán tanulságos a min oszlop: az első kérés a Nagle-lel is gyors volt, mert a Linux a friss kapcsolatot „quickack” módban indítja, és eleinte azonnal nyugtáz. Csak néhány szegmens után vált delayed üzemmódra – onnantól kapja meg minden kérés a maga 40 millisecundumát. Vagyis a gyors smoke-teszt simán átmegy, a jelenség pedig majd élesben, tartós kapcsolatokon jön elő. Ennél alattomosabb viselkedést nehéz tervezni.

Ki kapcsolta ki helyetted – és hol maradsz magadra

A jó hír, hogy a HTTP-világ nagy része már rég letette a voksát:

  • A Node 18 óta a http.Server és a http.Agent minden socketjén noDelay: true az alapértelmezés (SEMVER-MAJOR változás volt, PR #42163) – a bejövő és a kimenő HTTP-forgalom is mentesül.
  • A fetch() mögött dolgozó undici feltétel nélkül meghívja a setNoDelay(true)-t – még opció sincs rá, hogy visszakapcsold.
  • A curl a 7.50.2 óta (2016!), az nginx pedig a tcp_nodelay on defaulttal szintén Nagle nélkül megy.

A csapda a nyers TCP: a net.Socket dokumentációja szó szerint kimondja, hogy „when a TCP connection is created, it will have Nagle’s algorithm enabled”. Ha tehát saját protokollt beszélsz – bináris RPC két microservice között, adatbázis-driver, üzenetsor-kliens, TCP-proxy –, ott ez a te felelősséged. És jellemzően pont ezek a kódok írogatnak fejlécet meg törzset külön write()-okkal.

Maga Nagle is a delayed ACK-ot okolja

A történet legszebb csavarja, hogy John Nagle a mai napig aktív a Hacker Newson (Animats néven), és időről időre elmagyarázza, hogy nem az ő algoritmusa a bűnös. Egy 2015-ös kommentjében így fogalmaz:

„A valódi baj nem a tinygram-védelem, hanem az ACK-késleltetés és az az ostoba fix időzítő. […] A delayed ACK arra fogad, hogy a túloldal szinte azonnal válaszol valamit, amire a nyugta ráülhet. Ezt a fogadást újra és újra elveszíti – késlelteti az ACK-ot, várja a csomagot, amire rátehetné, nem kapja meg, aztán elküldi az ACK-ot, megkésve.”

A két mechanizmus nagyjából egyszerre, egymástól függetlenül került a TCP-be; mire Nagle egyáltalán értesült a delayed ACK létezéséről, már nem hálózatokkal foglalkozott. Az ő javaslata a fogadó oldali TCP_QUICKACK lenne – amit Node-ból natívan be sem tudsz állítani, mert nincs rá API. Marad a küldő oldali noDelay.

Egy figyelmeztetést viszont ő is hozzátesz: kikapcsolt Nagle mellett minden write() külön IP-csomag. Aki egy ciklusban pár bájtonként írogat egy socketre, az visszakapja az eredeti 1984-es problémát, negyvenszeres fejléc-overheaddel. A helyes válasz erre nem a Nagle visszakapcsolása, hanem az alkalmazásszintű pufferelés: egy logikai üzenet, egy write(). Akinek ez megvan, annál a Nagle-nek amúgy sem lenne mit késleltetnie.

Mikor kell ezzel foglalkoznod

  • Node 18+ és HTTP? Semmi teendő, a platform már elintézte helyetted – valószínűleg nem is tudtál róla.
  • Saját TCP-protokoll, régebbi runtime, egzotikus kliens? setNoDelay(true), és a kérés egyetlen write()-tal menjen ki.
  • Diagnosztika: ha a latency-hisztogramban 40 ms-nál (vagy a többszöröseinél) púp ül, nagy eséllyel ezt látod. Előbb a socketopciót ellenőrizd, aztán kezdj profilozni.

Production-ben én ma már mindenhol kikapcsolnám a Nagle-t – a modern, TLS-sel és keretezett protokollokkal teli világban a tinygram-probléma az alkalmazási rétegben dől el, nem a TCP-ben. De a „sok kis write” mintát attól még refaktorálnám, mert az a noDelay-jel együtt is pazarlás. A tanulság meg marad: két jó szándékú, negyvenéves folt a mai napig adót szed a microservice-eiden – és az elengedéséhez egyetlen sor kell.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük