Tässä artikkelissa tarkastellaan I/O-reaktorin ja sen toimintatapoja, kirjoitetaan toteutus alle 200 koodirivillä ja tehdään yksinkertainen HTTP-palvelinprosessi yli 40 miljoonalla pyynnöllä/min.
Esipuhe
Artikkeli on kirjoitettu auttamaan ymmärtämään I/O-reaktorin toimintaa ja siten ymmärtämään sen käytön riskejä.
Artikkelin ymmärtäminen edellyttää perusasioiden tuntemusta. C-kieli ja jonkin verran kokemusta verkkosovellusten kehittämisestä.
Kaikki koodi on kirjoitettu C-kielellä tiukasti (varoitus: pitkä PDF) C11-standardin mukaan Linuxille ja saatavilla GitHub.
Miksi se?
Internetin kasvavan suosion myötä web-palvelimet alkoivat joutua käsittelemään suurta määrää yhteyksiä samanaikaisesti, ja siksi kokeiltiin kahta lähestymistapaa: I/O:n estäminen suurella määrällä käyttöjärjestelmäsäikeitä ja ei-esto I/O yhdessä tapahtumailmoitusjärjestelmä, jota kutsutaan myös "järjestelmän valitsimeksi" (epoll/kqueue/IOCP/jne).
Ensimmäinen lähestymistapa sisälsi uuden käyttöjärjestelmäsäikeen luomisen jokaiselle saapuvalle yhteydelle. Sen haittana on huono skaalautuvuus: käyttöjärjestelmän on otettava käyttöön monia kontekstin siirtymät и järjestelmäpuhelut. Ne ovat kalliita toimintoja ja voivat johtaa vapaan RAM-muistin puutteeseen vaikuttavalla määrällä yhteyksiä.
Muokattu versio korostaa kiinteä määrä lankoja (säievarasto), mikä estää järjestelmää keskeyttämästä suoritusta, mutta samalla tuo mukanaan uuden ongelman: jos säiepooli on tällä hetkellä tukossa pitkien lukutoimintojen takia, muut pistokkeet, jotka jo pystyvät vastaanottamaan tietoja, eivät pysty tee niin.
Toinen lähestymistapa käyttää tapahtumailmoitusjärjestelmä (järjestelmän valitsin), jonka käyttöjärjestelmä tarjoaa. Tässä artikkelissa käsitellään yleisintä järjestelmän valitsintyyppiä, joka perustuu hälytyksiin (tapahtumat, ilmoitukset) I/O-toimintojen valmiudesta sen sijaan, että ilmoitukset niiden valmistumisesta. Yksinkertaistettu esimerkki sen käytöstä voidaan esittää seuraavalla lohkokaaviolla:
Ero näiden lähestymistapojen välillä on seuraava:
I/O-toimintojen estäminen keskeyttää käyttäjävirta siihen asti kunkunnes käyttöjärjestelmä on kunnossa eheytys saapuva IP-paketit tavuvirtaan (TCP, vastaanottaa tietoja) tai sisäisissä kirjoituspuskureissa ei ole tarpeeksi tilaa myöhempää lähettämistä varten NIC (lähettää dataa).
Järjestelmän valitsin ajan myötä ilmoittaa ohjelmalle, että käyttöjärjestelmä jo eheytetyt IP-paketit (TCP, tiedon vastaanotto) tai riittävästi tilaa sisäisissä kirjoituspuskureissa jo käytettävissä (lähetetään tietoja).
Yhteenvetona voidaan todeta, että käyttöjärjestelmäsäikeen varaaminen kullekin I/O:lle on laskentatehon tuhlausta, koska todellisuudessa säikeet eivät tee hyödyllistä työtä (tämä termi "ohjelmiston keskeytys"). Järjestelmän valitsin ratkaisee tämän ongelman, jolloin käyttäjäohjelma voi käyttää CPU-resursseja paljon taloudellisemmin.
I/O-reaktorin malli
I/O-reaktori toimii kerroksena järjestelmävalitsimen ja käyttäjäkoodin välillä. Sen toimintaperiaate on kuvattu seuraavalla lohkokaaviolla:
Haluan muistuttaa, että tapahtuma on ilmoitus siitä, että tietty socket pystyy suorittamaan estävän I/O-toiminnon.
Tapahtumakäsittelijä on I/O-reaktorin kutsuma toiminto, kun tapahtuma vastaanotetaan, ja joka sitten suorittaa estävän I/O-operaation.
On tärkeää huomata, että I/O-reaktori on määritelmän mukaan yksisäikeinen, mutta mikään ei estä konseptia käyttämästä monisäikeisessä ympäristössä suhteessa 1 säie:1 reaktori, mikä kierrättää kaikki CPU:n ytimet.
Реализация
Sijoitamme julkisen käyttöliittymän tiedostoon reactor.h, ja toteutus - sisään reactor.c. reactor.h koostuu seuraavista ilmoituksista:
Näytä ilmoitukset reaktorissa.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-reaktorin rakenne koostuu tiedostokuvaaja селектора epoll и hash-taulukoitaGHashTable, joka yhdistää jokaisen pistorasian CallbackData (tapahtumakäsittelijän rakenne ja käyttäjäargumentti sille).
Huomaa, että olemme ottaneet käyttöön mahdollisuuden käsitellä epätäydellinen tyyppi indeksin mukaan. SISÄÄN reactor.h kerromme rakenteen reactorja sisään reactor.c määrittelemme sen ja estämme siten käyttäjää muuttamasta kenttiä erikseen. Tämä on yksi malleista tietojen piilottaminen, joka sopii ytimekkäästi C-semantiikkaan.
Tehtävät reactor_register, reactor_deregister и reactor_reregister päivitä luettelo kiinnostavista pistokkeista ja vastaavista tapahtumakäsittelijöistä järjestelmävalitsimessa ja hash-taulukossa.
Kun I/O-reaktori on siepannut tapahtuman kuvaajan kanssa fd, se kutsuu vastaavaa tapahtumakäsittelijää, jolle se siirtyy fd, bitti naamio luodut tapahtumat ja käyttäjän osoitin void.
Näytä reactor_run()-funktio
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;
}
Yhteenvetona voidaan todeta, että toimintokutsujen ketju käyttäjäkoodissa on seuraavanlainen:
Yksisäikeinen palvelin
I/O-reaktorin testaamiseksi suurella kuormituksella kirjoitamme yksinkertaisen HTTP-verkkopalvelimen, joka vastaa kaikkiin pyyntöihin kuvalla.
Pikaviittaus HTTP-protokollaan
HTTP - Tämä on protokolla sovellustaso, jota käytetään ensisijaisesti palvelimen ja selaimen vuorovaikutukseen.
HTTP:tä voidaan helposti käyttää yli kuljetus protokollaa TCP, lähettää ja vastaanottaa viestejä määritetyssä muodossa erittely.
CRLF on kahden merkin sarja: r и n, joka erottaa pyynnön ensimmäisen rivin, otsikot ja tiedot.
<КОМАНДА> - Yksi CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Selain lähettää komennon palvelimellemme GET, joka tarkoittaa "Lähetä minulle tiedoston sisältö".
<URI> - yhtenäinen resurssitunniste. Esimerkiksi jos URI = /index.html, то клиент запрашивает главную страницу сайта.
<ВЕРСИЯ HTTP> — HTTP-protokollan versio muodossa HTTP/X.Y. Nykyään yleisimmin käytetty versio on HTTP/1.1.
<ЗАГОЛОВОК N> on avain-arvo-pari muodossa <КЛЮЧ>: <ЗНАЧЕНИЕ>, lähetetään palvelimelle lisäanalyysiä varten.
<ДАННЫЕ> — palvelimen toiminnon suorittamiseen tarvitsemat tiedot. Usein se on yksinkertaista JSON или любой другой формат.
<КОД СТАТУСА> on luku, joka edustaa operaation tulosta. Palvelimemme palauttaa aina tilan 200 (onnistunut toiminta).
<ОПИСАНИЕ СТАТУСА> — tilakoodin merkkijonoesitys. Tilakoodille 200 tämä on OK.
<ЗАГОЛОВОК N> — otsikko, jonka muoto on sama kuin pyynnössä. Palautamme otsikot Content-Length (tiedoston koko) ja Content-Type: text/html (palautustietotyyppi).
<ДАННЫЕ> — käyttäjän pyytämät tiedot. Meidän tapauksessamme tämä on polku sisään tulevaan kuvaan HTML.
tiedosto http_server.c (yksisäikeinen palvelin) sisältää tiedoston common.h, который содержит следующие прототипы функций:
Näytä funktion prototyypit yhteisissä.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);
Myös toiminnallinen makro kuvataan SAFE_CALL() ja funktio on määritelty fail(). Makro vertaa lausekkeen arvoa virheeseen, ja jos ehto on tosi, se kutsuu funktiota fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Toiminto fail() tulostaa päätelaitteelle välitetyt argumentit (esim printf()) ja lopettaa ohjelman koodilla EXIT_FAILURE:
Toiminto new_server() palauttaa järjestelmäkutsujen luoman "palvelin" -socketin tiedostokuvaajan socket(), bind() и listen() ja pystyy vastaanottamaan saapuvia yhteyksiä estotilassa.
Huomaa, että pistoke luodaan alun perin estotilassa lippua käyttämällä SOCK_NONBLOCKniin että funktiossa on_accept() (lue lisää) järjestelmäkutsu accept() ei pysäyttänyt langan suorittamista.
Jos reuse_port on yhtä suuri kuin true, tämä toiminto määrittää liittimen vaihtoehdolla SO_REUSEPORT kautta setsockopt()käyttää samaa porttia monisäikeisessä ympäristössä (katso kohta "Monisäikeinen palvelin").
Tapahtumakäsittelijä on_accept() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLIN, mikä tässä tapauksessa tarkoittaa, että uusi yhteys voidaan hyväksyä. on_accept() hyväksyy uuden yhteyden, vaihtaa sen estotilaan ja rekisteröityy tapahtumakäsittelijään on_recv() I/O-reaktorissa.
Tapahtumakäsittelijä on_recv() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLIN, mikä tässä tapauksessa tarkoittaa, että yhteys rekisteröity on_accept(), valmis vastaanottamaan tietoja.
on_recv() lukee dataa yhteydestä, kunnes HTTP-pyyntö on vastaanotettu kokonaan, sitten se rekisteröi käsittelijän on_send() lähettääksesi HTTP-vastauksen. Jos asiakas katkaisee yhteyden, pistorasia poistetaan rekisteristä ja suljetaan käyttämällä close().
Näytä funktio 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);
}
}
Tapahtumakäsittelijä on_send() kutsutaan sen jälkeen, kun käyttöjärjestelmä on luonut tapahtuman EPOLLOUT, mikä tarkoittaa, että yhteys on rekisteröity on_recv(), valmis lähettämään tietoja. Tämä toiminto lähettää asiakkaalle HTTP-vastauksen, joka sisältää HTML-koodin ja kuvan, ja muuttaa sitten tapahtumakäsittelijäksi takaisin on_recv().
Ja lopuksi tiedostossa http_server.c, toiminnassa main() luomme I/O-reaktorin käyttämällä reactor_new(), luo palvelinpistoke ja rekisteröi se, käynnistä reaktori käyttämällä reactor_run() tarkalleen minuutin ajan, minkä jälkeen vapautamme resurssit ja poistumme ohjelmasta.
Tarkastetaan, että kaikki toimii odotetusti. Kääntäminen (chmod a+x compile.sh && ./compile.sh projektin juuressa) ja käynnistä itsekirjoitettu palvelin, avaa http://127.0.0.1:18470 selaimessa ja katso mitä odotimme:
Mittaataan yksisäikeisen palvelimen suorituskykyä. Avataan kaksi terminaalia: toisessa ajamme ./http_server, eri - wrk. Minuutin kuluttua toisessa päätteessä näytetään seuraavat tilastot:
Yksisäikeinen palvelimemme pystyi käsittelemään yli 11 miljoonaa pyyntöä minuutissa 100 yhteydestä. Ei huono tulos, mutta voiko sitä parantaa?
Monisäikeinen palvelin
Kuten edellä mainittiin, I/O-reaktori voidaan luoda erillisinä säikeinä, jolloin hyödynnetään kaikkia CPU-ytimiä. Laitetaan tämä lähestymistapa käytäntöön:
Huomaa, että funktion argumentti new_server() säädökset true. Tämä tarkoittaa, että määritämme vaihtoehdon palvelinpistokkeelle SO_REUSEPORTkäyttää sitä monisäikeisessä ympäristössä. Voit lukea lisätietoja täällä.
Toinen juoksu
Mittaa nyt monisäikeisen palvelimen suorituskykyä:
1 minuutissa käsiteltyjen pyyntöjen määrä kasvoi ~3.28-kertaiseksi! Mutta meillä oli vain ~XNUMX miljoonaa pulaa pyöreästä numerosta, joten yritetään korjata se.
CPU Affinityn käyttäminen, kokoelma kanssa -march=native, PGO, osumien määrän kasvu Käteinen raha, lisääntyä MAX_EVENTS ja käyttää EPOLLET ei lisännyt merkittävästi suorituskykyä. Mutta mitä tapahtuu, jos lisäät samanaikaisten yhteyksien määrää?
Haluttu tulos saatiin ja sen mukana mielenkiintoinen kaavio, joka näyttää 1 minuutin aikana käsiteltyjen pyyntöjen määrän riippuvuuden yhteyksien määrästä:
Näemme, että parin sadan yhteyden jälkeen molempien palvelimien käsiteltyjen pyyntöjen määrä laskee jyrkästi (monisäikeisessä versiossa tämä on havaittavampi). Liittyykö tämä Linuxin TCP/IP-pinon toteutukseen? Voit vapaasti kirjoittaa kommentteihin oletuksesi tästä kaavion käyttäytymisestä ja optimoinnista monisäikeisille ja yksisäikeisille vaihtoehdoille.
miten huomioitu kommenteissa tämä suorituskykytesti ei näytä I/O-reaktorin käyttäytymistä todellisissa kuormituksissa, koska lähes aina palvelin on vuorovaikutuksessa tietokannan kanssa, tulostaa lokeja, käyttää salausta TLS jne., minkä seurauksena kuormitus muuttuu epätasaiseksi (dynaamiseksi). Testit yhdessä kolmannen osapuolen komponenttien kanssa suoritetaan I/O-proactoria käsittelevässä artikkelissa.
I/O-reaktorin haitat
Sinun on ymmärrettävä, että I/O-reaktorissa ei ole haittoja, nimittäin:
I/O-reaktorin käyttö monisäikeisessä ympäristössä on jonkin verran vaikeampaa, koska joudut hallitsemaan virtoja manuaalisesti.
Käytäntö osoittaa, että useimmissa tapauksissa kuormitus on epätasaista, mikä voi johtaa yhden säikeen kirjautumiseen, kun toinen on kiireinen töissä.
Jos yksi tapahtumakäsittelijä estää säiettä, myös järjestelmän valitsin itse estää, mikä voi johtaa vaikeasti löydettäviin virheisiin.
Ratkaisee nämä ongelmat I/O proactor, jossa on usein ajastin, joka jakaa kuorman tasaisesti säikeiden joukkoon, ja jossa on myös kätevämpi API. Puhumme siitä myöhemmin, toisessa artikkelissani.
Johtopäätös
Tässä on matkamme teoriasta suoraan profilerin pakokaasuun päättynyt.
Sinun ei pitäisi jäädä tähän, koska verkkoohjelmistojen kirjoittamiseen on monia muita yhtä mielenkiintoisia tapoja, joilla on erilainen mukavuus ja nopeus. Mielenkiintoisia, mielestäni alla on linkkejä.