Selles artiklis vaatleme I/O reaktori läbi ja lõhki ning selle toimimist, kirjutame juurutusse vähem kui 200 koodireaga ja teeme lihtsa HTTP-serveri protsessi üle 40 miljoni päringu minutis.
Eessõna
Artikkel on kirjutatud selleks, et aidata mõista I/O reaktori toimimist ja seega mõista selle kasutamisel tekkivaid riske.
Artikli mõistmiseks on vaja põhiteadmisi. C keel ja mõningane kogemus võrgurakenduste arendamisel.
Kogu kood on kirjutatud C-keeles rangelt vastavalt (Ettevaatust: pikk PDF) standardile C11 Linuxi jaoks ja saadaval GitHub.
Miks seda teha?
Interneti populaarsuse kasvuga hakkasid veebiserverid korraga käsitlema suurt hulka ühendusi ja seetõttu prooviti kahte lähenemisviisi: I/O blokeerimine suurel hulgal OS-i lõimedel ja mitteblokeeriv I/O kombinatsioonis sündmuste teavitussüsteem, mida nimetatakse ka "süsteemi valijaks" (epoll/kqueue/IOCP/jne).
Esimene lähenemisviis hõlmas uue OS-i lõime loomist iga sissetuleva ühenduse jaoks. Selle puuduseks on halb skaleeritavus: operatsioonisüsteem peab rakendama paljusid konteksti üleminekud и süsteemikõned. Need on kallid toimingud ja võivad põhjustada vaba RAM-i puudumise ja muljetavaldava arvu ühenduste.
Muudetud versioon tõstab esile fikseeritud arv niidid (lõimekogum), takistades sellega süsteemi täitmist katkestamast, kuid samal ajal tekitades uue probleemi: kui lõimede kogum on praegu pikkade lugemistoimingute tõttu blokeeritud, siis teised pesad, mis juba on võimelised andmeid vastu võtma, ei saa seda teha. tee nii.
Teine lähenemisviis kasutab sündmuste teavitussüsteem (süsteemi valija), mida pakub OS. Selles artiklis käsitletakse kõige levinumat süsteemivalija tüüpi, mis põhinevad hoiatustel (sündmused, märguanded) I/O toimingute valmisoleku kohta, mitte teateid nende lõpetamise kohta. Selle kasutamise lihtsustatud näidet saab esitada järgmise plokkskeemi abil:
Nende lähenemisviiside erinevus on järgmine:
I/O toimingute blokeerimine riputama kasutajavoog kunikuni OS on õige defragmendid sissetulevad IP-paketid baitide voogu (TCP, andmete vastuvõtmine) vastasel juhul ei ole sisemistes kirjutuspuhvrites piisavalt ruumi järgnevaks saatmiseks NIC (andmete saatmine).
Süsteemi valija üle aja teatab programmile, et OS juba defragmenteeritud IP-paketid (TCP, andmete vastuvõtt) või piisavalt ruumi sisemistes kirjutuspuhvrites juba saadaval (andmete saatmine).
Kokkuvõtteks võib öelda, et iga sisendi/väljundi jaoks OS-i lõime reserveerimine on arvutusvõimsuse raiskamine, sest tegelikkuses ei tee lõimed kasulikku tööd (sellest ka termin "tarkvara katkestus"). Süsteemi valija lahendab selle probleemi, võimaldades kasutajaprogrammil kasutada protsessori ressursse palju säästlikumalt.
I/O reaktori mudel
I/O reaktor toimib kihina süsteemivalija ja kasutajakoodi vahel. Selle tööpõhimõtet kirjeldab järgmine plokkskeem:
Tuletan meelde, et sündmus on teade, et teatud pesa on võimeline sooritama mitteblokeeriva I/O toimingu.
Sündmuste töötleja on funktsioon, mille kutsub välja I/O reaktor sündmuse vastuvõtmisel, mis seejärel teostab mitteblokeeriva I/O toimingu.
Oluline on märkida, et I/O reaktor on definitsiooni järgi ühe keermega, kuid miski ei takista seda kontseptsiooni kasutamast mitme keermega keskkonnas vahekorras 1 lõime: 1 reaktor, mis taaskasutab kõik protsessori südamikud.
Реализация
Asetame avaliku liidese faili reactor.h, ja rakendamine - sisse reactor.c. reactor.h koosneb järgmistest teadaannetest:
Näita deklaratsioone reaktoris.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);
I/O reaktori struktuur koosneb faili deskriptor valija epoll и räsi tabelidGHashTable, mis kaardistab iga pesa CallbackData (sündmuse töötleja struktuur ja kasutaja argument selle jaoks).
Pange tähele, et oleme lubanud käsitsemise võimaluse mittetäielik tüüp indeksi järgi. IN reactor.h deklareerime struktuuri reactorja sisse reactor.c me määratleme selle, takistades sellega kasutajal oma välju selgesõnaliselt muutmast. See on üks mustritest andmete peitmine, mis sobib kokkuvõtlikult C-semantikasse.
Funktsioonid reactor_register, reactor_deregister и reactor_reregister värskendage huvipakkuvate pistikupesade loendit ja vastavaid sündmuste käitlejaid süsteemivalijas ja räsitabelis.
Pärast seda, kui I/O reaktor on sündmuse deskriptoriga kinni püüdnud fd, kutsub see vastava sündmuste käitleja, millele see edasi läheb fd, natuke mask loodud sündmused ja kasutaja osuti void.
Näita funktsiooni 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;
}
Kokkuvõtteks võib öelda, et kasutajakoodi funktsioonikutsete ahel on järgmisel kujul:
Ühe keermega server
I/O reaktori suure koormuse all testimiseks kirjutame lihtsa HTTP veebiserveri, mis vastab igale päringule pildiga.
Kiire viide HTTP-protokollile
HTTP - see on protokoll rakenduse tase, mida kasutatakse peamiselt serveri ja brauseri suhtluseks.
HTTP-d saab hõlpsasti kasutada transport protokoll TCP, sõnumite saatmine ja vastuvõtmine määratud vormingus spetsifikatsioon.
CRLF on kahe märgi jada: r и n, eraldades päringu esimese rea, päised ja andmed.
<КОМАНДА> - üks neist CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Brauser saadab meie serverile käsu GET, mis tähendab "Saada mulle faili sisu".
<КОД СТАТУСА> on arv, mis tähistab operatsiooni tulemust. Meie server tagastab alati oleku 200 (edukas toiming).
<ОПИСАНИЕ СТАТУСА> — olekukoodi stringesitus. Olekukoodi 200 puhul on see OK.
<ЗАГОЛОВОК N> — päringuga samas vormingus päis. Tagastame pealkirjad Content-Length (faili suurus) ja Content-Type: text/html (tagastatav andmetüüp).
<ДАННЫЕ> — kasutaja küsitud andmed. Meie puhul on see tee pildini HTML.
fail http_server.c (ühe keermega server) sisaldab faili common.h, mis sisaldab järgmisi funktsioonide prototüüpe:
Näita funktsioonide prototüüpe ühiselt.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);
Samuti kirjeldatakse funktsionaalset makrot SAFE_CALL() ja funktsioon on määratletud fail(). Makro võrdleb avaldise väärtust veaga ja kui tingimus on tõene, kutsub funktsiooni välja fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funktsioon fail() prindib terminali edastatud argumendid (nt printf()) ja lõpetab programmi koodiga EXIT_FAILURE:
Funktsioon new_server() tagastab süsteemikutsete loodud "serveri" pesa failideskriptori socket(), bind() и listen() ja on võimeline vastu võtma sissetulevaid ühendusi mitteblokeerivas režiimis.
Pange tähele, et pesa luuakse algselt mitteblokeerivas režiimis lipu abil SOCK_NONBLOCKnii et funktsioonis on_accept() (loe edasi) süsteemikõne accept() ei peatanud lõime täitmist.
kui reuse_port on true, siis konfigureerib see funktsioon pistikupesa valikuga SO_REUSEPORT läbi setsockopt()sama pordi kasutamiseks mitme lõimega keskkonnas (vt jaotist "Mitme lõimega server").
Sündmuste haldaja on_accept() kutsutakse pärast OS-i sündmuse genereerimist EPOLLIN, mis antud juhul tähendab, et uue ühendusega saab nõustuda. on_accept() võtab vastu uue ühenduse, lülitab selle mitteblokeerivasse režiimi ja registreerub sündmuste käitlejas on_recv() I/O reaktoris.
Sündmuste haldaja on_recv() kutsutakse pärast OS-i sündmuse genereerimist EPOLLIN, mis antud juhul tähendab, et ühendus on registreeritud on_accept(), valmis andmete vastuvõtmiseks.
on_recv() loeb ühendusest andmeid kuni HTTP päringu täieliku vastuvõtmiseni, seejärel registreerib töötleja on_send() HTTP vastuse saatmiseks. Kui klient katkestab ühenduse, kustutatakse pistikupesa registreerimine ja suletakse kasutades close().
Näita funktsiooni 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);
}
}
Sündmuste haldaja on_send() kutsutakse pärast OS-i sündmuse genereerimist EPOLLOUT, mis tähendab, et ühendus on registreeritud on_recv(), valmis andmete saatmiseks. See funktsioon saadab kliendile HTTP-vastuse, mis sisaldab HTML-i koos pildiga, ja muudab seejärel sündmuste töötleja uuesti on_recv().
Ja lõpuks failis http_server.c, funktsioonis main() loome I/O reaktori kasutades reactor_new(), looge serveri pesa ja registreerige see, käivitage reaktor kasutades reactor_run() täpselt ühe minuti ja siis vabastame ressursid ja väljume programmist.
Kontrollime, kas kõik toimib ootuspäraselt. Koostamine (chmod a+x compile.sh && ./compile.sh projekti juures) ja käivitage ise kirjutatud server, avage http://127.0.0.1:18470 brauseris ja vaadake, mida ootasime:
Mõõdame ühe lõimega serveri jõudlust. Avame kaks terminali: ühes hakkame jooksma ./http_server, teises - wrk. Minuti pärast kuvatakse teises terminalis järgmine statistika:
Pange tähele, et funktsiooni argument new_server() toetab true. See tähendab, et määrame valiku serveri pesale SO_REUSEPORTkasutada seda mitme lõimega keskkonnas. Täpsemalt saate lugeda siin.
CPU Affinity kasutamine, koostamine koos -march=native, PGO, tabamuste arvu kasv vahemälu, suurendama MAX_EVENTS ja kasutada EPOLLET ei toonud jõudlust oluliselt juurde. Aga mis juhtub, kui suurendate samaaegsete ühenduste arvu?
Soovitud tulemus saadi ja koos sellega huvitav graafik, mis näitab 1 minuti jooksul töödeldud päringute arvu sõltuvust ühenduste arvust:
Näeme, et pärast paarisada ühendust langeb mõlema serveri töödeldud päringute arv järsult (mitme lõimega versioonis on see märgatavam). Kas see on seotud Linuxi TCP/IP-virna juurutamisega? Kirjutage kommentaaridesse oma eeldused graafiku sellise käitumise ja mitmelõimeliste ja ühelõimeliste valikute optimeerimise kohta.
Kui märkis kommentaarides see jõudluskatse ei näita I/O reaktori käitumist reaalsete koormuste korral, sest peaaegu alati suhtleb server andmebaasiga, väljastab logisid, kasutab krüptograafiat TLS jne, mille tulemusena muutub koormus ebaühtlaseks (dünaamiliseks). Testid koos kolmanda osapoole komponentidega viiakse läbi I/O proaktorit käsitlevas artiklis.
I/O reaktori puudused
Peate mõistma, et I/O reaktoril pole puudusi, nimelt:
I/O reaktori kasutamine mitme keermega keskkonnas on mõnevõrra keerulisem, kuna peate vooge käsitsi haldama.
Praktika näitab, et enamikul juhtudel on koormus ebaühtlane, mis võib kaasa tuua ühe lõime logisemise, samal ajal kui teine on tööga hõivatud.
Kui üks sündmuste töötleja blokeerib lõime, blokeerib ka süsteemivalija ise, mis võib põhjustada raskesti leitavaid vigu.
Lahendab need probleemid I/O proaktor, millel on sageli ajakava, mis jaotab koormuse ühtlaselt lõimede kogumile, ja millel on ka mugavam API. Me räägime sellest hiljem, minu teises artiklis.
Järeldus
Siin on meie teekond teooriast otse profileerija heitgaasini jõudnud lõpule.
Te ei tohiks sellel pikemalt peatuda, sest võrgutarkvara kirjutamiseks on palju teisi võrdselt huvitavaid erineva mugavuse ja kiirusega lähenemisviise. Minu arvates on huvitavad lingid allpool.