I/O-reactor (enkele schroefdraad gebeurtenis lus) is een patroon voor het schrijven van software met hoge belasting, die in veel populaire oplossingen wordt gebruikt:
In dit artikel bekijken we de ins en outs van een I/O-reactor en hoe deze werkt, schrijven we een implementatie in minder dan 200 regels code en maken we een eenvoudig HTTP-serverproces van meer dan 40 miljoen verzoeken/min.
Voorwoord
Het artikel is geschreven om de werking van de I/O-reactor beter te begrijpen en daarmee de risico's bij het gebruik ervan te begrijpen.
Het begrijpen van de basisprincipes is vereist om het artikel te begrijpen. C-taal en enige ervaring met de ontwikkeling van netwerkapplicaties.
Alle code is strikt in de C-taal geschreven volgens (let op: lange pdf) volgens C11-standaard voor Linux en beschikbaar op GitHub.
Waarom doen?
Met de groeiende populariteit van internet begonnen webservers een groot aantal verbindingen tegelijkertijd te verwerken, en daarom werden twee benaderingen geprobeerd: het blokkeren van I/O op een groot aantal OS-threads en niet-blokkerende I/O in combinatie met een gebeurtenismeldingssysteem, ook wel “systeemkiezer” genoemd (epol/in de rij staan/IOCP/enz).
De eerste aanpak bestond uit het creëren van een nieuwe OS-thread voor elke inkomende verbinding. Het nadeel is een slechte schaalbaarheid: het besturingssysteem zal er veel moeten implementeren contextovergangen и systeemoproepen. Het zijn dure handelingen en kunnen leiden tot een gebrek aan vrij RAM-geheugen met een indrukwekkend aantal verbindingen.
De gewijzigde versie benadrukt vast aantal draden (threadpool), waardoor wordt voorkomen dat het systeem crasht, maar tegelijkertijd een nieuw probleem wordt geïntroduceerd: als een threadpool momenteel wordt geblokkeerd door lange leesbewerkingen, zullen andere sockets die al gegevens kunnen ontvangen dit niet kunnen doen Dus.
De tweede benadering maakt gebruik van meldingssysteem voor evenementen (systeemkiezer) geleverd door het besturingssysteem. In dit artikel wordt het meest voorkomende type systeemkiezer besproken, gebaseerd op waarschuwingen (gebeurtenissen, meldingen) over de gereedheid voor I/O-bewerkingen, in plaats van op meldingen over de voltooiing ervan. Een vereenvoudigd voorbeeld van het gebruik ervan kan worden weergegeven door het volgende blokdiagram:
Het verschil tussen deze benaderingen is als volgt:
I/O-bewerkingen blokkeren opschorten gebruikersstroom tottotdat het besturingssysteem correct is defragmenteert inkomend IP-pakketten naar bytestream (TCP, gegevens ontvangen) of er zal niet voldoende ruimte beschikbaar zijn in de interne schrijfbuffers voor het daaropvolgende verzenden via NIC (gegevens verzenden).
Systeemkiezer na een tijdje meldt het programma dat het besturingssysteem reeds gedefragmenteerde IP-pakketten (TCP, gegevensontvangst) of voldoende ruimte in interne schrijfbuffers reeds beschikbaar (gegevens verzenden).
Kortom: het reserveren van een OS-thread voor elke I/O is een verspilling van rekenkracht, omdat de threads in werkelijkheid geen nuttig werk doen (hier komt de term vandaan). "software-onderbreking"). De systeemselector lost dit probleem op, waardoor het gebruikersprogramma CPU-bronnen veel zuiniger kan gebruiken.
I/O-reactormodel
De I/O-reactor fungeert als laag tussen de systeemkiezer en de gebruikerscode. Het principe van de werking ervan wordt beschreven door het volgende blokdiagram:
Ik wil u eraan herinneren dat een gebeurtenis een melding is dat een bepaalde socket een niet-blokkerende I/O-bewerking kan uitvoeren.
Een gebeurtenishandler is een functie die door de I/O-reactor wordt aangeroepen wanneer een gebeurtenis wordt ontvangen en die vervolgens een niet-blokkerende I/O-bewerking uitvoert.
Het is belangrijk op te merken dat de I/O-reactor per definitie single-threaded is, maar niets weerhoudt het concept ervan om te worden gebruikt in een multi-threaded omgeving met een verhouding van 1 thread: 1 reactor, waardoor alle CPU-kernen worden gerecycled.
uitvoering
We plaatsen de publieke interface in een bestand reactor.h, en implementatie - in reactor.c. reactor.h zal bestaan uit de volgende aankondigingen:
Toon declaraties in 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);
De I/O-reactorstructuur bestaat uit bestandsbeschrijving keuzeschakelaar epol и hash-tabellenGHashTable, waarmee elke socket wordt toegewezen CallbackData (structuur van een gebeurtenishandler en een gebruikersargument ervoor).
Houd er rekening mee dat we de mogelijkheid tot afhandeling hebben ingeschakeld onvolledige soort volgens de index. IN reactor.h wij verklaren de structuur reactoren in reactor.c we definiëren het, waardoor wordt voorkomen dat de gebruiker de velden expliciet wijzigt. Dit is een van de patronen gegevens verbergen, wat beknopt past in de C-semantiek.
functies reactor_register, reactor_deregister и reactor_reregister werk de lijst met interessante sockets en bijbehorende gebeurtenishandlers in de systeemkiezer en hashtabel bij.
Nadat de I/O-reactor de gebeurtenis met de descriptor heeft onderschept fd, roept het de corresponderende gebeurtenishandler aan, waarnaar het doorgaat fd, beetje masker gegenereerde gebeurtenissen en een gebruikersaanwijzer naar void.
Toon de reactor_run()-functie
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;
}
Samenvattend zal de keten van functieaanroepen in gebruikerscode de volgende vorm aannemen:
Single-threaded-server
Om de I/O-reactor onder hoge belasting te testen, zullen we een eenvoudige HTTP-webserver schrijven die op elk verzoek reageert met een afbeelding.
Een korte verwijzing naar het HTTP-protocol
HTTP - dit is het protocol toepassingsniveau, voornamelijk gebruikt voor server-browserinteractie.
HTTP kan eenvoudig worden gebruikt vervoer protocol TCP, het verzenden en ontvangen van berichten in een opgegeven formaat specificatie.
CRLF is een reeks van twee karakters: r и n, waarbij de eerste regel van het verzoek, headers en gegevens worden gescheiden.
<КОМАНДА> - een van de CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. De browser stuurt een commando naar onze server GET, wat betekent "Stuur mij de inhoud van het bestand."
<URI> - uniforme bronidentificatie. Als URI bijvoorbeeld = /index.html, dan vraagt de klant de hoofdpagina van de site op.
<ВЕРСИЯ HTTP> — versie van het HTTP-protocol in het formaat HTTP/X.Y. De meest gebruikte versie van vandaag is HTTP/1.1.
<ЗАГОЛОВОК N> is een sleutelwaardepaar in de indeling <КЛЮЧ>: <ЗНАЧЕНИЕ>, verzonden naar de server voor verdere analyse.
<ДАННЫЕ> — gegevens die de server nodig heeft om de bewerking uit te voeren. Vaak is het eenvoudig JSON of een ander formaat.
<КОД СТАТУСА> is een getal dat het resultaat van de bewerking vertegenwoordigt. Onze server retourneert altijd de status 200 (succesvolle bewerking).
<ОПИСАНИЕ СТАТУСА> — tekenreeksweergave van de statuscode. Voor statuscode 200 is dit OK.
<ЗАГОЛОВОК N> — header van hetzelfde formaat als in het verzoek. We zullen de titels retourneren Content-Length (bestandsgrootte) en Content-Type: text/html (gegevenstype retourneren).
<ДАННЫЕ> — gegevens opgevraagd door de gebruiker. In ons geval is dit het pad naar de afbeelding in HTML.
file http_server.c (server met enkele thread) bevat bestand common.h, dat de volgende functieprototypes bevat:
Toon functieprototypes gemeenschappelijk.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);
Ook wordt de functionele macro beschreven SAFE_CALL() en de functie is gedefinieerd fail(). De macro vergelijkt de waarde van de expressie met de fout en roept de functie aan als de voorwaarde waar is fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Functie fail() drukt de doorgegeven argumenten af naar de terminal (zoals printf()) en beëindigt het programma met de code EXIT_FAILURE:
Functie new_server() retourneert de bestandsdescriptor van de "server"-socket die is gemaakt door systeemaanroepen socket(), bind() и listen() en in staat om inkomende verbindingen te accepteren in een niet-blokkerende modus.
Houd er rekening mee dat de socket aanvankelijk in niet-blokkerende modus wordt gemaakt met behulp van de vlag SOCK_NONBLOCKdus dat in de functie on_accept() (lees meer) systeemoproep accept() heeft de uitvoering van de thread niet gestopt.
als reuse_port is gelijk aan true, dan zal deze functie de socket configureren met de optie SO_REUSEPORT door middel van setsockopt()om dezelfde poort te gebruiken in een multi-threaded omgeving (zie sectie “Multi-threaded server”).
Gebeurtenisbehandelaar on_accept() aangeroepen nadat het besturingssysteem een gebeurtenis heeft gegenereerd EPOLLIN, wat in dit geval betekent dat de nieuwe verbinding kan worden geaccepteerd. on_accept() accepteert een nieuwe verbinding, schakelt deze over naar de niet-blokkerende modus en registreert zich bij een gebeurtenishandler on_recv() in een I/O-reactor.
Gebeurtenisbehandelaar on_recv() aangeroepen nadat het besturingssysteem een gebeurtenis heeft gegenereerd EPOLLIN, in dit geval betekent dit dat de verbinding is geregistreerd on_accept(), klaar om gegevens te ontvangen.
on_recv() leest gegevens van de verbinding totdat het HTTP-verzoek volledig is ontvangen en registreert vervolgens een handler on_send() om een HTTP-antwoord te verzenden. Als de client de verbinding verbreekt, wordt de socket afgemeld en gesloten met behulp van close().
Toon functie 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);
}
}
Gebeurtenisbehandelaar on_send() aangeroepen nadat het besturingssysteem een gebeurtenis heeft gegenereerd EPOLLOUT, wat betekent dat de verbinding is geregistreerd on_recv(), klaar om gegevens te verzenden. Deze functie verzendt een HTTP-antwoord met HTML met een afbeelding naar de client en wijzigt vervolgens de gebeurtenishandler terug naar on_recv().
En ten slotte in het bestand http_server.c, in functie main() we creëren een I/O-reactor met behulp van reactor_new(), maak een server socket en registreer deze, start de reactor met behulp van reactor_run() gedurende precies één minuut, en dan geven we bronnen vrij en verlaten we het programma.
Laten we controleren of alles werkt zoals verwacht. Compileren (chmod a+x compile.sh && ./compile.sh in de projectroot) en start de zelfgeschreven server, open http://127.0.0.1:18470 in de browser en kijk wat we verwachtten:
Laten we de prestaties van een single-threaded server meten. Laten we twee terminals openen: in één zullen we rennen ./http_server, op een andere - werk. Na een minuut worden de volgende statistieken weergegeven in de tweede terminal:
Onze single-threaded server kon ruim 11 miljoen verzoeken per minuut verwerken, afkomstig van 100 verbindingen. Geen slecht resultaat, maar kan het verbeterd worden?
Multithreaded-server
Zoals hierboven vermeld, kan de I/O-reactor in afzonderlijke threads worden gemaakt, waardoor alle CPU-kernen worden gebruikt. Laten we deze aanpak in de praktijk brengen:
Houd er rekening mee dat het functieargument new_server() acts true. Dit betekent dat we de optie toewijzen aan de server socket SO_REUSEPORTom het te gebruiken in een omgeving met meerdere threads. U kunt meer details lezen hier.
Tweede run
Laten we nu de prestaties van een multi-threaded server meten:
Het aantal verwerkte verzoeken in 1 minuut is met ~3.28 keer toegenomen! Maar we kwamen slechts ~XNUMX miljoen tekort aan het ronde getal, dus laten we proberen dat op te lossen.
Laten we eerst eens kijken naar de gegenereerde statistieken perf:
CPU-affiniteit gebruiken, compilatie met -march=native, PGO, een toename van het aantal hits cache, toename MAX_EVENTS en gebruiken EPOLLET leverde geen noemenswaardige prestatieverbetering op. Maar wat gebeurt er als je het aantal gelijktijdige verbindingen vergroot?
Het gewenste resultaat werd verkregen, en daarmee een interessante grafiek die de afhankelijkheid van het aantal verwerkte verzoeken in 1 minuut laat zien van het aantal verbindingen:
We zien dat na een paar honderd verbindingen het aantal verwerkte verzoeken voor beide servers sterk daalt (in de multi-threaded versie is dit meer merkbaar). Heeft dit te maken met de implementatie van de Linux TCP/IP-stack? Voel je vrij om je aannames over dit gedrag van de grafiek en optimalisaties voor multi-threaded en single-threaded opties in de commentaren te schrijven.
Als dat is genoteerd in de commentaren toont deze prestatietest niet het gedrag van de I/O-reactor onder echte belasting, omdat de server bijna altijd communiceert met de database, logs uitvoert, cryptografie gebruikt met TLS enz., waardoor de belasting niet-uniform (dynamisch) wordt. Tests samen met componenten van derden worden uitgevoerd in het artikel over de I/O-proactor.
Nadelen van I/O-reactor
Je moet begrijpen dat de I/O-reactor niet zonder nadelen is, namelijk:
Het gebruik van een I/O-reactor in een omgeving met meerdere threads is iets moeilijker, omdat u zult de stromen handmatig moeten beheren.
De praktijk leert dat de belasting in de meeste gevallen niet-uniform is, wat ertoe kan leiden dat de ene thread vastloopt terwijl de andere bezig is met werken.
Als één gebeurtenishandler een thread blokkeert, blokkeert de systeemselector zelf ook, wat kan leiden tot moeilijk te vinden bugs.
Lost deze problemen op I/O-proactor, dat vaak een planner heeft die de belasting gelijkmatig verdeelt over een pool van threads, en ook een handiger API heeft. We zullen er later over praten, in mijn andere artikel.
Conclusie
Dit is waar onze reis van theorie rechtstreeks naar de uitlaat van de profiler ten einde is gekomen.
Je moet hier niet bij stilstaan, want er zijn veel andere, even interessante benaderingen voor het schrijven van netwerksoftware met verschillende niveaus van gemak en snelheid. Interessant, naar mijn mening, links worden hieronder gegeven.