I/O reaktor (enojni navoj zanka dogodkov) je vzorec za pisanje visoko obremenjene programske opreme, ki se uporablja v številnih priljubljenih rešitvah:
V tem članku si bomo ogledali podrobnosti V/I reaktorja in kako deluje, napisali izvedbo v manj kot 200 vrsticah kode in naredili preprost strežnik HTTP, ki obdela več kot 40 milijonov zahtev/min.
Predgovor
Članek je bil napisan za pomoč pri razumevanju delovanja I/O reaktorja in s tem razumevanju tveganj pri njegovi uporabi.
Za razumevanje članka je potrebno poznavanje osnov. jezik C in nekaj izkušenj z razvojem omrežnih aplikacij.
Vsa koda je napisana v jeziku C strogo v skladu z (pozor: dolg PDF) po standardu C11 za Linux in na voljo na GitHub.
Zakaj to?
Z naraščajočo priljubljenostjo interneta so spletni strežniki začeli obravnavati veliko število povezav hkrati, zato sta bila preizkušena dva pristopa: blokiranje V/I na velikem številu niti OS in neblokiranje V/I v kombinaciji z sistem za obveščanje o dogodkih, imenovan tudi "izbirnik sistema" (epoll/kqueue/IOCP/itd).
Prvi pristop je vključeval ustvarjanje nove niti OS za vsako dohodno povezavo. Njegova pomanjkljivost je slaba razširljivost: operacijski sistem bo moral implementirati veliko prehodi konteksta и sistemske klice. So drage operacije in lahko povzročijo pomanjkanje prostega RAM-a z impresivnim številom povezav.
Spremenjena različica poudarja fiksno število niti (področje niti), s čimer sistemu preprečite prekinitev izvajanja, a hkrati uvedete novo težavo: če je področje niti trenutno blokirano z operacijami dolgega branja, potem druge vtičnice, ki že lahko sprejemajo podatke, ne bodo mogle naredi tako.
Drugi pristop uporablja sistem obveščanja o dogodkih (izbirnik sistema), ki ga zagotavlja OS. Ta članek obravnava najpogostejšo vrsto sistemskega izbirnika, ki temelji na opozorilih (dogodkih, obvestilih) o pripravljenosti na V/I operacije, ne pa na obvestila o njihovem zaključku. Poenostavljen primer njegove uporabe je lahko predstavljen z naslednjim blokovnim diagramom:
Razlika med temi pristopi je naslednja:
Blokiranje V/I operacij prekiniti tok uporabnikov doklerdokler OS ni pravilen defragmentira dohodni IP paketi v tok bajtov (TCP, prejemanje podatkov) ali pa v notranjih zapisovalnih medpomnilnikih ne bo dovolj prostora za nadaljnje pošiljanje prek NIC (pošiljanje podatkov).
Izbirnik sistema čez čas obvesti program, da OS že defragmentirani paketi IP (TCP, sprejem podatkov) ali dovolj prostora v notranjih pisalnih medpomnilnikih že na voljo (pošiljanje podatkov).
Če povzamemo, je rezerviranje niti OS za vsak V/I izguba računalniške moči, ker v resnici niti ne opravljajo koristnega dela (od tod izraz "programska prekinitev"). Sistemski izbirnik rešuje to težavo in uporabniškemu programu omogoča veliko bolj ekonomično uporabo virov procesorja.
Model I/O reaktorja
V/I reaktor deluje kot plast med sistemskim izbirnikom in uporabniško kodo. Načelo njegovega delovanja opisuje naslednji blokovni diagram:
Naj vas spomnim, da je dogodek obvestilo, da lahko določena vtičnica izvede neblokirno V/I operacijo.
Upravljalnik dogodkov je funkcija, ki jo pokliče V/I reaktor, ko prejme dogodek, ki nato izvede neblokirno V/I operacijo.
Pomembno je omeniti, da je I/O reaktor po definiciji enoniten, vendar nič ne preprečuje uporabe tega koncepta v večnitnem okolju v razmerju 1 nit: 1 reaktor, s čimer se reciklirajo vsa jedra CPE.
Реализация
Javni vmesnik bomo postavili v datoteko reactor.h, in izvajanje - v reactor.c. reactor.h bo sestavljen iz naslednjih objav:
Prikaži deklaracije v reactor.h
typedef struct reactor Reactor;
/*
* Указатель на функцию, которая будет вызываться I/O реактором при поступлении
* события от системного селектора.
*/
typedef void (*Callback)(void *arg, int fd, uint32_t events);
/*
* Возвращает `NULL` в случае ошибки, не-`NULL` указатель на `Reactor` в
* противном случае.
*/
Reactor *reactor_new(void);
/*
* Освобождает системный селектор, все зарегистрированные сокеты в данный момент
* времени и сам I/O реактор.
*
* Следующие функции возвращают -1 в случае ошибки, 0 в случае успеха.
*/
int reactor_destroy(Reactor *reactor);
int reactor_register(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg);
int reactor_deregister(const Reactor *reactor, int fd);
int reactor_reregister(const Reactor *reactor, int fd, uint32_t interest,
Callback callback, void *callback_arg);
/*
* Запускает цикл событий с тайм-аутом `timeout`.
*
* Эта функция передаст управление вызывающему коду если отведённое время вышло
* или/и при отсутствии зарегистрированных сокетов.
*/
int reactor_run(const Reactor *reactor, time_t timeout);
Struktura I/O reaktorja je sestavljena iz deskriptor datoteke selektor epoll и zgoščene tabeleGHashTable, ki preslika vsako vtičnico v CallbackData (struktura upravljalnika dogodkov in uporabniški argument zanj).
Upoštevajte, da smo omogočili možnost upravljanja nepopolni tip glede na indeks. IN reactor.h deklariramo strukturo reactor, in v reactor.c definiramo in s tem preprečimo, da bi uporabnik izrecno spreminjal njegova polja. To je eden od vzorcev skrivanje podatkov, ki se jedrnato ujema s semantiko C.
Funkcije reactor_register, reactor_deregister и reactor_reregister posodobite seznam vtičnic, ki vas zanimajo, in ustrezne obdelovalce dogodkov v sistemskem izbirniku in zgoščeni tabeli.
Ko V/I reaktor prestreže dogodek z deskriptorjem fd, pokliče ustrezen upravljalnik dogodkov, h kateremu posreduje fd, bitna maska generirani dogodki in uporabniški kazalec na void.
Prikaži funkcijo reactor_run().
int reactor_run(const Reactor *reactor, time_t timeout) {
int result;
struct epoll_event *events;
if ((events = calloc(MAX_EVENTS, sizeof(*events))) == NULL)
abort();
time_t start = time(NULL);
while (true) {
time_t passed = time(NULL) - start;
int nfds =
epoll_wait(reactor->epoll_fd, events, MAX_EVENTS, timeout - passed);
switch (nfds) {
// Ошибка
case -1:
perror("epoll_wait");
result = -1;
goto cleanup;
// Время вышло
case 0:
result = 0;
goto cleanup;
// Успешная операция
default:
// Вызвать обработчиков событий
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
CallbackData *callback =
g_hash_table_lookup(reactor->table, &fd);
callback->callback(callback->arg, fd, events[i].events);
}
}
}
cleanup:
free(events);
return result;
}
Če povzamemo, bo veriga funkcijskih klicev v uporabniški kodi imela naslednjo obliko:
Strežnik z eno nitjo
Da bi testirali I/O reaktor pod visoko obremenitvijo, bomo napisali preprost spletni strežnik HTTP, ki se na vsako zahtevo odzove s sliko.
Hiter sklic na protokol HTTP
HTTP - to je protokol ravni uporabe, ki se uporablja predvsem za interakcijo med strežnikom in brskalnikom.
HTTP je mogoče preprosto uporabiti transport protokol TCP, pošiljanje in prejemanje sporočil v določenem formatu specifikacija.
CRLF je zaporedje dveh znakov: r и n, ki ločuje prvo vrstico zahteve, glave in podatke.
<КОМАНДА> - eden od CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Brskalnik bo poslal ukaz našemu strežniku GET, kar pomeni "Pošlji mi vsebino datoteke."
<URI> - enotni identifikator vira. Na primer, če je URI = /index.html, potem stranka zahteva glavno stran spletnega mesta.
<ВЕРСИЯ HTTP> — različica protokola HTTP v formatu HTTP/X.Y. Danes najpogosteje uporabljena različica je HTTP/1.1.
<ЗАГОЛОВОК N> je par ključ-vrednost v formatu <КЛЮЧ>: <ЗНАЧЕНИЕ>, poslana strežniku za nadaljnjo analizo.
<ДАННЫЕ> — podatki, ki jih zahteva strežnik za izvedbo operacije. Pogosto je preprosto JSON ali katero koli drugo obliko.
<КОД СТАТУСА> je število, ki predstavlja rezultat operacije. Naš strežnik bo vedno vrnil status 200 (uspešno delovanje).
<ОПИСАНИЕ СТАТУСА> — nizovna predstavitev statusne kode. Za statusno kodo 200 je to OK.
<ЗАГОЛОВОК N> — glava istega formata kot v zahtevku. Naslove bomo vrnili Content-Length (velikost datoteke) in Content-Type: text/html (vrsta vrnjenega podatka).
<ДАННЫЕ> — podatki, ki jih zahteva uporabnik. V našem primeru je to pot do slike v HTML.
datoteka http_server.c (strežnik z eno nitjo) vključuje datoteko common.h, ki vsebuje naslednje prototipe funkcij:
Prikaži prototipe funkcij v common.h
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять новое соединение.
*/
static void on_accept(void *arg, int fd, uint32_t events);
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов отправить HTTP ответ.
*/
static void on_send(void *arg, int fd, uint32_t events);
/*
* Обработчик событий, который вызовется после того, как сокет будет
* готов принять часть HTTP запроса.
*/
static void on_recv(void *arg, int fd, uint32_t events);
/*
* Переводит входящее соединение в неблокирующий режим.
*/
static void set_nonblocking(int fd);
/*
* Печатает переданные аргументы в stderr и выходит из процесса с
* кодом `EXIT_FAILURE`.
*/
static noreturn void fail(const char *format, ...);
/*
* Возвращает файловый дескриптор сокета, способного принимать новые
* TCP соединения.
*/
static int new_server(bool reuse_port);
Opisan je tudi funkcionalni makro SAFE_CALL() in funkcija je definirana fail(). Makro primerja vrednost izraza z napako in, če je pogoj resničen, pokliče funkcijo fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funkcija fail() natisne posredovane argumente terminalu (npr printf()) in prekine program s kodo EXIT_FAILURE:
Funkcija new_server() vrne deskriptor datoteke "strežniške" vtičnice, ustvarjene s sistemskimi klici socket(), bind() и listen() in lahko sprejema dohodne povezave v načinu brez blokiranja.
Upoštevajte, da je vtičnica prvotno ustvarjena v načinu brez blokiranja z uporabo zastavice SOCK_NONBLOCKtako da v funkciji on_accept() (preberi več) sistemski klic accept() ni zaustavil izvajanja niti.
če reuse_port je enako true, potem bo ta funkcija konfigurirala vtičnico z možnostjo SO_REUSEPORT skozi setsockopt()za uporabo istih vrat v okolju z več nitmi (glejte razdelek »Strežnik z več nitmi«).
Obravnavalec dogodkov on_accept() poklican po tem, ko OS ustvari dogodek EPOLLIN, kar v tem primeru pomeni, da je nova povezava lahko sprejeta. on_accept() sprejme novo povezavo, jo preklopi v način brez blokiranja in se registrira z obdelovalcem dogodkov on_recv() v I/O reaktorju.
Obravnavalec dogodkov on_recv() poklican po tem, ko OS ustvari dogodek EPOLLIN, kar v tem primeru pomeni, da je povezava registrirana on_accept(), pripravljen na sprejem podatkov.
on_recv() bere podatke iz povezave, dokler ni v celoti sprejeta zahteva HTTP, nato registrira obdelovalca on_send() za pošiljanje odziva HTTP. Če odjemalec prekine povezavo, se vtičnica odjavi in zapre z uporabo close().
Pokaži funkcijo on_recv()
static void on_recv(void *arg, int fd, uint32_t events) {
RequestBuffer *buffer = arg;
// Принимаем входные данные до тех пор, что recv возвратит 0 или ошибку
ssize_t nread;
while ((nread = recv(fd, buffer->data + buffer->size,
REQUEST_BUFFER_CAPACITY - buffer->size, 0)) > 0)
buffer->size += nread;
// Клиент оборвал соединение
if (nread == 0) {
SAFE_CALL(reactor_deregister(reactor, fd), -1);
SAFE_CALL(close(fd), -1);
request_buffer_destroy(buffer);
return;
}
// read вернул ошибку, отличную от ошибки, при которой вызов заблокирует
// поток
if (errno != EAGAIN && errno != EWOULDBLOCK) {
request_buffer_destroy(buffer);
fail("read");
}
// Получен полный HTTP запрос от клиента. Теперь регистрируем обработчика
// событий для отправки данных
if (request_buffer_is_complete(buffer)) {
request_buffer_clear(buffer);
SAFE_CALL(reactor_reregister(reactor, fd, EPOLLOUT, on_send, buffer),
-1);
}
}
Obravnavalec dogodkov on_send() poklican po tem, ko OS ustvari dogodek EPOLLOUT, kar pomeni, da je povezava registrirana on_recv(), pripravljen za pošiljanje podatkov. Ta funkcija odjemalcu pošlje odziv HTTP, ki vsebuje HTML s sliko, nato pa spremeni obravnavo dogodkov nazaj v on_recv().
In končno, v datoteki http_server.c, v funkciji main() ustvarimo I/O reaktor z uporabo reactor_new(), ustvarite strežniško vtičnico in jo registrirajte, zaženite reaktor z uporabo reactor_run() natanko eno minuto, nato sprostimo sredstva in zapustimo program.
Preverimo, ali vse deluje po pričakovanjih. Prevajanje (chmod a+x compile.sh && ./compile.sh v korenu projekta) in zaženite samonapisani strežnik, odprite http://127.0.0.1:18470 v brskalniku in si oglejte, kaj smo pričakovali:
Izmerimo zmogljivost enonitnega strežnika. Odprimo dva terminala: v enem bomo zagnali ./http_server, v drugačni - delo. Po minuti se na drugem terminalu prikaže naslednja statistika:
Naš strežnik z eno nitjo je lahko obdelal več kot 11 milijonov zahtev na minuto, ki izvirajo iz 100 povezav. Ni slab rezultat, a ga je mogoče izboljšati?
Večnitni strežnik
Kot je bilo omenjeno zgoraj, je V/I reaktor mogoče ustvariti v ločenih nitih, s čimer se uporabljajo vsa jedra CPE. Uporabimo ta pristop v praksi:
Upoštevajte, da argument funkcije new_server() storitve true. To pomeni, da strežniškemu socketu dodelimo možnost SO_REUSEPORTza uporabo v večnitnem okolju. Več podrobnosti si lahko preberete tukaj.
Drugi tek
Zdaj pa izmerimo zmogljivost večnitnega strežnika:
Število zahtev, obdelanih v 1 minuti, se je povečalo za ~3.28-krat! Toda do okrogle številke nam je manjkala le ~XNUMX milijona, zato poskusimo to popraviti.
Uporaba CPE Affinity, kompilacija z -march=native, PGO, povečanje števila zadetkov gotovina, porast MAX_EVENTS in uporabo EPOLLET ni prineslo bistvenega povečanja učinkovitosti. Toda kaj se zgodi, če povečate število hkratnih povezav?
Dobili smo želeni rezultat in s tem zanimiv graf, ki prikazuje odvisnost števila obdelanih zahtevkov v 1 minuti od števila povezav:
Vidimo, da po nekaj sto povezavah število obdelanih zahtev za oba strežnika močno upade (v večnitni različici je to bolj opazno). Je to povezano z implementacijo sklada TCP/IP v Linuxu? V komentarje lahko napišete svoje domneve o tem obnašanju grafa in optimizacijah za večnitne in enonitne možnosti.
Kot opozoriti v komentarjih ta preizkus zmogljivosti ne prikazuje obnašanja I/O reaktorja pod resničnimi obremenitvami, ker skoraj vedno strežnik komunicira z bazo podatkov, izpisuje dnevnike, uporablja kriptografijo z TLS itd., zaradi česar postane obremenitev neenakomerna (dinamična). Testi skupaj s komponentami tretjih oseb bodo izvedeni v članku o V/I proaktorju.
Slabosti V/I reaktorja
Morate razumeti, da V/I reaktor ni brez pomanjkljivosti, in sicer:
Uporaba V/I reaktorja v večnitnem okolju je nekoliko težja, ker boste morali ročno upravljati tokove.
Praksa kaže, da je v večini primerov obremenitev neenakomerna, kar lahko privede do beleženja ene niti, medtem ko je druga zaposlena z delom.
Če en obravnavalec dogodkov blokira nit, bo blokiral tudi sistemski izbirnik, kar lahko privede do napak, ki jih je težko najti.
Rešuje te težave V/I proaktor, ki ima pogosto razporejevalnik, ki enakomerno porazdeli obremenitev na skupino niti, ima pa tudi bolj priročen API. O tem bomo govorili kasneje, v mojem drugem članku.
Zaključek
Tu se je naše potovanje od teorije naravnost do profilnega izpuha končalo.
Ne bi se smeli zadrževati na tem, ker obstaja veliko drugih enako zanimivih pristopov k pisanju omrežne programske opreme z različnimi stopnjami priročnosti in hitrosti. Spodaj so po mojem mnenju zanimive povezave.