Ghid pentru modelele de servere de rețea cu procesare multiple
Publicat: 2022-03-11Fiind cineva care scrie cod de rețea de înaltă performanță de câțiva ani acum (teza mea de doctorat a fost pe tema unui server cache pentru aplicații distribuite adaptate la sisteme multicore), văd multe tutoriale pe acest subiect care dor sau omit complet orice discuție a elementelor fundamentale ale modelelor de servere de rețea. Prin urmare, acest articol este conceput ca o prezentare generală și o comparație utilă, sperăm, a modelelor de server de rețea, cu scopul de a elimina o parte din misterul scrierii codului de rețea de înaltă performanță.
Acest articol este destinat „programatorilor de sistem”, adică dezvoltatorilor back-end care vor lucra cu detaliile de nivel scăzut ale aplicațiilor lor, implementând codul serverului de rețea. Acest lucru se va face de obicei în C++ sau C, deși în prezent majoritatea limbajelor și cadrelor moderne oferă funcționalități decente de nivel scăzut, cu diferite niveluri de eficiență.
Voi considera că, din moment ce este mai ușor să scalați procesoarele prin adăugarea de nuclee, este firesc să adaptăm software-ul pentru a utiliza aceste nuclee cât mai bine. Astfel, întrebarea devine cum să partiționați software-ul între fire (sau procese) care pot fi executate în paralel pe mai multe procesoare.
De asemenea, voi lua de la sine înțeles că cititorul este conștient că „concurența” înseamnă practic „multitasking”, adică mai multe instanțe de cod (fie același cod sau diferit, nu contează), care sunt active în același timp. Concurența poate fi realizată pe un singur procesor și, înainte de epoca modernă, de obicei era. Mai exact, concurența poate fi obținută prin comutarea rapidă între mai multe procese sau fire de execuție pe un singur CPU. Așa reușeau sistemele vechi, cu un singur CPU, să ruleze mai multe aplicații în același timp, într-un mod pe care utilizatorul le-ar percepe ca aplicații executate simultan, deși chiar nu erau. Paralelismul, pe de altă parte, înseamnă în mod specific că codul este executat în același timp, literalmente, de mai multe procesoare sau nuclee de procesor.
Partiționarea unei aplicații (în mai multe procese sau fire)
În scopul acestei discuții, în mare măsură nu este relevant dacă vorbim despre fire sau procese complete. Sistemele de operare moderne (cu excepția notabilă a Windows) tratează procesele aproape la fel de ușoare ca firele de execuție (sau, în unele cazuri, invers, firele de execuție au câștigat caracteristici care le fac la fel de ponderale ca procesele). În zilele noastre, diferența majoră dintre procese și fire de execuție constă în capacitățile de comunicare între procese sau încrucișate și partajare a datelor. Acolo unde este importantă distincția dintre procese și fire de execuție, voi face o notă adecvată, în caz contrar, este sigur să considerăm că cuvintele „fir” și „proces” din această secțiune sunt interschimbabile.
Sarcini comune de aplicație de rețea și modele de server de rețea
Acest articol tratează în mod specific codul serverului de rețea, care implementează în mod necesar următoarele trei sarcini:
- Sarcina #1: Stabilirea (și desființarea) conexiunilor de rețea
- Sarcina #2: Comunicarea în rețea (IO)
- Sarcina #3: Muncă utilă; adică sarcina utilă sau motivul pentru care există aplicația
Există mai multe modele generale de server de rețea pentru partiționarea acestor sarcini între procese; și anume:
- MP: Multi-Proces
- SPEED: Proces unic, bazat pe evenimente
- SEDA: Arhitectură condusă de evenimente în scenă
- AMPED: Asimetric Multi-Proces-Drived Event
- SYMPED: simetric multi-proces, bazat pe evenimente
Acestea sunt numele modelelor de server de rețea folosite în comunitatea academică și îmi amintesc că am găsit sinonime „în sălbăticie” pentru cel puțin unele dintre ele. (Numele în sine sunt, desigur, mai puțin importante – valoarea reală este în modul de a raționa despre ceea ce se întâmplă în cod.)
Fiecare dintre aceste modele de server de rețea este descris în continuare în secțiunile care urmează.
Modelul Multi-Proces (MP).
Modelul de server de rețea MP este cel pe care toată lumea obișnuia să-l învețe mai întâi, mai ales când învață despre multithreading. În modelul MP, există un proces „master” care acceptă conexiuni (Sarcina #1). Odată stabilită o conexiune, procesul principal creează un nou proces și îi transmite soclul de conectare, astfel încât există un proces per conexiune. Acest nou proces funcționează de obicei cu conexiunea într-un mod simplu, secvenţial, cu pas de blocare: citește ceva din el (Sarcina #2), apoi face unele calcule (Sarcina #3), apoi îi scrie ceva (Sarcina #2). din nou).
Modelul MP este foarte simplu de implementat și de fapt funcționează extrem de bine atâta timp cât numărul total de procese rămâne destul de scăzut. Cât de jos? Răspunsul depinde cu adevărat de ceea ce implică sarcinile #2 și #3. Ca regulă generală, să presupunem că numărul de procese sau fire de execuție nu trebuie să depășească aproximativ de două ori numărul de nuclee CPU. Odată ce sunt prea multe procese active în același timp, sistemul de operare tinde să petreacă mult prea mult timp thraching (adică, jonglarea cu procesele sau firele în jurul nucleelor CPU disponibile) și astfel de aplicații ajung, în general, să-și cheltuie aproape tot CPU-ul. timp în codul „sys” (sau kernel), făcând puțină muncă de fapt utilă.
Pro: Foarte simplu de implementat, funcționează foarte bine atâta timp cât numărul de conexiuni este mic.
Contra: Are tendința de a suprasolicita sistemul de operare dacă numărul de procese crește prea mare și poate avea fluctuații de latență pe măsură ce IO-ul rețelei așteaptă până când faza de sarcină utilă (de calcul) se încheie.
Modelul controlat de evenimente cu un singur proces (SPED).
Modelul de server de rețea SPED a fost făcut celebru de unele aplicații de server de rețea de profil relativ recente, cum ar fi Nginx. Practic, face toate cele trei sarcini în același proces, multiplexându-se între ele. Pentru a fi eficient, necesită unele funcționalități de nucleu destul de avansate, cum ar fi epoll și kqueue. În acest model, codul este condus de conexiunile de intrare și de „evenimente” de date și implementează o „buclă de evenimente” care arată astfel:
- Întrebați sistemul de operare dacă există noi „evenimente” de rețea (cum ar fi conexiuni noi sau date primite)
- Dacă există conexiuni noi disponibile, stabiliți-le (sarcina #1)
- Dacă există date disponibile, citiți-le (Sarcina #2) și acționați în conformitate cu acestea (Sarcina #3)
- Repetați până când serverul iese
Toate acestea sunt realizate într-un singur proces și pot fi realizate extrem de eficient, deoarece evită complet schimbarea contextului între procese, ceea ce de obicei distruge performanța în modelul MP. Singurele schimbări de context aici provin de la apelurile de sistem, iar acestea sunt minimizate acționând doar asupra conexiunilor specifice care au unele evenimente atașate. Acest model poate gestiona zeci de mii de conexiuni simultan, atâta timp cât munca utilă (sarcina #3) nu este prea complicată sau necesită resurse.
Există, totuși, două dezavantaje majore ale acestei abordări:
- Deoarece toate cele trei sarcini sunt efectuate secvențial într-o singură iterație de buclă, munca de sarcină utilă (sarcina #3) se face sincron cu orice altceva, ceea ce înseamnă că, dacă este nevoie de mult timp pentru a calcula un răspuns la datele primite de client, orice altceva se oprește în timp ce se face acest lucru, introducând fluctuații potențial uriașe ale latenței.
- Este folosit doar un singur nucleu CPU. Acest lucru are, din nou, avantajul limitării absolute a numărului de schimbări de context necesare de la sistemul de operare, ceea ce crește performanța generală, dar are dezavantajul semnificativ că orice alte nuclee CPU disponibile nu fac absolut nimic.
Din aceste motive sunt necesare modele mai avansate.

Avantaje: Poate fi foarte performant și ușor pe sistemul de operare (adică necesită intervenție minimă în sistemul de operare). Necesita doar un singur nucleu CPU.
Contra: Utilizează doar un singur procesor (indiferent de numărul disponibil). Dacă sarcina utilă nu este uniformă, rezultă o latență neuniformă a răspunsurilor.
Modelul de arhitectură bazată pe evenimente (SEDA).
Modelul de server de rețea SEDA este puțin complicat. Descompune o aplicație complexă, bazată pe evenimente, într-un set de etape conectate prin cozi. Dacă nu este implementat cu atenție, totuși, performanța sa poate suferi de aceeași problemă ca și cazul MP. Funcționează așa:
- Sarcina utilă (sarcina #3) este împărțită în cât mai multe etape, sau module, posibil. Fiecare modul implementează o singură funcție specifică (gândiți-vă la „microservicii” sau „microkernel-uri”) care rezidă în propriul său proces separat, iar aceste module comunică între ele prin cozi de mesaje. Această arhitectură poate fi reprezentată ca un grafic de noduri, unde fiecare nod este un proces, iar marginile sunt cozi de mesaje.
- Un singur proces realizează Sarcina #1 (de obicei urmând modelul SPED), care descarcă conexiuni noi către anumite noduri de intrare. Acele noduri pot fi fie noduri de rețea pure (Sarcina #2) care transmit datele către alte noduri pentru calcul, fie pot implementa și procesarea sarcinii utile (Sarcina #3). De obicei, nu există un proces „master” (de exemplu, unul care colectează și agregează răspunsurile și le trimite înapoi prin conexiune), deoarece fiecare nod poate răspunde singur.
În teorie, acest model poate fi arbitrar complex, graficul nodului având posibil bucle, conexiuni la alte aplicații similare sau unde nodurile se execută de fapt pe sisteme la distanță. În practică, totuși, chiar și cu mesaje bine definite și cozi eficiente, poate deveni greu să te gândești și să raționezi despre comportamentul sistemului în ansamblu. Mesajul care trece peste cap poate distruge performanța acestui model, în comparație cu modelul SPED, dacă munca depusă la fiecare nod este scurtă. Eficiența acestui model este semnificativ mai mică decât cea a modelului SPED și, prin urmare, este folosit de obicei în situații în care munca utilă este complexă și consumatoare de timp.
Pro: Visul suprem al arhitectului software: totul este separat în module independente.
Contra: Complexitatea poate exploda doar din numărul de module, iar coada de mesaje este încă mult mai lentă decât partajarea directă a memoriei.
Modelul Asymmetric Multi-Process Event-Driven (AMPED).
Serverul de rețea AMPED este o versiune mai îmblânzită, mai ușor de modelat a SEDA. Nu există atât de multe module și procese diferite și nici atât de multe cozi de mesaje. Iată cum funcționează:
- Implementați Sarcinile #1 și #2 într-un singur proces „master”, în stilul SPED. Acesta este singurul proces care face IO în rețea.
- Implementați Sarcina #3 într-un proces separat „lucrător” (posibil început în mai multe instanțe), conectat la procesul principal cu o coadă (o coadă per proces).
- Când datele sunt primite în procesul „master”, găsiți un proces de lucru subutilizat (sau inactiv) și transmiteți datele în coada sa de mesaje. Procesul principal este trimis de către proces atunci când un răspuns este gata, moment în care transmite răspunsul la conexiune.
Lucrul important aici este că munca de sarcină utilă este efectuată într-un număr fix (de obicei configurabil) de procese, care este independent de numărul de conexiuni. Beneficiile aici sunt că sarcina utilă poate fi arbitrar complexă și nu va afecta IO-ul rețelei (ceea ce este bun pentru latență). Există, de asemenea, o posibilitate de securitate sporită, deoarece doar un singur proces face IO în rețea.
Avantaje: Separarea foarte curată a IO-urilor din rețea și a sarcinii utile.
Contra: Utilizează o coadă de mesaje pentru transmiterea datelor înainte și înapoi între procese, care, în funcție de natura protocolului, poate deveni un blocaj.
Modelul SYmmetric Multi-Process Event-Driven (SYMPED).
Modelul de server de rețea SYMPED este în multe privințe „Sfântul Graal” al modelelor de server de rețea, deoarece este ca și cum ați avea mai multe instanțe de procese independente SPED „lucrător”. Este implementat prin existența unui singur proces care acceptă conexiuni într-o buclă, apoi le transmite proceselor de lucru, fiecare dintre ele având o buclă de evenimente similară SPED. Acest lucru are câteva consecințe foarte favorabile:
- CPU-urile sunt încărcate exact pentru numărul de procese generate, care în fiecare moment efectuează fie IO în rețea, fie procesarea sarcinii utile. Nu există nicio modalitate de a intensifica utilizarea procesorului în continuare.
- Dacă conexiunile sunt independente (cum ar fi HTTP), nu există nicio comunicare între procese între procesele de lucru.
Aceasta este, de fapt, ceea ce fac versiunile mai noi de Nginx; generează un număr mic de procese de lucru, fiecare dintre acestea rulând o buclă de evenimente. Pentru a face lucrurile și mai bune, majoritatea sistemelor de operare oferă o funcție prin care mai multe procese pot asculta independent conexiunile de intrare pe un port TCP, eliminând necesitatea unui proces specific dedicat lucrului cu conexiunile de rețea. Dacă aplicația la care lucrați poate fi implementată în acest fel, vă recomand să faceți acest lucru.
Avantaje: Plafon de utilizare strict al procesorului, cu un număr controlabil de bucle asemănătoare SPED.
Contra: Deoarece fiecare proces are o buclă asemănătoare SPED, dacă sarcina utilă este neuniformă, latența poate varia din nou, la fel ca în cazul modelului SPED normal.
Câteva trucuri de nivel scăzut
Pe lângă selectarea celui mai bun model arhitectural pentru aplicația dvs., există câteva trucuri de nivel scăzut care pot fi folosite pentru a crește și mai mult performanța codului de rețea. Iată o listă scurtă a unora dintre cele mai eficiente:
- Evitați alocarea dinamică a memoriei. Ca o explicație, priviți pur și simplu codul pentru alocatoarele de memorie populare - folosesc structuri de date complexe, mutexuri și pur și simplu există atât de mult cod în ele (jemalloc, de exemplu, are aproximativ 450 KiB de cod C!). Cele mai multe dintre modelele de mai sus pot fi implementate cu rețea complet statică (sau prealocată) și/sau buffer-uri care își schimbă proprietatea între fire doar acolo unde este necesar.
- Utilizați maximul pe care sistemul de operare îl poate oferi. Majoritatea sistemelor de operare permit ascultarea mai multor procese pe un singur socket și implementează caracteristici în care o conexiune nu va fi acceptată până când primul octet (sau chiar o primă solicitare completă!) este primită pe socket. Folosiți sendfile() dacă puteți.
- Înțelegeți protocolul de rețea pe care îl utilizați! De exemplu, de obicei este logic să dezactivați algoritmul lui Nagle și poate avea sens să dezactivați persistarea dacă rata de (re)conectare este mare. Aflați despre algoritmii de control al congestiei TCP și vedeți dacă are sens să încercați unul dintre cei mai noi.
S-ar putea să vorbesc mai multe despre acestea, precum și despre tehnici și trucuri suplimentare de folosit, într-o postare viitoare pe blog. Dar, deocamdată, sperăm că aceasta oferă o bază utilă și informativă cu privire la alegerile arhitecturale pentru scrierea codului de rețea de înaltă performanță și avantajele și dezavantajele lor relative.
