Në këtë artikull, ne do të shikojmë të dhënat e një reaktori I/O dhe mënyrën se si funksionon, do të shkruajmë një zbatim në më pak se 200 rreshta kodi dhe do të bëjmë një proces të thjeshtë të serverit HTTP mbi 40 milionë kërkesa/min.
Parathënie libri
Artikulli u shkrua për të ndihmuar në kuptimin e funksionimit të reaktorit I/O, dhe për këtë arsye për të kuptuar rreziqet gjatë përdorimit të tij.
Për të kuptuar artikullin kërkohet njohja e bazave. gjuha C dhe disa përvojë në zhvillimin e aplikacioneve të rrjetit.
I gjithë kodi është shkruar në gjuhën C në mënyrë rigoroze sipas (kujdes: PDF e gjatë) sipas standardit C11 për Linux dhe në dispozicion në GitHub.
Pse bëhet kjo?
Me rritjen e popullaritetit të internetit, serverët e uebit filluan të kenë nevojë të trajtojnë një numër të madh lidhjesh njëkohësisht, dhe për këtë arsye u provuan dy qasje: bllokimi i hyrjes/daljes në një numër të madh temash të sistemit operativ dhe mosbllokimi i hyrjes/daljes në kombinim me një sistem njoftimi për ngjarje, i quajtur gjithashtu "zgjedhësi i sistemit" (epoll/në radhë/IOCP/etj).
Qasja e parë përfshinte krijimin e një filli të ri OS për çdo lidhje hyrëse. Disavantazhi i tij është shkallëzueshmëria e dobët: sistemi operativ do të duhet të zbatojë shumë tranzicionet e kontekstit и thirrjet e sistemit. Ato janë operacione të shtrenjta dhe mund të çojnë në mungesë të RAM-it të lirë me një numër mbresëlënës lidhjesh.
Versioni i modifikuar thekson numër fiks i fijeve (peshinë thread), duke parandaluar kështu sistemin nga ndërprerja e ekzekutimit, por në të njëjtën kohë duke paraqitur një problem të ri: nëse një grup thread është aktualisht i bllokuar nga operacionet e leximit të gjatë, atëherë prizat e tjera që tashmë janë në gjendje të marrin të dhëna nuk do të jenë në gjendje të bej keshtu.
Qasja e dytë përdor sistemi i njoftimit të ngjarjeve (zgjedhësi i sistemit) i siguruar nga OS. Ky artikull diskuton llojin më të zakonshëm të përzgjedhësit të sistemit, bazuar në sinjalizimet (ngjarjet, njoftimet) në lidhje me gatishmërinë për operacionet I/O, dhe jo në njoftimet për përfundimin e tyre. Një shembull i thjeshtuar i përdorimit të tij mund të përfaqësohet nga bllok diagrami i mëposhtëm:
Dallimi midis këtyre qasjeve është si më poshtë:
Bllokimi i operacioneve I/O pezullojë fluksi i përdoruesit deri saderisa OS të jetë në rregull defragmentet hyrëse paketat IP në rrjedhën e bajtit (TCP, duke marrë të dhëna) ose nuk do të ketë hapësirë të mjaftueshme në buferët e brendshëm të shkrimit për dërgimin e mëvonshëm nëpërmjet NIC (dërgimi i të dhënave).
Zgjedhësi i sistemit me kalimin e kohës njofton programin se OS tashmë paketa IP të defragmentuara (TCP, marrja e të dhënave) ose hapësirë e mjaftueshme në buferët e brendshëm të shkrimit tashmë në dispozicion (dërgimi i të dhënave).
Për ta përmbledhur, rezervimi i një thread OS për çdo I/O është humbje e fuqisë kompjuterike, sepse në realitet, thread-ët nuk po bëjnë punë të dobishme (prandaj termi "ndërprerje e softuerit"). Përzgjedhësi i sistemit e zgjidh këtë problem, duke i lejuar programit të përdoruesit të përdorë burimet e CPU-së shumë më ekonomikisht.
Modeli i reaktorit I/O
Reaktori I/O vepron si një shtresë ndërmjet përzgjedhësit të sistemit dhe kodit të përdoruesit. Parimi i funksionimit të tij përshkruhet nga bllok diagrami i mëposhtëm:
Më lejoni t'ju kujtoj se një ngjarje është një njoftim që një prizë e caktuar është në gjendje të kryejë një operacion I/O jo-bllokues.
Një mbajtës i ngjarjeve është një funksion i thirrur nga reaktori I/O kur merret një ngjarje, i cili më pas kryen një operacion I/O jo-bllokues.
Është e rëndësishme të theksohet se reaktori I/O është sipas përkufizimit me një filetim të vetëm, por nuk ka asgjë që e ndalon konceptin të përdoret në një mjedis me shumë fije në një raport prej 1 fije: 1 reaktor, duke ricikluar kështu të gjitha bërthamat e CPU.
Zbatimi
Ne do të vendosim ndërfaqen publike në një skedar reactor.h, dhe zbatimi - në reactor.c. reactor.h do të përbëhet nga njoftimet e mëposhtme:
Trego deklaratat në reaktor.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 e reaktorit I/O përbëhet nga përshkruesi i skedarit përzgjedhës epoll и tabela hashGHashTable, e cila e lidh çdo fole në CallbackData (struktura e një mbajtësi të ngjarjeve dhe një argument i përdoruesit për të).
Ju lutemi vini re se ne kemi aktivizuar aftësinë për të trajtuar lloj i paplotë sipas indeksit. NË reactor.h ne deklarojmë strukturën reactordhe në reactor.c ne e përcaktojmë atë, duke parandaluar kështu përdoruesin të ndryshojë në mënyrë eksplicite fushat e tij. Ky është një nga modelet fshehja e të dhënave, e cila përshtatet në mënyrë të përmbledhur në semantikën C.
Funksionet reactor_register, reactor_deregister и reactor_reregister përditësoni listën e prizave me interes dhe mbajtësit përkatës të ngjarjeve në përzgjedhësin e sistemit dhe tabelën hash.
Pasi reaktori I/O ka përgjuar ngjarjen me përshkruesin fd, ai thërret mbajtësin përkatës të ngjarjeve, tek i cili kalon fd, maskë bit ngjarjet e krijuara dhe një tregues përdoruesi për void.
Shfaq funksionin 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;
}
Për ta përmbledhur, zinxhiri i thirrjeve të funksionit në kodin e përdoruesit do të marrë formën e mëposhtme:
Server me një filetim të vetëm
Për të testuar reaktorin I/O nën ngarkesë të lartë, ne do të shkruajmë një server të thjeshtë në internet HTTP që i përgjigjet çdo kërkese me një imazh.
Një referencë e shpejtë për protokollin HTTP
HTTP - ky është protokolli niveli i aplikimit, përdoret kryesisht për ndërveprimin server-shfletues.
HTTP mund të përdoret lehtësisht transporti protokoll TCP, dërgimi dhe marrja e mesazheve në një format të specifikuar Specifikim.
CRLF është një sekuencë prej dy karakteresh: r и n, duke ndarë rreshtin e parë të kërkesës, titujt dhe të dhënat.
<КОМАНДА> - nje nga CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Shfletuesi do të dërgojë një komandë në serverin tonë GET, që do të thotë "Më dërgoni përmbajtjen e skedarit."
<URI> - identifikues uniform i burimit. Për shembull, nëse URI = /index.html, atëherë klienti kërkon faqen kryesore të faqes.
<ВЕРСИЯ HTTP> — versioni i protokollit HTTP në format HTTP/X.Y. Versioni më i përdorur sot është HTTP/1.1.
<ЗАГОЛОВОК N> është një çift çelës-vlerë në format <КЛЮЧ>: <ЗНАЧЕНИЕ>, dërguar në server për analiza të mëtejshme.
<ДАННЫЕ> — të dhënat e kërkuara nga serveri për të kryer operacionin. Shpesh është e thjeshtë JSON ose ndonjë format tjetër.
<КОД СТАТУСА> është një numër që përfaqëson rezultatin e operacionit. Serveri ynë gjithmonë do të kthejë statusin 200 (operim i suksesshëm).
<ОПИСАНИЕ СТАТУСА> — paraqitja e vargut të kodit të statusit. Për kodin e statusit 200 kjo është OK.
<ЗАГОЛОВОК N> - titulli i të njëjtit format si në kërkesë. Ne do t'i kthejmë titujt Content-Length (madhësia e skedarit) dhe Content-Type: text/html (kthimi i llojit të të dhënave).
<ДАННЫЕ> — të dhënat e kërkuara nga përdoruesi. Në rastin tonë, kjo është rruga drejt imazhit në HTML.
skedar http_server.c (server me një filetim të vetëm) përfshin skedarin common.h, i cili përmban prototipet e mëposhtme të funksionit:
Trego prototipet e funksioneve të përbashkëta.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);
Makro funksionale është përshkruar gjithashtu SAFE_CALL() dhe funksioni është i përcaktuar fail(). Makroja krahason vlerën e shprehjes me gabimin, dhe nëse kushti është i vërtetë, thërret funksionin fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funksion fail() printon argumentet e kaluara në terminal (si printf()) dhe përfundon programin me kodin EXIT_FAILURE:
Funksion new_server() kthen përshkruesin e skedarit të prizës "server" të krijuar nga thirrjet e sistemit socket(), bind() и listen() dhe të aftë për të pranuar lidhjet hyrëse në një modalitet jo-bllokues.
Vini re se priza fillimisht krijohet në modalitetin jo-bllokues duke përdorur flamurin SOCK_NONBLOCKnë mënyrë që në funksion on_accept() (lexo më shumë) thirrje sistemi accept() nuk e ndaloi ekzekutimin e fillit.
Nëse reuse_port është true, atëherë ky funksion do të konfigurojë prizën me opsionin SO_REUSEPORT përmes setsockopt()për të përdorur të njëjtën portë në një mjedis me shumë fije (shih seksionin "Serveri me shumë fije").
Mbajtës i ngjarjeve on_accept() thirret pasi OS gjeneron një ngjarje EPOLLIN, në këtë rast do të thotë se lidhja e re mund të pranohet. on_accept() pranon një lidhje të re, e kalon atë në modalitetin pa bllokim dhe regjistrohet me një mbajtës të ngjarjeve on_recv() në një reaktor I/O.
Mbajtës i ngjarjeve on_recv() thirret pasi OS gjeneron një ngjarje EPOLLIN, në këtë rast do të thotë se lidhja është regjistruar on_accept(), gati për të marrë të dhëna.
on_recv() lexon të dhënat nga lidhja derisa kërkesa HTTP të merret plotësisht, pastaj regjistron një mbajtës on_send() për të dërguar një përgjigje HTTP. Nëse klienti prish lidhjen, priza çregjistrohet dhe mbyllet duke përdorur close().
Shfaq funksionin 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);
}
}
Mbajtës i ngjarjeve on_send() thirret pasi OS gjeneron një ngjarje EPOLLOUT, që do të thotë se lidhja është regjistruar on_recv(), gati për të dërguar të dhëna. Ky funksion dërgon një përgjigje HTTP që përmban HTML me një imazh te klienti dhe më pas ndryshon trajtuesin e ngjarjeve përsëri në on_recv().
Dhe së fundi, në dosje http_server.c, në funksion main() ne krijojmë një reaktor I/O duke përdorur reactor_new(), krijoni një prizë serveri dhe regjistrojeni atë, filloni duke përdorur reaktorin reactor_run() për saktësisht një minutë, dhe më pas lëshojmë burime dhe dalim nga programi.
Le të kontrollojmë që gjithçka po funksionon siç pritej. Përpilimi (chmod a+x compile.sh && ./compile.sh në rrënjën e projektit) dhe hapni serverin e vetë-shkruar http://127.0.0.1:18470 në shfletues dhe shikoni se çfarë prisnim:
Le të matim performancën e një serveri me një filetim të vetëm. Le të hapim dy terminale: në një do të vrapojmë ./http_server, në një tjetër - dërrmues. Pas një minutë, statistikat e mëposhtme do të shfaqen në terminalin e dytë:
Serveri ynë me një fije të vetme ishte në gjendje të përpunonte mbi 11 milionë kërkesa në minutë, me origjinë nga 100 lidhje. Nuk është një rezultat i keq, por a mund të përmirësohet?
Server me shumë fije
Siç u përmend më lart, reaktori I/O mund të krijohet në fije të veçanta, duke shfrytëzuar kështu të gjitha bërthamat e CPU. Le ta zbatojmë këtë qasje në praktikë:
Ju lutemi vini re se argumenti i funksionit new_server() aktet true. Kjo do të thotë që ne ia caktojmë opsionin prizës së serverit SO_REUSEPORTpër ta përdorur atë në një mjedis me shumë fije. Mund të lexoni më shumë detaje këtu.
Vrapimi i dytë
Tani le të matim performancën e një serveri me shumë fije:
Numri i kërkesave të përpunuara në 1 minutë është rritur me ~3.28 herë! Por ne kishim vetëm XNUMX milionë pak nga numri i rrumbullakët, kështu që le të përpiqemi ta rregullojmë atë.
Së pari le të shohim statistikat e krijuara perfekte:
Përdorimi i afinitetit të CPU, përmbledhje me -march=native, PGO, një rritje në numrin e goditjeve vend i fshehtë, rrit MAX_EVENTS dhe përdorni EPOLLET nuk dha një rritje të ndjeshme të performancës. Por çfarë ndodh nëse rrit numrin e lidhjeve të njëkohshme?
Rezultati i dëshiruar u mor dhe me të një grafik interesant që tregon varësinë e numrit të kërkesave të përpunuara në 1 minutë nga numri i lidhjeve:
Ne shohim që pas disa qindra lidhjeve, numri i kërkesave të përpunuara për të dy serverët bie ndjeshëm (në versionin me shumë fije kjo është më e dukshme). A është kjo e lidhur me zbatimin e pirgut të Linux TCP/IP? Mos ngurroni të shkruani supozimet tuaja në lidhje me këtë sjellje të grafikut dhe optimizimet për opsionet me shumë fije dhe me një fije në komente.
Si vuri në dukje në komente, ky test i performancës nuk tregon sjelljen e reaktorit I/O nën ngarkesa reale, sepse pothuajse gjithmonë serveri ndërvepron me bazën e të dhënave, nxjerr regjistrat, përdor kriptografinë me TLS etj., si rezultat i së cilës ngarkesa bëhet jo uniforme (dinamike). Testet së bashku me komponentët e palëve të treta do të kryhen në artikullin për proaktorin I/O.
Disavantazhet e reaktorit I/O
Ju duhet të kuptoni se reaktori I/O nuk është pa të metat e tij, përkatësisht:
Përdorimi i një reaktori I/O në një mjedis me shumë fije është disi më i vështirë, sepse do t'ju duhet të menaxhoni manualisht flukset.
Praktika tregon se në shumicën e rasteve ngarkesa është jo uniforme, gjë që mund të çojë në prerjen e një fijeje ndërsa një tjetër është e zënë me punë.
Nëse një mbajtës ngjarjesh bllokon një thread, vetë zgjedhësi i sistemit do të bllokojë gjithashtu, gjë që mund të çojë në gabime të vështira për t'u gjetur.
Zgjidh këto probleme Proaktor I/O, i cili shpesh ka një planifikues që shpërndan në mënyrë të barabartë ngarkesën në një grup fijesh, dhe gjithashtu ka një API më të përshtatshëm. Ne do të flasim për këtë më vonë, në artikullin tim tjetër.
Përfundim
Këtu ka përfunduar udhëtimi ynë nga teoria drejt e në shter të profilit.
Ju nuk duhet të ndaleni në këtë, sepse ka shumë qasje të tjera po aq interesante për të shkruar softuer rrjeti me nivele të ndryshme komoditeti dhe shpejtësie. Interesante, për mendimin tim, lidhjet janë dhënë më poshtë.